Skip to content

Commit

Permalink
Merge branch 'main' into ci_container
Browse files Browse the repository at this point in the history
  • Loading branch information
rowanseymour committed Aug 19, 2024
2 parents 227d211 + 4bc7725 commit 17e8cb3
Show file tree
Hide file tree
Showing 25 changed files with 341 additions and 253 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
v9.3.21 (2024-08-19)
-------------------------
* Use correct URL when breaking spa-container
* Delete API tokens when user deleted and use generate_secret to create new tokens
* Update API token management UI to support multiple tokens

v9.3.20 (2024-08-14)
-------------------------
* Rework S3 code to always use real S3 clients, even in tests
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
"dependencies": {
"@nyaruka/flow-editor": "1.35.1",
"@nyaruka/temba-components": "0.104.0",
"@nyaruka/temba-components": "0.104.1",
"codemirror": "5.18.2",
"colorette": "1.2.2",
"fa-icons": "0.2.0",
Expand Down
47 changes: 22 additions & 25 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "temba"
version = "9.3.20"
version = "9.3.21"
description = "Hosted service for visually building interactive messaging applications"
authors = ["Nyaruka <code@nyaruka.com>"]

Expand All @@ -9,16 +9,16 @@ repository = "http://github.com/rapidpro/rapidpro"

[tool.poetry.dependencies]
python = "~3.11"
Django = "^5.0.8"
Django = "^5.1"
django-compressor = "^4.3.1"
django-countries = "^7.0"
django-mptt = "^0.12.0"
django-mptt = "^0.16.0"
django-redis = "^4.12.1"
django-storages = "^1.11.1"
django-timezone-field = "^6.1.0"
djangorestframework = "^3.15.1"
dj-database-url = "^0.5.0"
smartmin = "^5.0.7"
smartmin = "^5.1.0"
celery = "^5.4.0"
redis = "^5.0.7"
boto3 = "^1.34.137"
Expand Down
11 changes: 7 additions & 4 deletions static/js/frame.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,12 @@ function fetchAjax(url, options) {
if (container) {
// if we got redirected when updating our container, make sure reflect it in the url
if (response.redirected) {
var url = response.url;
if (url) {
window.history.replaceState({ url: url }, '', url);
if (response.url) {
window.history.replaceState(
{ url: response.url },
'',
response.url
);
}
}

Expand All @@ -317,7 +320,7 @@ function fetchAjax(url, options) {
container === '.spa-content' &&
response.headers.get('x-temba-content-only') != 1
) {
document.location.href = url;
document.location.href = response.url;
return;
}

Expand Down
2 changes: 1 addition & 1 deletion temba/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "9.3.20"
__version__ = "9.3.21"

# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
Expand Down
20 changes: 20 additions & 0 deletions temba/api/migrations/0046_alter_apitoken_role.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 5.1 on 2024-08-19 18:37

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("api", "0045_apitoken_last_used_on"),
("auth", "0012_alter_user_first_name_max_length"),
]

operations = [
migrations.AlterField(
model_name="apitoken",
name="role",
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to="auth.group"),
),
]
77 changes: 14 additions & 63 deletions temba/api/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import hmac
import logging
from hashlib import sha1

from django_redis import get_redis_connection
from rest_framework.permissions import BasePermission
Expand All @@ -14,7 +12,7 @@

from temba.orgs.models import Org, OrgRole, User
from temba.utils.models import JSONAsTextField
from temba.utils.uuid import uuid4
from temba.utils.text import generate_secret

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -74,13 +72,13 @@ def has_permission(self, request, view):
org = request.org

if request.auth:
# check that user is still allowed to use the token's role
if not request.auth.is_valid():
return False
# auth token was used
role = org.get_user_role(request.auth.user)

role = OrgRole.from_group(request.auth.role)
# only editors and administrators can use API tokens
if role not in APIToken.ALLOWED_ROLES:
return False
elif org:
# user may not have used token authentication
role = org.get_user_role(request.user)
else:
return False
Expand Down Expand Up @@ -205,68 +203,30 @@ class WebHookEvent(models.Model):

class APIToken(models.Model):
"""
An org+user+role specific access token for the API
An org+user specific access token for the API
"""

GROUP_GRANTED_TO = {
"Administrators": (OrgRole.ADMINISTRATOR,),
"Editors": (OrgRole.ADMINISTRATOR, OrgRole.EDITOR),
}
ALLOWED_ROLES = (OrgRole.ADMINISTRATOR, OrgRole.EDITOR)

key = models.CharField(max_length=40, primary_key=True)
org = models.ForeignKey(Org, on_delete=models.PROTECT, related_name="api_tokens")
user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="api_tokens")
role = models.ForeignKey(Group, on_delete=models.PROTECT)
created = models.DateTimeField(default=timezone.now)
last_used_on = models.DateTimeField(null=True)
is_active = models.BooleanField(default=True)

@classmethod
def get_or_create(cls, org, user, *, role: OrgRole = None, refresh: bool = False):
"""
Gets or creates an API token for this user
"""

role = role or cls.get_default_role(org, user)
role_group = role.group if role else None

if not role_group:
raise ValueError("User '%s' has no suitable role for API usage" % str(user))
elif role_group.name not in cls.GROUP_GRANTED_TO:
raise ValueError("Role %s is not valid for API usage" % role_group.name)

tokens = cls.objects.filter(is_active=True, user=user, org=org, role=role_group)

# if we are refreshing the token, clear existing ones
if refresh and tokens:
for token in tokens:
token.release()
tokens = None

if not tokens:
return cls.objects.create(user=user, org=org, role=role_group)
else:
return tokens.first()
# TODO remove
role = models.ForeignKey(Group, on_delete=models.PROTECT, null=True)

@classmethod
def get_default_role(cls, org, user):
def create(cls, org, user):
"""
Gets the default API role for the given user
Creates a new API token for this user
"""
role = org.get_user_role(user)

if not role or role.group.name not in cls.GROUP_GRANTED_TO: # don't allow creating tokens for VIEWER role etc
return None

return role
assert org.get_user_role(user) in cls.ALLOWED_ROLES

def is_valid(self) -> bool:
"""
A user's role in an org can change so this return whether this token is still valid.
"""
role = self.org.get_user_role(self.user)
roles_allowed_this_perm_group = self.GROUP_GRANTED_TO.get(self.role.name, ())
return role and role in roles_allowed_this_perm_group
return cls.objects.create(user=user, org=org, key=generate_secret(40))

def record_used(self):
r = get_redis_connection()
Expand All @@ -277,15 +237,6 @@ def get_used_keys(self) -> list:
r = get_redis_connection()
return [k.decode() for k in r.spop("api_tokens_used", r.scard("api_tokens_used"))]

def save(self, *args, **kwargs):
if not self.key:
self.key = self.generate_key()
return super().save(*args, **kwargs)

def generate_key(self):
unique = uuid4()
return hmac.new(unique.bytes, digestmod=sha1).hexdigest()

def release(self):
self.is_active = False
self.save(update_fields=("is_active",))
Expand Down
Loading

0 comments on commit 17e8cb3

Please sign in to comment.