feat: rework auth model with UserToken and Daedalus/Pallas integration
- 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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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``.
|
||||
|
||||
@@ -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``
|
||||
|
||||
@@ -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 /
|
||||
|
||||
54
mnemosyne/mcp_server/drf_auth.py
Normal file
54
mnemosyne/mcp_server/drf_auth.py
Normal 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
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
@@ -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=[
|
||||
|
||||
@@ -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]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">I’ve saved it — go to token list</a>
|
||||
<a href="{% url 'mcp_server:token-list' %}" class="btn btn-primary">I’ve saved it — go to token list</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
131
mnemosyne/mcp_server/tests/test_drf_auth.py
Normal file
131
mnemosyne/mcp_server/tests/test_drf_auth.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user