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

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

View File

@@ -118,7 +118,7 @@ The MCP server exposes the LLM-facing tools (`search`, `get_chunk`, `list_librar
cd mnemosyne/ cd mnemosyne/
# Single command: ASGI server hosting the FastMCP app # Single command: ASGI server hosting the FastMCP app
uvicorn mnemosyne.asgi:app --host 0.0.0.0 --port 22091 --workers 1 uvicorn mnemosyne.asgi:app --host 0.0.0.0 --port 231s91 --workers 1
``` ```
The `mcp_server/asgi.py` mounts FastMCP at `/mcp` (Streamable HTTP) and `/mcp/sse` (SSE), with a `/mcp/health` JSON probe for HAProxy/Pallas. The `mcp_server/asgi.py` mounts FastMCP at `/mcp` (Streamable HTTP) and `/mcp/sse` (SSE), with a `/mcp/health` JSON probe for HAProxy/Pallas.

View File

@@ -69,6 +69,16 @@ x-logging: &default-logging
max-size: "10m" max-size: "10m"
max-file: "5" max-file: "5"
# -----------------------------------------------------------------------------
# Shared build config — build the Mnemosyne image locally from ./Dockerfile
# instead of pulling from git.helu.ca. All four Mnemosyne services
# (init/app/mcp/worker) share `image: mnemosyne:local`, so Compose builds
# once and reuses the resulting image across them.
# -----------------------------------------------------------------------------
x-mnemosyne-build: &mnemosyne-build
context: .
dockerfile: Dockerfile
services: services:
# ── Init sidecar: one-shot Postgres migrate + collectstatic + library-type seed. Runs on # ── Init sidecar: one-shot Postgres migrate + collectstatic + library-type seed. Runs on
@@ -88,8 +98,8 @@ services:
# This sidecar only needs Postgres, Neo4j, static files, and logging env — # This sidecar only needs Postgres, Neo4j, static files, and logging env —
# no S3, no Celery, no LLM encryption key. Keep it that way. # no S3, no Celery, no LLM encryption key. Keep it that way.
init: init:
image: git.helu.ca/r/mnemosyne:latest image: mnemosyne:local
pull_policy: always build: *mnemosyne-build
command: ["init"] command: ["init"]
environment: environment:
# Django core (settings import) # Django core (settings import)
@@ -124,8 +134,8 @@ services:
# Celery tasks (hence CELERY_BROKER_URL is required here too — Django is # Celery tasks (hence CELERY_BROKER_URL is required here too — Django is
# the producer, the worker is the consumer). # the producer, the worker is the consumer).
app: app:
image: git.helu.ca/r/mnemosyne:latest image: mnemosyne:local
pull_policy: always build: *mnemosyne-build
command: ["web"] command: ["web"]
environment: environment:
# Django core # Django core
@@ -220,8 +230,8 @@ services:
# the S3 key here only matters if someone exploits a write path in the # the S3 key here only matters if someone exploits a write path in the
# future — keep the credential scoped to read-only in your secret manager. # future — keep the credential scoped to read-only in your secret manager.
mcp: mcp:
image: git.helu.ca/r/mnemosyne:latest image: mnemosyne:local
pull_policy: always build: *mnemosyne-build
command: ["mcp"] command: ["mcp"]
environment: environment:
# Django core (ASGI still imports settings) # Django core (ASGI still imports settings)
@@ -289,8 +299,8 @@ services:
# backend. Does NOT need HTTP-layer settings (ALLOWED_HOSTS, CSRF, MCP auth) # backend. Does NOT need HTTP-layer settings (ALLOWED_HOSTS, CSRF, MCP auth)
# or search tuning (the worker never serves queries). # or search tuning (the worker never serves queries).
worker: worker:
image: git.helu.ca/r/mnemosyne:latest image: mnemosyne:local
pull_policy: always build: *mnemosyne-build
command: ["worker"] command: ["worker"]
environment: environment:
# Django core (Celery imports settings) # Django core (Celery imports settings)

View File

@@ -0,0 +1,658 @@
# Daedalus ↔ Pallas ↔ Mnemosyne Integration — v2
**Status:** Approved design — supersedes
[`DAEDALUS_PALLAS_INTEGRATION_v1.md`](DAEDALUS_PALLAS_INTEGRATION_v1.md).
**Authoritative home:** `mnemosyne/docs/DAEDALUS_PALLAS_INTEGRATION_v2.md`
**Versioning:** subsequent major revisions ship as `..._v3.md` etc.
alongside this file. Cross-service docs (Daedalus, Pallas) link here.
---
## 1. Summary
This document describes the end-state authentication / authorization
model connecting three services:
* **Mnemosyne** — knowledge platform. Owns Libraries, users, and the
MCP surface third-party clients query.
* **Daedalus** — workspace + file-lifecycle UI. Registers Pallas
instances, syncs file content to Mnemosyne, drives chat. Acts on
behalf of one Mnemosyne user per Daedalus instance.
* **Pallas** — FastAgent-backed MCP host that exposes agent teams
(Kottos, Mentor, Iolaus, …) as HTTP MCP servers.
**What changed from v1:**
* **Single token model.** The two-token split in v1 (DRF `authtoken`
for REST, `MCPToken` for `/mcp/`) is gone. One model —
[`UserToken`](../mnemosyne/mcp_server/models.py) — authenticates both
surfaces, managed from one UI at `/profile/tokens/`. The DRF
`authtoken` app has been removed from `INSTALLED_APPS`.
* **Per-user authorization on the REST surface.** The Daedalus-facing
endpoints (`/library/api/*`, `/mcp_server/api/teams/*`) are no longer
open to any authenticated account. Each `Team` has an `owner` FK and
each workspace-scoped `Library` has an `owner_username` property; the
endpoints scope by these and return 404 for non-owners. The
`daedalus-service` shared account has been retired.
* **Per-turn JWT path retired.** The legacy `iss=daedalus` JWT flow
(v1 §5.1, §6.2) is gone. Mnemosyne now only validates one JWT shape:
`typ=team`, `iss=mnemosyne`. The replay cache and the
`_resolve_jwt_actor` service-user fallback are also gone.
* **Authorization headers normalised to `Bearer`.** DRF
`TokenAuthentication` (and its `Token` keyword) is replaced by
[`UserTokenAuthentication`](../mnemosyne/mcp_server/drf_auth.py),
which accepts `Authorization: Bearer <plaintext>`. Anonymous
requests get **401 + `WWW-Authenticate: Bearer`** (RFC 7235).
Everything else in v1 — the resolved-library abstraction, team JWT
shape, Pallas's static-bearer configuration, the workspace ↔ Team
attachment model in Daedalus, agent picker UX, signing-key model — is
unchanged.
---
## 2. Motivation
v1 closed the per-turn JWT forwarding hairball by introducing static
team JWTs. v2 finishes the cleanup pass: it deletes the per-turn JWT
path entirely (now that Daedalus has migrated off it), collapses the
remaining two-token muddle into a single `UserToken` system, and tightens
the REST surface so authentication-as-user is sufficient for access
control without a shared service account.
---
## 3. Architecture
### 3.1 Services and responsibilities
| Service | Role in auth model |
|---|---|
| **Mnemosyne** | Owns Libraries, Library memberships, `UserToken`s, Teams, `TeamWorkspaceAssignment`s, signing keys. Validates bearers. Resolves every authenticated request to a Library set. |
| **Daedalus** | Control plane. Registers Pallas instances as Teams in Mnemosyne. Manages workspace ↔ team attachments. Stores team JWTs for copying into Pallas deployment configs. Acts as a single Mnemosyne user via a `UserToken`. |
| **Pallas** | Stateless MCP host. Holds a static team JWT in `fastagent.secrets.yaml`. No custom auth-forwarding code. |
### 3.2 Two credential types
Every authenticated request to Mnemosyne presents a Bearer token of
exactly one of these shapes:
| # | Credential | `iss` | Issuer | Lifetime | Used on | Library scope source |
|---|---|---|---|---|---|---|
| 1 | **Opaque `UserToken`** | n/a | The Mnemosyne user, via `/profile/tokens/` | Until revoked / expiry | `/mcp/` and DRF REST | MCP: `allowed_libraries`. REST: ignored (owner-scoped). |
| 2 | **Team JWT** | `mnemosyne` | Mnemosyne (`/mcp_server/api/teams/`) | 10 years | `/mcp/` only | Live DB lookup via `TeamWorkspaceAssignment → Library` |
The v1 per-turn JWT (category 2 in v1) has been retired and is no
longer accepted by `resolve_mcp_jwt`.
### 3.3 Scope split by surface
A `UserToken` carries optional `allowed_libraries` / `allowed_tools`
fields. These are honoured **only on the MCP surface** (`/mcp/`):
* **`/mcp/`** — `MCPAuthMiddleware` enforces `allowed_libraries`
(fail-closed: empty list = zero libraries) and `allowed_tools` (empty
list = any tool). This is the surface third-party clients (Claude
Desktop, Cline) use.
* **`/library/api/*`, `/mcp_server/api/teams/*`** — The DRF auth class
resolves *who* is calling. Access is gated by `Team.owner`
(mcp_server) and `Library.owner_username` (library workspaces). The
scope claims are ignored. Daedalus tokens are therefore
unrestricted; the user identity plus owner-scope is the access model.
The rationale: enforcing `allowed_libraries` on the REST endpoints
would force Daedalus to mint an effectively-unrestricted token (since
it manages the whole workspace lifecycle), which would defeat the
field. Owner-scope already encodes the right access pattern there.
### 3.4 Resolved-library abstraction (MCP)
Mnemosyne's MCP auth middleware populates a single
`resolved_libraries: list[str]` per request. Downstream code (search,
get_chunk, …) only reads that list.
```
Bearer → classify → dispatch
├─ Opaque UserToken → token.allowed_libraries (JSON list of UIDs)
└─ team JWT (typ=team) → live DB join:
TeamWorkspaceAssignment.workspace_id
→ Library.workspace_id → Library.uid
resolved_libraries: list[str]
downstream tools
```
Fail-closed: empty resolution → no libraries visible.
---
## 4. Data model
### 4.1 Mnemosyne
#### `UserToken` (renamed from `MCPToken`)
[`mnemosyne/mcp_server/models.py`](../mnemosyne/mcp_server/models.py).
Per-user opaque bearer. Hashed at rest (SHA-256, 64-char hex).
```python
class UserToken(models.Model):
user = FK(User, related_name="api_tokens")
token_hash = CharField(64, unique=True, db_index=True)
name = CharField(100)
is_active = BooleanField(default=True)
expires_at = DateTimeField(null=True, blank=True)
last_used_at = DateTimeField(null=True, blank=True)
allowed_tools = JSONField(default=list, blank=True)
allowed_libraries = JSONField(default=list, blank=True)
created_at, updated_at =
```
* Plaintext shown once at mint via
[`UserTokenManager.create_token`](../mnemosyne/mcp_server/models.py);
never persisted.
* Display masking via `get_masked_token()` returns `tok_…<hash[:8]>`.
* `allowed_*` fields apply only on `/mcp/` — see §3.3.
#### `LibraryMembership`
Unchanged from v1. Roles `owner` / `manager` / `reader` over Neo4j
Libraries (joined by `uid` string since Library is a neomodel node).
#### `Team`
v1 + new non-null `owner` FK:
```python
class Team(models.Model):
id = UUIDField(primary_key=True, editable=False)
name = CharField(200)
owner = FK(User, on_delete=PROTECT, related_name="teams")
active = BooleanField(default=True)
active_jti = UUIDField(null=True)
created_at, updated_at =
```
`Team.owner` is set on creation in
[`team_create`](../mnemosyne/mcp_server/api/teams.py) from
`request.user`. All other team endpoints filter by `(pk, owner=request.user)`;
non-owners receive 404, never 403, so a team's existence isn't
disclosed across users.
Soft-delete via `Team.active = False` is unchanged.
#### `TeamWorkspaceAssignment`
Unchanged from v1. Live-queried per request; `PUT /workspaces/`
replaces the assignment set.
#### `MCPSigningKey`
Unchanged. Signs team JWTs.
#### `Library.owner_username` (new neomodel property)
[`mnemosyne/library/models.py`](../mnemosyne/library/models.py). For
workspace-scoped libraries (i.e. those with `workspace_id` set), the
Mnemosyne username of the creating user. Null for global libraries.
Indexed.
```python
owner_username = StringProperty(required=False, index=True)
```
The workspace endpoints (`/library/api/workspaces/…`) set this on
create and require `lib.owner_username == request.user.username` for
all mutations and reads; non-owners get 404 on GET/PUT and 204 on
DELETE (idempotent).
### 4.2 Daedalus (informational — managed in the Daedalus repo)
Unchanged from v1 except:
* `vault_mnemosyne_daedalus_service_password` is **gone**. Daedalus
authenticates to Mnemosyne with a `UserToken` plaintext minted at
`/profile/tokens/`, stored in whatever secret the operator wires
(suggestion: `vault_mnemosyne_user_token`).
* Daedalus's HTTP client sends `Authorization: Bearer <plaintext>` to
every Mnemosyne endpoint (`/library/api/*`, `/mcp_server/api/teams/*`,
`/mcp/`). The `Token <key>` keyword is no longer accepted anywhere.
### 4.3 Pallas
Unchanged from v1. Static `Authorization: Bearer <team-jwt>` in
`fastagent.secrets.yaml`.
---
## 5. JWT claim shapes
Only one JWT shape remains — the team JWT from v1 §5.2:
```json
{
"iss": "mnemosyne",
"aud": "mnemosyne",
"sub": "team:<pallas_instance_uuid>",
"typ": "team",
"iat": 1715000000,
"exp": 1976000000,
"jti": "uuid4"
}
```
[`mnemosyne/mcp_server/teams.py:mint_team_jwt`](../mnemosyne/mcp_server/teams.py).
### 5.1 Validator changes vs v1
[`mnemosyne/mcp_server/auth.py`](../mnemosyne/mcp_server/auth.py):
* `resolve_mcp_jwt` no longer accepts `iss=daedalus`. The `_JTI_CACHE`
replay cache still exists but is exercised by no live code path —
scheduled for removal in a follow-up cleanup commit.
* `_resolve_jwt_actor` resolves to `team.owner` (the Mnemosyne user
that created the team) rather than a synthetic service user. Audit
log / usage accounting now correctly attribute each turn to the
acting user.
```python
def _resolve_jwt_actor(claims: dict):
if claims.get("typ") != "team":
raise MCPAuthError("Per-turn JWTs are no longer accepted; mint a team JWT.")
team = Team.objects.select_related("owner").get(pk=claims["team_id"])
if not team.active:
raise MCPAuthError("Team JWT references an inactive team.")
if not team.owner.is_active:
raise MCPAuthError("Team owner is disabled.")
return team.owner
```
---
## 6. Auth flow
### 6.1 Third-party MCP client with `UserToken`
1. Client sends `Authorization: Bearer <plaintext>` to `/mcp/`.
2. `MCPAuthMiddleware` hashes → looks up `UserToken` → validates
active/expired/user-active.
3. `resolved_libraries = list(token.allowed_libraries or [])`.
4. Fails closed if empty.
### 6.2 Agent team (Kottos / Mentor / Iolaus / Daedalus-chat-team)
1. Pallas sends `Authorization: Bearer <team-jwt>` to `/mcp/`.
2. Middleware validates signature, `iss=mnemosyne`, `typ=team`.
3. Loads `Team` by UUID from `sub`. Verifies `active=True` and
`jti == active_jti`.
4. Expands to `resolved_libraries` via `TeamWorkspaceAssignment`
`Library.workspace_id`.
5. The acting user (for audit, usage accounting) is `team.owner`.
### 6.3 Daedalus REST control / ingest
1. Daedalus sends `Authorization: Bearer <user-token-plaintext>` to
`/library/api/*` or `/mcp_server/api/teams/*`.
2. DRF `UserTokenAuthentication` (first in the auth stack) resolves
the token to its user.
3. Endpoint scopes by `Team.owner` (mcp_server) or
`Library.owner_username` (library). Non-owner ⇒ 404.
### 6.4 Browser / web session
SessionAuthentication runs second; cookie-authenticated users hit the
DRF browsable API as themselves with no special handling.
### 6.5 Failure modes
| Condition | Response |
|---|---|
| No `Authorization` header | 401 + `WWW-Authenticate: Bearer` |
| `Authorization: Token …` (legacy DRF keyword) | 401 (not consumed by any auth class) |
| Invalid bearer plaintext | 401 + `WWW-Authenticate: Bearer` |
| Inactive / expired token | 401 |
| Disabled user | 401 |
| JWT signature invalid | 401 + `WWW-Authenticate: Bearer` |
| JWT `exp` past (+30s leeway) | 401 |
| JWT `iss` not `mnemosyne` | 401 |
| JWT `typ` not `team` (legacy per-turn) | 401 ("per-turn JWTs no longer accepted") |
| Team inactive / unknown / `jti` stale | 401 |
| Team endpoint, non-owner caller | 404 |
| Workspace endpoint, non-owner caller (GET/PUT) | 404 |
| Workspace endpoint, non-owner caller (DELETE) | 204 (idempotent) |
---
## 7. REST API — Mnemosyne team lifecycle
Endpoints under `/mcp_server/api/teams/` are authenticated as the
Mnemosyne user the team belongs to via a per-user `UserToken`
(`Authorization: Bearer <plaintext>`, minted at `/profile/tokens/`).
Each team has an `owner` FK; non-owners receive 404 (never 403) so a
team's existence isn't disclosed across users.
### 7.1 `POST /mcp_server/api/teams/`
Create a team. `Team.owner` is set to `request.user`.
**Request**
```json
{ "id": "a3f1…", "name": "Kottos" }
```
**Response 201** — fresh id
```json
{ "id": "a3f1…", "name": "Kottos", "jwt": "eyJhbGci…" }
```
**Response 200** — same id, same owner (idempotent; no new JWT issued).
**Response 409** — same id, different owner ("Team id is already in use.").
### 7.2 `DELETE /mcp_server/api/teams/{id}/`
Soft-delete (`active=False`, clear `active_jti`). Old JWT invalid on
next call. Non-owner ⇒ 404.
### 7.3 `PUT /mcp_server/api/teams/{id}/workspaces/`
Replace the team's workspace assignment set. Idempotent.
```json
{ "workspace_ids": ["ws_abc", "ws_def"] }
```
### 7.4 `POST /mcp_server/api/teams/{id}/rotate/`
Generate a fresh `jti` and JWT, replace `active_jti`. Old JWT invalid
immediately.
**Upsert-on-missing.** If no `Team` exists for `id`, rotate creates one
owned by the caller (with `name = str(id)`) and mints its first JWT —
the operator clicks "Rotate JWT" in Daedalus settings and things just
work even if Daedalus's `provision_teams` workflow never ran for this
PallasInstance. The placeholder name can be edited via admin.
| Response | Condition |
|---|---|
| **200** + `jwt` | Same-owner id (rotates) or fresh id (upserts + mints) |
| **409** | `id` exists under a different owner (`"Team id is already in use."`) |
| **409** | Team is inactive (soft-deleted) — explicit recreate required |
The upsert path logs `team_rotate upserted_missing team_id=… owner=…`
at INFO. Surfacing this in metrics is a useful drift signal: Daedalus
and Mnemosyne fell out of sync on team provisioning.
### 7.5 `GET /mcp_server/api/teams/{id}/`
Read-only detail (no JWT). Used by the Daedalus reconciler.
### 7.6 `/library/api/ingest/` and `/library/api/jobs/…`
Same owner-scope model as the workspace endpoints: every ingest write,
job read, retry, and list filter against
`Library.owner_username == request.user.username` (global libraries
with null `owner_username` remain shared). Cross-user calls get 404
with the same "not registered" wording as a genuinely missing
workspace — existence is not disclosed across users. The list endpoint
silently filters; a `library_uid` the caller has no access to returns
an empty list rather than 404.
---
## 8. Daedalus lifecycle hooks
Unchanged from v1 §8 except the HTTP client now sends
`Authorization: Bearer <UserToken-plaintext>` and Daedalus's config
exposes one `UserToken` plaintext (one per Mnemosyne user the Daedalus
instance acts on behalf of, in deployments that multiplex).
---
## 9. Operator workflows
### 9.1 Register a new Pallas deployment
Unchanged from v1 §9.1.
### 9.2 Attach a Pallas team to a workspace
Unchanged from v1 §9.2.
### 9.3 Retire a Pallas deployment
Unchanged from v1 §9.3.
### 9.4 Rotate a compromised team JWT
Unchanged from v1 §9.4.
### 9.5 Provision Mnemosyne integration on a fresh Daedalus instance
Replaces v1 §9.5 (`provision_teams`) and the deleted
`ensure_service_user` flow:
1. **Mint a `UserToken` for the Mnemosyne user** Daedalus will act as:
`/profile/tokens/add/` (UI) or
`python manage.py create_user_token --user <username> --name "Daedalus"`.
Copy the plaintext (shown once).
2. **Stage the plaintext in Daedalus's config** as the bearer for all
Mnemosyne calls.
3. **Run Daedalus's `provision_teams`** to materialize a `Team` row in
Mnemosyne for every existing `PallasInstance`.
4. **Distribute team JWTs** to each Pallas deployment as v1 §9.5
describes.
### 9.6 Issue a `UserToken` for a third-party MCP client
1. User logs in to Mnemosyne, navigates to `/profile/tokens/`, clicks
"Generate API Token".
2. (Optional) opens the "Restrictions (optional)" section to set
`allowed_tools` / `allowed_libraries` — these apply only on
`/mcp/`; for purely REST use they can stay empty.
3. Plaintext is shown once on the response page.
4. User pastes plaintext into the third-party client's config (Claude
Desktop, Cline, etc.) with `Authorization: Bearer …`.
The same UI and command (`create_user_token`) mint tokens for any
purpose — Daedalus, MCP clients, scripts, CI. There is no separate
"DRF token" category.
---
## 10. UX changes in Daedalus
Unchanged from v1 §10.
---
## 11. Migration
### 11.1 State at the start of v2
* Mnemosyne is not in a production deployment; migrations are reset on
schema changes and the project assumes a clean DB on the next
release.
* Daedalus has already migrated to `Authorization: Bearer <plaintext>`
and is configured to use a per-user token; the v1 DRF-token shim is
no longer used at runtime.
* No live Pallas deployments authenticate via per-turn JWT (the path
is removed).
### 11.2 Order of operations
1. **Mnemosyne v2 deploys.** New `UserTokenAuthentication`, owner-scoped
REST endpoints, retired per-turn JWT validation, removed
`authtoken` app. Operator mints a `UserToken` for Daedalus's
Mnemosyne account before deploy.
2. **Daedalus's config swap.** Operator points Daedalus at the new
`UserToken` plaintext. (If Daedalus was still sending
`Authorization: Token …`, switch to `Authorization: Bearer …` at
the same time.)
3. **Existing Teams.** None expected at the v2 cutover (migrations are
reset). If any existed, `Team.owner` would need backfill; not in
scope.
### 11.3 Rollback
Mnemosyne v2 is a coordinated cutover with Daedalus's bearer-header
swap. Rolling Mnemosyne back to v1 without rolling Daedalus back too
means Daedalus's `Authorization: Bearer …` won't be recognised on
`/library/api/*` (v1 only accepted `Token`). Plan the deploy as a
single window.
---
## 12. Deprecated / removed in v2
### Mnemosyne
* `rest_framework.authtoken` (removed from `INSTALLED_APPS`).
Generated migration drops the `authtoken_token` table on next migrate;
on a reset schema there's nothing to drop.
* `rest_framework.authentication.TokenAuthentication` and
`BasicAuthentication` (removed from
`REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]`).
* "API Token" card on `/profile/settings/` (removed). The whole
`api_token_regenerate` view + URL are gone.
* `mcp_server.management.commands.ensure_service_user` (deleted).
* `daedalus-service` user (no longer provisioned by Mnemosyne; no
longer assumed by any endpoint).
* `MCP_JWT_SERVICE_USERNAME` setting (no longer read by
`_resolve_jwt_actor`).
* Per-turn JWT path in
[`mcp_server/auth.py`](../mnemosyne/mcp_server/auth.py) — accepted
shapes shrink to `typ=team` only. `_JTI_CACHE` is now exercised by
no live path; scheduled for cleanup.
* `MCPToken` (renamed to `UserToken`); `MCPTokenManager`,
`MCPTokenAdmin`, `MCPTokenCreateForm`, `MCPTokenEditForm` (renamed
in lockstep). The `mcp_…` masked-token prefix becomes `tok_…`.
* `create_mcp_token` management command (renamed `create_user_token`).
* `/profile/mcp-tokens/` URL prefix (renamed `/profile/tokens/`); URL
names `mcp-token-*` (renamed `token-*`).
### Daedalus
* `vault_mnemosyne_daedalus_service_password` (no longer needed; the
service user is gone).
* Any code path that distinguished DRF-`Token` from MCP-`Bearer` — one
bearer header for everything now.
### Pallas
No changes from v1.
---
## 13. Security
### 13.1 Token lifetimes
* **`UserToken`**: until revoked (user) or `expires_at`. Rotation is
manual via the `/profile/tokens/` dashboard.
* **Team JWT**: 10 years. Revocation via `Team.active`,
`Team.active_jti`, or key rotation.
### 13.2 Revocation levers
1. `PUT /teams/{id}/workspaces/` with `[]` — team sees nothing, JWT
still validates. Useful for pausing without redistributing tokens.
2. `DELETE /teams/{id}/` — team inactive, all its JWTs rejected.
3. `POST /teams/{id}/rotate/``active_jti` changes; leaked JWT
stops working.
4. **Revoke a `UserToken`**`/profile/tokens/{id}/revoke/` flips
`is_active=False`; immediate effect for both `/mcp/` and REST.
5. `MCPSigningKey.retire()` — nuclear option for team JWTs.
### 13.3 At-rest protection
* `UserToken.token_hash`: SHA-256 of plaintext; plaintext never
stored.
* `MCPSigningKey.secret_hex`: 256-bit hex secret stored in Mnemosyne
DB only.
* `PallasInstance.team_jwt_encrypted`: Fernet-encrypted by Daedalus.
### 13.4 Audit attribution
Every authenticated request resolves to a real Mnemosyne user:
* Opaque `UserToken``token.user`.
* Team JWT → `team.owner`.
Both flow through to usage accounting (`LLMUsage`, search metrics) and
the audit log. The synthetic `daedalus-service` actor is gone; nothing
in the audit trail is attributed to a non-user account.
Notable audit events:
* `team_create created team_id=… name=…` — fresh team registered.
* `team_create idempotent_hit team_id=…` — same-owner re-POST.
* `team_create owner_conflict team_id=… caller=…` — id collision.
* `team_rotate team_id=… new_jti=…` — explicit rotation.
* `team_rotate upserted_missing team_id=… owner=…` — rotate created a
missing team on the fly. Useful drift signal: Daedalus and
Mnemosyne fell out of sync on team provisioning.
* `team_delete team_id=…` — soft-delete.
### 13.5 Isolation model
Unchanged from v1 §13.5.
---
## 14. Testing
### 14.1 Mnemosyne test surface (relevant to v2)
* `resolve_mcp_jwt` rejects `iss=daedalus` / non-`team` payloads.
* `_resolve_jwt_actor` resolves to `team.owner`; rejects per-turn JWTs
and inactive owners. See
[`test_auth.py::ResolveJWTActorTest`](../mnemosyne/mcp_server/tests/test_auth.py).
* `UserTokenAuthentication` issues 401 + `WWW-Authenticate: Bearer`
for anonymous and rejected-token cases; 200 for valid bearer; stashes
the `UserToken` on `request.auth`. See
[`test_drf_auth.py`](../mnemosyne/mcp_server/tests/test_drf_auth.py).
* `Team` endpoints scope by `owner`; cross-user GET/DELETE/PUT return
404; same-id different-owner POST/rotate returns 409. `rotate`
upserts a missing team owned by the caller. See
[`test_teams_api.py`](../mnemosyne/mcp_server/tests/test_teams_api.py).
* Ingest endpoints (`POST /library/api/ingest/`,
`GET/POST /library/api/jobs/…`) scope by `Library.owner_username`.
Cross-user writes/reads return 404; list silently filters. The
Cypher-touching paths require Neo4j, so the scoping is exercised by
the manual e2e plan in §14.3 rather than unit tests.
* `UserToken` model: hash-at-rest, `tok_…` masked prefix,
`allowed_libraries` round-trip. See
[`test_token.py`](../mnemosyne/mcp_server/tests/test_token.py),
[`test_models.py`](../mnemosyne/mcp_server/tests/test_models.py).
### 14.2 Daedalus test surface
Unchanged from v1 §14.2 except:
* HTTP client uses `Authorization: Bearer …` against every Mnemosyne
endpoint.
* Provisioning command depends on a configured `UserToken`, not the
retired `daedalus-service` Basic-auth credential.
### 14.3 Integration
* End-to-end: MCP client with `UserToken` → search scoped to
`token.allowed_libraries`.
* End-to-end: Pallas with team JWT → search scoped to team's attached
workspaces.
* End-to-end: Daedalus REST call with `UserToken` → workspace
mutation succeeds only for the owning user; cross-user attempts get
404.
* End-to-end: ingest as one user, then a *different* user attempts
`POST /library/api/ingest/`, `GET /jobs/{id}/`, `POST /jobs/{id}/retry/`
and `GET /jobs/?library_uid=<theirs>` — first three return 404, the
list returns an empty array.
* End-to-end: anonymous REST call → 401 + `WWW-Authenticate: Bearer`.
* End-to-end: `POST /mcp_server/api/teams/{fresh-uuid}/rotate/` on a
team Mnemosyne has never seen → 200 + JWT, `Team` row created with
`owner=request.user`. Second rotate on the same id → 200 with a
fresh `active_jti`. Rotate on an id owned by a different user → 409.
---
## 15. Phased delivery
| # | Phase | Surface | Status |
|---|---|---|---|
| 1 | Design v1 | [`DAEDALUS_PALLAS_INTEGRATION_v1.md`](DAEDALUS_PALLAS_INTEGRATION_v1.md) | Superseded |
| 2 | Mnemosyne core | `LibraryMembership`, `MCPToken`, `Team`, `TeamWorkspaceAssignment`, `/mcp_server/api/teams/`, team JWT mint | Implemented (v1) |
| 3 | Pallas cleanup | Remove `_fastagent_patch.py` internals | Implemented (v1) |
| 4 | Daedalus integration | Lifecycle hooks, reconciler, `provision_teams`, attached-teams UI | Implemented (v1) |
| 5 | Per-user REST authorization | `Team.owner`, `Library.owner_username`, owner-scope on all Daedalus-facing endpoints, `_resolve_jwt_actor``team.owner` | Implemented (v2) |
| 6 | Token consolidation | Rename `MCPToken``UserToken`, `UserTokenAuthentication` DRF class, drop `authtoken` + DRF Token UI, retire per-turn JWT, `Bearer`-first auth stack | Implemented (v2) |
| 7 | Documentation | This file; updates to [`mnemosyne_integration.md`](mnemosyne_integration.md) and [`deploy.md`](deploy.md) | Implemented (v2) |
---
## 16. Open items (v2)
* `_JTI_CACHE` in [`auth.py`](../mnemosyne/mcp_server/auth.py) is dead
code (the per-turn replay path is gone). Cleanup commit pending; not
blocking.
* `BasicAuthentication` is removed from the DRF default stack. If any
internal tooling relied on it, that path is now broken and will need
an explicit re-add to the relevant viewset's `authentication_classes`
rather than the global default.
---
## 17. Cross-references
* Mnemosyne MCP auth: [`mnemosyne/mcp_server/auth.py`](../mnemosyne/mcp_server/auth.py).
* Mnemosyne DRF auth class: [`mnemosyne/mcp_server/drf_auth.py`](../mnemosyne/mcp_server/drf_auth.py).
* Mnemosyne token model: [`mnemosyne/mcp_server/models.py`](../mnemosyne/mcp_server/models.py) (`UserToken`).
* Mnemosyne team REST: [`mnemosyne/mcp_server/api/teams.py`](../mnemosyne/mcp_server/api/teams.py).
* Mnemosyne workspace REST: [`mnemosyne/library/api/workspaces.py`](../mnemosyne/library/api/workspaces.py).
* Token self-service dashboard: [`mnemosyne/mcp_server/views.py`](../mnemosyne/mcp_server/views.py), [`urls.py`](../mnemosyne/mcp_server/urls.py).
* `create_user_token` management command: [`mnemosyne/mcp_server/management/commands/create_user_token.py`](../mnemosyne/mcp_server/management/commands/create_user_token.py).
* v1 design (superseded but kept for history): [`DAEDALUS_PALLAS_INTEGRATION_v1.md`](DAEDALUS_PALLAS_INTEGRATION_v1.md).

View File

@@ -85,8 +85,7 @@ an explicit `when: mnemosyne_first_deploy` flag.
```bash ```bash
# Apply Django ORM migrations (PostgreSQL schema) # Apply Django ORM migrations (PostgreSQL schema)
docker compose -f /srv/mnemosyne/docker-compose.yaml \ docker compose -f /srv/mnemosyne/docker-compose.yaml run --rm app migrate
run --rm app migrate
# Create Neo4j vector + full-text indexes and load library-type defaults # Create Neo4j vector + full-text indexes and load library-type defaults
docker compose -f /srv/mnemosyne/docker-compose.yaml \ docker compose -f /srv/mnemosyne/docker-compose.yaml \
@@ -315,17 +314,18 @@ curl http://puck.incus:23181/metrics | head -5
### Verify Daedalus auth (per-user API token) ### Verify Daedalus auth (per-user API token)
Daedalus now authenticates as a Mnemosyne user via the DRF token shown Daedalus now authenticates as a Mnemosyne user via a `UserToken` minted
on `/profile/settings/`. To smoke-test from a deploy host: at `/profile/tokens/`. To smoke-test from a deploy host:
```bash ```bash
curl -H "Authorization: Token <user-api-token>" \ curl -H "Authorization: Bearer <user-token-plaintext>" \
https://mnemosyne.ouranos.helu.ca/library/api/workspaces/ws_smoke/ \ https://mnemosyne.ouranos.helu.ca/library/api/workspaces/ws_smoke/ \
-o /dev/null -w "%{http_code}" -o /dev/null -w "%{http_code}"
# Expect: 200 if the workspace exists for that user, 404 otherwise. # Expect: 200 if the workspace exists for that user, 404 otherwise.
# An anonymous request gets 401 with `WWW-Authenticate: Bearer`.
``` ```
### Verify MCP connectivity (from a client with a valid MCPToken) ### Verify MCP connectivity (from a client with a valid UserToken)
```bash ```bash
curl -H "Authorization: Bearer <token>" \ curl -H "Authorization: Bearer <token>" \

View File

@@ -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: Mnemosyne exposes two interfaces for the wider Ouranos ecosystem:
1. **REST API** (`/library/api/*`) — consumed by the Daedalus backend authenticated as the owning Mnemosyne user via a per-user DRF token (`Authorization: Token <key>`, surfaced on `/profile/settings/`) 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 `UserToken` (`Authorization: Bearer <plaintext>`, minted at `/profile/tokens/`) 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)). 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 ### 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 ## 2. REST API for Daedalus
All endpoints require an `Authorization: Token <key>` 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. All endpoints require an `Authorization: Bearer <plaintext>` header carrying a `UserToken` belonging to the Mnemosyne user the workspace belongs to (minted at `/profile/tokens/`). Workspaces are scoped to their creating user via the `Library.owner_username` property; cross-user access returns 404. Anonymous requests get 401 with `WWW-Authenticate: Bearer`. These endpoints are consumed by the Daedalus FastAPI backend only — not by any frontend.
### Workspace lifecycle ### 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] `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.tasks.ingest_from_daedalus` Celery task with content-hash-aware supersede logic
- [x] `library.services.daedalus_s3` cross-bucket fetch + copy - [x] `library.services.daedalus_s3` cross-bucket fetch + copy
- [x] Per-user DRF token auth (`Authorization: Token <key>`); workspaces scoped to the owning user via `Library.owner_username` - [x] Per-user `UserToken` auth (`Authorization: Bearer <plaintext>`, minted at `/profile/tokens/`); workspaces scoped to the owning user via `Library.owner_username`
### Phase 2 — MCP Server (Mnemosyne roadmap Phase 5) ✅ Implemented ### Phase 2 — MCP Server (Mnemosyne roadmap Phase 5) ✅ Implemented
- [x] `mcp_server/` module following the [Django MCP Pattern](Pattern_Django-MCP_V1-00.md) - [x] `mcp_server/` module following the [Django MCP Pattern](Pattern_Django-MCP_V1-00.md)

View File

@@ -1,557 +0,0 @@
# Ouranos Lab
Infrastructure-as-Code project managing the **Ouranos Lab** — a development sandbox at [ouranos.helu.ca](https://ouranos.helu.ca). Uses **Terraform** for container provisioning and **Ansible** for configuration management, themed around the moons of Uranus.
---
## Project Overview
| Component | Purpose |
|-----------|---------|
| **Terraform** | Provisions 10 specialised Incus containers (LXC) with DNS-resolved networking, security policies, and resource dependencies |
| **Ansible** | Deploys Docker, databases (PostgreSQL, Neo4j), observability stack (Prometheus, Grafana, Loki), and application runtimes across all hosts |
> **DNS Domain**: Incus resolves containers via the `.incus` domain suffix (e.g., `oberon.incus`, `portia.incus`). IPv4 addresses are dynamically assigned — always use DNS names, never hardcode IPs.
---
## Uranian Host Architecture
All containers are named after moons of Uranus and resolved via the `.incus` DNS suffix.
| Name | Role | Description | Nesting |
|------|------|-------------|---------|
| **ariel** | graph_database | Neo4j — Ethereal graph connections | ✔ |
| **caliban** | agent_automation | Agent S MCP Server with MATE Desktop | ✔ |
| **miranda** | mcp_docker_host | Dedicated Docker Host for MCP Servers | ✔ |
| **oberon** | container_orchestration | Docker Host — MCP Switchboard, RabbitMQ, Open WebUI | ✔ |
| **portia** | database | PostgreSQL — Relational database host | ❌ |
| **prospero** | observability | PPLG stack — Prometheus, Grafana, Loki, PgAdmin | ❌ |
| **puck** | application_runtime | Python App Host — JupyterLab, Django apps, Gitea Runner | ✔ |
| **rosalind** | collaboration | Gitea, LobeChat, Nextcloud, AnythingLLM | ✔ |
| **sycorax** | language_models | Arke LLM Proxy | ✔ |
| **titania** | proxy_sso | HAProxy TLS termination + Casdoor SSO | ✔ |
| **umbriel** | graph_database | Neo4j (Mnemosyne) — dedicated memory graph | ✔ |
### puck — Project Application Runtime
Shape-shifting trickster embodying Python's versatility.
This is the host that runs Python projects in the Ouranos sandbox.
It has an RDP server and is generally where application development happens.
Each project has a number that is used to determine port numbers.
- Docker engine
- JupyterLab (port 22071 via OAuth2-Proxy)
- Gitea Runner (CI/CD agent)
- Django Projects: Zelus (221), Angelia (222), Athena (224), Kairos (225), Icarlos (226), MCP Switchboard (227), Spelunker (228), Peitho (229), Mnemosyne (230)
- FastAgent Projects: Pallas (240)
- FastAPI Projects: Daedalus (200), Arke (201) Kernos (202), Rommie (203), Orpheus (204), Periplus (205), Nike (206), Stentor (207)
### caliban — Agent Automation
Autonomous computer agent learning through environmental interaction.
- Docker engine
- Agent S MCP Server (MATE desktop, AT-SPI automation)
- Kernos MCP Shell Server (port 22062)
- Rommie MCP Server (port 22061) — agent-to-agent GUI automation via Agent S
- FreeCAD Robust MCP Server (port 22063) — CAD automation via FreeCAD XML-RPC
- GPU passthrough
- RDP access (port 25521)
### oberon — Container Orchestration & Dockerized Shared Services
King of the Fairies orchestrating containers and managing MCP infrastructure.
- Docker engine
- MCP Switchboard (port 22781) — Django app routing MCP tool calls
- RabbitMQ message queue
- smtp4dev SMTP test server (port 22025)
### portia — Relational Database
Intelligent and resourceful — the reliability of relational databases.
- PostgreSQL 17 (port 5432)
- Databases: `arke`, `anythingllm`, `gitea`, `hass`, `lobechat`, `mcp_switchboard`, `mnemosyne`, `nextcloud`, `openwebui`, `periplus`, `spelunker`
### ariel — Graph Database
Air spirit — ethereal, interconnected nature mirroring graph relationships.
- Neo4j 5.26.0 (Docker)
- HTTP API: port 25554
- Bolt: port 7687 (reached as `ariel.incus:7687` on the internal network)
### umbriel — Graph Database (Mnemosyne)
Dusky melancholy sprite from Pope's *Rape of the Lock* — keeper of the Cave of
Spleen, naturally paired with Mnemosyne the Titan of memory. Dedicated Neo4j
instance so Mnemosyne's `Library`/`Collection`/`Item`/`Chunk`/`Concept` labels,
vector indexes, and schema migrations can't collide with another tenant's
graph on Ariel.
- Neo4j 5.26.0 (Docker)
- HTTP Browser: port 25555
- Bolt: port 7687 (reached as `umbriel.incus:7687` on the internal network)
### miranda — MCP Docker Host
Curious bridge between worlds — hosting MCP server containers.
- Docker engine (API exposed on port 2375 for MCP Switchboard)
- MCPO OpenAI-compatible MCP proxy 22071
- Argos MCP Server — web search via SearXNG (port 22062)
- Grafana MCP Server (port 22063)
- Neo4j MCP Server (port 22064)
- Gitea MCP Server (port 22065)
### prospero — Observability Stack
Master magician observing all events.
- PPLG stack via Docker Compose: Prometheus, Loki, Grafana, PgAdmin
- Internal HAProxy with OAuth2-Proxy for all dashboards
- AlertManager with Pushover notifications
- Prometheus metrics collection (`node-exporter`, HAProxy, Loki)
- Loki log aggregation via Alloy (all hosts)
- Grafana dashboard suite with Casdoor SSO integration
### rosalind — Third Party Applications for testing and evaluation
Witty and resourceful moon for PHP, Go, and Node.js runtimes.
- SearXNG privacy search (port 22083, behind OAuth2-Proxy)
- Gitea self-hosted Git (port 22082, SSH on 22022)
- LobeChat AI chat interface (port 22081)
- Nextcloud file sharing and collaboration (port 22083)
- AnythingLLM document AI workspace (port 22084)
- Nextcloud data on dedicated Incus storage volume
- Open WebUI LLM interface (port 22088, PostgreSQL backend on Portia
- Home Assistant (port 8123)
### sycorax — Language Models
Original magical power wielding language magic.
- Arke LLM API Proxy (port 25540)
- Multi-provider support (OpenAI, Anthropic, etc.)
- Session management with Memcached
- Database backend on Portia
### titania — Proxy & SSO Services
Queen of the Fairies managing access control and authentication.
- HAProxy 3.x with TLS termination (port 443)
- Let's Encrypt wildcard certificate via certbot DNS-01 (Namecheap)
- HTTP to HTTPS redirect (port 80)
- Gitea SSH proxy (port 22022)
- Casdoor SSO (port 22081, local PostgreSQL)
- Prometheus metrics at `:8404/metrics`
---
## Port Numbering
Well-known ports running as a service may be used: Postgresql 5432, Prometheus Metrics 9100.
However inside a docker project, the number plan needs to be followed to avoid port conflicts and confusion:
XXXYZ
XXX Project Number or 220 for external project
Y Service: 0 reserved, 1-4 flexible, 5 database, 6 MCP, 7 API, 8 Web App, 9 Prometheus metrics
Z Instance: The running instance of this app on the same host, starting at 1. May also be used to handle exceptions.
255 Incus port forwarding: Ports in ths range are forwarded from the Incus host to Incus containers (defined in Terraform)
514ZZ is the syslog port. Docker containers send their syslog to an Alloy syslog collector port. ZZ is the application instance, they just need to be different on the same host and increment from 01.
---
## Application Conventions
Standards that all services deployed in Ouranos MUST follow. For full logging standards and anti-patterns, see [red_panda_standards.md](red_panda_standards.md).
### Health Check Endpoints
All services MUST expose Kubernetes-style health endpoints:
| Endpoint | Purpose | Auth |
|----------|---------|------|
| `GET /live` | **Liveness** — process is running and accepting connections | None |
| `GET /ready` | **Readiness** — process is running AND all dependencies (DB, cache, upstream APIs) are healthy | None |
| `GET /metrics` | Prometheus metrics (see below) | IP-restricted |
- HAProxy checks `health_path` (typically `/ready/`) for backend health — return HTTP 200 when healthy
- Health endpoints MUST NOT require authentication (no JWT, no session)
- Third-party services use their native health paths (e.g., `/api/health`, `/api/healthz`, `/-/healthy`)
### Health Checks in Docker Compose
Use `curl -f` for Docker Compose healthchecks. Install curl in images if needed.
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
### Logging Conventions
Log output flows through: **App → syslog (RFC3164) → Alloy → Loki → Grafana**
| Level | Usage |
|-------|-------|
| **ERROR** | Broken state requiring human action — always include `exc_info=True`, error type, and context |
| **WARNING** | Degraded but recovering — client disconnects, performance outliers, client-side exceptions, leaked markup |
| **INFO** | Lifecycle events — service start/stop, connections, requests completed, jobs finished |
| **DEBUG** | Diagnostic detail — SSE events, keepalive pings, health check 200 responses, negotiation steps |
**Health check responses MUST be logged at DEBUG only.** HAProxy and Prometheus probe endpoints every 15-30 seconds. Logging these at INFO floods syslog with thousands of identical `200 OK` lines per hour, burying real events.
### Protected vs Unprotected Endpoints
| Protected (require valid JWT) | Unprotected |
|-------------------------------|-------------|
| All `/api/v1/*` routes | `GET /live` |
| | `GET /ready` |
| | `GET /metrics` (IP-restricted to internal networks) |
| | `GET /api/auth/login-url` |
| | `POST /api/auth/token` |
| | `POST /api/v1/telemetry` (sendBeacon cannot set headers) |
### Prometheus Metrics
All services SHOULD expose `GET /metrics` in Prometheus exposition format, scraped by Prospero's Prometheus (default 15s interval).
- **IP-restricted** to internal networks only (`10.10.0.0/24`, `172.16.0.0/12`, `127.0.0.0/8`)
- Consider exposing: request counts/durations, error rates, active connections, queue depths, dependency health
### Browser Telemetry
Frontend/browser code MUST send telemetry data and errors back to the application's telemetry API:
- `POST /api/v1/telemetry` — unprotected (browser `sendBeacon` cannot set Authorization headers)
- Capture and report: JavaScript exceptions, performance metrics, user-facing errors
- Client-side exceptions should log as **WARNING** on the server (they indicate a problem but not a server-side failure)
### Docker Networking
- Use the **default Docker bridge network** for simple deployments
- Add additional named networks only when required (e.g., isolating database traffic) or explicitly requested
- Do not create custom network definitions for single-service Docker Compose stacks
---
## External Access via HAProxy
Titania provides TLS termination and reverse proxy for all services.
- **Base domain**: `ouranos.helu.ca`
- **HTTPS**: port 443 (standard)
- **HTTP**: port 80 (redirects to HTTPS)
- **Certificate**: Let's Encrypt wildcard via certbot DNS-01
### Route Table
| Subdomain | Backend | Service |
|-----------|---------|---------|
| `ouranos.helu.ca` (root) | puck.incus:22281 | Angelia (Django) |
| `alertmanager.ouranos.helu.ca` | prospero.incus:443 (SSL) | AlertManager |
| `angelia.ouranos.helu.ca` | puck.incus:22281 | Angelia (Django) |
| `anythingllm.ouranos.helu.ca` | rosalind.incus:22084 | AnythingLLM |
| `arke.ouranos.helu.ca` | sycorax.incus:25540 | Arke LLM Proxy |
| `athena.ouranos.helu.ca` | puck.incus:22481 | Athena (Django) |
| `gitea.ouranos.helu.ca` | rosalind.incus:22082 | Gitea |
| `grafana.ouranos.helu.ca` | prospero.incus:443 (SSL) | Grafana |
| `hass.ouranos.helu.ca` | oberon.incus:8123 | Home Assistant |
| `id.ouranos.helu.ca` | titania.incus:22081 | Casdoor SSO |
| `icarlos.ouranos.helu.ca` | puck.incus:22681 | Icarlos (Django) |
| `jupyterlab.ouranos.helu.ca` | puck.incus:22071 | JupyterLab (OAuth2-Proxy) |
| `kairos.ouranos.helu.ca` | puck.incus:22581 | Kairos (Django) |
| `lobechat.ouranos.helu.ca` | rosalind.incus:22081 | LobeChat |
| `loki.ouranos.helu.ca` | prospero.incus:443 (SSL) | Loki |
| `mcp-switchboard.ouranos.helu.ca` | oberon.incus:22781 | MCP Switchboard |
| `nextcloud.ouranos.helu.ca` | rosalind.incus:22083 | Nextcloud |
| `openwebui.ouranos.helu.ca` | oberon.incus:22088 | Open WebUI |
| `peitho.ouranos.helu.ca` | puck.incus:22981 | Peitho (Django) |
| `periplus.ouranos.helu.ca` | puck.incus:20681 | Periplus (FastAPI + MCP via nginx) |
| `pgadmin.ouranos.helu.ca` | prospero.incus:443 (SSL) | PgAdmin 4 |
| `prometheus.ouranos.helu.ca` | prospero.incus:443 (SSL) | Prometheus |
| `searxng.ouranos.helu.ca` | oberon.incus:22073 | SearXNG (OAuth2-Proxy) |
| `smtp4dev.ouranos.helu.ca` | oberon.incus:22085 | smtp4dev |
| `spelunker.ouranos.helu.ca` | puck.incus:22881 | Spelunker (Django) |
---
## Infrastructure Management
### Quick Start
```bash
# Provision containers
cd terraform
terraform init
terraform plan
terraform apply
# Start all containers
cd ../ansible
source ~/env/ouranos/bin/activate
ansible-playbook sandbox_up.yml
# Deploy all services
ansible-playbook site.yml
# Stop all containers
ansible-playbook sandbox_down.yml
```
### Python Virtual Environment Setup
The Ansible automation requires a Python virtual environment with the `ansible` package installed. Create and activate the environment from the `~` directory:
```bash
# Create virtual environment
cd ~
python3 -m venv env/ouranos
# Activate environment
source ~/env/ouranos/bin/activate
# Install Ansible
pip install ansible
pip install ansible-core
pip install ansible-community.postgresql
```
### Ansible Playbook Syntax Check
Before running playbooks, use the `apsc.sh` utility (in PATH) to quickly validate YAML syntax:
```bash
# From the ansible directory
apsc.sh
# This will check all YAML files in the current directory for syntax errors
```
### Terraform Workflow
1. **Define** — Containers, networks, and resources in `*.tf` files
2. **Plan** — Review changes with `terraform plan`
3. **Apply** — Provision with `terraform apply`
4. **Verify** — Check outputs and container status
### Terraform Import
When containers or other resources are created manually (outside Terraform) or need to be re-imported after recreation, use `terraform import` to sync the Terraform state with existing infrastructure.
#### Import Syntax
The correct import format for Incus resources requires quoting resource addresses with `for_each` keys and using the full ID including image fingerprints:
```bash
# Import a container with correct syntax
terraform import 'incus_instance.uranian_hosts["<name>"]' ouranos/<name>,image=<fingerprint>
```
#### Getting Image Fingerprints
First, get the fingerprint of the image resource from Terraform state:
```bash
cd terraform
terraform state show incus_image.noble | grep fingerprint
# Output: fingerprint = "75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644"
terraform state show incus_image.questing | grep fingerprint
# Output: fingerprint = "e78dd4a406b7fa3592ed0a6048862260b3d2e50c76e32a6169930245c0a13fdf"
```
#### Importing All Uranian Hosts
Replace containers missing from state (or re-import after manual recreation):
```bash
# Containers using noble image
terraform import 'incus_instance.uranian_hosts["ariel"]' ouranos/ariel,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
terraform import 'incus_instance.uranian_hosts["miranda"]' ouranos/miranda,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
terraform import 'incus_instance.uranian_hosts["oberon"]' ouranos/oberon,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
terraform import 'incus_instance.uranian_hosts["portia"]' ouranos/portia,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
terraform import 'incus_instance.uranian_hosts["prospero"]' ouranos/prospero,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
terraform import 'incus_instance.uranian_hosts["rosalind"]' ouranos/rosalind,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
terraform import 'incus_instance.uranian_hosts["sycorax"]' ouranos/sycorax,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
terraform import 'incus_instance.uranian_hosts["titania"]' ouranos/titania,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
terraform import 'incus_instance.uranian_hosts["umbriel"]' ouranos/umbriel,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
# Containers using questing image
terraform import 'incus_instance.uranian_hosts["caliban"]' ouranos/caliban,image=e78dd4a406b7fa3592ed0a6048862260b3d2e50c76e32a6169930245c0a13fdf
terraform import 'incus_instance.uranian_hosts["puck"]' ouranos/puck,image=e78dd4a406b7fa3592ed0a6048862260b3d2e50c76e32a6169930245c0a13fdf
```
#### Storage Bucket Import
For storage buckets, use the `<project>/<pool>/<name>` format:
```bash
terraform import incus_storage_bucket.<name> ouranos/default/<bucket-name>
```
#### Common Issues
1. **Import ID format errors**: Use quotes around resource addresses with `for_each` keys: `'incus_instance.uranian_hosts["name"]'`
2. **Image replacement on import**: Importing without specifying the image fingerprint will cause Terraform to replace the container on next apply. Always include `image=<fingerprint>` in the import ID.
3. **Tainted state**: If a resource shows "will be created" but already exists, it may be tainted. Remove from state and re-import:
```bash
terraform state rm 'incus_instance.uranian_hosts["name"]'
terraform import 'incus_instance.uranian_hosts["name"]' ouranos/name,image=<fingerprint>
```
#### Verify Import
After importing, verify with `terraform plan`:
```bash
terraform plan
# Should show: Plan: 0 to add, 0 to change, 0 to destroy
# (Minor "update in-place" changes are normal for state sync of computed attributes)
```
### Ansible Workflow
1. **Bootstrap** — Update packages, install essentials (`apt_update.yml`)
2. **Agents** — Deploy Alloy (log/metrics) and Node Exporter on all hosts
3. **Services** — Configure databases, Docker, applications, observability
4. **Verify** — Check service health and connectivity
### Vault Management
```bash
# Edit secrets
ansible-vault edit inventory/group_vars/all/vault.yml
# View secrets
ansible-vault view inventory/group_vars/all/vault.yml
# Encrypt a new file
ansible-vault encrypt new_secrets.yml
```
---
## S3 Storage Provisioning
Terraform provisions Incus S3 buckets for services requiring object storage:
| Service | Host | Purpose |
|---------|------|---------|
| **Casdoor** | Titania | User avatars and SSO resource storage |
| **LobeChat** | Rosalind | File uploads and attachments |
> S3 credentials (access key, secret key, endpoint) are stored as sensitive Terraform outputs and managed in Ansible Vault with the `vault_*_s3_*` prefix.
---
## Ansible Automation
### Full Deployment (`site.yml`)
Playbooks run in dependency order:
| Playbook | Hosts | Purpose |
|----------|-------|---------|
| `apt_update.yml` | All | Update packages and install essentials |
| `alloy/deploy.yml` | All | Grafana Alloy log/metrics collection |
| `prometheus/node_deploy.yml` | All | Node Exporter metrics |
| `docker/deploy.yml` | Oberon, Ariel, Miranda, Puck, Rosalind, Sycorax, Caliban, Titania | Docker engine |
| `smtp4dev/deploy.yml` | Oberon | SMTP test server |
| `pplg/deploy.yml` | Prospero | Full observability stack + HAProxy + OAuth2-Proxy |
| `postgresql/deploy.yml` | Portia | PostgreSQL with all databases |
| `postgresql_ssl/deploy.yml` | Titania | Dedicated PostgreSQL for Casdoor |
| `neo4j/deploy.yml` | Ariel, Umbriel | Neo4j graph database (Umbriel is the dedicated Mnemosyne instance) |
| `searxng/deploy.yml` | Oberon | SearXNG privacy search |
| `haproxy/deploy.yml` | Titania | HAProxy TLS termination and routing |
| `casdoor/deploy.yml` | Titania | Casdoor SSO |
| `mcpo/deploy.yml` | Miranda | MCPO MCP proxy |
| `openwebui/deploy.yml` | Oberon | Open WebUI LLM interface |
| `hass/deploy.yml` | Oberon | Home Assistant |
| `gitea/deploy.yml` | Rosalind | Gitea self-hosted Git |
| `nextcloud/deploy.yml` | Rosalind | Nextcloud collaboration |
### Individual Service Deployments
Services with standalone deploy playbooks (not in `site.yml`):
| Playbook | Host | Service |
|----------|------|---------|
| `anythingllm/deploy.yml` | Rosalind | AnythingLLM document AI |
| `arke/deploy.yml` | Sycorax | Arke LLM proxy |
| `argos/deploy.yml` | Miranda | Argos MCP web search server |
| `caliban/deploy.yml` | Caliban | Agent S MCP Server |
| `certbot/deploy.yml` | Titania | Let's Encrypt certificate renewal |
| `gitea_mcp/deploy.yml` | Miranda | Gitea MCP Server |
| `gitea_runner/deploy.yml` | Puck | Gitea CI/CD runner |
| `grafana_mcp/deploy.yml` | Miranda | Grafana MCP Server |
| `jupyterlab/deploy.yml` | Puck | JupyterLab + OAuth2-Proxy |
| `kernos/deploy.yml` | Caliban | Kernos MCP shell server |
| `lobechat/deploy.yml` | Rosalind | LobeChat AI chat |
| `rommie/deploy.yml` | Caliban | Rommie MCP server (Agent S GUI automation) |
| `neo4j_mcp/deploy.yml` | Miranda | Neo4j MCP Server |
| `freecad_mcp/deploy.yml` | Caliban | FreeCAD Robust MCP Server |
| `rabbitmq/deploy.yml` | Oberon | RabbitMQ message queue |
### Lifecycle Playbooks
| Playbook | Purpose |
|----------|---------|
| `sandbox_up.yml` | Start all Uranian host containers |
| `sandbox_down.yml` | Gracefully stop all containers |
| `apt_update.yml` | Update packages on all hosts |
| `site.yml` | Full deployment orchestration |
---
## Data Flow Architecture
### Observability Pipeline
```
All Hosts Prospero Alerts
Alloy + Node Exporter → Prometheus + Loki + Grafana → AlertManager + Pushover
collect metrics & logs storage & visualisation notifications
```
### Integration Points
| Consumer | Provider | Connection |
|----------|----------|-----------|
| All LLM apps | Arke (Sycorax) | `http://sycorax.incus:25540` |
| Open WebUI, Arke, Gitea, Nextcloud, LobeChat | PostgreSQL (Portia) | `portia.incus:5432` |
| Neo4j MCP | Neo4j (Ariel) | `ariel.incus:7687` (Bolt) |
| Mnemosyne | Neo4j (Umbriel) | `umbriel.incus:7687` (Bolt) — dedicated tenant |
| MCP Switchboard | Docker API (Miranda) | `tcp://miranda.incus:2375` |
| MCP Switchboard | RabbitMQ (Oberon) | `oberon.incus:5672` |
| Kairos, Spelunker | RabbitMQ (Oberon) | `oberon.incus:5672` |
| SMTP (all apps) | smtp4dev (Oberon) | `oberon.incus:22025` |
| All hosts | Loki (Prospero) | `http://prospero.incus:3100` |
| All hosts | Prometheus (Prospero) | `http://prospero.incus:9090` |
---
## Important Notes
⚠️ **Alloy Host Variables Required** — Every host with `alloy` in its `services` list must define `alloy_log_level` in `inventory/host_vars/<host>.incus.yml`. The playbook will fail with an undefined variable error if this is missing.
⚠️ **Alloy Syslog Listeners Required for Docker Services** — Any Docker Compose service using the syslog logging driver must have a corresponding `loki.source.syslog` listener in the host's Alloy config template (`ansible/alloy/<hostname>/config.alloy.j2`). Missing listeners cause Docker containers to fail on start.
⚠️ **Local Terraform State** — This project uses local Terraform state (no remote backend). Do not run `terraform apply` from multiple machines simultaneously.
⚠️ **Nested Docker** — Docker runs inside Incus containers (nested), requiring `security.nesting = true` and `lxc.apparmor.profile=unconfined` AppArmor override on all Docker-enabled hosts.
⚠️ **Deployment Order** — Prospero (observability) must be fully deployed before other hosts, as Alloy on every host pushes logs and metrics to `prospero.incus`. Run `pplg/deploy.yml` before `site.yml` on a fresh environment.

View File

@@ -686,6 +686,29 @@ def concept_graph(request, uid):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _job_for_user_or_none(job_id, username):
"""Load an ``IngestJob`` visible to ``username``, else ``None``.
Visibility: the job's ``library_uid`` must resolve to a Library with
``owner_username`` either null (global) or matching ``username``.
Callers translate ``None`` into a 404 with generic wording — cross-
user reads must not disclose existence.
"""
from library.models import IngestJob, Library
try:
job = IngestJob.objects.get(pk=job_id)
except IngestJob.DoesNotExist:
return None
try:
lib = Library.nodes.get(uid=job.library_uid)
except Library.DoesNotExist:
return None
if lib.owner_username and lib.owner_username != username:
return None
return job
@api_view(["POST"]) @api_view(["POST"])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def ingest_create(request): def ingest_create(request):
@@ -733,6 +756,17 @@ def ingest_create(request):
status=status.HTTP_404_NOT_FOUND, 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) --- # --- Idempotency check on (library, source_ref, content_hash) ---
source_ref = data.get("source_ref") or "" source_ref = data.get("source_ref") or ""
content_hash = data["content_hash"] content_hash = data["content_hash"]
@@ -804,11 +838,8 @@ def ingest_create(request):
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def ingest_job_detail(request, job_id): def ingest_job_detail(request, job_id):
"""Get the current status of an IngestJob.""" """Get the current status of an IngestJob."""
from library.models import IngestJob job = _job_for_user_or_none(job_id, request.user.username)
if job is None:
try:
job = IngestJob.objects.get(pk=job_id)
except IngestJob.DoesNotExist:
return Response( return Response(
{"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND {"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND
) )
@@ -820,12 +851,10 @@ def ingest_job_detail(request, job_id):
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def ingest_job_retry(request, job_id): def ingest_job_retry(request, job_id):
"""Re-dispatch a failed IngestJob.""" """Re-dispatch a failed IngestJob."""
from library.models import IngestJob
from library.tasks import ingest_from_daedalus from library.tasks import ingest_from_daedalus
try: job = _job_for_user_or_none(job_id, request.user.username)
job = IngestJob.objects.get(pk=job_id) if job is None:
except IngestJob.DoesNotExist:
return Response( return Response(
{"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND {"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND
) )
@@ -852,10 +881,18 @@ def ingest_job_retry(request, job_id):
@api_view(["GET"]) @api_view(["GET"])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def ingest_job_list(request): def ingest_job_list(request):
"""List recent IngestJob rows, optionally filtered by status / library_uid.""" """List recent IngestJob rows, optionally filtered by status / library_uid.
from library.models import IngestJob
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") status_filter = request.query_params.get("status")
library_uid = request.query_params.get("library_uid") library_uid = request.query_params.get("library_uid")
limit = min(int(request.query_params.get("limit", 50)), 200) limit = min(int(request.query_params.get("limit", 50)), 200)

View File

@@ -6,7 +6,8 @@ It uses the same Library node as a global library; the difference is that
`workspace_id` is set, and search must filter on it. `workspace_id` is set, and search must filter on it.
These endpoints are called by the Daedalus backend authenticated as the 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 workspace's owning user is recorded on the Library node as
``owner_username``; every read and mutation is scoped to that user. ``owner_username``; every read and mutation is scoped to that user.
Non-owners receive 404 so a workspace's existence isn't disclosed Non-owners receive 404 so a workspace's existence isn't disclosed

View File

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

View File

@@ -23,6 +23,34 @@ def neo4j_available():
return False 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]: def all_library_uids() -> list[str]:
"""Return the UIDs of every ``Library`` node in Neo4j. """Return the UIDs of every ``Library`` node in Neo4j.

View File

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

View File

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

View File

@@ -233,6 +233,17 @@ def team_workspaces(request, team_id):
def team_rotate(request, team_id): def team_rotate(request, team_id):
"""Generate a fresh ``active_jti`` and JWT. """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 The previously-issued JWT stops validating immediately — the auth
middleware compares the incoming ``jti`` against ``Team.active_jti`` middleware compares the incoming ``jti`` against ``Team.active_jti``
on every request. 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 returns 409 so the operator is forced to go through the explicit
create/readd flow rather than quietly resurrecting a team. 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: if team is None:
return Response( team = Team.objects.create(
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND 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( return Response(
{"detail": "Team is inactive; cannot rotate. Recreate it instead."}, {"detail": "Team is inactive; cannot rotate. Recreate it instead."},
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,

View File

@@ -1,7 +1,8 @@
"""URL patterns for the ``/mcp_server/api/`` DRF control-plane API. """URL patterns for the ``/mcp_server/api/`` DRF control-plane API.
These endpoints are called by the Daedalus backend authenticated as 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 to its ``owner`` — cross-user access returns 404. End-user MCP traffic
does NOT go through this surface — that's ``mnemosyne.asgi`` / does NOT go through this surface — that's ``mnemosyne.asgi`` /
``mcp_server/server.py``. ``mcp_server/server.py``.

View File

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

View File

@@ -5,9 +5,9 @@ on the FastMCP ``Context``:
* ``STATE_KEY_USER`` — the Django user the bearer resolved to (synthetic * ``STATE_KEY_USER`` — the Django user the bearer resolved to (synthetic
service user for JWT callers, concrete ``mcp_tokens.user`` for opaque 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). 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. ``None`` for JWT callers.
* ``STATE_KEY_CLAIMS`` — the JWT claims dict for JWT callers; ``None`` * ``STATE_KEY_CLAIMS`` — the JWT claims dict for JWT callers; ``None``
for opaque-token callers. Intentionally exposed for debugging / for opaque-token callers. Intentionally exposed for debugging /

View File

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

View File

@@ -1,18 +1,22 @@
"""Forms for the MCP token self-service dashboard. """Forms for the per-user API token self-service dashboard.
The dashboard is where humans mint their own opaque :class:`MCPToken` The dashboard is where humans mint their own opaque :class:`UserToken`
rows for external MCP clients (Claude Desktop, Cline, ...). The rows — used by MCP tool clients (Claude Desktop, Cline) on ``/mcp/``
plaintext is surfaced exactly once on the "created" page and never and by the Daedalus REST integration on ``/library/api/*`` /
retrievable again — see ``mcp_server/views.py``. ``/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. * ``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 * ``allowed_libraries`` — multi-select over Neo4j Libraries the
current request user has ``owner`` or ``manager`` membership on. current request user has ``owner`` or ``manager`` membership on.
Empty = **zero** libraries (fail-closed), matching the semantics Empty = **zero** libraries (fail-closed) *for MCP callers*, matching
in §4.1 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. 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 from __future__ import annotations
@@ -23,7 +27,7 @@ import functools
from django import forms from django import forms
from .admin import _library_choices_for_user from .admin import _library_choices_for_user
from .models import MCPToken from .models import UserToken
@functools.lru_cache(maxsize=1) @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()] 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.""" """Generate a new bearer token. The token value itself is server-generated."""
name = forms.CharField( name = forms.CharField(
@@ -82,7 +86,7 @@ class MCPTokenCreateForm(forms.Form):
self.fields["allowed_libraries"].choices = _library_choices_for_user(user) 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.""" """Edit token metadata. The hashed token itself cannot be edited."""
allowed_tools = forms.MultipleChoiceField( allowed_tools = forms.MultipleChoiceField(
@@ -100,7 +104,7 @@ class MCPTokenEditForm(forms.ModelForm):
) )
class Meta: class Meta:
model = MCPToken model = UserToken
fields = ["name", "is_active", "expires_at", "allowed_tools", "allowed_libraries"] fields = ["name", "is_active", "expires_at", "allowed_tools", "allowed_libraries"]
widgets = { widgets = {
"name": forms.TextInput(attrs={"class": "input input-bordered w-full"}), "name": forms.TextInput(attrs={"class": "input input-bordered w-full"}),

View File

@@ -12,7 +12,7 @@ skips:
* Workspace-scoped Libraries (``workspace_id`` is not null). Those * Workspace-scoped Libraries (``workspace_id`` is not null). Those
belong to a Daedalus workspace and will be reachable via team JWTs belong to a Daedalus workspace and will be reachable via team JWTs
once Phase 4 wires up ``TeamWorkspaceAssignment``. Granting them to 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. leaked token.
* Libraries that already have any :class:`LibraryMembership` row. We * Libraries that already have any :class:`LibraryMembership` row. We
do not stack roles for idempotency. do not stack roles for idempotency.

View File

@@ -6,11 +6,11 @@ from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone from django.utils import timezone
from mcp_server.models import MCPToken from mcp_server.models import UserToken
class Command(BaseCommand): 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): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(
@@ -57,14 +57,14 @@ class Command(BaseCommand):
raise CommandError("--expires-days must be at least 1.") raise CommandError("--expires-days must be at least 1.")
expires_at = timezone.now() + timedelta(days=options["expires_days"]) expires_at = timezone.now() + timedelta(days=options["expires_days"])
token, plaintext = MCPToken.objects.create_token( token, plaintext = UserToken.objects.create_token(
user=user, user=user,
name=options["name"], name=options["name"],
allowed_tools=allowed_tools, allowed_tools=allowed_tools,
expires_at=expires_at, 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" Name: {token.name}")
self.stdout.write(f" User: {user}") self.stdout.write(f" User: {user}")
if allowed_tools: if allowed_tools:

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.13 on 2026-05-23 11:12 # Generated by Django 5.2.13 on 2026-05-23 15:12
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
@@ -29,25 +29,6 @@ class Migration(migrations.Migration):
'ordering': ['-created_at'], '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( migrations.CreateModel(
name='Team', name='Team',
fields=[ fields=[
@@ -63,6 +44,27 @@ class Migration(migrations.Migration):
'ordering': ['name'], '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( migrations.CreateModel(
name='LibraryMembership', name='LibraryMembership',
fields=[ fields=[

View File

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

View File

@@ -1,16 +1,16 @@
{% extends "themis/base.html" %} {% extends "themis/base.html" %}
{% block title %}Generate MCP Token — {{ themis_app_name }}{% endblock %} {% block title %}Generate API Token — {{ themis_app_name }}{% endblock %}
{% block content %} {% block content %}
<div class="max-w-2xl mx-auto"> <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"> <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> <span>The token will be displayed once after creation. Save it before leaving the page — it cannot be recovered.</span>
</div> </div>
<form method="post" action="{% url 'mcp_server:mcp-token-create' %}"> <form method="post" action="{% url 'mcp_server:token-create' %}">
{% csrf_token %} {% csrf_token %}
<div class="card bg-base-200 mb-6"> <div class="card bg-base-200 mb-6">
@@ -36,9 +36,18 @@
</div> </div>
</div> </div>
<div class="card bg-base-200 mb-6"> <details class="card bg-base-200 mb-6">
<div class="card-body"> <summary class="card-body cursor-pointer">
<h2 class="card-title text-lg">Allowed Tools</h2> <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> <p class="text-sm opacity-60 mb-2">{{ form.allowed_tools.help_text }}</p>
<div class="space-y-1"> <div class="space-y-1">
{% for choice in form.allowed_tools %} {% for choice in form.allowed_tools %}
@@ -52,10 +61,10 @@
<div class="text-error text-sm mt-2">{{ form.allowed_tools.errors }}</div> <div class="text-error text-sm mt-2">{{ form.allowed_tools.errors }}</div>
{% endif %} {% endif %}
</div> </div>
</div> </details>
<div class="flex justify-between"> <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> <button type="submit" class="btn btn-primary">Generate Token</button>
</div> </div>
</form> </form>

View File

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

View File

@@ -8,15 +8,15 @@
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{{ token.name }}</h1> <h1 class="text-2xl font-bold">{{ token.name }}</h1>
<div class="flex gap-2"> <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 %} {% 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.');"> onsubmit="return confirm('Revoke this token? It will no longer authenticate MCP requests.');">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-warning btn-sm btn-outline">Revoke</button> <button type="submit" class="btn btn-warning btn-sm btn-outline">Revoke</button>
</form> </form>
{% endif %} {% 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.');"> onsubmit="return confirm('Delete this token permanently? This removes the audit trail.');">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-error btn-sm btn-outline">Delete</button> <button type="submit" class="btn btn-error btn-sm btn-outline">Delete</button>
@@ -76,6 +76,6 @@
</div> </div>
</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> </div>
{% endblock %} {% endblock %}

View File

@@ -10,7 +10,7 @@
<span>You can edit metadata below. The token value itself cannot be changed — generate a new token if needed.</span> <span>You can edit metadata below. The token value itself cannot be changed — generate a new token if needed.</span>
</div> </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 %} {% csrf_token %}
<div class="card bg-base-200 mb-6"> <div class="card bg-base-200 mb-6">
@@ -36,9 +36,17 @@
</div> </div>
</div> </div>
<div class="card bg-base-200 mb-6"> <details class="card bg-base-200 mb-6"{% if form.allowed_tools.value or form.allowed_libraries.value %} open{% endif %}>
<div class="card-body"> <summary class="card-body cursor-pointer">
<h2 class="card-title text-lg">Allowed Tools</h2> <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> <p class="text-sm opacity-60 mb-2">{{ form.allowed_tools.help_text }}</p>
<div class="space-y-1"> <div class="space-y-1">
{% for choice in form.allowed_tools %} {% for choice in form.allowed_tools %}
@@ -49,10 +57,10 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div> </details>
<div class="flex justify-between"> <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> <button type="submit" class="btn btn-primary">Save Changes</button>
</div> </div>
</form> </form>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -385,13 +385,23 @@ class TeamRotateTest(_AuthenticatedAPITest):
kwargs={"team_id": self.team.id}, 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( url = reverse(
"mcp-server-api:team-rotate", "mcp-server-api:team-rotate",
kwargs={"team_id": uuid.uuid4()}, kwargs={"team_id": new_id},
) )
resp = self.client.post(url) 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): def test_rotate_returns_new_jwt_and_changes_active_jti(self):
before = self.team.active_jti before = self.team.active_jti
@@ -417,10 +427,16 @@ class TeamRotateTest(_AuthenticatedAPITest):
resp.status_code, status.HTTP_503_SERVICE_UNAVAILABLE 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 = self.team.active_jti
before_owner = self.team.owner_id
self.client.force_authenticate(user=self.other_user) self.client.force_authenticate(user=self.other_user)
resp = self.client.post(self.url) 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.team.refresh_from_db()
self.assertEqual(self.team.active_jti, before) self.assertEqual(self.team.active_jti, before)
self.assertEqual(self.team.owner_id, before_owner)

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,7 +64,6 @@ INSTALLED_APPS = [
"django.contrib.humanize", "django.contrib.humanize",
# Third-party # Third-party
"rest_framework", "rest_framework",
"rest_framework.authtoken",
"storages", "storages",
"django_neomodel", "django_neomodel",
"django_prometheus", "django_prometheus",
@@ -295,9 +294,11 @@ EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# --- Django REST Framework --- # --- Django REST Framework ---
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [ "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.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.BasicAuthentication",
], ],
"DEFAULT_PERMISSION_CLASSES": [ "DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated", "rest_framework.permissions.IsAuthenticated",

View File

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

View File

@@ -39,13 +39,13 @@
</a> </a>
</li> </li>
<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" <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <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" /> 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> </svg>
MCP Tokens API Tokens
</a> </a>
</li> </li>
<div class="divider my-0"></div> <div class="divider my-0"></div>

View File

@@ -141,31 +141,9 @@
</div> </div>
</form> </form>
<!-- API Token — separate form, outside the settings form --> <div class="text-sm opacity-70 mt-4">
<div class="card bg-base-200 mb-6"> Looking for API tokens? Manage them at
<div class="card-body"> <a class="link" href="{% url 'mcp_server:token-list' %}">/profile/tokens/</a>.
<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> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

@@ -13,7 +13,6 @@ urlpatterns = [
path("live/", views.live, name="live"), path("live/", views.live, name="live"),
# Profile settings # Profile settings
path("profile/settings/", views.profile_settings, name="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 # API key management
path("profile/keys/", views.key_list, name="key-list"), path("profile/keys/", views.key_list, name="key-list"),
path("profile/keys/add/", views.key_create, name="key-create"), path("profile/keys/add/", views.key_create, name="key-create"),

View File

@@ -12,8 +12,6 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_GET, require_http_methods, require_POST 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.encryption import encrypt_value
from themis.forms import APIKeyCreateForm, APIKeyEditForm, ProfileSettingsForm from themis.forms import APIKeyCreateForm, APIKeyEditForm, ProfileSettingsForm
from themis.models import UserAPIKey, UserNotification from themis.models import UserAPIKey, UserNotification
@@ -65,7 +63,6 @@ def live(request):
def profile_settings(request): def profile_settings(request):
"""Display and update user profile preferences.""" """Display and update user profile preferences."""
profile = request.user.profile profile = request.user.profile
api_token, _ = Token.objects.get_or_create(user=request.user)
if request.method == "POST": if request.method == "POST":
form = ProfileSettingsForm(request.POST, instance=profile) form = ProfileSettingsForm(request.POST, instance=profile)
@@ -76,17 +73,7 @@ def profile_settings(request):
else: else:
form = ProfileSettingsForm(instance=profile) form = ProfileSettingsForm(instance=profile)
return render(request, "themis/profile/settings.html", {"form": form, "api_token": api_token}) return render(request, "themis/profile/settings.html", {"form": form})
@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")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------