Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion backend/apps/account/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
60 changes: 59 additions & 1 deletion backend/apps/account_auth/admin.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand All @@ -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)
43 changes: 43 additions & 0 deletions backend/apps/account_auth/migrations/0002_backendtoken.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
],
),
]
30 changes: 30 additions & 0 deletions backend/apps/account_auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
127 changes: 127 additions & 0 deletions backend/apps/account_auth/mutations.py
Original file line number Diff line number Diff line change
@@ -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()
25 changes: 25 additions & 0 deletions backend/apps/account_auth/queries.py
Original file line number Diff line number Diff line change
@@ -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]
Loading
Loading