Document the end-state auth/authz model unifying the three services around a bearer → resolved library set abstraction. Replaces the per-turn JWT forwarding scheme with static team JWTs held by Pallas deployments, eliminating custom transport code and the monkey-patch chain that caused opaque failures in agent teams. Also records the UX shift where Daedalus workspaces attach Teams (Pallas instances) rather than individual agents.
744 lines
28 KiB
Markdown
744 lines
28 KiB
Markdown
# 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 set of Library UIDs the caller may
|
||
read; the principal type (opaque `MCPToken`, Daedalus per-turn JWT,
|
||
team JWT) only determines how that set is derived.
|
||
|
||
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: set[str]` per request. Downstream code (search,
|
||
get_document, list_libraries, etc.) only reads that set; it does not
|
||
care where the set came from.
|
||
|
||
```
|
||
Bearer → classify → dispatch
|
||
├─ Opaque MCPToken → allowed_libraries M2M
|
||
├─ per-turn JWT → claims["libs"]
|
||
└─ team JWT (typ=team) → live DB: team.workspaces → libraries
|
||
(filtered by Library.workspace_id)
|
||
↓
|
||
resolved_libraries: set[str]
|
||
↓
|
||
downstream tools
|
||
```
|
||
|
||
Fail-closed: if the resolution produces an empty set, the request sees
|
||
no Libraries. There is no "empty means everything" 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 M2M on existing model)
|
||
```python
|
||
allowed_libraries = models.ManyToManyField(Library, 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 2–3 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.
|
||
pass
|
||
else:
|
||
if _remember_jti(jti, float(exp)):
|
||
raise MCPAuthError("Token replay detected.")
|
||
|
||
return claims
|
||
```
|
||
|
||
Downstream, the middleware branches:
|
||
```python
|
||
if claims.get("typ") == "team":
|
||
team = Team.objects.get(id=uuid_from_sub(claims["sub"]),
|
||
active=True,
|
||
active_jti=claims["jti"])
|
||
resolved_libraries = _libraries_for_team(team)
|
||
else:
|
||
resolved_libraries = claims["libs"]
|
||
```
|
||
|
||
`_libraries_for_team(team)` = all `Library` UIDs whose `workspace_id`
|
||
is in the team's `TeamWorkspaceAssignment` set.
|
||
|
||
---
|
||
|
||
## 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 = token.allowed_libraries.values_list("uid")`.
|
||
4. Fails closed if empty.
|
||
|
||
### 6.2 Daedalus chat per-turn JWT (legacy, retires Phase 4)
|
||
Unchanged from today. `iss=daedalus`, `typ` absent, `libs` carries the
|
||
workspace's user-managed libraries, `ws` carries the workspace id.
|
||
Mnemosyne validates 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 protected
|
||
by the existing `daedalus-service` HTTP Basic account (same auth as
|
||
`/library/api/workspaces/` and `/library/api/ingest/`).
|
||
|
||
### 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 as `daedalus-service`.
|
||
|
||
### 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`.
|