feat: rework auth model with UserToken and Daedalus/Pallas integration
- Rename MCPToken to UserToken across models, views, and tests - Update URL names from mcp-token-* to token-* - Add Daedalus/Pallas integration design doc (v2) - Switch docker-compose to build local mnemosyne:local image via shared build config instead of pulling from git.helu.ca
This commit is contained in:
658
docs/DAEDALUS_PALLAS_INTEGRATION_v2.md
Normal file
658
docs/DAEDALUS_PALLAS_INTEGRATION_v2.md
Normal 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).
|
||||
Reference in New Issue
Block a user