Files
mnemosyne/docs/DAEDALUS_PALLAS_INTEGRATION_v1.md
Robert Helewka 409da7d109
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 56s
CVE Scan & Docker Build / build-and-push (push) Successful in 3m30s
docs: replace daedalus-service basic auth with per-user DRF tokens
2026-05-22 22:59:59 -04:00

796 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Daedalus ↔ Pallas ↔ Mnemosyne Integration — v1
**Status:** Draft / approved design
**Authoritative home:** `mnemosyne/docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`
**Versioning:** subsequent major revisions ship as `..._v2.md`, `..._v3.md`
alongside this file rather than overwriting it. Cross-service docs
(Daedalus, Pallas) link here rather than duplicating.
---
## 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. Single-user per
instance. Registers Pallas instances, syncs file content to
Mnemosyne, drives chat.
* **Pallas** — FastAgent-backed MCP host that exposes agent teams
(Kottos, Mentor, Iolaus, Daedalus-chat, …) as HTTP MCP servers.
The model replaces the per-turn JWT *forwarding* scheme with a unified
**bearer → resolved library set** abstraction. Every authenticated
Mnemosyne request resolves to a single ordered `resolved_libraries`
list of Library UIDs the caller may read; the principal type (opaque
`MCPToken`, Daedalus per-turn JWT, team JWT) only determines how that
list is derived. `Library.workspace_id` is a Daedalus content-routing
attribute used by the ingest and workspace-lifecycle APIs; it is **not**
consulted by the auth layer.
It also records the UX shift in Daedalus: **workspaces attach Teams
(Pallas instances), not individual agents**; the agent picker in chat
is filtered by the workspace's attached teams.
---
## 2. Motivation
The previous design forwarded a Daedalus-minted per-turn JWT through
Pallas to Mnemosyne via a custom `_DynamicBearerAuth`, a `ContextVar`,
a YAML scanner (`_refresh_forward_servers`), a header-mutation
monkey-patch, and three trace wrappers in
`pallas/pallas/_fastagent_patch.py`. When something broke on this
path, tracebacks landed nowhere visible because fast-agent's
`MCPAggregator._execute_on_server.try_execute` swallowed exceptions
(`except Exception as e: logger.error(…str(e)…); return error_factory(...)`),
and we were monkey-patching under it.
The failure mode (agent teams like Harper going into infinite
token-burning loops when Mnemosyne was unhappy) combined with the
diagnostic opacity made this architecture unsustainable. Per-turn
forwarding was also the wrong shape for non-interactive agent teams
that have no user session per call.
This design eliminates forwarding. Each Pallas deployment carries a
static, long-lived team JWT in its own `fastagent.secrets.yaml`. No
custom transport code in Pallas. Authorization happens server-side in
Mnemosyne against live DB rows.
---
## 3. Architecture
### 3.1 Services and responsibilities
| Service | Role in auth model |
|---|---|
| **Mnemosyne** | Owns Libraries, Library memberships, MCPTokens, Teams, TeamWorkspaceAssignments, 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. |
| **Pallas** | Stateless MCP host. Holds a static team JWT in `fastagent.secrets.yaml`. No custom auth-forwarding code. |
### 3.2 Three credential types
Every Mnemosyne MCP call presents a Bearer token that falls into one
of three categories:
| # | Credential | `iss` | Issuer | Lifetime | Library scope source |
|---|---|---|---|---|---|
| 1 | **Opaque `MCPToken`** | n/a | Mnemosyne admin | Until revoked | `MCPToken.allowed_libraries` (M2M, set at mint) |
| 2 | **Per-turn JWT** | `daedalus` | Daedalus | Minutes | `libs` claim (baked in at mint) |
| 3 | **Team JWT** | `mnemosyne` | Mnemosyne | 10 years | Live DB lookup via `Team.workspaces → Library` |
Category 2 is used only by Daedalus chat. Once Daedalus-chat itself
registers as a Pallas Team in Phase 4, category 2 retires entirely
and the design collapses to two credential types.
### 3.3 Resolved-library abstraction
Mnemosyne's auth middleware populates a single
`resolved_libraries: list[str]` per request. Downstream code (search,
get_chunk, list_libraries, list_collections, list_items, …) only
reads that list; it does not care where it came from.
```
Bearer → classify → dispatch
├─ Opaque MCPToken → token.allowed_libraries (JSON list of UIDs)
├─ per-turn JWT → claims["libs"]
└─ team JWT (typ=team) → live DB join:
TeamWorkspaceAssignment.workspace_id
→ Library.workspace_id → Library.uid
resolved_libraries: list[str]
downstream tools
```
Fail-closed: if the resolution produces an empty list, the request
sees no Libraries. There is no "empty means everything" fallback.
#### 3.3.1 Retirement of the old three-branch scope clause
The pre-phase-2 search pipeline ran every Cypher query against a
`_WORKSPACE_SCOPE_CLAUSE` with three branches keyed on whether
`workspace_id` and/or `allowed_libraries` were set. Phase 2 removes
that clause entirely. Every authorization check collapses to:
```cypher
WHERE lib.uid IN $resolved_libraries
```
`Library.workspace_id` stays on the node as a Daedalus content-routing
attribute (used by the ingest API to find-or-create the per-workspace
Library, and by the workspace-lifecycle API to cascade-delete that
Library's contents). It is **not** an authorization axis and is not
consulted anywhere in the auth middleware, the MCP tool surface, or
the search service.
Admin-UI-initiated searches (Django staff logged into the Mnemosyne
admin / search page) materialize `resolved_libraries` explicitly as
"every Library UID the database contains" — the same mechanism used
today as a workaround, now the only code path.
---
## 4. Data model
### 4.1 Mnemosyne additions
#### `LibraryMembership` (new)
```python
class LibraryMembership(models.Model):
user = FK(User, related_name="library_memberships")
library_uid = CharField(max_length=64, db_index=True) # neo4j Library.uid
role = CharField(choices=[("owner","owner"),
("manager","manager"),
("reader","reader")])
created_at = DateTimeField(auto_now_add=True)
class Meta:
unique_together = ("user", "library_uid", "role")
```
- **owner** — full control: rename, delete, reassign ownership,
grant/revoke any role.
- **manager** — can grant `reader` and scope Library into MCPTokens
they own, but cannot delete the library or remove other owners.
- **reader** — can read via their own MCPTokens; cannot grant to other
users.
User can scope a Library into `MCPToken.allowed_libraries` iff they
have `owner` or `manager` role on it.
#### `MCPToken.allowed_libraries` (new field on existing model)
```python
# JSON list of Library.uid strings. A real M2M isn't possible because
# Library lives in Neo4j (neomodel StructuredNode), not Django's ORM.
# The admin/dashboard form materializes the picker by querying
# Library.nodes and filtering to libraries where the token's user has
# an ``owner`` or ``manager`` LibraryMembership.
allowed_libraries = models.JSONField(default=list, blank=True)
```
Fail-closed: empty → token grants access to zero libraries.
Admin form filters the picker by the current user's owned/managed
memberships.
#### `Team` (new)
```python
class Team(models.Model):
id = UUIDField(primary_key=True, editable=False)
# = Daedalus PallasInstance.id. Stays stable across redeploy /
# rehost of a given Pallas instance.
name = CharField(max_length=200) # display; mirrored from Daedalus
active = BooleanField(default=True)
active_jti = UUIDField(null=True) # current valid jti
created_at = DateTimeField(auto_now_add=True)
updated_at = DateTimeField(auto_now=True)
```
#### `TeamWorkspaceAssignment` (new)
```python
class TeamWorkspaceAssignment(models.Model):
team = FK(Team, related_name="workspace_assignments",
on_delete=CASCADE)
workspace_id = CharField(max_length=64) # matches Library.workspace_id
created_at = DateTimeField(auto_now_add=True)
class Meta:
unique_together = ("team", "workspace_id")
```
No library-level assignment for teams. Teams gain access to *all*
libraries of their assigned workspaces. If finer control is ever
needed later, it layers on without disturbing this model.
#### `MCPSigningKey` (existing, unchanged)
Re-used to sign team JWTs. The same signing key can back both
per-turn tokens (pre-retirement) and team tokens (long-lived).
### 4.2 Daedalus additions
#### `PallasInstance.team_jwt_encrypted` (new column, text, Fernet)
Stores the team JWT received from Mnemosyne at registration time.
Fernet-encrypted at rest using the same pattern as
`daedalus/llm_manager/encryption.py`. Displayed plaintext exactly once
in the admin detail page immediately after provisioning, so the
operator can copy it into `fastagent.secrets.yaml` on the Pallas
deployment.
#### `PallasInstance.pallas_team_mnemosyne_status` (new column)
`NULL | "pending" | "provisioned" | "failed"`. Drives the reconciler;
analogous to the existing `WorkspaceFile.mnemosyne_status`.
#### `workspace_pallas_assignments` (new M2M table)
```sql
CREATE TABLE workspace_pallas_assignments (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
pallas_instance_id TEXT NOT NULL REFERENCES pallas_instances(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (workspace_id, pallas_instance_id)
);
```
Starts empty on workspace create. Operator explicitly attaches Pallas
instances (Teams) before any Mnemosyne-backed agent — including
Daedalus chat — can search that workspace.
### 4.3 Nothing changes in Pallas
After cleanup, `pallas/pallas/_fastagent_patch.py` either becomes an
empty placeholder or is removed entirely. `pallas/__init__.py` no
longer invokes `install()`. Pallas deployments configure stock
fast-agent with a static `Authorization: Bearer <team-jwt>`.
---
## 5. JWT claim shapes
### 5.1 Per-turn JWT (category 2 — legacy, retires in Phase 4)
```json
{
"iss": "daedalus",
"aud": "mnemosyne", // optional, not enforced
"sub": "daedalus-chat",
"iat": 1715000000,
"exp": 1715000600, // ≤ 10 minutes
"jti": "uuid4",
"ws": "ws_abc", // Daedalus workspace id
"libs": ["lib_xxx", "lib_yyy"] // user-managed libraries
}
```
Kept unchanged during Phase 23 so Daedalus chat continues to work
while we ship the team infrastructure.
### 5.2 Team JWT (category 3 — new)
```json
{
"iss": "mnemosyne",
"aud": "mnemosyne",
"sub": "team:<pallas_instance_uuid>", // UUID; Daedalus id
"typ": "team", // distinguishes from per-turn
"iat": 1715000000,
"exp": 1976000000, // +10 years
"jti": "uuid4"
}
```
No `ws`, no `libs`. Authorization is evaluated live against
`TeamWorkspaceAssignment` rows on every request.
### 5.3 Validator changes in `mcp_server/auth.py`
```python
# Accept both issuers; distinguish paths by typ.
_JWT_ISS = {"daedalus", "mnemosyne"}
def resolve_mcp_jwt(token_string: str) -> dict:
... # validate signature, iat/exp, required claims including sub
typ = claims.get("typ")
if typ == "team":
# No replay cache — team tokens are reused on every request.
# Validate sub=="team:<uuid>" shape; stash the uuid on claims.
pass
else:
if _remember_jti(jti, float(exp)):
raise MCPAuthError("Token replay detected.")
return claims
```
Middleware populates `STATE_KEY_RESOLVED_LIBRARIES` per request:
```python
# Opaque MCPToken
resolved_libraries = list(token.allowed_libraries or [])
# Per-turn JWT (legacy; retires phase 4)
resolved_libraries = list(claims.get("libs") or [])
# Team JWT
team = Team.objects.get(id=uuid_from_sub(claims["sub"]),
active=True,
active_jti=claims["jti"])
resolved_libraries = _libraries_for_team(team) # see below
```
`_libraries_for_team(team)` runs a single Cypher query against Neo4j:
```cypher
MATCH (l:Library)
WHERE l.workspace_id IN $workspace_ids
RETURN l.uid
```
where `$workspace_ids` is `list(team.workspace_assignments.values_list("workspace_id", flat=True))`.
---
## 6. Auth flow
### 6.1 Third-party MCP client with opaque `MCPToken`
1. Client sends `Authorization: Bearer <plaintext>`.
2. Middleware hashes → looks up `MCPToken` → validates active/expired.
3. `resolved_libraries = list(token.allowed_libraries or [])` — the
JSON list of Library UIDs the admin / dashboard granted at mint.
4. Fails closed if empty.
### 6.2 Daedalus chat per-turn JWT (legacy, retires Phase 4)
`iss=daedalus`, `typ` absent, `libs` carries the full library set
Daedalus pre-computed for that turn (the workspace's auto-Library
plus any user-managed extras), `ws` is present but no longer consulted
server-side. Middleware assigns `resolved_libraries = claims["libs"]`.
Mnemosyne validates the JWT against `MCPSigningKey` keyed by `kid`.
### 6.3 Agent team (Kottos / Mentor / Iolaus / post-migration Daedalus-chat)
1. Pallas sends `Authorization: Bearer <team-jwt>` (static, read from
`fastagent.secrets.yaml`).
2. Middleware validates signature → detects `typ=team`.
3. Reads `Team` row by UUID from `sub`. Verifies `active=True` and
`jti == active_jti`. Rejects otherwise.
4. Expands to `resolved_libraries` via
`TeamWorkspaceAssignment``Library.workspace_id`.
5. Fails closed if the team has no workspaces attached.
### 6.4 Failure modes
| Condition | Response |
|---|---|
| JWT signature invalid | `PermissionError("Invalid MCP token.")` |
| `exp` past (+30s leeway) | `PermissionError("Token has expired.")` |
| `iss` not in `_JWT_ISS` | `PermissionError("Invalid token issuer.")` |
| `typ=team`, team not found | `PermissionError("Invalid MCP token.")` |
| `typ=team`, team not active | `PermissionError("Token has been deactivated.")` |
| `typ=team`, `jti` stale | `PermissionError("Invalid MCP token.")` |
| Opaque token not found | `PermissionError("Invalid MCP token.")` |
| Opaque token, inactive user | `PermissionError("User account is disabled.")` |
| Resolved library set empty | Tool call proceeds but returns empty — this is *authorization*, not *authentication*, and the caller is legitimately scoped to nothing. |
---
## 7. REST API — Mnemosyne team lifecycle
All endpoints live under `/mcp_server/api/teams/` and are authenticated
as the Mnemosyne user the team belongs to via a per-user DRF token
(`Authorization: Token <key>`, surfaced on `/profile/settings/`). Each
team has an `owner` FK; non-owners receive 404 (never 403) so a team's
existence isn't disclosed across users. `/library/api/workspaces/` and
`/library/api/ingest/` use the same per-user auth model.
### 7.1 `POST /mcp_server/api/teams/`
Create a team.
**Request**
```json
{
"id": "a3f1…", // UUID; mirrors Daedalus PallasInstance.id
"name": "Kottos"
}
```
**Response 201**
```json
{
"id": "a3f1…",
"name": "Kottos",
"jwt": "eyJhbGci…" // shown once; not recoverable later
}
```
On `id` collision: idempotent — returns existing team **without** the
JWT. Caller must call `/rotate` to get a new one.
### 7.2 `DELETE /mcp_server/api/teams/{id}/`
Soft-delete. Sets `active=False`. Old JWT invalid on next call.
### 7.3 `PUT /mcp_server/api/teams/{id}/workspaces/`
Replace the team's workspace assignment set. Idempotent.
**Request**
```json
{ "workspace_ids": ["ws_abc", "ws_def"] }
```
**Response 200**
```json
{ "workspace_ids": ["ws_abc", "ws_def"] }
```
Non-existent workspaces silently accepted (they become active if/when
a `Library` with that `workspace_id` is later created). Mirrors the
Daedalus source of truth.
### 7.4 `POST /mcp_server/api/teams/{id}/rotate/`
Generate a fresh `jti` and JWT, replace `active_jti`. Old JWT invalid
immediately.
**Response 200**
```json
{ "jwt": "eyJhbGci…" }
```
### 7.5 `GET /mcp_server/api/teams/{id}/`
Read-only team detail (no JWT). Used by Daedalus reconciler to
confirm state.
**Response 200**
```json
{
"id": "a3f1…",
"name": "Kottos",
"active": true,
"active_jti": "…", // for diagnostics, not a credential
"workspace_ids": ["ws_abc"]
}
```
---
## 8. Daedalus lifecycle hooks
Mirrors the pattern in `daedalus/backend/daedalus/mnemosyne/lifecycle.py`:
every hook is best-effort, logs errors without blocking the local
operation, and is retried by the reconciler.
### 8.1 `on_pallas_registered(instance)`
1. `POST /mcp_server/api/teams/` with `id=instance.id`, `name=instance.name`.
2. Encrypt JWT via Fernet; store on `instance.team_jwt_encrypted`;
set `pallas_team_mnemosyne_status="provisioned"`.
3. Log `pallas_team_provisioned`.
On failure: status `"failed"`, reconciler retries.
### 8.2 `on_pallas_deleted(instance_id)`
`DELETE /mcp_server/api/teams/{id}/`. Row cascade locally; Mnemosyne
soft-deletes. Best-effort.
### 8.3 `on_workspace_pallas_attached(workspace_id, instance_id)`
1. Read all current `workspace_pallas_assignments` where
`pallas_instance_id=instance_id`.
2. `PUT /mcp_server/api/teams/{id}/workspaces/` with the resulting
workspace_id list.
### 8.4 `on_workspace_pallas_detached(workspace_id, instance_id)`
Symmetric to 8.3.
### 8.5 `on_workspace_deleted(workspace_id)`
For every attached Pallas instance, recompute and push updated
workspace list (so teams lose the deleted workspace).
### 8.6 Reconciler extension
Extends
`daedalus/backend/daedalus/mnemosyne/reconciler.py`:
- Re-runs `POST /teams/` for instances with status NULL or `"failed"`.
- Re-syncs workspace assignments for all instances on every cycle
(cheap idempotent PUT; guards against silent drift).
---
## 9. Operator workflows
### 9.1 Register a new Pallas deployment
1. Operator adds entry in Daedalus admin: `POST /api/v1/pallas`
with the Pallas registry URL. Daedalus fetches the registry,
creates the `PallasInstance` row, then calls Mnemosyne
`POST /mcp_server/api/teams/`. The JWT lands in
`instance.team_jwt_encrypted`.
2. Daedalus admin detail page surfaces the JWT plaintext **once**
(decrypted client-side or via a one-shot "reveal" endpoint that
logs the access). Operator copies it.
3. On the Pallas deployment machine, operator pastes the JWT into
`fastagent.secrets.yaml`:
```yaml
mcp:
servers:
mnemosyne:
transport: http
url: https://mnemosyne.example.helu.ca/mcp/
headers:
Authorization: Bearer eyJhbGci…
```
Operator removes any stale `forward_inbound_auth: true` from the
corresponding entry in `fastagent.config.yaml`. Restart Pallas.
### 9.2 Attach a Pallas team to a workspace
1. Daedalus workspace settings → "Attached Teams" → multi-select
across registered Pallas instances → save.
2. Daedalus fires `on_workspace_pallas_attached`, Mnemosyne's
`TeamWorkspaceAssignment` updates.
3. Agent picker in chat immediately shows agents from that team for
this workspace.
### 9.3 Retire a Pallas deployment
1. Daedalus admin → delete PallasInstance.
2. Daedalus calls `DELETE /mcp_server/api/teams/{id}/`; row marked
inactive in Mnemosyne. JWT rejected on next call.
3. Operator shuts down the Pallas deployment.
### 9.4 Rotate a compromised team JWT
1. Daedalus admin → "Rotate team JWT" action on the PallasInstance.
2. Daedalus calls `POST /mcp_server/api/teams/{id}/rotate/`,
re-encrypts and stores the new JWT.
3. Operator copies the new JWT into the Pallas deployment's
`fastagent.secrets.yaml`, restarts.
### 9.5 Provision existing Pallas instances (one-time migration)
After Mnemosyne phase 2 deploys:
```
$ daedalus manage.py provision_teams
```
Walks all existing `PallasInstance` rows, calls
`POST /mcp_server/api/teams/` for each, stores + prints JWTs in a
table for the operator to distribute. Idempotent: rows already
`provisioned` are skipped.
### 9.6 Issue an MCPToken for a third-party MCP client
1. Mnemosyne admin → MCPTokens → add. Pick user. Library picker is
filtered to libraries where that user has `owner` or `manager`
membership.
2. Submit. Plaintext shown once on the response page.
3. Operator pastes the plaintext into the third-party client's
config (Claude Desktop, Cline, etc.).
---
## 10. UX changes in Daedalus
### 10.1 Workspace → attached Teams
Today: workspaces accumulate `AgentConnection` rows across every
registered Pallas instance; the agent picker is long, sub-agents
share names with parents (e.g. two distinct "Harper" agents), and it
is unclear which workspace grants which agents Mnemosyne access.
New:
- Workspace settings has a new section **Attached Teams**, a
multi-select over registered `PallasInstance` rows. Starts empty on
workspace create.
- Attaching/detaching a team triggers the lifecycle hook that updates
Mnemosyne's `TeamWorkspaceAssignment` for that team.
### 10.2 Agent picker in chat
New behavior:
- Lists only agents belonging to teams attached to the current
workspace.
- Displays each agent as **`TeamName AgentTitle`** so sub-agent
name collisions resolve visually.
- Empty state: "No teams attached to this workspace. Go to workspace
settings → Attached Teams." (links inline).
### 10.3 Agent switching during chat
Unchanged mechanically: each chat turn may target a different
`agent_server_id`. New constraint: the target agent must belong to a
team currently attached to the workspace. The REST endpoint validates
this and returns 403 otherwise. The frontend picker already enforces
it as a UX affordance.
---
## 11. Migration
### 11.1 Current state (pre-migration)
* Mnemosyne is currently not in a released/working deployment; a
fresh rollout is possible.
* Daedalus has existing `PallasInstance` rows (registered via
`POST /api/v1/pallas`) but none have a team JWT.
* Kottos / Mentor / Iolaus each carry `forward_inbound_auth: true`
in `fastagent.config.yaml` and currently rely on the Pallas
forwarding patch to pass Daedalus's per-turn JWT to Mnemosyne.
### 11.2 Order of operations (must follow)
1. **Mnemosyne phase 2 deploys.** REST `/mcp_server/api/teams/` is
live; old per-turn JWT path still works. No consumers yet.
2. **Daedalus phase 4 deploys.** New columns + lifecycle hooks +
`provision_teams` command. On upgrade, migration creates columns
with default NULL status; existing PallasInstances remain
functional on the legacy (non-Mnemosyne) path.
3. **Operator runs `provision_teams`.** Every existing PallasInstance
gets a team in Mnemosyne and a stored JWT.
4. **Operator distributes JWTs** to each Pallas deployment
(Kottos / Mentor / Iolaus / Daedalus-chat). Each deployment
updates `fastagent.secrets.yaml`, removes
`forward_inbound_auth: true`, restarts.
5. **Pallas phase 3 cleanup deploys.** Forwarding infrastructure
removed from Pallas codebase. Safe only after all deployments
have switched to static JWTs.
6. **Daedalus per-turn token path retires.** `mnemosyne/tokens.py`
and its config (`MNEMOSYNE_SIGNING_SECRET`,
`MNEMOSYNE_SIGNING_KID`, `MNEMOSYNE_TOKEN_TTL_SECONDS`) are
removed after Daedalus chat's own team JWT is in place.
### 11.3 Rollback story
* **Mnemosyne phase 2**: safe to roll back — old per-turn JWT path
untouched; new endpoints simply disappear.
* **Daedalus phase 4**: safe to roll back until `provision_teams`
has run. After that, the JWTs are already distributed; rolling
back means the JWTs go unused but nothing breaks.
* **Pallas phase 3**: *not* safe to roll back independently — if any
deployment still has `forward_inbound_auth: true` pointing at code
that no longer exists, that deployment fails to start. Sequence
correctly.
---
## 12. Deprecated / removed
At end-of-migration (after Phase 6):
### Pallas (`pallas/pallas/_fastagent_patch.py`)
- `_DynamicBearerAuth` (httpx Auth subclass)
- `_CurrentBearer` ContextVar plumbing
- `_refresh_forward_servers()` YAML scanner
- `_prepare_headers_and_auth_with_forward` (the header-mutation monkey-patch)
- `_send_request_with_trace`, `_session_call_tool_with_trace`,
`_execute_on_server_with_trace` (diagnostic wrappers installed
because the forwarding path was opaque)
- `install()` function in `_fastagent_patch`; `pallas/__init__.py` no
longer auto-installs
### Agent team configs (`kottos/`, `mentor/`, `iolaus/`)
- `forward_inbound_auth: true` under any server stanza in
`fastagent.config.yaml`
### Daedalus (`daedalus/backend/daedalus/`)
- `mnemosyne/tokens.py` (per-turn JWT mint)
- Config settings: `MNEMOSYNE_SIGNING_SECRET`, `MNEMOSYNE_SIGNING_KID`,
`MNEMOSYNE_TOKEN_TTL_SECONDS`, `MNEMOSYNE_MCP_URL`
### Mnemosyne (`mnemosyne/mcp_server/`)
- `_JWT_ISS` constant-string (replaced by set containing `daedalus`
and `mnemosyne`; `daedalus` entry removed in a later version once
per-turn path is deleted)
- The `_JTI_CACHE` replay logic continues to exist for the per-turn
path until that path retires; team JWTs bypass it entirely
---
## 13. Security
### 13.1 Token lifetimes
* **Opaque MCPToken**: until revoked (admin). Rotation is manual.
* **Per-turn JWT**: ≤ 10 minutes (existing `MNEMOSYNE_TOKEN_TTL_SECONDS`).
Retires with Phase 4 completion.
* **Team JWT**: 10 years. Rationale: operator cannot tolerate a
silent expiry-induced outage in a year. Revocation is explicit via
`Team.active`, `Team.active_jti`, or key rotation.
### 13.2 Revocation levers, in order of granularity
1. `PUT /teams/{id}/workspaces/` with `[]` — team sees nothing, JWT
still validates. Useful for pausing a deployment without
redistributing tokens.
2. `DELETE /teams/{id}/` — team marked inactive. All its tokens
rejected. Restoring requires re-POST (new id) or admin DB edit.
3. `POST /teams/{id}/rotate/` — `active_jti` changes; the token that
leaked stops working; the new JWT must be distributed.
4. `MCPSigningKey.retire()` — nuclear option. All JWTs signed with
that kid stop validating. Re-key + re-issue every team token.
### 13.3 At-rest protection
* `MCPToken.token_hash`: SHA-256 of plaintext; plaintext never stored.
* `MCPSigningKey.secret_hex`: 256-bit hex secret stored in Mnemosyne
DB only (not distributed).
* `PallasInstance.team_jwt_encrypted`: Fernet-encrypted by Daedalus's
`SECRET_KEY` (or `MNEMOSYNE_FERNET_KEY` if configured);
ciphertext at rest.
### 13.4 Audit points
* Every auth failure increments `mcp_auth_failures_total{reason=…}`.
* Every team lifecycle action logs `pallas_team_{provisioned,
deleted, rotated, workspaces_updated}` in Daedalus with full
correlation IDs.
* Every bearer resolution logs the principal type + resolved
library count at DEBUG (INFO until shakedown stabilizes).
### 13.5 Isolation model
Separation of "work" vs "personal" agents composes from three
independent mechanisms:
1. **Per-token scope (Mnemosyne)**: each MCPToken carries its own
`allowed_libraries`. A personal token and a work token may
belong to the same user yet see disjoint Library sets.
2. **Per-workspace attachment (Daedalus)**: a Pallas instance only
sees workspaces explicitly attached to it. Work Pallas and
personal Pallas attach to disjoint workspaces.
3. **Per-Daedalus-instance (deployment)**: the strongest isolation
is two Daedalus deployments pointing at distinct Mnemosyne
accounts. Nothing the operator does in deployment A reaches any
data accessible to deployment B.
For typical operator isolation, (1) + (2) suffice. (3) is the
escape hatch for hard compartmentalization.
---
## 14. Testing
### 14.1 Mnemosyne test surface
* `resolve_mcp_jwt` accepts `iss in {daedalus, mnemosyne}`.
* `typ=team` branch: bypasses replay cache; resolves team + active_jti.
* `typ=team`: rejects if team missing, inactive, or `jti` stale.
* `LibraryMembership`: owner can grant; reader cannot; grant form
filters correctly.
* `MCPToken.allowed_libraries` empty → resolved library set empty.
* `TeamWorkspaceAssignment` PUT is idempotent and replaces, not
unions.
* `/mcp_server/api/teams/` endpoints: create, delete, rotate,
workspaces PUT, all authenticated with a per-user DRF token and
scoped to the team's `owner` (non-owner requests return 404).
### 14.2 Daedalus test surface
* `on_pallas_registered` populates `team_jwt_encrypted` and transitions
status to `provisioned`.
* `on_workspace_pallas_attached` triggers the correct PUT payload.
* Agent-picker endpoint filters `AgentConnection` by attached
Pallas instances.
* `provision_teams` is idempotent.
### 14.3 Integration
* End-to-end: third-party MCP client with MCPToken → Mnemosyne
search scoped to `allowed_libraries`.
* End-to-end: Pallas agent with team JWT → Mnemosyne search scoped
to team's attached workspaces.
* End-to-end: workspace detached from team → agent no longer sees
that workspace's libraries (on next request, not stale-cached).
---
## 15. Phased delivery
| # | Phase | Surface | Deployable independently? |
|---|---|---|---|
| 1 | Design doc | This file | Yes — this document |
| 2 | Mnemosyne | `LibraryMembership`, `MCPToken.allowed_libraries`, `Team`, `TeamWorkspaceAssignment`, unified `auth.py` resolver, `/mcp_server/api/teams/` REST, admin UIs, backfill, tests | Yes — old per-turn JWT path untouched |
| 3 | Pallas cleanup | Remove `_fastagent_patch.py` internals, docs | No — must wait until all deployments use static JWTs |
| 4 | Daedalus integration | `workspace_pallas_assignments`, `team_jwt_encrypted`, `pallas_team_mnemosyne_status`, lifecycle hooks, reconciler, `provision_teams`, admin API, agent-picker filter, register chat as team | Yes — new columns nullable, legacy path still works |
| 5 | Daedalus frontend | Workspace settings attached-teams picker, agent picker namespacing | Yes — backwards-compatible once phase 4 ships |
| 6 | Agent team cutovers | Kottos / Mentor / Iolaus paste JWT, remove `forward_inbound_auth`, restart | Yes — one at a time |
| 7 | Documentation | Mnemosyne README, Pallas README + `docs/auth.md`, Daedalus operator docs, cross-references to this file | Yes |
---
## 16. Open items (v1)
None — all decisions are closed in this revision. Future revisions
that add scope (e.g., team-level library scoping finer than
workspace granularity; OAuth 2.1 for external MCP clients;
per-library audit logs) will ship as `_v2.md` alongside this file.
---
## 17. Cross-references
* Existing Mnemosyne per-turn JWT implementation:
`mnemosyne/mnemosyne/mcp_server/auth.py`,
`mnemosyne/mnemosyne/mcp_server/models.py` (`MCPToken`,
`MCPSigningKey`).
* Existing Daedalus Mnemosyne integration:
`daedalus/backend/daedalus/mnemosyne/` (`client.py`,
`tokens.py`, `lifecycle.py`, `reconciler.py`),
`daedalus/backend/daedalus/api/v1/pallas.py`.
* Existing Pallas auth-forwarding patch (to be removed):
`pallas/pallas/_fastagent_patch.py`,
`pallas/pallas/__init__.py`.