diff --git a/backend/apps/account/admin.py b/backend/apps/account/admin.py index daa12189..8d2e4730 100644 --- a/backend/apps/account/admin.py +++ b/backend/apps/account/admin.py @@ -20,6 +20,8 @@ Team, ) from backend.apps.account.tasks import sync_subscription_task +from backend.apps.account_auth.admin import BackendTokenAdminForm +from backend.apps.account_auth.models import BackendToken def sync_subscription(modeladmin: ModelAdmin, request: HttpRequest, queryset: QuerySet): @@ -127,6 +129,15 @@ def __init__(self, *args, **kwargs): user_permissions.queryset = user_permissions.queryset.select_related("content_type") +class BackendTokenInline(admin.TabularInline): + model = BackendToken + form = BackendTokenAdminForm + extra = 0 + fields = ("name", "scopes", "expires_at", "is_active", "prefix", "created_at", "last_used_at") + readonly_fields = ("prefix", "created_at", "last_used_at") + show_change_link = True + + class CareerInline(admin.StackedInline): model = Career extra = 0 @@ -301,9 +312,33 @@ class AccountAdmin(BaseAccountAdmin): ) search_fields = ("email", "full_name") ordering = ["-created_at"] - inlines = (CareerInline, SubscriptionInline) + inlines = (CareerInline, SubscriptionInline, BackendTokenInline) filter_horizontal = () + def save_formset(self, request, form, formset, change): + if formset.model is not BackendToken: + return super().save_formset(request, form, formset, change) + + from django.contrib import messages as dj_messages + + from backend.apps.account_auth.mutations import _generate_raw_token, _hash_token + + instances = formset.save(commit=False) + for obj in instances: + if not obj.pk: + raw = _generate_raw_token() + obj.prefix = raw[len("bdtoken_") : len("bdtoken_") + 8] + obj.hashed_key = _hash_token(raw) + obj.save() + self.message_user( + request, + f"Token '{obj.name}' created: {raw} — copy now, it will not be shown again.", + level=dj_messages.WARNING, + ) + else: + obj.save() + formset.save_m2m() + def is_subscriber(self, instance): return bool(instance.is_subscriber) diff --git a/backend/apps/account_auth/admin.py b/backend/apps/account_auth/admin.py index d832b92d..b6a6ac17 100644 --- a/backend/apps/account_auth/admin.py +++ b/backend/apps/account_auth/admin.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- -from django.contrib import admin +from django import forms +from django.contrib import admin, messages from backend.apps.account_auth.models import ( + SCOPE_CHOICES, Access, + BackendToken, Domain, Token, ) @@ -29,3 +32,58 @@ class AccessAdmin(admin.ModelAdmin): admin.site.register(Domain, DomainAdmin) admin.site.register(Token, TokenAdmin) admin.site.register(Access, AccessAdmin) + + +# --------------------------------------------------------------------------- +# BackendToken admin +# --------------------------------------------------------------------------- + +_SCOPE_WIDGET_CHOICES = [(s, s) for s in SCOPE_CHOICES] + + +class BackendTokenAdminForm(forms.ModelForm): + scopes = forms.MultipleChoiceField( + choices=_SCOPE_WIDGET_CHOICES, + widget=forms.CheckboxSelectMultiple, + required=False, + ) + + class Meta: + model = BackendToken + fields = "__all__" + + def clean_scopes(self): + return list(self.cleaned_data.get("scopes", [])) + + +class BackendTokenAdmin(admin.ModelAdmin): + form = BackendTokenAdminForm + list_display = ("user", "name", "prefix", "scopes", "expires_at", "last_used_at", "is_active") + list_filter = ("is_active",) + readonly_fields = ("prefix", "hashed_key", "created_at", "last_used_at") + search_fields = ("user__email", "name") + + def save_model(self, request, obj, form, change): + from backend.apps.account_auth.mutations import _generate_raw_token, _hash_token + + if not change: + raw = _generate_raw_token() + obj.prefix = raw[len("bdtoken_") : len("bdtoken_") + 8] + obj.hashed_key = _hash_token(raw) + super().save_model(request, obj, form, change) + self.message_user( + request, + f"Token created: {raw} — copy now, it will not be shown again.", + level=messages.WARNING, + ) + else: + super().save_model(request, obj, form, change) + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + if obj: + form.base_fields["scopes"].initial = obj.scopes + return form + + +admin.site.register(BackendToken, BackendTokenAdmin) diff --git a/backend/apps/account_auth/migrations/0002_backendtoken.py b/backend/apps/account_auth/migrations/0002_backendtoken.py new file mode 100644 index 00000000..853134d6 --- /dev/null +++ b/backend/apps/account_auth/migrations/0002_backendtoken.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Generated by Django 4.2.10 on 2026-04-03 16:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("account_auth", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="BackendToken", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=255)), + ("prefix", models.CharField(editable=False, max_length=8)), + ("hashed_key", models.CharField(editable=False, max_length=64, unique=True)), + ("scopes", models.JSONField(default=list)), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("last_used_at", models.DateTimeField(blank=True, null=True)), + ("is_active", models.BooleanField(default=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="backend_tokens", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/backend/apps/account_auth/models.py b/backend/apps/account_auth/models.py index 2ecc2237..3c8a60aa 100644 --- a/backend/apps/account_auth/models.py +++ b/backend/apps/account_auth/models.py @@ -6,6 +6,21 @@ USER_MODEL = settings.AUTH_USER_MODEL +SCOPE_CHOICES = [ + "datasets:read", + "datasets:write", + "tables:read", + "tables:write", + "organizations:read", + "organizations:write", + "accounts:read", + "accounts:write", + "subscriptions:read", + "admin", +] + +STAFF_ONLY_SCOPES = {"accounts:read", "accounts:write", "admin"} + class Domain(models.Model): name = models.CharField(max_length=255) @@ -37,6 +52,21 @@ def save(self): super().save() +class BackendToken(models.Model): + user = models.ForeignKey(USER_MODEL, on_delete=models.CASCADE, related_name="backend_tokens") + name = models.CharField(max_length=255) + prefix = models.CharField(max_length=8, editable=False) + hashed_key = models.CharField(max_length=64, unique=True, editable=False) + scopes = models.JSONField(default=list) + expires_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + last_used_at = models.DateTimeField(null=True, blank=True) + is_active = models.BooleanField(default=True) + + def __str__(self): + return f"{self.user.email} — {self.name} [{self.prefix}...]" + + class Access(models.Model): user = models.ForeignKey(USER_MODEL, on_delete=models.CASCADE, null=True, blank=True) token = models.ForeignKey( diff --git a/backend/apps/account_auth/mutations.py b/backend/apps/account_auth/mutations.py new file mode 100644 index 00000000..53029e0b --- /dev/null +++ b/backend/apps/account_auth/mutations.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +import hashlib +import os +from datetime import datetime + +import graphene +from graphql_jwt import exceptions + +from backend.apps.account_auth.models import SCOPE_CHOICES, STAFF_ONLY_SCOPES, BackendToken + +# --------------------------------------------------------------------------- +# GraphQL types +# --------------------------------------------------------------------------- + + +class BackendTokenType(graphene.ObjectType): + id = graphene.ID() + name = graphene.String() + prefix = graphene.String() + scopes = graphene.List(graphene.String) + expires_at = graphene.DateTime() + created_at = graphene.DateTime() + last_used_at = graphene.DateTime() + is_active = graphene.Boolean() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _generate_raw_token() -> str: + return "bdtoken_" + os.urandom(32).hex() + + +def _hash_token(raw: str) -> str: + return hashlib.sha256(raw.encode()).hexdigest() + + +def _bt_to_type(bt: BackendToken) -> BackendTokenType: + return BackendTokenType( + id=bt.pk, + name=bt.name, + prefix=bt.prefix, + scopes=bt.scopes, + expires_at=bt.expires_at, + created_at=bt.created_at, + last_used_at=bt.last_used_at, + is_active=bt.is_active, + ) + + +# --------------------------------------------------------------------------- +# Mutations +# --------------------------------------------------------------------------- + + +class CreateBackendToken(graphene.Mutation): + """Create a new BackendToken. Returns the raw token once — store it securely.""" + + class Arguments: + name = graphene.String(required=True) + scopes = graphene.List(graphene.String, required=True) + expires_at = graphene.DateTime(required=False) + + raw_token = graphene.String() + token = graphene.Field(BackendTokenType) + + def mutate(self, info, name: str, scopes: list, expires_at: datetime = None): + user = info.context.user + if not user.is_authenticated: + raise exceptions.PermissionDenied() + + invalid_scopes = [s for s in scopes if s not in SCOPE_CHOICES] + if invalid_scopes: + raise ValueError(f"Invalid scopes: {invalid_scopes}") + + if not user.is_staff: + forbidden = [s for s in scopes if s in STAFF_ONLY_SCOPES] + if forbidden: + raise exceptions.PermissionDenied() + + raw = _generate_raw_token() + # prefix = first 8 hex chars of the random part (after "bdtoken_") + prefix = raw[len("bdtoken_") : len("bdtoken_") + 8] + hashed = _hash_token(raw) + + bt = BackendToken.objects.create( + user=user, + name=name, + prefix=prefix, + hashed_key=hashed, + scopes=scopes, + expires_at=expires_at, + ) + + return CreateBackendToken(raw_token=raw, token=_bt_to_type(bt)) + + +class RevokeBackendToken(graphene.Mutation): + """Set a BackendToken as inactive. Users can only revoke their own tokens; + staff can revoke any token.""" + + class Arguments: + id = graphene.ID(required=True) + + ok = graphene.Boolean() + + def mutate(self, info, id): + user = info.context.user + if not user.is_authenticated: + raise exceptions.PermissionDenied() + + qs = BackendToken.objects.filter(pk=id) + if not user.is_staff: + qs = qs.filter(user=user) + + updated = qs.update(is_active=False) + if not updated: + raise exceptions.PermissionDenied() + + return RevokeBackendToken(ok=True) + + +class BackendTokenMutation: + create_backend_token = CreateBackendToken.Field() + revoke_backend_token = RevokeBackendToken.Field() diff --git a/backend/apps/account_auth/queries.py b/backend/apps/account_auth/queries.py new file mode 100644 index 00000000..52f70419 --- /dev/null +++ b/backend/apps/account_auth/queries.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +import graphene +from graphql_jwt import exceptions + +from backend.apps.account_auth.models import BackendToken +from backend.apps.account_auth.mutations import BackendTokenType, _bt_to_type + + +class BackendTokenQuery(graphene.ObjectType): + list_backend_tokens = graphene.List( + BackendTokenType, + user_id=graphene.ID(required=False), + ) + + def resolve_list_backend_tokens(self, info, user_id=None): + user = info.context.user + if not user.is_authenticated: + raise exceptions.PermissionDenied() + + if user_id is not None and user.is_staff: + qs = BackendToken.objects.filter(user_id=user_id) + else: + qs = BackendToken.objects.filter(user=user) + + return [_bt_to_type(bt) for bt in qs] diff --git a/backend/apps/api/v1/models.py b/backend/apps/api/v1/models.py index cbbcc10f..739084b0 100644 --- a/backend/apps/api/v1/models.py +++ b/backend/apps/api/v1/models.py @@ -12,6 +12,7 @@ from ordered_model.models import OrderedModel from backend.apps.account.models import Account +from backend.custom.graphql_jwt import require_scope from backend.custom.model import BaseModel from backend.custom.storage import OverwriteStorage, upload_to, validate_image from backend.custom.utils import check_kebab_case, check_snake_case @@ -77,9 +78,9 @@ def clean(self): if self.parent and self.parent.slug != "world": if self.administrative_level is None: - errors[ - "administrative_level" - ] = "Administrative level is required when parent is set" + errors["administrative_level"] = ( + "Administrative level is required when parent is set" + ) elif self.parent.administrative_level is None: errors["parent"] = "Parent must have an administrative level" elif self.parent.administrative_level != self.administrative_level - 1: @@ -437,6 +438,8 @@ class Meta: class Organization(BaseModel): """Organization model""" + graphql_query_decorator = require_scope("organizations:read") + id = models.UUIDField(primary_key=True, default=uuid4) slug = models.SlugField(unique=False, max_length=255) name = models.CharField(max_length=255) @@ -514,6 +517,8 @@ class Meta: class Dataset(BaseModel): """Dataset model""" + graphql_query_decorator = require_scope("datasets:read") + id = models.UUIDField(primary_key=True, default=uuid4) slug = models.SlugField(unique=False, max_length=255) name = models.CharField(max_length=255) @@ -1029,7 +1034,7 @@ def clean(self) -> None: errors = {} if bool(self.raw_data_source) == bool(self.information_request): raise ValidationError( - "One and only one of 'raw_data_source'," " or 'information_request' must be set." + "One and only one of 'raw_data_source', or 'information_request' must be set." ) if self.entity and self.entity.category.slug != "datetime": errors["entity"] = 'Entity must have category "datetime"' @@ -1041,6 +1046,8 @@ def clean(self) -> None: class Table(BaseModel, OrderedModel): """Table model""" + graphql_query_decorator = require_scope("tables:read") + id = models.UUIDField(primary_key=True, default=uuid4) slug = models.SlugField(unique=False, max_length=255) name = models.CharField(max_length=255) @@ -1558,6 +1565,8 @@ class Meta: class Column(BaseModel, OrderedModel): """Model definition for Column.""" + graphql_query_decorator = require_scope("tables:read") + id = models.UUIDField(primary_key=True, default=uuid4) table = models.ForeignKey("Table", on_delete=models.CASCADE, related_name="columns") name = models.CharField(max_length=255) @@ -1675,13 +1684,13 @@ def clean(self) -> None: """Clean method for Column model""" errors = {} if self.observation_level and self.observation_level.table != self.table: - errors[ - "observation_level" - ] = "Observation level is not in the same table as the column." + errors["observation_level"] = ( + "Observation level is not in the same table as the column." + ) if self.directory_primary_key and self.directory_primary_key.table.is_directory is False: - errors[ - "directory_primary_key" - ] = "Column indicated as a directory's primary key is not in a directory." + errors["directory_primary_key"] = ( + "Column indicated as a directory's primary key is not in a directory." + ) if errors: raise ValidationError(errors) return super().clean() @@ -1734,6 +1743,8 @@ def __str__(self): class CloudTable(BaseModel): """Model definition for CloudTable.""" + graphql_query_decorator = require_scope("tables:read") + id = models.UUIDField(primary_key=True, default=uuid4) table = models.ForeignKey("Table", on_delete=models.CASCADE, related_name="cloud_tables") columns = models.ManyToManyField( diff --git a/backend/apps/schema.py b/backend/apps/schema.py index 4ef832d4..5915ffb3 100644 --- a/backend/apps/schema.py +++ b/backend/apps/schema.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- from backend.apps.account.graphql import AccountMutation +from backend.apps.account_auth.mutations import BackendTokenMutation +from backend.apps.account_auth.queries import BackendTokenQuery from backend.apps.account_payment.graphql import Mutation as PaymentMutation from backend.apps.account_payment.graphql import Query as PaymentQuery from backend.apps.api.v1.graphql import APIMutation @@ -14,7 +16,7 @@ schema = build_schema( applications=["account", "v1"], - extra_queries=[APIQuery, PaymentQuery, UserNotificationQuery], + extra_queries=[APIQuery, PaymentQuery, UserNotificationQuery, BackendTokenQuery], extra_mutations=[ AccountMutation, APIMutation, @@ -22,5 +24,6 @@ TableUpdateNotification, DeactivateTableUpdateNotification, DeactivateAllTableUpdateNotification, + BackendTokenMutation, ], ) diff --git a/backend/custom/backend_token_auth.py b/backend/custom/backend_token_auth.py new file mode 100644 index 00000000..4f4d5ff7 --- /dev/null +++ b/backend/custom/backend_token_auth.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +import hashlib + +from django.utils import timezone + + +def _hash_token(raw: str) -> str: + return hashlib.sha256(raw.encode()).hexdigest() + + +class BackendTokenAuthBackend: + """Django authentication backend for BackendToken credentials. + + Called by BackendTokenMiddleware with token= kwarg. + Returns (Account, BackendToken) on success, None on failure. + Does not access request — that is the middleware's responsibility. + """ + + def authenticate(self, request, token: str = None): + if not token or not token.startswith("bdtoken_"): + return None + + from backend.apps.account_auth.models import BackendToken + + hashed = _hash_token(token) + try: + bt = BackendToken.objects.select_related("user").get( + hashed_key=hashed, + is_active=True, + ) + except BackendToken.DoesNotExist: + return None + + if bt.expires_at is not None and bt.expires_at <= timezone.now(): + return None + + return (bt.user, bt) + + def get_user(self, user_id): + from backend.apps.account.models import Account + + try: + return Account.objects.get(pk=user_id) + except Account.DoesNotExist: + return None diff --git a/backend/custom/backend_token_middleware.py b/backend/custom/backend_token_middleware.py new file mode 100644 index 00000000..aeb0a21f --- /dev/null +++ b/backend/custom/backend_token_middleware.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +from graphql_jwt import exceptions + +from backend.custom.backend_token_auth import BackendTokenAuthBackend + +_backend = BackendTokenAuthBackend() + + +class BackendTokenMiddleware: + """Graphene middleware that authenticates requests carrying an + 'Authorization: Token bdtoken_...' header. + + Runs after graphql_jwt.middleware.JSONWebTokenMiddleware. If the header + uses the 'Bearer' prefix (JWT) or is absent, this middleware does nothing. + On success: sets request.user and request._backend_token, updates last_used_at. + On failure (bad/expired token): raises PermissionDenied. + """ + + def resolve(self, next_, root, info, **kwargs): + request = info.context + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + + if auth_header.startswith("Token "): + raw = auth_header[len("Token ") :] + result = _backend.authenticate(request, token=raw) + + if result is None: + raise exceptions.PermissionDenied() + + user, backend_token = result + request.user = user + request._backend_token = backend_token + + # Update last_used_at without triggering full model validation + from django.utils import timezone + + type(backend_token).objects.filter(pk=backend_token.pk).update( + last_used_at=timezone.now() + ) + + return next_(root, info, **kwargs) diff --git a/backend/custom/graphql_jwt.py b/backend/custom/graphql_jwt.py index 1f398416..1dfde173 100644 --- a/backend/custom/graphql_jwt.py +++ b/backend/custom/graphql_jwt.py @@ -154,6 +154,32 @@ def wrapper(context, *args, **kwargs): return decorator +def require_scope(scope: str): + """Decorator that enforces a BackendToken scope on a GraphQL resolver. + + - JWT-authenticated requests (password / Google login) pass unconditionally. + - BackendToken-authenticated requests must have the requested scope, or "admin". + - Unauthenticated or insufficient-scope requests raise PermissionDenied. + """ + + def decorator(f): + @wraps(f) + @context(f) + def wrapper(ctx, *args, **kwargs): + # Only enforce scopes for BackendToken-authenticated requests. + # Anonymous and JWT-authenticated requests pass through unchanged. + if not hasattr(ctx, "_backend_token"): + return f(*args, **kwargs) + token_scopes = ctx._backend_token.scopes + if scope in token_scopes or "admin" in token_scopes: + return f(*args, **kwargs) + raise exceptions.PermissionDenied() + + return wrapper + + return decorator + + class CustomVerify(Verify): @classmethod def mutate(cls, *args, **kwargs): diff --git a/backend/settings/base.py b/backend/settings/base.py index 3e6232ef..5339b397 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -165,6 +165,7 @@ "SCHEMA": "backend.apps.schema.schema", "MIDDLEWARE": [ "graphql_jwt.middleware.JSONWebTokenMiddleware", + "backend.custom.backend_token_middleware.BackendTokenMiddleware", ], "RELAY_CONNECTION_MAX_LIMIT": 1500, } @@ -172,6 +173,7 @@ # Authentication backends AUTHENTICATION_BACKENDS = [ "graphql_jwt.backends.JSONWebTokenBackend", + "backend.custom.backend_token_auth.BackendTokenAuthBackend", "django.contrib.auth.backends.ModelBackend", ]