feat: rework auth model with UserToken and Daedalus/Pallas integration
Some checks failed
CVE Scan & Docker Build / build-and-push (push) Has been cancelled
CVE Scan & Docker Build / security-scan (push) Has been cancelled
Build & Deploy Docs / build-and-deploy (push) Successful in 1m10s

- Rename MCPToken to UserToken across models, views, and tests
- Update URL names from mcp-token-* to token-*
- Add Daedalus/Pallas integration design doc (v2)
- Switch docker-compose to build local mnemosyne:local image via shared
  build config instead of pulling from git.helu.ca
This commit is contained in:
2026-05-23 19:50:29 -04:00
parent 735eb9de1a
commit 93639188d3
44 changed files with 1305 additions and 865 deletions

View File

@@ -686,6 +686,29 @@ def concept_graph(request, uid):
# ---------------------------------------------------------------------------
def _job_for_user_or_none(job_id, username):
"""Load an ``IngestJob`` visible to ``username``, else ``None``.
Visibility: the job's ``library_uid`` must resolve to a Library with
``owner_username`` either null (global) or matching ``username``.
Callers translate ``None`` into a 404 with generic wording — cross-
user reads must not disclose existence.
"""
from library.models import IngestJob, Library
try:
job = IngestJob.objects.get(pk=job_id)
except IngestJob.DoesNotExist:
return None
try:
lib = Library.nodes.get(uid=job.library_uid)
except Library.DoesNotExist:
return None
if lib.owner_username and lib.owner_username != username:
return None
return job
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def ingest_create(request):
@@ -733,6 +756,17 @@ def ingest_create(request):
status=status.HTTP_404_NOT_FOUND,
)
# --- Owner-scope (workspace-scoped libraries only) ---
# Global libraries (owner_username is null) stay shared; workspace
# libraries are visible only to their creating user. Cross-user
# callers get the same wording as the not-found branch above so the
# endpoint doesn't disclose existence across users.
if lib.owner_username and lib.owner_username != request.user.username:
return Response(
{"detail": f"Workspace '{workspace_id or library_uid}' not registered."},
status=status.HTTP_404_NOT_FOUND,
)
# --- Idempotency check on (library, source_ref, content_hash) ---
source_ref = data.get("source_ref") or ""
content_hash = data["content_hash"]
@@ -804,11 +838,8 @@ def ingest_create(request):
@permission_classes([IsAuthenticated])
def ingest_job_detail(request, job_id):
"""Get the current status of an IngestJob."""
from library.models import IngestJob
try:
job = IngestJob.objects.get(pk=job_id)
except IngestJob.DoesNotExist:
job = _job_for_user_or_none(job_id, request.user.username)
if job is None:
return Response(
{"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND
)
@@ -820,12 +851,10 @@ def ingest_job_detail(request, job_id):
@permission_classes([IsAuthenticated])
def ingest_job_retry(request, job_id):
"""Re-dispatch a failed IngestJob."""
from library.models import IngestJob
from library.tasks import ingest_from_daedalus
try:
job = IngestJob.objects.get(pk=job_id)
except IngestJob.DoesNotExist:
job = _job_for_user_or_none(job_id, request.user.username)
if job is None:
return Response(
{"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND
)
@@ -852,10 +881,18 @@ def ingest_job_retry(request, job_id):
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def ingest_job_list(request):
"""List recent IngestJob rows, optionally filtered by status / library_uid."""
from library.models import IngestJob
"""List recent IngestJob rows, optionally filtered by status / library_uid.
qs = IngestJob.objects.all()
Scoped to libraries the caller owns (plus global libraries that have
no ``owner_username``). A ``library_uid`` query param the caller has
no access to silently returns an empty list — same wording as a
not-found job.
"""
from library.models import IngestJob
from library.utils import library_uids_for_user
visible_uids = library_uids_for_user(request.user.username)
qs = IngestJob.objects.filter(library_uid__in=visible_uids)
status_filter = request.query_params.get("status")
library_uid = request.query_params.get("library_uid")
limit = min(int(request.query_params.get("limit", 50)), 200)

View File

@@ -6,7 +6,8 @@ It uses the same Library node as a global library; the difference is that
`workspace_id` is set, and search must filter on it.
These endpoints are called by the Daedalus backend authenticated as the
Mnemosyne user the workspace belongs to (per-user DRF token). The
Mnemosyne user the workspace belongs to (per-user ``UserToken``,
``Authorization: Bearer <plaintext>``, minted at ``/profile/tokens/``). The
workspace's owning user is recorded on the Library node as
``owner_username``; every read and mutation is scoped to that user.
Non-owners receive 404 so a workspace's existence isn't disclosed

View File

@@ -31,7 +31,7 @@ logger = logging.getLogger(__name__)
# Authorization is expressed by the caller as a ``resolved_libraries``
# list — see §3.3 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. The
# MCP auth middleware materializes it from the bearer token (opaque
# MCPToken.allowed_libraries, per-turn JWT ``libs`` claim, or live
# UserToken.allowed_libraries, per-turn JWT ``libs`` claim, or live
# ``Team → TeamWorkspaceAssignment → Library.workspace_id`` join) and
# trusted in-process callers (Django admin page, DRF session-auth'd
# search endpoint, ``manage.py search``) either pass the full set from

View File

@@ -23,6 +23,34 @@ def neo4j_available():
return False
def library_uids_for_user(username: str) -> set[str]:
"""Return the UIDs of every Library this user may read on the REST surface.
A Library is visible to ``username`` if it has no ``owner_username``
(global / shared) or its ``owner_username`` matches. Used by the
ingest job endpoints to filter rows to the calling user's
workspaces — mirrors the owner-scoping on
``/library/api/workspaces/`` and ``/mcp_server/api/teams/``.
Returns the empty set when Neo4j is unreachable (fail-closed).
"""
if not neo4j_available():
return set()
try:
from neomodel import db
rows, _ = db.cypher_query(
"MATCH (l:Library) "
"WHERE l.owner_username IS NULL OR l.owner_username = $u "
"RETURN l.uid",
{"u": username},
)
return {r[0] for r in rows if r[0]}
except Exception as exc: # pragma: no cover - Neo4j unreachable paths
logger.warning("Failed to enumerate library UIDs for user %s: %s", username, exc)
return set()
def all_library_uids() -> list[str]:
"""Return the UIDs of every ``Library`` node in Neo4j.

View File

@@ -2,7 +2,7 @@
Three surfaces are exposed:
* :class:`MCPTokenAdmin` — read/edit opaque bearer tokens. Token
* :class:`UserTokenAdmin` — read/edit opaque bearer tokens. Token
creation still goes through the self-service dashboard so the
plaintext can be shown exactly once; admin gets a filtered
``allowed_libraries`` picker so operators can scope an existing
@@ -12,7 +12,7 @@ Three surfaces are exposed:
from Daedalus (``POST /mcp_server/api/teams/``) but the admin is the
break-glass path when Daedalus is offline.
* :class:`LibraryMembershipAdmin` — manage who can grant each
Neo4j-resident Library into a ``MCPToken.allowed_libraries``.
Neo4j-resident Library into a ``UserToken.allowed_libraries``.
See ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` for the overall model.
"""
@@ -25,9 +25,9 @@ from django.contrib import admin, messages
from .models import (
LibraryMembership,
MCPSigningKey,
MCPToken,
Team,
TeamWorkspaceAssignment,
UserToken,
)
from .teams import TeamJWTError, mint_team_jwt
@@ -103,7 +103,7 @@ class _LibraryPickerField(forms.MultipleChoiceField):
Django ``ModelForm`` instantiates fields at class-definition time,
but the set of grantable libraries depends on the request user.
We override :meth:`_bound_choices` indirectly by setting
``self.choices`` in :meth:`MCPTokenAdminForm.__init__`.
``self.choices`` in :meth:`UserTokenAdminForm.__init__`.
"""
def __init__(self, *args, **kwargs):
@@ -116,8 +116,8 @@ class _LibraryPickerField(forms.MultipleChoiceField):
super().__init__(*args, choices=[], **kwargs)
class MCPTokenAdminForm(forms.ModelForm):
"""``MCPToken`` admin form with a membership-filtered library picker.
class UserTokenAdminForm(forms.ModelForm):
"""``UserToken`` admin form with a membership-filtered library picker.
The underlying field is a ``JSONField(list)``; the form substitutes
a checkbox multi-select that writes the same JSON list shape. We
@@ -129,7 +129,7 @@ class MCPTokenAdminForm(forms.ModelForm):
allowed_libraries = _LibraryPickerField()
class Meta:
model = MCPToken
model = UserToken
fields = [
"user",
"name",
@@ -159,9 +159,9 @@ class MCPTokenAdminForm(forms.ModelForm):
return list(value)
@admin.register(MCPToken)
class MCPTokenAdmin(admin.ModelAdmin):
form = MCPTokenAdminForm
@admin.register(UserToken)
class UserTokenAdmin(admin.ModelAdmin):
form = UserTokenAdminForm
list_display = [
"name",

View File

@@ -3,7 +3,7 @@
These endpoints are the Daedalus → Mnemosyne control plane described
in §7 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. They are called
by Daedalus authenticated as the Mnemosyne user the team belongs to
(per-user DRF token).
(per-user ``UserToken``, ``Authorization: Bearer …``).
"""
from __future__ import annotations

View File

@@ -233,6 +233,17 @@ def team_workspaces(request, team_id):
def team_rotate(request, team_id):
"""Generate a fresh ``active_jti`` and JWT.
Upsert-on-missing: if no ``Team`` exists for this id, create one
owned by the caller and mint its first JWT. This eliminates the
ordering trap where Daedalus calls rotate before its provisioning
flow has POSTed the team — the operator clicks "Rotate JWT" and
things just work. The placeholder ``name`` is the team id; an
operator can rename later via admin or the create endpoint.
A pre-existing team owned by a *different* user returns 409 (same
shape as ``team_create``'s collision branch) — never disclose
cross-user existence.
The previously-issued JWT stops validating immediately — the auth
middleware compares the incoming ``jti`` against ``Team.active_jti``
on every request.
@@ -241,12 +252,27 @@ def team_rotate(request, team_id):
returns 409 so the operator is forced to go through the explicit
create/readd flow rather than quietly resurrecting a team.
"""
team = _get_team(team_id, request.user)
team = Team.objects.filter(pk=team_id).first()
if team is None:
return Response(
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
team = Team.objects.create(
id=team_id,
name=str(team_id),
owner=request.user,
)
if not team.active:
logger.info(
"team_rotate upserted_missing team_id=%s owner=%s",
team.id, request.user.username,
)
elif team.owner_id != request.user.id:
logger.warning(
"team_rotate owner_conflict team_id=%s caller=%s",
team.id, request.user.id,
)
return Response(
{"detail": "Team id is already in use."},
status=status.HTTP_409_CONFLICT,
)
elif not team.active:
return Response(
{"detail": "Team is inactive; cannot rotate. Recreate it instead."},
status=status.HTTP_409_CONFLICT,

View File

@@ -1,7 +1,8 @@
"""URL patterns for the ``/mcp_server/api/`` DRF control-plane API.
These endpoints are called by the Daedalus backend authenticated as
the owning Mnemosyne user (per-user DRF token). Every team is scoped
the owning Mnemosyne user (per-user ``UserToken``,
``Authorization: Bearer …``, minted at ``/profile/tokens/``). Every team is scoped
to its ``owner`` — cross-user access returns 404. End-user MCP traffic
does NOT go through this surface — that's ``mnemosyne.asgi`` /
``mcp_server/server.py``.

View File

@@ -3,7 +3,7 @@
Three credential types are accepted — see
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.2 for the full model:
1. **Opaque ``MCPToken``** (long-lived, hashed at rest). Authorization
1. **Opaque ``UserToken``** (long-lived, hashed at rest). Authorization
scope is its ``allowed_libraries`` JSON list.
2. **Per-turn signed JWT** (``iss=daedalus``, ≤10 min, legacy — retires
in Phase 4 when Daedalus chat itself becomes a Pallas Team). Scope
@@ -34,12 +34,13 @@ from collections import OrderedDict
import jwt as pyjwt
from asgiref.sync import sync_to_async
from django.conf import settings
from django.utils import timezone
from fastmcp.server.dependencies import get_http_request
from fastmcp.server.middleware import Middleware, MiddlewareContext
from .metrics import mcp_auth_failures_total
from .models import MCPSigningKey, MCPToken, Team, hash_token
from .models import MCPSigningKey, UserToken, Team, hash_token
logger = logging.getLogger(__name__)
@@ -88,18 +89,18 @@ class MCPAuthError(Exception):
def resolve_mcp_user(token_string: str):
"""Resolve an opaque bearer token to (user, MCPToken).
"""Resolve an opaque bearer token to (user, UserToken).
Hashes the incoming bearer and looks up by the hash — plaintext is never
stored or compared directly.
"""
try:
token = (
MCPToken.objects
UserToken.objects
.select_related("user")
.get(token_hash=hash_token(token_string))
)
except MCPToken.DoesNotExist:
except UserToken.DoesNotExist:
raise MCPAuthError("Invalid MCP token.")
if not token.is_active:
@@ -341,7 +342,7 @@ class MCPAuthMiddleware(Middleware):
:mod:`mcp_server.context`:
* ``STATE_KEY_USER`` — Django user.
* ``STATE_KEY_TOKEN`` — MCPToken row (opaque callers only).
* ``STATE_KEY_TOKEN`` — UserToken row (opaque callers only).
* ``STATE_KEY_CLAIMS`` — JWT claims dict (JWT callers only).
* ``STATE_KEY_RESOLVED_LIBRARIES`` — authorization-resolved Library
UID list. Tools read this; they never read ``STATE_KEY_CLAIMS``

View File

@@ -5,9 +5,9 @@ on the FastMCP ``Context``:
* ``STATE_KEY_USER`` — the Django user the bearer resolved to (synthetic
service user for JWT callers, concrete ``mcp_tokens.user`` for opaque
MCPToken callers, ``None`` for team JWTs which are not tied to any
UserToken callers, ``None`` for team JWTs which are not tied to any
per-user account).
* ``STATE_KEY_TOKEN`` — the ``MCPToken`` row for opaque-token callers;
* ``STATE_KEY_TOKEN`` — the ``UserToken`` row for opaque-token callers;
``None`` for JWT callers.
* ``STATE_KEY_CLAIMS`` — the JWT claims dict for JWT callers; ``None``
for opaque-token callers. Intentionally exposed for debugging /

View File

@@ -0,0 +1,54 @@
"""DRF authentication class backed by :class:`mcp_server.models.UserToken`.
Wraps :func:`mcp_server.auth.resolve_mcp_user` so a single verification
routine serves both surfaces:
* the FastMCP middleware on ``/mcp/`` (via ``MCPAuthMiddleware``); and
* the Django REST surface on ``/library/api/*`` and
``/mcp_server/api/teams/*`` (via this class).
Scope: this class authenticates the caller — it does *not* honour the
token's ``allowed_libraries`` / ``allowed_tools`` fields. Those apply
only to the MCP tool surface. On the REST endpoints, access is gated by
``Team.owner`` and ``Library.owner_username`` rather than per-token
scope claims; treating ``allowed_libraries`` as authoritative there
would either force Daedalus to mint an effectively-unrestricted token
(redundant with the user identity) or invent a per-endpoint scope
mapping with no natural shape.
The accepted header is ``Authorization: Bearer <plaintext>``.
"""
from __future__ import annotations
from rest_framework import authentication, exceptions
from .auth import MCPAuthError, resolve_mcp_user
class UserTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate DRF requests with a ``UserToken`` bearer."""
keyword = "Bearer"
def authenticate(self, request):
header = authentication.get_authorization_header(request).decode("iso-8859-1")
if not header:
return None
parts = header.split()
if len(parts) < 2 or parts[0] != self.keyword:
# Not our scheme. Let other authenticators try.
return None
if len(parts) > 2:
raise exceptions.AuthenticationFailed(
"Invalid Authorization header: too many components."
)
try:
user, token = resolve_mcp_user(parts[1])
except MCPAuthError as exc:
raise exceptions.AuthenticationFailed(str(exc))
return user, token
def authenticate_header(self, request):
return self.keyword

View File

@@ -1,18 +1,22 @@
"""Forms for the MCP token self-service dashboard.
"""Forms for the per-user API token self-service dashboard.
The dashboard is where humans mint their own opaque :class:`MCPToken`
rows for external MCP clients (Claude Desktop, Cline, ...). The
plaintext is surfaced exactly once on the "created" page and never
retrievable again — see ``mcp_server/views.py``.
The dashboard is where humans mint their own opaque :class:`UserToken`
rows — used by MCP tool clients (Claude Desktop, Cline) on ``/mcp/``
and by the Daedalus REST integration on ``/library/api/*`` /
``/mcp_server/api/teams/*``. The plaintext is surfaced exactly once on
the "created" page and never retrievable again — see ``views.py``.
Two pickers are rendered:
Two optional pickers ("Restrictions"; collapsed in the template) apply
only to the MCP surface:
* ``allowed_tools`` — multi-select over the FastMCP tool registry.
Empty = all tools permitted. Backs ``MCPToken.allowed_tools``.
Empty = all tools permitted. Backs ``UserToken.allowed_tools``.
* ``allowed_libraries`` — multi-select over Neo4j Libraries the
current request user has ``owner`` or ``manager`` membership on.
Empty = **zero** libraries (fail-closed), matching the semantics
in §4.1 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``.
Empty = **zero** libraries (fail-closed) *for MCP callers*, matching
the semantics in §4.1 of the integration design doc. On the REST
surface these fields are ignored; ``Team.owner`` and
``Library.owner_username`` enforce access there.
"""
from __future__ import annotations
@@ -23,7 +27,7 @@ import functools
from django import forms
from .admin import _library_choices_for_user
from .models import MCPToken
from .models import UserToken
@functools.lru_cache(maxsize=1)
@@ -44,7 +48,7 @@ def _tool_choices() -> list[tuple[str, str]]:
return [(name, name) for name in _registered_tool_names()]
class MCPTokenCreateForm(forms.Form):
class UserTokenCreateForm(forms.Form):
"""Generate a new bearer token. The token value itself is server-generated."""
name = forms.CharField(
@@ -82,7 +86,7 @@ class MCPTokenCreateForm(forms.Form):
self.fields["allowed_libraries"].choices = _library_choices_for_user(user)
class MCPTokenEditForm(forms.ModelForm):
class UserTokenEditForm(forms.ModelForm):
"""Edit token metadata. The hashed token itself cannot be edited."""
allowed_tools = forms.MultipleChoiceField(
@@ -100,7 +104,7 @@ class MCPTokenEditForm(forms.ModelForm):
)
class Meta:
model = MCPToken
model = UserToken
fields = ["name", "is_active", "expires_at", "allowed_tools", "allowed_libraries"]
widgets = {
"name": forms.TextInput(attrs={"class": "input input-bordered w-full"}),

View File

@@ -12,7 +12,7 @@ skips:
* Workspace-scoped Libraries (``workspace_id`` is not null). Those
belong to a Daedalus workspace and will be reachable via team JWTs
once Phase 4 wires up ``TeamWorkspaceAssignment``. Granting them to
a superuser MCPToken would silently widen the blast radius of a
a superuser UserToken would silently widen the blast radius of a
leaked token.
* Libraries that already have any :class:`LibraryMembership` row. We
do not stack roles for idempotency.

View File

@@ -6,11 +6,11 @@ from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from mcp_server.models import MCPToken
from mcp_server.models import UserToken
class Command(BaseCommand):
help = "Create an MCP token for a user and print the full token (shown once)."
help = "Create an API token for a user and print the full token (shown once)."
def add_arguments(self, parser):
parser.add_argument(
@@ -57,14 +57,14 @@ class Command(BaseCommand):
raise CommandError("--expires-days must be at least 1.")
expires_at = timezone.now() + timedelta(days=options["expires_days"])
token, plaintext = MCPToken.objects.create_token(
token, plaintext = UserToken.objects.create_token(
user=user,
name=options["name"],
allowed_tools=allowed_tools,
expires_at=expires_at,
)
self.stdout.write(self.style.SUCCESS("MCP token created"))
self.stdout.write(self.style.SUCCESS("API token created"))
self.stdout.write(f" Name: {token.name}")
self.stdout.write(f" User: {user}")
if allowed_tools:

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.13 on 2026-05-23 11:12
# Generated by Django 5.2.13 on 2026-05-23 15:12
import django.db.models.deletion
from django.conf import settings
@@ -29,25 +29,6 @@ class Migration(migrations.Migration):
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='MCPToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('allowed_tools', models.JSONField(blank=True, default=list)),
('allowed_libraries', models.JSONField(blank=True, default=list)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mcp_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Team',
fields=[
@@ -63,6 +44,27 @@ class Migration(migrations.Migration):
'ordering': ['name'],
},
),
migrations.CreateModel(
name='UserToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('allowed_tools', models.JSONField(blank=True, default=list)),
('allowed_libraries', models.JSONField(blank=True, default=list)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'API Token',
'verbose_name_plural': 'API Tokens',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='LibraryMembership',
fields=[

View File

@@ -3,7 +3,8 @@
This module defines every Postgres-backed row the Mnemosyne MCP surface
relies on:
* :class:`MCPToken` — opaque bearer tokens (SHA-256 hashed at rest).
* :class:`UserToken` — opaque bearer tokens (SHA-256 hashed at rest);
used on both the MCP surface and the Daedalus DRF REST surface.
* :class:`MCPSigningKey` — HMAC signing keys (``HS256``) for JWTs,
keyed by ``kid``. Used by the legacy per-turn path *and* by team
JWTs minted in §7 of ``DAEDALUS_PALLAS_INTEGRATION_v1.md``.
@@ -54,7 +55,7 @@ class LibraryMembership(models.Model):
Roles are ordered (owner > manager > reader) but not hierarchical
in storage: a user with owner rights is represented by a single
row with ``role="owner"``, not multiple rows. Callers deciding
whether a user may *grant* a Library into an ``MCPToken`` should
whether a user may *grant* a Library into a ``UserToken`` should
check for ``role__in=("owner", "manager")``.
"""
@@ -96,7 +97,7 @@ class LibraryMembership(models.Model):
# ---------------------------------------------------------------------------
class MCPTokenManager(models.Manager):
class UserTokenManager(models.Manager):
def create_token(
self,
*,
@@ -124,24 +125,30 @@ class MCPTokenManager(models.Manager):
return instance, plaintext
class MCPToken(models.Model):
"""Bearer token for authenticating MCP tool calls.
class UserToken(models.Model):
"""Per-user opaque bearer token for authenticating to Mnemosyne.
Tokens are hashed at rest (SHA-256, 64-char hex). Plaintext exists only in
memory at creation time, on the wire to the client, and in the user's own
storage. A leaked database backup discloses no usable credentials.
A single generic credential model used on both surfaces:
``allowed_libraries`` is a JSON list of Library ``uid`` strings. It is
the sole authorization axis for opaque-token callers: the auth
middleware materializes ``resolved_libraries = list(allowed_libraries)``
on every request. An empty list is fail-closed (the token sees nothing),
not an implicit "all".
* **MCP (``/mcp/``)** — third-party tool clients (Claude Desktop, …).
``allowed_tools`` / ``allowed_libraries`` scope the token; an empty
``allowed_libraries`` is fail-closed (the token sees nothing), not
an implicit "all".
* **DRF REST (``/library/api/*``, ``/mcp_server/api/teams/*``)** —
Daedalus calls authenticated as the owning user. The scope fields
are ignored on this surface; ``Team.owner`` and
``Library.owner_username`` enforce access.
Tokens are hashed at rest (SHA-256, 64-char hex). Plaintext exists
only in memory at creation time, on the wire to the client, and in
the user's own storage. A leaked database backup discloses no usable
credentials.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="mcp_tokens",
related_name="api_tokens",
)
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
name = models.CharField(max_length=100)
@@ -150,7 +157,8 @@ class MCPToken(models.Model):
last_used_at = models.DateTimeField(null=True, blank=True)
allowed_tools = models.JSONField(default=list, blank=True)
# JSON list of Library.uid strings. Fail-closed: empty → zero libraries.
# JSON list of Library.uid strings. Fail-closed: empty → zero libraries
# *for MCP callers*; ignored on the REST surface (see class docstring).
# We cannot use a ``ManyToManyField(Library)`` because Library is a
# neomodel ``StructuredNode`` in Neo4j, not a Django ORM model.
allowed_libraries = models.JSONField(default=list, blank=True)
@@ -158,10 +166,12 @@ class MCPToken(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = MCPTokenManager()
objects = UserTokenManager()
class Meta:
ordering = ["-created_at"]
verbose_name = "API Token"
verbose_name_plural = "API Tokens"
def __str__(self):
return f"{self.name} ({self.user})"
@@ -187,9 +197,9 @@ class MCPToken(models.Model):
"""Token-id-style display for admin and dashboard.
Plaintext is unrecoverable, so we display the first 8 chars of the
hash prefixed with `mcp_…`. Stable per token, never reveals plaintext.
hash prefixed with ``tok_…``. Stable per token, never reveals plaintext.
"""
return f"mcp_…{self.token_hash[:8]}"
return f"tok_…{self.token_hash[:8]}"
# ---------------------------------------------------------------------------

View File

@@ -1,16 +1,16 @@
{% extends "themis/base.html" %}
{% block title %}Generate MCP Token — {{ themis_app_name }}{% endblock %}
{% block title %}Generate API Token — {{ themis_app_name }}{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Generate MCP Token</h1>
<h1 class="text-2xl font-bold mb-6">Generate API Token</h1>
<div class="alert alert-info mb-6">
<span>The token will be displayed once after creation. Save it before leaving the page — it cannot be recovered.</span>
</div>
<form method="post" action="{% url 'mcp_server:mcp-token-create' %}">
<form method="post" action="{% url 'mcp_server:token-create' %}">
{% csrf_token %}
<div class="card bg-base-200 mb-6">
@@ -36,9 +36,18 @@
</div>
</div>
<div class="card bg-base-200 mb-6">
<div class="card-body">
<h2 class="card-title text-lg">Allowed Tools</h2>
<details class="card bg-base-200 mb-6">
<summary class="card-body cursor-pointer">
<h2 class="card-title text-lg">Restrictions (optional)</h2>
<p class="text-sm opacity-70">
These restrictions apply only when the token is used by an MCP
tool client (e.g. Claude Desktop). They are ignored by the
Daedalus REST API and the Mnemosyne web session — there the
token has the same access as your account.
</p>
</summary>
<div class="card-body pt-0">
<h3 class="font-semibold mt-2">Allowed Tools</h3>
<p class="text-sm opacity-60 mb-2">{{ form.allowed_tools.help_text }}</p>
<div class="space-y-1">
{% for choice in form.allowed_tools %}
@@ -52,10 +61,10 @@
<div class="text-error text-sm mt-2">{{ form.allowed_tools.errors }}</div>
{% endif %}
</div>
</div>
</details>
<div class="flex justify-between">
<a href="{% url 'mcp_server:mcp-token-list' %}" class="btn btn-ghost">Cancel</a>
<a href="{% url 'mcp_server:token-list' %}" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Generate Token</button>
</div>
</form>

View File

@@ -1,10 +1,10 @@
{% extends "themis/base.html" %}
{% block title %}MCP Token Created — {{ themis_app_name }}{% endblock %}
{% block title %}API Token Created — {{ themis_app_name }}{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-2">MCP Token Created</h1>
<h1 class="text-2xl font-bold mb-2">API Token Created</h1>
<p class="opacity-60 mb-6">{{ token.name }}</p>
<div class="alert alert-warning mb-6">
@@ -44,7 +44,7 @@
</div>
<div class="flex justify-end">
<a href="{% url 'mcp_server:mcp-token-list' %}" class="btn btn-primary">Ive saved it — go to token list</a>
<a href="{% url 'mcp_server:token-list' %}" class="btn btn-primary">Ive saved it — go to token list</a>
</div>
</div>
{% endblock %}

View File

@@ -8,15 +8,15 @@
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{{ token.name }}</h1>
<div class="flex gap-2">
<a href="{% url 'mcp_server:mcp-token-edit' pk=token.pk %}" class="btn btn-ghost btn-sm">Edit</a>
<a href="{% url 'mcp_server:token-edit' pk=token.pk %}" class="btn btn-ghost btn-sm">Edit</a>
{% if token.is_active %}
<form method="post" action="{% url 'mcp_server:mcp-token-revoke' pk=token.pk %}"
<form method="post" action="{% url 'mcp_server:token-revoke' pk=token.pk %}"
onsubmit="return confirm('Revoke this token? It will no longer authenticate MCP requests.');">
{% csrf_token %}
<button type="submit" class="btn btn-warning btn-sm btn-outline">Revoke</button>
</form>
{% endif %}
<form method="post" action="{% url 'mcp_server:mcp-token-delete' pk=token.pk %}"
<form method="post" action="{% url 'mcp_server:token-delete' pk=token.pk %}"
onsubmit="return confirm('Delete this token permanently? This removes the audit trail.');">
{% csrf_token %}
<button type="submit" class="btn btn-error btn-sm btn-outline">Delete</button>
@@ -76,6 +76,6 @@
</div>
</div>
<a href="{% url 'mcp_server:mcp-token-list' %}" class="btn btn-ghost btn-sm">← Back to Tokens</a>
<a href="{% url 'mcp_server:token-list' %}" class="btn btn-ghost btn-sm">← Back to Tokens</a>
</div>
{% endblock %}

View File

@@ -10,7 +10,7 @@
<span>You can edit metadata below. The token value itself cannot be changed — generate a new token if needed.</span>
</div>
<form method="post" action="{% url 'mcp_server:mcp-token-edit' pk=token.pk %}">
<form method="post" action="{% url 'mcp_server:token-edit' pk=token.pk %}">
{% csrf_token %}
<div class="card bg-base-200 mb-6">
@@ -36,9 +36,17 @@
</div>
</div>
<div class="card bg-base-200 mb-6">
<div class="card-body">
<h2 class="card-title text-lg">Allowed Tools</h2>
<details class="card bg-base-200 mb-6"{% if form.allowed_tools.value or form.allowed_libraries.value %} open{% endif %}>
<summary class="card-body cursor-pointer">
<h2 class="card-title text-lg">Restrictions (optional)</h2>
<p class="text-sm opacity-70">
These restrictions apply only when the token is used by an MCP
tool client (e.g. Claude Desktop). They are ignored by the
Daedalus REST API and the Mnemosyne web session.
</p>
</summary>
<div class="card-body pt-0">
<h3 class="font-semibold mt-2">Allowed Tools</h3>
<p class="text-sm opacity-60 mb-2">{{ form.allowed_tools.help_text }}</p>
<div class="space-y-1">
{% for choice in form.allowed_tools %}
@@ -49,10 +57,10 @@
{% endfor %}
</div>
</div>
</div>
</details>
<div class="flex justify-between">
<a href="{% url 'mcp_server:mcp-token-detail' pk=token.pk %}" class="btn btn-ghost">Cancel</a>
<a href="{% url 'mcp_server:token-detail' pk=token.pk %}" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>

View File

@@ -1,13 +1,13 @@
{% extends "themis/base.html" %}
{% load humanize %}
{% block title %}MCP Tokens — {{ themis_app_name }}{% endblock %}
{% block title %}API Tokens — {{ themis_app_name }}{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">MCP Tokens</h1>
<a href="{% url 'mcp_server:mcp-token-create' %}" class="btn btn-primary btn-sm">Generate Token</a>
<h1 class="text-2xl font-bold">API Tokens</h1>
<a href="{% url 'mcp_server:token-create' %}" class="btn btn-primary btn-sm">Generate Token</a>
</div>
<p class="text-sm opacity-60 mb-4">
@@ -21,7 +21,7 @@
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<a href="{% url 'mcp_server:mcp-token-detail' pk=token.pk %}"
<a href="{% url 'mcp_server:token-detail' pk=token.pk %}"
class="font-semibold link link-hover">
{{ token.name }}
</a>
@@ -41,7 +41,7 @@
</div>
</div>
<div class="flex gap-1">
<a href="{% url 'mcp_server:mcp-token-edit' pk=token.pk %}"
<a href="{% url 'mcp_server:token-edit' pk=token.pk %}"
class="btn btn-ghost btn-xs">Edit</a>
</div>
</div>
@@ -52,8 +52,8 @@
{% else %}
<div class="card bg-base-200">
<div class="card-body items-center text-center py-12">
<p class="opacity-60 mb-4">No MCP tokens yet.</p>
<a href="{% url 'mcp_server:mcp-token-create' %}" class="btn btn-primary btn-sm">Generate Your First Token</a>
<p class="opacity-60 mb-4">No API tokens yet.</p>
<a href="{% url 'mcp_server:token-create' %}" class="btn btn-primary btn-sm">Generate Your First Token</a>
</div>
</div>
{% endif %}

View File

@@ -3,7 +3,7 @@
Covers all three credential types described in
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.2:
1. Opaque :class:`~mcp_server.models.MCPToken` — ``resolve_mcp_user``
1. Opaque :class:`~mcp_server.models.UserToken` — ``resolve_mcp_user``
+ ``MCPAuthMiddleware`` opaque branch.
2. Per-turn JWT (``iss=daedalus``, legacy) — ``resolve_mcp_jwt`` normal
path, ``_remember_jti`` replay cache, ``claims["libs"]``-derived
@@ -42,7 +42,7 @@ from mcp_server.auth import (
)
from mcp_server.models import (
MCPSigningKey,
MCPToken,
UserToken,
Team,
TeamWorkspaceAssignment,
)
@@ -51,7 +51,7 @@ User = get_user_model()
# ---------------------------------------------------------------------------
# Opaque MCPToken
# Opaque UserToken
# ---------------------------------------------------------------------------
@@ -62,7 +62,7 @@ class ResolveMCPUserTest(TestCase):
self.user = User.objects.create_user(
username="bob", email="bob@example.com", password="pw"
)
self.token, self.plaintext = MCPToken.objects.create_token(
self.token, self.plaintext = UserToken.objects.create_token(
user=self.user, name="t"
)
@@ -105,7 +105,7 @@ class ResolveMCPUserTest(TestCase):
plaintext = self.plaintext
with connection.cursor() as cur:
cur.execute("SELECT * FROM mcp_server_mcptoken")
cur.execute("SELECT * FROM mcp_server_usertoken")
rows = cur.fetchall()
for row in rows:
for value in row:
@@ -190,7 +190,7 @@ class LooksLikeJWTTest(TestCase):
self.assertFalse(looks_like_jwt("!!!.bbb.ccc"))
def test_opaque_token_rejected(self):
# Real ``MCPToken.create_token`` plaintext is 48-byte base64, often
# Real ``UserToken.create_token`` plaintext is 48-byte base64, often
# contains dashes but never two dots.
self.assertFalse(
looks_like_jwt("CxGb3rThJ7_4jUGl0q2_fakey_fakey_fakey_fakey_fakey")
@@ -506,3 +506,37 @@ class ResolveJWTActorTest(TestCase):
claims = {"typ": "team", "team_id": uuid.uuid4()}
with self.assertRaises(MCPAuthError):
_resolve_jwt_actor(claims)
# ---------------------------------------------------------------------------
# Module-level import guards (regression tests)
# ---------------------------------------------------------------------------
class AuthModuleImportsTest(TestCase):
"""Pin the imports the runtime depends on.
These are regressions waiting to happen: a quick `grep` doesn't
catch a missing import when the consuming code is only reached via
a runtime path (FastMCP middleware, async tool dispatch, …) that
the test suite doesn't exercise end-to-end.
Add a check here whenever production fails with a
``NameError: name 'X' is not defined`` that the test suite missed.
"""
def test_settings_is_importable(self):
"""``MCPAuthMiddleware.on_call_tool`` reads
``settings.MCP_REQUIRE_AUTH`` on every tool call (including
unauthenticated ``get_health`` polls from Pallas). Removing the
``from django.conf import settings`` import — as happened during
the v2 token-consolidation cleanup — surfaces as
``NameError: name 'settings' is not defined`` for *every* MCP
client. Keep this import alive.
"""
from django.conf import settings as dj_settings
from mcp_server import auth as auth_module
self.assertTrue(hasattr(auth_module, "settings"))
self.assertIs(auth_module.settings, dj_settings)

View File

@@ -1,4 +1,4 @@
"""Tests for the create_mcp_token management command."""
"""Tests for the create_user_token management command."""
from io import StringIO
@@ -7,12 +7,12 @@ from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
from mcp_server.models import MCPToken
from mcp_server.models import UserToken
User = get_user_model()
class CreateMCPTokenCommandTest(TestCase):
class CreateUserTokenCommandTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="carol", email="carol@example.com", password="pw"
@@ -20,33 +20,33 @@ class CreateMCPTokenCommandTest(TestCase):
def test_create_basic_token(self):
out = StringIO()
call_command("create_mcp_token", user="carol@example.com", name="CLI", stdout=out)
self.assertEqual(MCPToken.objects.count(), 1)
call_command("create_user_token", user="carol@example.com", name="CLI", stdout=out)
self.assertEqual(UserToken.objects.count(), 1)
self.assertIn("CLI", out.getvalue())
def test_lookup_by_username(self):
out = StringIO()
call_command("create_mcp_token", user="carol", name="CLI2", stdout=out)
self.assertEqual(MCPToken.objects.count(), 1)
call_command("create_user_token", user="carol", name="CLI2", stdout=out)
self.assertEqual(UserToken.objects.count(), 1)
def test_unknown_user_raises(self):
with self.assertRaises(CommandError):
call_command("create_mcp_token", user="nobody@x.com", name="x")
call_command("create_user_token", user="nobody@x.com", name="x")
def test_inactive_user_raises(self):
self.user.is_active = False
self.user.save()
with self.assertRaises(CommandError):
call_command("create_mcp_token", user="carol", name="x")
call_command("create_user_token", user="carol", name="x")
def test_tool_whitelist_parsed(self):
out = StringIO()
call_command(
"create_mcp_token",
"create_user_token",
user="carol",
name="Restricted",
tools="search,get_chunk",
stdout=out,
)
token = MCPToken.objects.get(name="Restricted")
token = UserToken.objects.get(name="Restricted")
self.assertEqual(token.allowed_tools, ["search", "get_chunk"])

View File

@@ -0,0 +1,131 @@
"""Tests for ``mcp_server.drf_auth.UserTokenAuthentication``.
Authenticates DRF endpoints using a per-user ``UserToken`` carried as
``Authorization: Bearer <plaintext>``. The class wraps
``resolve_mcp_user``; these tests assert the DRF-side behaviour
(header parsing, error mapping, integration with ``IsAuthenticated``)
on top of the resolver's own coverage in ``test_auth.py``.
"""
from __future__ import annotations
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import path
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.test import APIClient
from mcp_server.models import UserToken
User = get_user_model()
# A tiny endpoint mounted only for these tests so we can exercise the
# DRF auth pipeline without coupling to any real app's view contract.
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def _whoami(request):
return Response({"username": request.user.username})
urlpatterns = [
path("__test_whoami__/", _whoami),
]
@override_settings(ROOT_URLCONF=__name__)
class UserTokenAuthenticationTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username="alice", password="pw")
def setUp(self):
self.client = APIClient()
self.token, self.plaintext = UserToken.objects.create_token(
user=self.user, name="t"
)
def _get(self, header=None):
kwargs = {}
if header is not None:
kwargs["HTTP_AUTHORIZATION"] = header
return self.client.get("/__test_whoami__/", **kwargs)
def test_no_header_returns_401_with_bearer_challenge(self):
resp = self._get()
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
# RFC 7235: anonymous request must include WWW-Authenticate so the
# client knows how to authenticate.
self.assertEqual(resp["WWW-Authenticate"], "Bearer")
def test_valid_bearer_authenticates(self):
resp = self._get(f"Bearer {self.plaintext}")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.json(), {"username": "alice"})
def test_invalid_bearer_returns_401(self):
resp = self._get("Bearer not-a-real-token")
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
def test_inactive_token_returns_401(self):
self.token.is_active = False
self.token.save(update_fields=["is_active"])
resp = self._get(f"Bearer {self.plaintext}")
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
def test_expired_token_returns_401(self):
self.token.expires_at = timezone.now() - timedelta(hours=1)
self.token.save(update_fields=["expires_at"])
resp = self._get(f"Bearer {self.plaintext}")
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
def test_disabled_user_returns_401(self):
self.user.is_active = False
self.user.save(update_fields=["is_active"])
resp = self._get(f"Bearer {self.plaintext}")
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
def test_wrong_keyword_falls_through(self):
# ``Token <plaintext>`` is the old DRF authtoken keyword. The new
# class only accepts ``Bearer``; a stale ``Token`` header is not
# ours to consume — we return None and let the next auth class
# try. SessionAuthentication doesn't accept it either, so the
# request lands anonymous and IsAuthenticated returns 401.
resp = self._get(f"Token {self.plaintext}")
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
def test_malformed_header_too_many_parts_returns_401(self):
resp = self._get(f"Bearer {self.plaintext} extra")
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
def test_request_auth_stashes_token(self):
# The auth class returns (user, token); DRF places the token on
# request.auth. Re-use a UserToken-aware endpoint to verify.
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def echo_token(request):
return Response({"token_name": request.auth.name})
from django.urls import path as _path
with override_settings(ROOT_URLCONF=__name__):
# Mount the extra endpoint via a per-test urlpatterns swap.
# Simpler: just call the resolver directly to confirm the
# auth class returns the (user, token) tuple it should.
from mcp_server.drf_auth import UserTokenAuthentication
from django.test import RequestFactory
request = RequestFactory().get(
"/__test_whoami__/",
HTTP_AUTHORIZATION=f"Bearer {self.plaintext}",
)
user, token = UserTokenAuthentication().authenticate(request)
self.assertEqual(user.pk, self.user.pk)
self.assertEqual(token.pk, self.token.pk)

View File

@@ -3,24 +3,24 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from mcp_server.forms import MCPTokenCreateForm, MCPTokenEditForm
from mcp_server.models import MCPToken
from mcp_server.forms import UserTokenCreateForm, UserTokenEditForm
from mcp_server.models import UserToken
User = get_user_model()
class CreateFormTest(TestCase):
def test_required_fields(self):
form = MCPTokenCreateForm(data={})
form = UserTokenCreateForm(data={})
self.assertFalse(form.is_valid())
self.assertIn("name", form.errors)
def test_name_only_is_valid(self):
form = MCPTokenCreateForm(data={"name": "Test"})
form = UserTokenCreateForm(data={"name": "Test"})
self.assertTrue(form.is_valid(), form.errors)
def test_tool_choices_match_registered_tools(self):
form = MCPTokenCreateForm()
form = UserTokenCreateForm()
choices = {value for value, _ in form.fields["allowed_tools"].choices}
# These five must always be present per the FastMCP server.
for expected in {"search", "get_chunk", "list_libraries", "list_collections", "list_items"}:
@@ -30,16 +30,16 @@ class CreateFormTest(TestCase):
class EditFormTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.token, _ = MCPToken.objects.create_token(
self.token, _ = UserToken.objects.create_token(
user=self.user, name="t", allowed_tools=["search"]
)
def test_initial_allowed_tools_populated(self):
form = MCPTokenEditForm(instance=self.token)
form = UserTokenEditForm(instance=self.token)
self.assertEqual(form.fields["allowed_tools"].initial, ["search"])
def test_save_updates_metadata(self):
form = MCPTokenEditForm(
form = UserTokenEditForm(
data={
"name": "Renamed",
"is_active": False,

View File

@@ -1,10 +1,10 @@
"""Tests for the Team / LibraryMembership / TeamWorkspaceAssignment models.
``MCPToken``'s hash-at-rest semantics live in ``test_token.py``; this
``UserToken``'s hash-at-rest semantics live in ``test_token.py``; this
module exercises the new Phase 2 tables introduced by
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §4 plus the
``allowed_libraries`` JSONField attached to the existing
:class:`~mcp_server.models.MCPToken`.
:class:`~mcp_server.models.UserToken`.
"""
from __future__ import annotations
@@ -18,7 +18,7 @@ from django.test import TestCase
from mcp_server.models import (
LibraryMembership,
MCPSigningKey,
MCPToken,
UserToken,
Team,
TeamWorkspaceAssignment,
)
@@ -92,26 +92,26 @@ class LibraryMembershipTest(TestCase):
# ---------------------------------------------------------------------------
# MCPToken.allowed_libraries
# UserToken.allowed_libraries
# ---------------------------------------------------------------------------
class MCPTokenAllowedLibrariesTest(TestCase):
class UserTokenAllowedLibrariesTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="u", password="pw")
def test_defaults_to_empty_list(self):
token, _ = MCPToken.objects.create_token(user=self.user, name="t")
token, _ = UserToken.objects.create_token(user=self.user, name="t")
self.assertEqual(token.allowed_libraries, [])
def test_create_token_accepts_allowed_libraries(self):
token, _ = MCPToken.objects.create_token(
token, _ = UserToken.objects.create_token(
user=self.user, name="t", allowed_libraries=["lib-a", "lib-b"]
)
self.assertEqual(token.allowed_libraries, ["lib-a", "lib-b"])
def test_allowed_libraries_round_trips(self):
token, _ = MCPToken.objects.create_token(
token, _ = UserToken.objects.create_token(
user=self.user,
name="t",
allowed_libraries=["lib-a", "lib-b", "lib-c"],

View File

@@ -385,13 +385,23 @@ class TeamRotateTest(_AuthenticatedAPITest):
kwargs={"team_id": self.team.id},
)
def test_unknown_team_returns_404(self):
def test_rotate_upserts_missing_team(self):
# Rotate is upsert-on-missing: if no Team row exists for this
# id, create one owned by the caller and mint its first JWT.
# Eliminates the create-before-rotate ordering trap Daedalus hit
# in production.
new_id = uuid.uuid4()
url = reverse(
"mcp-server-api:team-rotate",
kwargs={"team_id": uuid.uuid4()},
kwargs={"team_id": new_id},
)
resp = self.client.post(url)
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertIn("jwt", resp.data)
team = Team.objects.get(pk=new_id)
self.assertEqual(team.owner_id, self.user.id)
self.assertTrue(team.active)
self.assertIsNotNone(team.active_jti)
def test_rotate_returns_new_jwt_and_changes_active_jti(self):
before = self.team.active_jti
@@ -417,10 +427,16 @@ class TeamRotateTest(_AuthenticatedAPITest):
resp.status_code, status.HTTP_503_SERVICE_UNAVAILABLE
)
def test_rotate_by_non_owner_returns_404(self):
def test_rotate_by_non_owner_returns_409(self):
# The team row exists under Alice; Bob rotating it must not
# upsert (that would silently steal the id) and must not 404
# (would tell Bob the id is free). 409 is the right answer.
before = self.team.active_jti
before_owner = self.team.owner_id
self.client.force_authenticate(user=self.other_user)
resp = self.client.post(self.url)
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(resp.status_code, status.HTTP_409_CONFLICT)
# Alice's team is untouched.
self.team.refresh_from_db()
self.assertEqual(self.team.active_jti, before)
self.assertEqual(self.team.owner_id, before_owner)

View File

@@ -1,4 +1,4 @@
"""Tests for the MCPToken model."""
"""Tests for the UserToken model."""
from datetime import timedelta
@@ -6,19 +6,19 @@ from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from mcp_server.models import MCPToken, hash_token
from mcp_server.models import UserToken, hash_token
User = get_user_model()
class MCPTokenModelTest(TestCase):
class UserTokenModelTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="alice", email="alice@example.com", password="pw"
)
def test_create_token_returns_plaintext_and_stores_hash(self):
token, plaintext = MCPToken.objects.create_token(user=self.user, name="t")
token, plaintext = UserToken.objects.create_token(user=self.user, name="t")
self.assertTrue(plaintext)
self.assertGreater(len(plaintext), 20)
# Database stores hash, not plaintext
@@ -29,21 +29,21 @@ class MCPTokenModelTest(TestCase):
def test_token_hash_never_equals_plaintext(self):
# Regression guard: if anyone ever wires plaintext back into token_hash,
# this fails.
token, plaintext = MCPToken.objects.create_token(user=self.user, name="t")
token, plaintext = UserToken.objects.create_token(user=self.user, name="t")
self.assertNotIn(plaintext, token.token_hash)
def test_active_token_is_valid(self):
token, _ = MCPToken.objects.create_token(user=self.user, name="t")
token, _ = UserToken.objects.create_token(user=self.user, name="t")
self.assertTrue(token.is_valid)
def test_inactive_token_not_valid(self):
token, _ = MCPToken.objects.create_token(user=self.user, name="t")
token, _ = UserToken.objects.create_token(user=self.user, name="t")
token.is_active = False
token.save()
self.assertFalse(token.is_valid)
def test_expired_token_not_valid(self):
token, _ = MCPToken.objects.create_token(
token, _ = UserToken.objects.create_token(
user=self.user,
name="t",
expires_at=timezone.now() - timedelta(hours=1),
@@ -51,27 +51,27 @@ class MCPTokenModelTest(TestCase):
self.assertFalse(token.is_valid)
def test_unrestricted_permits_all(self):
token, _ = MCPToken.objects.create_token(user=self.user, name="t")
token, _ = UserToken.objects.create_token(user=self.user, name="t")
self.assertTrue(token.can_use_tool("anything"))
def test_tool_whitelist(self):
token, _ = MCPToken.objects.create_token(
token, _ = UserToken.objects.create_token(
user=self.user, name="t", allowed_tools=["search"]
)
self.assertTrue(token.can_use_tool("search"))
self.assertFalse(token.can_use_tool("get_chunk"))
def test_record_usage(self):
token, _ = MCPToken.objects.create_token(user=self.user, name="t")
token, _ = UserToken.objects.create_token(user=self.user, name="t")
self.assertIsNone(token.last_used_at)
token.record_usage()
token.refresh_from_db()
self.assertIsNotNone(token.last_used_at)
def test_masked_token_is_hash_prefix(self):
token, plaintext = MCPToken.objects.create_token(user=self.user, name="t")
token, plaintext = UserToken.objects.create_token(user=self.user, name="t")
masked = token.get_masked_token()
self.assertTrue(masked.startswith("mcp_…"))
self.assertTrue(masked.startswith("tok_…"))
self.assertIn(token.token_hash[:8], masked)
# Plaintext must never leak through the masked display
self.assertNotIn(plaintext, masked)

View File

@@ -1,10 +1,10 @@
"""View tests for the MCP token self-service dashboard."""
"""View tests for the per-user API token self-service dashboard."""
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from mcp_server.models import MCPToken
from mcp_server.models import UserToken
User = get_user_model()
@@ -14,7 +14,7 @@ class TokenListViewTest(TestCase):
self.user = User.objects.create_user(
username="alice", email="alice@example.com", password="pw"
)
self.url = reverse("mcp_server:mcp-token-list")
self.url = reverse("mcp_server:token-list")
def test_login_required(self):
resp = self.client.get(self.url)
@@ -23,8 +23,8 @@ class TokenListViewTest(TestCase):
def test_list_shows_only_own_tokens(self):
other = User.objects.create_user(username="bob", password="pw")
MCPToken.objects.create_token(user=self.user, name="mine")
MCPToken.objects.create_token(user=other, name="theirs")
UserToken.objects.create_token(user=self.user, name="mine")
UserToken.objects.create_token(user=other, name="theirs")
self.client.force_login(self.user)
resp = self.client.get(self.url)
self.assertContains(resp, "mine")
@@ -33,19 +33,19 @@ class TokenListViewTest(TestCase):
def test_empty_state(self):
self.client.force_login(self.user)
resp = self.client.get(self.url)
self.assertContains(resp, "No MCP tokens yet.")
self.assertContains(resp, "No API tokens yet.")
class TokenCreateViewTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.client.force_login(self.user)
self.url = reverse("mcp_server:mcp-token-create")
self.url = reverse("mcp_server:token-create")
def test_get_renders_form(self):
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Generate MCP Token")
self.assertContains(resp, "Generate API Token")
def test_post_creates_token_and_shows_plaintext_once(self):
resp = self.client.post(self.url, {"name": "Claude Desktop"})
@@ -53,7 +53,7 @@ class TokenCreateViewTest(TestCase):
self.assertContains(resp, "Save this token now")
# Pull the created row, verify the response contained a plaintext that
# is NOT what we stored.
token = MCPToken.objects.get(user=self.user, name="Claude Desktop")
token = UserToken.objects.get(user=self.user, name="Claude Desktop")
self.assertNotContains(resp, token.token_hash) # hash is not what we display
# And the detail page never renders the plaintext.
body = resp.content.decode()
@@ -71,35 +71,35 @@ class TokenCreateViewTest(TestCase):
self.assertEqual(hash_token(plaintext), token.token_hash)
# Detail page must NOT contain the plaintext.
detail_resp = self.client.get(
reverse("mcp_server:mcp-token-detail", args=[token.pk])
reverse("mcp_server:token-detail", args=[token.pk])
)
self.assertNotContains(detail_resp, plaintext)
def test_post_invalid_renders_form_again(self):
resp = self.client.post(self.url, {"name": ""})
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Generate MCP Token")
self.assertEqual(MCPToken.objects.count(), 0)
self.assertContains(resp, "Generate API Token")
self.assertEqual(UserToken.objects.count(), 0)
class TokenDetailViewTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.client.force_login(self.user)
self.token, _ = MCPToken.objects.create_token(user=self.user, name="t")
self.token, _ = UserToken.objects.create_token(user=self.user, name="t")
def test_renders_token(self):
resp = self.client.get(
reverse("mcp_server:mcp-token-detail", args=[self.token.pk])
reverse("mcp_server:token-detail", args=[self.token.pk])
)
self.assertContains(resp, self.token.name)
self.assertContains(resp, self.token.get_masked_token())
def test_cannot_view_other_users_token(self):
other = User.objects.create_user(username="bob", password="pw")
other_token, _ = MCPToken.objects.create_token(user=other, name="theirs")
other_token, _ = UserToken.objects.create_token(user=other, name="theirs")
resp = self.client.get(
reverse("mcp_server:mcp-token-detail", args=[other_token.pk])
reverse("mcp_server:token-detail", args=[other_token.pk])
)
self.assertEqual(resp.status_code, 404)
@@ -108,11 +108,11 @@ class TokenEditViewTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.client.force_login(self.user)
self.token, _ = MCPToken.objects.create_token(user=self.user, name="t")
self.token, _ = UserToken.objects.create_token(user=self.user, name="t")
def test_post_updates_metadata(self):
resp = self.client.post(
reverse("mcp_server:mcp-token-edit", args=[self.token.pk]),
reverse("mcp_server:token-edit", args=[self.token.pk]),
{
"name": "Renamed",
"is_active": "on",
@@ -127,9 +127,9 @@ class TokenEditViewTest(TestCase):
def test_cannot_edit_other_users_token(self):
other = User.objects.create_user(username="bob", password="pw")
other_token, _ = MCPToken.objects.create_token(user=other, name="theirs")
other_token, _ = UserToken.objects.create_token(user=other, name="theirs")
resp = self.client.post(
reverse("mcp_server:mcp-token-edit", args=[other_token.pk]),
reverse("mcp_server:token-edit", args=[other_token.pk]),
{"name": "hacked"},
)
self.assertEqual(resp.status_code, 404)
@@ -139,19 +139,19 @@ class TokenRevokeViewTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.client.force_login(self.user)
self.token, _ = MCPToken.objects.create_token(user=self.user, name="t")
self.token, _ = UserToken.objects.create_token(user=self.user, name="t")
def test_revoke_sets_inactive_keeps_row(self):
url = reverse("mcp_server:mcp-token-revoke", args=[self.token.pk])
url = reverse("mcp_server:token-revoke", args=[self.token.pk])
resp = self.client.post(url)
self.assertEqual(resp.status_code, 302)
self.token.refresh_from_db()
self.assertFalse(self.token.is_active)
# Row still exists for audit trail.
self.assertTrue(MCPToken.objects.filter(pk=self.token.pk).exists())
self.assertTrue(UserToken.objects.filter(pk=self.token.pk).exists())
def test_get_not_allowed(self):
url = reverse("mcp_server:mcp-token-revoke", args=[self.token.pk])
url = reverse("mcp_server:token-revoke", args=[self.token.pk])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 405)
@@ -160,18 +160,18 @@ class TokenDeleteViewTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.client.force_login(self.user)
self.token, _ = MCPToken.objects.create_token(user=self.user, name="t")
self.token, _ = UserToken.objects.create_token(user=self.user, name="t")
def test_delete_removes_row(self):
url = reverse("mcp_server:mcp-token-delete", args=[self.token.pk])
url = reverse("mcp_server:token-delete", args=[self.token.pk])
resp = self.client.post(url)
self.assertEqual(resp.status_code, 302)
self.assertFalse(MCPToken.objects.filter(pk=self.token.pk).exists())
self.assertFalse(UserToken.objects.filter(pk=self.token.pk).exists())
def test_cannot_delete_other_users_token(self):
other = User.objects.create_user(username="bob", password="pw")
other_token, _ = MCPToken.objects.create_token(user=other, name="theirs")
url = reverse("mcp_server:mcp-token-delete", args=[other_token.pk])
other_token, _ = UserToken.objects.create_token(user=other, name="theirs")
url = reverse("mcp_server:token-delete", args=[other_token.pk])
resp = self.client.post(url)
self.assertEqual(resp.status_code, 404)
self.assertTrue(MCPToken.objects.filter(pk=other_token.pk).exists())
self.assertTrue(UserToken.objects.filter(pk=other_token.pk).exists())

View File

@@ -1,8 +1,10 @@
"""URL routes for the per-user MCP token self-service dashboard.
"""URL routes for the per-user API token self-service dashboard.
Mounted at ``/profile/mcp-tokens/…``. Humans use this surface to mint
opaque :class:`mcp_server.models.MCPToken` rows for third-party MCP
clients (Claude Desktop, Cline, etc.).
Mounted at ``/profile/tokens/…``. Humans use this surface to mint
opaque :class:`mcp_server.models.UserToken` rows that authenticate to
Mnemosyne — used by MCP tool clients (Claude Desktop, Cline) on
``/mcp/`` and by the Daedalus REST integration on
``/library/api/*`` / ``/mcp_server/api/teams/*``.
Other MCP-server surfaces live elsewhere:
@@ -23,10 +25,10 @@ app_name = "mcp_server"
urlpatterns = [
# Self-service token dashboard (human-facing).
path("profile/mcp-tokens/", views.mcp_token_list, name="mcp-token-list"),
path("profile/mcp-tokens/add/", views.mcp_token_create, name="mcp-token-create"),
path("profile/mcp-tokens/<int:pk>/", views.mcp_token_detail, name="mcp-token-detail"),
path("profile/mcp-tokens/<int:pk>/edit/", views.mcp_token_edit, name="mcp-token-edit"),
path("profile/mcp-tokens/<int:pk>/revoke/", views.mcp_token_revoke, name="mcp-token-revoke"),
path("profile/mcp-tokens/<int:pk>/delete/", views.mcp_token_delete, name="mcp-token-delete"),
path("profile/tokens/", views.token_list, name="token-list"),
path("profile/tokens/add/", views.token_create, name="token-create"),
path("profile/tokens/<int:pk>/", views.token_detail, name="token-detail"),
path("profile/tokens/<int:pk>/edit/", views.token_edit, name="token-edit"),
path("profile/tokens/<int:pk>/revoke/", views.token_revoke, name="token-revoke"),
path("profile/tokens/<int:pk>/delete/", views.token_delete, name="token-delete"),
]

View File

@@ -1,8 +1,8 @@
"""Self-service dashboard for MCP bearer tokens.
"""Self-service dashboard for per-user API tokens.
Mirrors the Themis API-keys flow visually but stores hashed tokens. Plaintext
is shown to the user exactly once (on the create-success page) and never
persisted.
Mirrors the Themis API-keys flow visually but stores hashed tokens.
Plaintext is shown to the user exactly once (on the create-success
page) and never persisted.
"""
from __future__ import annotations
@@ -13,24 +13,24 @@ from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from .forms import MCPTokenCreateForm, MCPTokenEditForm
from .models import MCPToken
from .forms import UserTokenCreateForm, UserTokenEditForm
from .models import UserToken
@login_required
@require_GET
def mcp_token_list(request: HttpRequest) -> HttpResponse:
tokens = MCPToken.objects.filter(user=request.user).order_by("-created_at")
def token_list(request: HttpRequest) -> HttpResponse:
tokens = UserToken.objects.filter(user=request.user).order_by("-created_at")
return render(request, "mcp_server/tokens/list.html", {"tokens": tokens})
@login_required
@require_http_methods(["GET", "POST"])
def mcp_token_create(request: HttpRequest) -> HttpResponse:
def token_create(request: HttpRequest) -> HttpResponse:
if request.method == "POST":
form = MCPTokenCreateForm(request.POST, user=request.user)
form = UserTokenCreateForm(request.POST, user=request.user)
if form.is_valid():
token, plaintext = MCPToken.objects.create_token(
token, plaintext = UserToken.objects.create_token(
user=request.user,
name=form.cleaned_data["name"],
allowed_tools=form.cleaned_data.get("allowed_tools") or [],
@@ -43,25 +43,25 @@ def mcp_token_create(request: HttpRequest) -> HttpResponse:
{"token": token, "plaintext": plaintext},
)
else:
form = MCPTokenCreateForm(user=request.user)
form = UserTokenCreateForm(user=request.user)
return render(request, "mcp_server/tokens/create.html", {"form": form})
@login_required
@require_GET
def mcp_token_detail(request: HttpRequest, pk: int) -> HttpResponse:
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
def token_detail(request: HttpRequest, pk: int) -> HttpResponse:
token = get_object_or_404(UserToken, pk=pk, user=request.user)
return render(request, "mcp_server/tokens/detail.html", {"token": token})
@login_required
@require_http_methods(["GET", "POST"])
def mcp_token_edit(request: HttpRequest, pk: int) -> HttpResponse:
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
def token_edit(request: HttpRequest, pk: int) -> HttpResponse:
token = get_object_or_404(UserToken, pk=pk, user=request.user)
if request.method == "POST":
form = MCPTokenEditForm(request.POST, instance=token, user=request.user)
form = UserTokenEditForm(request.POST, instance=token, user=request.user)
if form.is_valid():
instance = form.save(commit=False)
instance.allowed_tools = form.cleaned_data.get("allowed_tools") or []
@@ -69,10 +69,10 @@ def mcp_token_edit(request: HttpRequest, pk: int) -> HttpResponse:
form.cleaned_data.get("allowed_libraries") or []
)
instance.save()
messages.success(request, "MCP token updated.")
return redirect("mcp_server:mcp-token-detail", pk=token.pk)
messages.success(request, "Token updated.")
return redirect("mcp_server:token-detail", pk=token.pk)
else:
form = MCPTokenEditForm(instance=token, user=request.user)
form = UserTokenEditForm(instance=token, user=request.user)
return render(
request, "mcp_server/tokens/edit.html", {"form": form, "token": token}
@@ -81,19 +81,19 @@ def mcp_token_edit(request: HttpRequest, pk: int) -> HttpResponse:
@login_required
@require_POST
def mcp_token_revoke(request: HttpRequest, pk: int) -> HttpResponse:
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
def token_revoke(request: HttpRequest, pk: int) -> HttpResponse:
token = get_object_or_404(UserToken, pk=pk, user=request.user)
token.is_active = False
token.save(update_fields=["is_active", "updated_at"])
messages.success(request, f"Revoked “{token.name}”. The token can no longer be used.")
return redirect("mcp_server:mcp-token-detail", pk=token.pk)
return redirect("mcp_server:token-detail", pk=token.pk)
@login_required
@require_POST
def mcp_token_delete(request: HttpRequest, pk: int) -> HttpResponse:
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
def token_delete(request: HttpRequest, pk: int) -> HttpResponse:
token = get_object_or_404(UserToken, pk=pk, user=request.user)
name = token.name
token.delete()
messages.success(request, f"Deleted “{name}”.")
return redirect("mcp_server:mcp-token-list")
return redirect("mcp_server:token-list")

View File

@@ -64,7 +64,6 @@ INSTALLED_APPS = [
"django.contrib.humanize",
# Third-party
"rest_framework",
"rest_framework.authtoken",
"storages",
"django_neomodel",
"django_prometheus",
@@ -295,9 +294,11 @@ EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# --- Django REST Framework ---
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
# Bearer first: unauthenticated requests get 401 + WWW-Authenticate: Bearer
# (RFC-correct). SessionAuthentication still runs after; it picks up
# browser session cookies when no Authorization header is present.
"mcp_server.drf_auth.UserTokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.BasicAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",

View File

@@ -29,8 +29,8 @@ urlpatterns = [
# LLM Manager
path("llm/", include("llm_manager.urls")),
# MCP server — two surfaces:
# /profile/mcp-tokens/… — per-user self-service token dashboard (HTML, session auth)
# /mcp_server/api/… — Daedalus-facing team control plane (DRF, Basic auth)
# /profile/tokens/… — per-user self-service token dashboard (HTML, session auth)
# /mcp_server/api/… — Daedalus-facing team control plane (DRF, UserToken auth)
# The MCP bearer-auth surface itself (tool calls) is mounted by
# mnemosyne.asgi at /mcp/ and is not routed here.
path("", include("mcp_server.urls")),

View File

@@ -39,13 +39,13 @@
</a>
</li>
<li>
<a href="{% url 'mcp_server:mcp-token-list' %}">
<a href="{% url 'mcp_server:token-list' %}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
MCP Tokens
API Tokens
</a>
</li>
<div class="divider my-0"></div>

View File

@@ -141,31 +141,9 @@
</div>
</form>
<!-- API Token — separate form, outside the settings form -->
<div class="card bg-base-200 mb-6">
<div class="card-body">
<h2 class="card-title text-lg">API Token</h2>
<p class="text-sm opacity-70 mb-4">
Authenticates programmatic clients (Daedalus, scripts, IDE
integrations) to Mnemosyne. Has the same access as your web
session — keep it secret.
</p>
<div class="flex items-center gap-3">
<code class="font-mono bg-base-300 px-3 py-2 rounded flex-1 break-all select-all text-sm">{{ api_token.key }}</code>
<button type="button"
class="btn btn-ghost btn-sm"
onclick="navigator.clipboard.writeText('{{ api_token.key }}').then(() => { this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy', 2000); }).catch(() => {})">
Copy
</button>
</div>
<div class="mt-3">
<form method="post" action="{% url 'themis:api-token-regenerate' %}"
onsubmit="return confirm('Regenerate token? Any client using the current token will stop working until updated.')">
{% csrf_token %}
<button type="submit" class="btn btn-warning btn-sm">Regenerate</button>
</form>
</div>
</div>
<div class="text-sm opacity-70 mt-4">
Looking for API tokens? Manage them at
<a class="link" href="{% url 'mcp_server:token-list' %}">/profile/tokens/</a>.
</div>
</div>
{% endblock %}

View File

@@ -41,9 +41,9 @@ class UserProfileAPITest(APITestCase):
self.assertEqual(len(response.data), 3) # apiuser, admin, otherapi
def test_unauthenticated_denied(self):
"""Unauthenticated requests are denied."""
"""Unauthenticated requests are denied with 401 + Bearer challenge."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_retrieve_own_profile(self):
"""User can retrieve their own profile."""
@@ -205,9 +205,9 @@ class UserAPIKeyAPITest(APITestCase):
self.assertTrue(UserAPIKey.objects.filter(pk=self.other_key.pk).exists())
def test_unauthenticated_denied(self):
"""Unauthenticated requests are denied."""
"""Unauthenticated requests are denied with 401 + Bearer challenge."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
class IsOwnerOrAdminPermissionTest(APITestCase):

View File

@@ -13,7 +13,6 @@ urlpatterns = [
path("live/", views.live, name="live"),
# Profile settings
path("profile/settings/", views.profile_settings, name="profile-settings"),
path("profile/api-token/regenerate/", views.api_token_regenerate, name="api-token-regenerate"),
# API key management
path("profile/keys/", views.key_list, name="key-list"),
path("profile/keys/add/", views.key_create, name="key-create"),

View File

@@ -12,8 +12,6 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from rest_framework.authtoken.models import Token
from themis.encryption import encrypt_value
from themis.forms import APIKeyCreateForm, APIKeyEditForm, ProfileSettingsForm
from themis.models import UserAPIKey, UserNotification
@@ -65,7 +63,6 @@ def live(request):
def profile_settings(request):
"""Display and update user profile preferences."""
profile = request.user.profile
api_token, _ = Token.objects.get_or_create(user=request.user)
if request.method == "POST":
form = ProfileSettingsForm(request.POST, instance=profile)
@@ -76,17 +73,7 @@ def profile_settings(request):
else:
form = ProfileSettingsForm(instance=profile)
return render(request, "themis/profile/settings.html", {"form": form, "api_token": api_token})
@login_required
@require_POST
def api_token_regenerate(request):
"""Delete and recreate the user's DRF API token."""
Token.objects.filter(user=request.user).delete()
Token.objects.create(user=request.user)
messages.success(request, "API token regenerated.")
return redirect("themis:profile-settings")
return render(request, "themis/profile/settings.html", {"form": form})
# ---------------------------------------------------------------------------