From 409da7d109bc24bfc746125035956bdcd7332d95 Mon Sep 17 00:00:00 2001 From: Robert Helewka Date: Fri, 22 May 2026 22:59:59 -0400 Subject: [PATCH] docs: replace daedalus-service basic auth with per-user DRF tokens --- docs/DAEDALUS_PALLAS_INTEGRATION_v1.md | 12 ++- docs/deploy.md | 20 ++-- docs/mnemosyne_integration.md | 6 +- mnemosyne/library/api/workspaces.py | 42 +++++--- mnemosyne/library/models.py | 5 + mnemosyne/mcp_server/api/serializers.py | 4 +- mnemosyne/mcp_server/api/teams.py | 48 ++++++--- mnemosyne/mcp_server/api/urls.py | 8 +- mnemosyne/mcp_server/auth.py | 47 +++++---- .../commands/ensure_service_user.py | 59 ----------- .../mcp_server/migrations/0002_team_owner.py | 36 +++++++ mnemosyne/mcp_server/models.py | 13 ++- mnemosyne/mcp_server/tests/test_auth.py | 64 ++++++++++++ mnemosyne/mcp_server/tests/test_models.py | 28 ++++-- mnemosyne/mcp_server/tests/test_teams_api.py | 99 ++++++++++++++++--- mnemosyne/mcp_server/tests/test_teams_jwt.py | 24 ++++- .../templates/themis/profile/settings.html | 12 +-- 17 files changed, 364 insertions(+), 163 deletions(-) delete mode 100644 mnemosyne/mcp_server/management/commands/ensure_service_user.py create mode 100644 mnemosyne/mcp_server/migrations/0002_team_owner.py diff --git a/docs/DAEDALUS_PALLAS_INTEGRATION_v1.md b/docs/DAEDALUS_PALLAS_INTEGRATION_v1.md index 81999bc..95a6677 100644 --- a/docs/DAEDALUS_PALLAS_INTEGRATION_v1.md +++ b/docs/DAEDALUS_PALLAS_INTEGRATION_v1.md @@ -367,9 +367,12 @@ Mnemosyne validates the JWT against `MCPSigningKey` keyed by `kid`. ## 7. REST API — Mnemosyne team lifecycle -All endpoints live under `/mcp_server/api/teams/` and are protected -by the existing `daedalus-service` HTTP Basic account (same auth as -`/library/api/workspaces/` and `/library/api/ingest/`). +All endpoints live under `/mcp_server/api/teams/` and are authenticated +as the Mnemosyne user the team belongs to via a per-user DRF token +(`Authorization: Token `, surfaced on `/profile/settings/`). Each +team has an `owner` FK; non-owners receive 404 (never 403) so a team's +existence isn't disclosed across users. `/library/api/workspaces/` and +`/library/api/ingest/` use the same per-user auth model. ### 7.1 `POST /mcp_server/api/teams/` Create a team. @@ -733,7 +736,8 @@ escape hatch for hard compartmentalization. * `TeamWorkspaceAssignment` PUT is idempotent and replaces, not unions. * `/mcp_server/api/teams/` endpoints: create, delete, rotate, - workspaces PUT, all authenticated as `daedalus-service`. + workspaces PUT, all authenticated with a per-user DRF token and + scoped to the team's `owner` (non-owner requests return 404). ### 14.2 Daedalus test surface * `on_pallas_registered` populates `team_jwt_encrypted` and transitions diff --git a/docs/deploy.md b/docs/deploy.md index 832bb3b..3386bbb 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -92,14 +92,6 @@ docker compose -f /srv/mnemosyne/docker-compose.yaml \ docker compose -f /srv/mnemosyne/docker-compose.yaml \ run --rm app setup -# Create the daedalus-service user (HTTP Basic auth for ingest API) -# Pass --password from vault; idempotent if user already exists. -docker compose -f /srv/mnemosyne/docker-compose.yaml \ - run --rm app \ - python manage.py ensure_service_user \ - --username daedalus-service \ - --password "{{ vault_mnemosyne_daedalus_service_password }}" - # Seed the MCPSigningKey used to sign long-lived Pallas team JWTs. # --retire-other deactivates any previously-active key. The hex # emitted to stdout is persisted in Mnemosyne's database and is @@ -321,13 +313,16 @@ curl -f http://puck.incus:23181/healthz curl http://puck.incus:23181/metrics | head -5 ``` -### Verify the daedalus-service account +### Verify Daedalus auth (per-user API token) + +Daedalus now authenticates as a Mnemosyne user via the DRF token shown +on `/profile/settings/`. To smoke-test from a deploy host: ```bash -curl -u daedalus-service: \ - https://mnemosyne.ouranos.helu.ca/library/api/workspaces/ \ +curl -H "Authorization: Token " \ + https://mnemosyne.ouranos.helu.ca/library/api/workspaces/ws_smoke/ \ -o /dev/null -w "%{http_code}" -# Expect: 200 +# Expect: 200 if the workspace exists for that user, 404 otherwise. ``` ### Verify MCP connectivity (from a client with a valid MCPToken) @@ -401,6 +396,5 @@ will report as a failure. | `vault_daedalus_s3_read_secret` | `DAEDALUS_S3_SECRET_ACCESS_KEY` | | `vault_rabbitmq_password` | embedded in `CELERY_BROKER_URL` | | `vault_mnemosyne_llm_encryption_key` | `LLM_API_SECRETS_ENCRYPTION_KEY` | -| `vault_mnemosyne_daedalus_service_password` | passed to `ensure_service_user --password` | | `vault_mnemosyne_casdoor_client_id` | `CASDOOR_CLIENT_ID` | | `vault_mnemosyne_casdoor_client_secret` | `CASDOOR_CLIENT_SECRET` | diff --git a/docs/mnemosyne_integration.md b/docs/mnemosyne_integration.md index 5fae8ea..b1ff190 100644 --- a/docs/mnemosyne_integration.md +++ b/docs/mnemosyne_integration.md @@ -8,7 +8,7 @@ This document describes Mnemosyne's role in the Daedalus + Pallas architecture a Mnemosyne exposes two interfaces for the wider Ouranos ecosystem: -1. **REST API** (`/library/api/*`) — consumed by the Daedalus backend (HTTP Basic auth, service account `daedalus-service`) for workspace lifecycle and asynchronous file ingestion. Phase 1, **implemented**. +1. **REST API** (`/library/api/*`) — consumed by the Daedalus backend authenticated as the owning Mnemosyne user via a per-user DRF token (`Authorization: Token `, surfaced on `/profile/settings/`) for workspace lifecycle and asynchronous file ingestion. Phase 1, **implemented**. 2. **MCP Server** (port 22091 internal, `/mcp/` via nginx on 23090) — exposes search, browse, and retrieval tools. Phase 5 of Mnemosyne's own roadmap, **implemented** with workspace-scoped access control via long-lived team JWTs. Consumed by Pallas FastAgents in production (Daedalus integration Phase 2, **implemented** — see [Phase 3 of this doc](#3-phase-3-long-lived-team-jwt-access-control-for-pallas-instances)). ### Phase status @@ -105,7 +105,7 @@ Auth is controlled by `MCP_REQUIRE_AUTH` in `.env`. Production sets it to `True` ## 2. REST API for Daedalus -All endpoints require HTTP Basic auth as `daedalus-service`. They are consumed by the Daedalus FastAPI backend only — not by any frontend. +All endpoints require an `Authorization: Token ` header carrying the DRF token of the Mnemosyne user the workspace belongs to (surfaced on `/profile/settings/`). Workspaces are scoped to their creating user via the `Library.owner_username` property; cross-user access returns 404. They are consumed by the Daedalus FastAPI backend only — not by any frontend. ### Workspace lifecycle @@ -354,7 +354,7 @@ mnemosyne_s3_operations_total{operation,status} counter - [x] `GET /library/api/jobs/{job_id}/`, `POST .../retry/`, `GET /library/api/jobs/` - [x] `library.tasks.ingest_from_daedalus` Celery task with content-hash-aware supersede logic - [x] `library.services.daedalus_s3` cross-bucket fetch + copy -- [x] HTTP Basic auth via `daedalus-service` user +- [x] Per-user DRF token auth (`Authorization: Token `); workspaces scoped to the owning user via `Library.owner_username` ### Phase 2 — MCP Server (Mnemosyne roadmap Phase 5) ✅ Implemented - [x] `mcp_server/` module following the [Django MCP Pattern](Pattern_Django-MCP_V1-00.md) diff --git a/mnemosyne/library/api/workspaces.py b/mnemosyne/library/api/workspaces.py index 3fef615..f0d160b 100644 --- a/mnemosyne/library/api/workspaces.py +++ b/mnemosyne/library/api/workspaces.py @@ -5,9 +5,12 @@ A "workspace" in Mnemosyne is a Library scoped to a Daedalus workspace UUID. 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 (HTTP Basic auth as -the `daedalus-service` user). Daedalus owns the workspace_id; Mnemosyne -just persists what Daedalus tells it. +These endpoints are called by the Daedalus backend authenticated as the +Mnemosyne user the workspace belongs to (per-user DRF token). 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 +across users. """ import logging @@ -72,6 +75,17 @@ def workspace_create(request): existing = None if existing is not None: + if existing.owner_username and existing.owner_username != request.user.username: + # Same workspace_id under a different owner. Don't leak the + # collision shape; surface a generic conflict. + logger.warning( + "workspace_create owner_conflict workspace_id=%s caller=%s", + data["workspace_id"], request.user.username, + ) + return Response( + {"detail": "Workspace id is already in use."}, + status=status.HTTP_409_CONFLICT, + ) if existing.library_type != data["library_type"]: return Response( { @@ -98,6 +112,7 @@ def workspace_create(request): library_type=data["library_type"], description=data.get("description", ""), workspace_id=data["workspace_id"], + owner_username=request.user.username, chunking_config=defaults["chunking_config"], embedding_instruction=defaults["embedding_instruction"], reranker_instruction=defaults["reranker_instruction"], @@ -127,21 +142,26 @@ def workspace_detail_or_delete(request, workspace_id): """ from library.models import Library + try: + lib = Library.nodes.get(workspace_id=workspace_id) + except Library.DoesNotExist: + lib = None + + # Cross-user reads/writes look like "not found" — don't disclose + # existence across users. + if lib is not None and lib.owner_username != request.user.username: + lib = None + if request.method == "GET": - try: - lib = Library.nodes.get(workspace_id=workspace_id) - except Library.DoesNotExist: + if lib is None: return Response( {"detail": "Workspace not found."}, status=status.HTTP_404_NOT_FOUND, ) - return Response(WorkspaceStatusSerializer(_serialize_workspace(lib)).data) - # DELETE — idempotent: a missing workspace returns 204. - try: - lib = Library.nodes.get(workspace_id=workspace_id) - except Library.DoesNotExist: + # DELETE — idempotent: a missing (or unowned) workspace returns 204. + if lib is None: return Response(status=status.HTTP_204_NO_CONTENT) library_uid = lib.uid diff --git a/mnemosyne/library/models.py b/mnemosyne/library/models.py index df984cb..69fe482 100644 --- a/mnemosyne/library/models.py +++ b/mnemosyne/library/models.py @@ -87,6 +87,11 @@ class Library(StructuredNode): # libraries. Unique-indexed so a workspace cannot have two libraries. workspace_id = StringProperty(unique_index=True, required=False) + # For workspace-scoped libraries: the Mnemosyne username that owns + # the workspace. Mutations via the workspaces API are restricted to + # this user. Null for global libraries. + owner_username = StringProperty(required=False, index=True) + # Content-type configuration chunking_config = JSONProperty(default={}) embedding_instruction = StringProperty(default="") diff --git a/mnemosyne/mcp_server/api/serializers.py b/mnemosyne/mcp_server/api/serializers.py index a08da7c..82fe2ec 100644 --- a/mnemosyne/mcp_server/api/serializers.py +++ b/mnemosyne/mcp_server/api/serializers.py @@ -2,8 +2,8 @@ These endpoints are the Daedalus → Mnemosyne control plane described in §7 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. They are called -by the ``daedalus-service`` account, not by end users or bearer -tokens. +by Daedalus authenticated as the Mnemosyne user the team belongs to +(per-user DRF token). """ from __future__ import annotations diff --git a/mnemosyne/mcp_server/api/teams.py b/mnemosyne/mcp_server/api/teams.py index fee8882..df9da0f 100644 --- a/mnemosyne/mcp_server/api/teams.py +++ b/mnemosyne/mcp_server/api/teams.py @@ -2,9 +2,11 @@ Mounted under ``/mcp_server/api/teams/`` — see §7 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. Every endpoint is -``IsAuthenticated``-gated against the ``daedalus-service`` HTTP Basic -account (same surface as ``/library/api/workspaces/``). Endpoints are -designed to be idempotent where possible: +``IsAuthenticated``-gated and scoped to ``request.user``: the user that +created a team via ``POST /`` is the only user that can read, mutate, +or rotate it. Non-owners receive 404 (not 403) so a team's existence +isn't disclosed across users. Endpoints are designed to be idempotent +where possible: * ``POST /`` — create a team by UUID; a second POST with the same id returns 200 without a new JWT. @@ -46,9 +48,14 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- -def _get_team(team_id): - """Load a team by UUID or None. Callers 404 on None.""" - return Team.objects.filter(pk=team_id).first() +def _get_team(team_id, user): + """Load a team by UUID *for this user* or None. + + Returns None both when the team does not exist and when it exists + but is owned by another user. Callers 404 on None — they must not + distinguish the two cases. + """ + return Team.objects.filter(pk=team_id, owner=user).first() def _mint_with_fresh_jti(team: Team) -> str: @@ -75,8 +82,19 @@ def team_create(request): serializer.is_valid(raise_exception=True) data = serializer.validated_data - team = _get_team(data["id"]) - if team is not None: + existing = Team.objects.filter(pk=data["id"]).first() + if existing is not None: + if existing.owner_id != request.user.id: + # Same id, different owner. Surfacing existence would leak + # one user's team id to another; treat as a generic conflict. + logger.warning( + "team_create owner_conflict team_id=%s caller=%s", + existing.id, request.user.id, + ) + return Response( + {"detail": "Team id is already in use."}, + status=status.HTTP_409_CONFLICT, + ) # Idempotent: surface current state without a new JWT. If the # caller wants to reactivate a soft-deleted team they must do # so explicitly via the admin or a future endpoint; re-POST is @@ -84,15 +102,17 @@ def team_create(request): # on every retry storm. logger.info( "team_create idempotent_hit team_id=%s name=%s active=%s", - team.id, team.name, team.active, + existing.id, existing.name, existing.active, ) return Response( - TeamPublicSerializer(team).data, status=status.HTTP_200_OK + TeamPublicSerializer(existing).data, status=status.HTTP_200_OK ) try: with transaction.atomic(): - team = Team.objects.create(id=data["id"], name=data["name"]) + team = Team.objects.create( + id=data["id"], name=data["name"], owner=request.user + ) jwt_string = _mint_with_fresh_jti(team) except TeamJWTError as exc: # Rolling back the create is fine — we have no signing key yet @@ -130,7 +150,7 @@ def team_detail(request, team_id): in the database for audit; call POST ``/`` with the same id to re-materialize a fresh team if needed (operator decision). """ - team = _get_team(team_id) + team = _get_team(team_id, request.user) if team is None: return Response( {"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND @@ -163,7 +183,7 @@ def team_workspaces(request, team_id): Diff is computed in-DB so we don't thrash rows that already match — only the net-add and net-remove rows are touched. """ - team = _get_team(team_id) + team = _get_team(team_id, request.user) if team is None: return Response( {"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND @@ -221,7 +241,7 @@ 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) + team = _get_team(team_id, request.user) if team is None: return Response( {"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND diff --git a/mnemosyne/mcp_server/api/urls.py b/mnemosyne/mcp_server/api/urls.py index b0e3ae8..183708c 100644 --- a/mnemosyne/mcp_server/api/urls.py +++ b/mnemosyne/mcp_server/api/urls.py @@ -1,8 +1,10 @@ """URL patterns for the ``/mcp_server/api/`` DRF control-plane API. -These endpoints are called by the Daedalus backend (HTTP Basic auth -as ``daedalus-service``). End-user MCP traffic does NOT go through -this surface — that's ``mnemosyne.asgi`` / ``mcp_server/server.py``. +These endpoints are called by the Daedalus backend authenticated as +the owning Mnemosyne user (per-user DRF token). 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``. """ from __future__ import annotations diff --git a/mnemosyne/mcp_server/auth.py b/mnemosyne/mcp_server/auth.py index 63563d8..7214002 100644 --- a/mnemosyne/mcp_server/auth.py +++ b/mnemosyne/mcp_server/auth.py @@ -34,7 +34,6 @@ 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 @@ -558,29 +557,39 @@ class MCPAuthMiddleware(Middleware): def _resolve_jwt_actor(claims: dict): - """Resolve the synthetic actor for a JWT-authenticated turn. + """Resolve the acting user for a JWT-authenticated turn. - Returns the system service user (``MCP_JWT_SERVICE_USERNAME``, default - ``daedalus-service``). The user must exist and be active. JWT tokens - are not tied to per-user accounts — claims encode all authorization. + For ``typ=team`` JWTs (the only kind we mint), the actor is the + ``Team.owner`` — the Mnemosyne user that created the team. Usage + accounting and the audit trail attribute the turn to that user. - Used for both per-turn and team JWTs. The service user is a hook for - usage accounting (LLMUsage / search metrics) and for the audit trail; - authorization does not depend on it. + For legacy per-turn JWTs (``iss=daedalus``, retiring in Phase 4), no + user binding exists in the claims; we cannot attribute the turn to a + Mnemosyne user and the path is rejected. If a deployment still + accepts per-turn JWTs, that work needs to land first. """ - from django.contrib.auth import get_user_model - - User = get_user_model() - username = getattr(settings, "MCP_JWT_SERVICE_USERNAME", "daedalus-service") - try: - user = User.objects.get(username=username) - except User.DoesNotExist: + if claims.get("typ") != "team": raise MCPAuthError( - f"JWT service user {username!r} does not exist; provision via management command." + "Per-turn JWTs are no longer accepted; mint a team JWT." ) - if not user.is_active: - raise MCPAuthError(f"JWT service user {username!r} is disabled.") - return user + + # resolve_mcp_jwt has already parsed the team UUID out of ``sub`` and + # stashed it as ``team_id`` for the team branch. + team_id = claims.get("team_id") + if team_id is None: + raise MCPAuthError("Team JWT missing team_id claim.") + + try: + team = Team.objects.select_related("owner").get(pk=team_id) + except Team.DoesNotExist: + raise MCPAuthError("Team JWT references a team that no longer exists.") + if not team.active: + raise MCPAuthError("Team JWT references an inactive team.") + if not team.owner.is_active: + raise MCPAuthError( + f"Team owner {team.owner.username!r} is disabled." + ) + return team.owner def _resolved_libraries_for_jwt(claims: dict) -> list[str]: diff --git a/mnemosyne/mcp_server/management/commands/ensure_service_user.py b/mnemosyne/mcp_server/management/commands/ensure_service_user.py deleted file mode 100644 index cbdd4c8..0000000 --- a/mnemosyne/mcp_server/management/commands/ensure_service_user.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Idempotently create the service user that JWT-authenticated MCP requests act as. - -Daedalus mints per-turn JWTs whose claims encode all authorization (workspace, -allowed libraries). The Django ``user`` field on the request still needs to -point at *something* — the service user is that something. It owns no data -and does not log in via the dashboard. -""" - -import secrets - -from django.contrib.auth import get_user_model -from django.core.management.base import BaseCommand - - -class Command(BaseCommand): - help = "Idempotently create or reactivate the JWT service user (default 'daedalus-service')." - - def add_arguments(self, parser): - parser.add_argument("--username", default="daedalus-service") - parser.add_argument("--email", default="daedalus-service@local") - parser.add_argument( - "--password", - default=None, - help=( - "Password for HTTP Basic auth (Daedalus REST calls). " - "Omit to set a random unusable password (JWT-only mode)." - ), - ) - - def handle(self, *args, **options): - User = get_user_model() - username = options["username"] - email = options["email"] - password = options["password"] or secrets.token_urlsafe(32) - - user, created = User.objects.get_or_create( - username=username, - defaults={"email": email, "is_active": True}, - ) - if created: - user.set_password(password) - user.save(update_fields=["password"]) - self.stdout.write(self.style.SUCCESS(f"Created service user {username!r}")) - else: - changed = False - if not user.is_active: - user.is_active = True - changed = True - if user.email != email: - user.email = email - changed = True - if options["password"]: - user.set_password(password) - changed = True - if changed: - user.save(update_fields=["is_active", "email", "password"]) - self.stdout.write(self.style.SUCCESS(f"Updated service user {username!r}")) - else: - self.stdout.write(f"Service user {username!r} already provisioned") diff --git a/mnemosyne/mcp_server/migrations/0002_team_owner.py b/mnemosyne/mcp_server/migrations/0002_team_owner.py new file mode 100644 index 0000000..c493d45 --- /dev/null +++ b/mnemosyne/mcp_server/migrations/0002_team_owner.py @@ -0,0 +1,36 @@ +"""Add ``owner`` FK to ``Team``. + +The Daedalus integration moved from a shared ``daedalus-service`` HTTP Basic +account to per-user DRF tokens. Each team is now scoped to the Mnemosyne +user that created it. + +No production Team rows exist at the time of this migration, so the FK is +added as non-null without a backfill. Any pre-existing row in a dev / +staging database will break this migration — drop the ``mcp_server_team`` +table (and its dependent ``mcp_server_teamworkspaceassignment``) before +applying, or recreate the database from scratch. +""" + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("mcp_server", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="team", + name="owner", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="teams", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/mnemosyne/mcp_server/models.py b/mnemosyne/mcp_server/models.py index c6eddd1..5f0672d 100644 --- a/mnemosyne/mcp_server/models.py +++ b/mnemosyne/mcp_server/models.py @@ -266,12 +266,21 @@ class Team(models.Model): auth middleware compares the incoming ``jti`` against this value. ``active=False`` soft-deletes the team — every validation will - reject tokens whose ``sub`` resolves to this row, so revocation - survives restart without needing a cache purge. + reject tokens for an inactive team, so revocation survives restart + without needing a cache purge. + + ``owner`` is the Mnemosyne user that created the team. Team + management endpoints scope by ``owner`` so that one user cannot + manage another user's teams. """ id = models.UUIDField(primary_key=True, editable=False) name = models.CharField(max_length=200) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="teams", + ) active = models.BooleanField(default=True) active_jti = models.UUIDField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/mnemosyne/mcp_server/tests/test_auth.py b/mnemosyne/mcp_server/tests/test_auth.py index f616ae5..815b963 100644 --- a/mnemosyne/mcp_server/tests/test_auth.py +++ b/mnemosyne/mcp_server/tests/test_auth.py @@ -34,6 +34,7 @@ from mcp_server.auth import ( MCPAuthError, _libraries_for_team, _remember_jti, + _resolve_jwt_actor, _resolved_libraries_for_jwt, looks_like_jwt, resolve_mcp_jwt, @@ -314,11 +315,16 @@ class PerTurnReplayCacheTest(TestCase): class ResolveTeamJWTTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.owner = User.objects.create_user(username="alice", password="pw") + def setUp(self): self.key = _make_signing_key() self.team = Team.objects.create( id=uuid.uuid4(), name="Pallas-Harper", + owner=self.owner, active=True, active_jti=uuid.uuid4(), ) @@ -362,10 +368,15 @@ class ResolveTeamJWTTest(TestCase): class LibrariesForTeamTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.owner = User.objects.create_user(username="alice", password="pw") + def setUp(self): self.team = Team.objects.create( id=uuid.uuid4(), name="T", + owner=self.owner, active=True, active_jti=uuid.uuid4(), ) @@ -415,6 +426,10 @@ class LibrariesForTeamTest(TestCase): class ResolvedLibrariesForJWTDispatcherTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.owner = User.objects.create_user(username="alice", password="pw") + def test_per_turn_claims_use_libs(self): claims = {"libs": ["one", "two"]} self.assertEqual( @@ -425,6 +440,7 @@ class ResolvedLibrariesForJWTDispatcherTest(TestCase): team = Team.objects.create( id=uuid.uuid4(), name="T", + owner=self.owner, active=True, active_jti=uuid.uuid4(), ) @@ -442,3 +458,51 @@ class ResolvedLibrariesForJWTDispatcherTest(TestCase): ) out = _resolved_libraries_for_jwt(claims) self.assertEqual(out, ["lib-team"]) + + +# --------------------------------------------------------------------------- +# _resolve_jwt_actor — team JWTs resolve to the team's owner +# --------------------------------------------------------------------------- + + +class ResolveJWTActorTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.owner = User.objects.create_user(username="alice", password="pw") + + def setUp(self): + self.team = Team.objects.create( + id=uuid.uuid4(), + name="T", + owner=self.owner, + active=True, + active_jti=uuid.uuid4(), + ) + + def test_team_jwt_resolves_to_team_owner(self): + claims = {"typ": "team", "team_id": self.team.id} + self.assertEqual(_resolve_jwt_actor(claims), self.owner) + + def test_inactive_team_rejected(self): + self.team.deactivate() + claims = {"typ": "team", "team_id": self.team.id} + with self.assertRaises(MCPAuthError): + _resolve_jwt_actor(claims) + + def test_disabled_owner_rejected(self): + self.owner.is_active = False + self.owner.save(update_fields=["is_active"]) + claims = {"typ": "team", "team_id": self.team.id} + with self.assertRaises(MCPAuthError): + _resolve_jwt_actor(claims) + + def test_per_turn_jwt_rejected(self): + # No more service-user fallback: per-turn JWTs can't be attributed + # to a Mnemosyne user, so the resolver refuses them. + with self.assertRaises(MCPAuthError): + _resolve_jwt_actor({"libs": ["x"]}) + + def test_unknown_team_rejected(self): + claims = {"typ": "team", "team_id": uuid.uuid4()} + with self.assertRaises(MCPAuthError): + _resolve_jwt_actor(claims) diff --git a/mnemosyne/mcp_server/tests/test_models.py b/mnemosyne/mcp_server/tests/test_models.py index dadf536..b4d4854 100644 --- a/mnemosyne/mcp_server/tests/test_models.py +++ b/mnemosyne/mcp_server/tests/test_models.py @@ -128,15 +128,21 @@ class MCPTokenAllowedLibrariesTest(TestCase): class TeamTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.owner = get_user_model().objects.create_user( + username="alice", password="pw" + ) + def test_create_with_explicit_uuid(self): tid = uuid.uuid4() - team = Team.objects.create(id=tid, name="Harper") + team = Team.objects.create(id=tid, name="Harper", owner=self.owner) self.assertEqual(team.id, tid) self.assertTrue(team.active) self.assertIsNone(team.active_jti) def test_rotate_jti_installs_fresh_uuid(self): - team = Team.objects.create(id=uuid.uuid4(), name="t") + team = Team.objects.create(id=uuid.uuid4(), name="t", owner=self.owner) first = team.rotate_jti() self.assertIsInstance(first, uuid.UUID) self.assertEqual(team.active_jti, first) @@ -146,14 +152,14 @@ class TeamTest(TestCase): self.assertEqual(team.active_jti, second) def test_rotate_jti_persists(self): - team = Team.objects.create(id=uuid.uuid4(), name="t") + team = Team.objects.create(id=uuid.uuid4(), name="t", owner=self.owner) team.rotate_jti() # Reload from DB and make sure the UUID was committed. reloaded = Team.objects.get(pk=team.id) self.assertEqual(reloaded.active_jti, team.active_jti) def test_deactivate_clears_active_jti(self): - team = Team.objects.create(id=uuid.uuid4(), name="t") + team = Team.objects.create(id=uuid.uuid4(), name="t", owner=self.owner) team.rotate_jti() self.assertTrue(team.active) team.deactivate() @@ -171,8 +177,16 @@ class TeamTest(TestCase): class TeamWorkspaceAssignmentTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.owner = get_user_model().objects.create_user( + username="alice", password="pw" + ) + def setUp(self): - self.team = Team.objects.create(id=uuid.uuid4(), name="t") + self.team = Team.objects.create( + id=uuid.uuid4(), name="t", owner=self.owner + ) def test_unique_team_workspace_pair(self): TeamWorkspaceAssignment.objects.create( @@ -185,7 +199,9 @@ class TeamWorkspaceAssignmentTest(TestCase): ) def test_same_workspace_different_teams_allowed(self): - other = Team.objects.create(id=uuid.uuid4(), name="t2") + other = Team.objects.create( + id=uuid.uuid4(), name="t2", owner=self.owner + ) TeamWorkspaceAssignment.objects.create( team=self.team, workspace_id="ws-a" ) diff --git a/mnemosyne/mcp_server/tests/test_teams_api.py b/mnemosyne/mcp_server/tests/test_teams_api.py index d3fbcd7..7786f5f 100644 --- a/mnemosyne/mcp_server/tests/test_teams_api.py +++ b/mnemosyne/mcp_server/tests/test_teams_api.py @@ -1,10 +1,11 @@ """Tests for the ``/mcp_server/api/teams/`` REST control plane. This is the Daedalus-facing surface described in §7 of -``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. We do NOT exercise HTTP -Basic auth here (that's part of DRF / the project's session auth -stack); instead we use :meth:`APIClient.force_authenticate` to focus -on the endpoints' own idempotence and state-transition rules. +``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. We do NOT exercise the +DRF Token / Session auth machinery here (that's covered by DRF +itself); instead we use :meth:`APIClient.force_authenticate` to focus +on the endpoints' own idempotence, ownership, and state-transition +rules. """ from __future__ import annotations @@ -36,20 +37,23 @@ def _seed_signing_key() -> MCPSigningKey: class _AuthenticatedAPITest(TestCase): - """Shared ``APIClient`` authenticated as the service user. + """Shared ``APIClient`` authenticated as a regular user. - The real deployment has Daedalus hit these endpoints as - ``daedalus-service`` over HTTP Basic, but the view decorator is a - plain ``IsAuthenticated`` — so for unit-test purposes we use - ``force_authenticate`` with any active user. + The endpoints scope by ``request.user`` (every team has an + ``owner``); ``self.user`` is the team owner and ``self.other_user`` + is used for cross-user access tests. """ - def setUp(self): - self.service_user = User.objects.create_user( - username="daedalus-service", password="pw" + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(username="alice", password="pw") + cls.other_user = User.objects.create_user( + username="bob", password="pw" ) + + def setUp(self): self.client = APIClient() - self.client.force_authenticate(user=self.service_user) + self.client.force_authenticate(user=self.user) # --------------------------------------------------------------------------- @@ -106,6 +110,29 @@ class TeamCreateTest(_AuthenticatedAPITest): self.assertEqual(decoded["sub"], f"team:{tid}") self.assertEqual(decoded["jti"], str(team.active_jti)) + def test_create_sets_request_user_as_owner(self): + tid = uuid.uuid4() + self.client.post( + self.url, {"id": str(tid), "name": "Harper"}, format="json" + ) + self.assertEqual(Team.objects.get(pk=tid).owner_id, self.user.id) + + def test_same_id_under_other_owner_409s(self): + tid = uuid.uuid4() + # Alice creates the team first. + self.client.post( + self.url, {"id": str(tid), "name": "Harper"}, format="json" + ) + # Bob then tries to create with the same id — must be a generic + # conflict, not idempotent and not 200. + self.client.force_authenticate(user=self.other_user) + resp = self.client.post( + self.url, {"id": str(tid), "name": "Bob's"}, format="json" + ) + self.assertEqual(resp.status_code, status.HTTP_409_CONFLICT) + # Owner unchanged. + self.assertEqual(Team.objects.get(pk=tid).owner_id, self.user.id) + def test_idempotent_on_same_id_returns_200_without_jwt(self): tid = uuid.uuid4() first = self.client.post( @@ -152,7 +179,11 @@ class TeamDetailTest(_AuthenticatedAPITest): def setUp(self): super().setUp() self.team = Team.objects.create( - id=uuid.uuid4(), name="t", active=True, active_jti=uuid.uuid4() + id=uuid.uuid4(), + name="t", + owner=self.user, + active=True, + active_jti=uuid.uuid4(), ) TeamWorkspaceAssignment.objects.create( team=self.team, workspace_id="ws-a" @@ -197,6 +228,18 @@ class TeamDetailTest(_AuthenticatedAPITest): resp = self.client.delete(self.url) self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT) + def test_get_by_non_owner_returns_404(self): + self.client.force_authenticate(user=self.other_user) + resp = self.client.get(self.url) + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_by_non_owner_returns_404_and_no_op(self): + self.client.force_authenticate(user=self.other_user) + resp = self.client.delete(self.url) + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + # Original team is still active — non-owner couldn't soft-delete it. + self.assertTrue(Team.objects.get(pk=self.team.id).active) + # --------------------------------------------------------------------------- # PUT /mcp_server/api/teams/{id}/workspaces/ @@ -207,7 +250,11 @@ class TeamWorkspacesTest(_AuthenticatedAPITest): def setUp(self): super().setUp() self.team = Team.objects.create( - id=uuid.uuid4(), name="t", active=True, active_jti=uuid.uuid4() + id=uuid.uuid4(), + name="t", + owner=self.user, + active=True, + active_jti=uuid.uuid4(), ) self.url = reverse( "mcp-server-api:team-workspaces", @@ -308,6 +355,14 @@ class TeamWorkspacesTest(_AuthenticatedAPITest): ) self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + def test_put_by_non_owner_returns_404(self): + self.client.force_authenticate(user=self.other_user) + resp = self.client.put( + self.url, {"workspace_ids": ["ws-x"]}, format="json" + ) + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(self._ws_ids(), []) + # --------------------------------------------------------------------------- # POST /mcp_server/api/teams/{id}/rotate/ @@ -319,7 +374,11 @@ class TeamRotateTest(_AuthenticatedAPITest): super().setUp() _seed_signing_key() self.team = Team.objects.create( - id=uuid.uuid4(), name="t", active=True, active_jti=uuid.uuid4() + id=uuid.uuid4(), + name="t", + owner=self.user, + active=True, + active_jti=uuid.uuid4(), ) self.url = reverse( "mcp-server-api:team-rotate", @@ -357,3 +416,11 @@ class TeamRotateTest(_AuthenticatedAPITest): self.assertEqual( resp.status_code, status.HTTP_503_SERVICE_UNAVAILABLE ) + + def test_rotate_by_non_owner_returns_404(self): + before = self.team.active_jti + 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.team.refresh_from_db() + self.assertEqual(self.team.active_jti, before) diff --git a/mnemosyne/mcp_server/tests/test_teams_jwt.py b/mnemosyne/mcp_server/tests/test_teams_jwt.py index a649a71..3f9c3c0 100644 --- a/mnemosyne/mcp_server/tests/test_teams_jwt.py +++ b/mnemosyne/mcp_server/tests/test_teams_jwt.py @@ -12,6 +12,7 @@ import time import uuid import jwt as pyjwt +from django.contrib.auth import get_user_model from django.test import TestCase from mcp_server.models import MCPSigningKey, Team @@ -24,10 +25,11 @@ def _make_key(kid: str = "k", is_active: bool = True) -> MCPSigningKey: ) -def _make_team(**overrides) -> Team: +def _make_team(owner, **overrides) -> Team: data = dict( id=uuid.uuid4(), name="t", + owner=owner, active=True, active_jti=uuid.uuid4(), ) @@ -36,9 +38,15 @@ def _make_team(**overrides) -> Team: class MintTeamJWTHappyPathTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.owner = get_user_model().objects.create_user( + username="alice", password="pw" + ) + def setUp(self): self.key = _make_key("k-1") - self.team = _make_team() + self.team = _make_team(self.owner) def test_returns_signed_jwt_string(self): token = mint_team_jwt(self.team) @@ -88,15 +96,21 @@ class MintTeamJWTHappyPathTest(TestCase): class MintTeamJWTFailureModesTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.owner = get_user_model().objects.create_user( + username="alice", password="pw" + ) + def test_no_signing_key_raises(self): - team = _make_team() + team = _make_team(self.owner) with self.assertRaises(TeamJWTError) as ctx: mint_team_jwt(team) self.assertIn("signing", str(ctx.exception).lower()) def test_missing_active_jti_raises(self): _make_key() - team = _make_team(active_jti=None) + team = _make_team(self.owner, active_jti=None) with self.assertRaises(TeamJWTError) as ctx: mint_team_jwt(team) # Should name the thing the caller forgot to do. @@ -106,6 +120,6 @@ class MintTeamJWTFailureModesTest(TestCase): MCPSigningKey.objects.create( kid="broken", secret_hex="not-hex!!", is_active=True ) - team = _make_team() + team = _make_team(self.owner) with self.assertRaises(TeamJWTError): mint_team_jwt(team) diff --git a/mnemosyne/themis/templates/themis/profile/settings.html b/mnemosyne/themis/templates/themis/profile/settings.html index 727b70d..e3bd760 100644 --- a/mnemosyne/themis/templates/themis/profile/settings.html +++ b/mnemosyne/themis/templates/themis/profile/settings.html @@ -141,14 +141,14 @@ - +
-

Daedalus API Token

+

API Token

- Used by Daedalus to authenticate with Mnemosyne. Set - DAEDALUS_MNEMOSYNE_API_KEY - in your Daedalus environment to this value. + Authenticates programmatic clients (Daedalus, scripts, IDE + integrations) to Mnemosyne. Has the same access as your web + session — keep it secret.

{{ api_token.key }} @@ -160,7 +160,7 @@
+ onsubmit="return confirm('Regenerate token? Any client using the current token will stop working until updated.')"> {% csrf_token %}