Files
mnemosyne/docs/DAEDALUS_PALLAS_INTEGRATION_v2.md
Robert Helewka 93639188d3
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
feat: rework auth model with UserToken and Daedalus/Pallas integration
- Rename MCPToken to UserToken across models, views, and tests
- Update URL names from mcp-token-* to token-*
- Add Daedalus/Pallas integration design doc (v2)
- Switch docker-compose to build local mnemosyne:local image via shared
  build config instead of pulling from git.helu.ca
2026-05-23 19:50:29 -04:00

659 lines
27 KiB
Markdown

# 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).