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:
@@ -118,7 +118,7 @@ The MCP server exposes the LLM-facing tools (`search`, `get_chunk`, `list_librar
|
|||||||
cd mnemosyne/
|
cd mnemosyne/
|
||||||
|
|
||||||
# Single command: ASGI server hosting the FastMCP app
|
# Single command: ASGI server hosting the FastMCP app
|
||||||
uvicorn mnemosyne.asgi:app --host 0.0.0.0 --port 22091 --workers 1
|
uvicorn mnemosyne.asgi:app --host 0.0.0.0 --port 231s91 --workers 1
|
||||||
```
|
```
|
||||||
|
|
||||||
The `mcp_server/asgi.py` mounts FastMCP at `/mcp` (Streamable HTTP) and `/mcp/sse` (SSE), with a `/mcp/health` JSON probe for HAProxy/Pallas.
|
The `mcp_server/asgi.py` mounts FastMCP at `/mcp` (Streamable HTTP) and `/mcp/sse` (SSE), with a `/mcp/health` JSON probe for HAProxy/Pallas.
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ x-logging: &default-logging
|
|||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "5"
|
max-file: "5"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Shared build config — build the Mnemosyne image locally from ./Dockerfile
|
||||||
|
# instead of pulling from git.helu.ca. All four Mnemosyne services
|
||||||
|
# (init/app/mcp/worker) share `image: mnemosyne:local`, so Compose builds
|
||||||
|
# once and reuses the resulting image across them.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
x-mnemosyne-build: &mnemosyne-build
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ── Init sidecar: one-shot Postgres migrate + collectstatic + library-type seed. Runs on
|
# ── Init sidecar: one-shot Postgres migrate + collectstatic + library-type seed. Runs on
|
||||||
@@ -88,8 +98,8 @@ services:
|
|||||||
# This sidecar only needs Postgres, Neo4j, static files, and logging env —
|
# This sidecar only needs Postgres, Neo4j, static files, and logging env —
|
||||||
# no S3, no Celery, no LLM encryption key. Keep it that way.
|
# no S3, no Celery, no LLM encryption key. Keep it that way.
|
||||||
init:
|
init:
|
||||||
image: git.helu.ca/r/mnemosyne:latest
|
image: mnemosyne:local
|
||||||
pull_policy: always
|
build: *mnemosyne-build
|
||||||
command: ["init"]
|
command: ["init"]
|
||||||
environment:
|
environment:
|
||||||
# Django core (settings import)
|
# Django core (settings import)
|
||||||
@@ -124,8 +134,8 @@ services:
|
|||||||
# Celery tasks (hence CELERY_BROKER_URL is required here too — Django is
|
# Celery tasks (hence CELERY_BROKER_URL is required here too — Django is
|
||||||
# the producer, the worker is the consumer).
|
# the producer, the worker is the consumer).
|
||||||
app:
|
app:
|
||||||
image: git.helu.ca/r/mnemosyne:latest
|
image: mnemosyne:local
|
||||||
pull_policy: always
|
build: *mnemosyne-build
|
||||||
command: ["web"]
|
command: ["web"]
|
||||||
environment:
|
environment:
|
||||||
# Django core
|
# Django core
|
||||||
@@ -220,8 +230,8 @@ services:
|
|||||||
# the S3 key here only matters if someone exploits a write path in the
|
# the S3 key here only matters if someone exploits a write path in the
|
||||||
# future — keep the credential scoped to read-only in your secret manager.
|
# future — keep the credential scoped to read-only in your secret manager.
|
||||||
mcp:
|
mcp:
|
||||||
image: git.helu.ca/r/mnemosyne:latest
|
image: mnemosyne:local
|
||||||
pull_policy: always
|
build: *mnemosyne-build
|
||||||
command: ["mcp"]
|
command: ["mcp"]
|
||||||
environment:
|
environment:
|
||||||
# Django core (ASGI still imports settings)
|
# Django core (ASGI still imports settings)
|
||||||
@@ -289,8 +299,8 @@ services:
|
|||||||
# backend. Does NOT need HTTP-layer settings (ALLOWED_HOSTS, CSRF, MCP auth)
|
# backend. Does NOT need HTTP-layer settings (ALLOWED_HOSTS, CSRF, MCP auth)
|
||||||
# or search tuning (the worker never serves queries).
|
# or search tuning (the worker never serves queries).
|
||||||
worker:
|
worker:
|
||||||
image: git.helu.ca/r/mnemosyne:latest
|
image: mnemosyne:local
|
||||||
pull_policy: always
|
build: *mnemosyne-build
|
||||||
command: ["worker"]
|
command: ["worker"]
|
||||||
environment:
|
environment:
|
||||||
# Django core (Celery imports settings)
|
# Django core (Celery imports settings)
|
||||||
|
|||||||
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).
|
||||||
@@ -85,8 +85,7 @@ an explicit `when: mnemosyne_first_deploy` flag.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Apply Django ORM migrations (PostgreSQL schema)
|
# Apply Django ORM migrations (PostgreSQL schema)
|
||||||
docker compose -f /srv/mnemosyne/docker-compose.yaml \
|
docker compose -f /srv/mnemosyne/docker-compose.yaml run --rm app migrate
|
||||||
run --rm app migrate
|
|
||||||
|
|
||||||
# Create Neo4j vector + full-text indexes and load library-type defaults
|
# Create Neo4j vector + full-text indexes and load library-type defaults
|
||||||
docker compose -f /srv/mnemosyne/docker-compose.yaml \
|
docker compose -f /srv/mnemosyne/docker-compose.yaml \
|
||||||
@@ -315,17 +314,18 @@ curl http://puck.incus:23181/metrics | head -5
|
|||||||
|
|
||||||
### Verify Daedalus auth (per-user API token)
|
### Verify Daedalus auth (per-user API token)
|
||||||
|
|
||||||
Daedalus now authenticates as a Mnemosyne user via the DRF token shown
|
Daedalus now authenticates as a Mnemosyne user via a `UserToken` minted
|
||||||
on `/profile/settings/`. To smoke-test from a deploy host:
|
at `/profile/tokens/`. To smoke-test from a deploy host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "Authorization: Token <user-api-token>" \
|
curl -H "Authorization: Bearer <user-token-plaintext>" \
|
||||||
https://mnemosyne.ouranos.helu.ca/library/api/workspaces/ws_smoke/ \
|
https://mnemosyne.ouranos.helu.ca/library/api/workspaces/ws_smoke/ \
|
||||||
-o /dev/null -w "%{http_code}"
|
-o /dev/null -w "%{http_code}"
|
||||||
# Expect: 200 if the workspace exists for that user, 404 otherwise.
|
# Expect: 200 if the workspace exists for that user, 404 otherwise.
|
||||||
|
# An anonymous request gets 401 with `WWW-Authenticate: Bearer`.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verify MCP connectivity (from a client with a valid MCPToken)
|
### Verify MCP connectivity (from a client with a valid UserToken)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "Authorization: Bearer <token>" \
|
curl -H "Authorization: Bearer <token>" \
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ This document describes Mnemosyne's role in the Daedalus + Pallas architecture a
|
|||||||
|
|
||||||
Mnemosyne exposes two interfaces for the wider Ouranos ecosystem:
|
Mnemosyne exposes two interfaces for the wider Ouranos ecosystem:
|
||||||
|
|
||||||
1. **REST API** (`/library/api/*`) — consumed by the Daedalus backend authenticated as the owning Mnemosyne user via a per-user DRF token (`Authorization: Token <key>`, surfaced on `/profile/settings/`) for workspace lifecycle and asynchronous file ingestion. Phase 1, **implemented**.
|
1. **REST API** (`/library/api/*`) — consumed by the Daedalus backend authenticated as the owning Mnemosyne user via a per-user `UserToken` (`Authorization: Bearer <plaintext>`, minted at `/profile/tokens/`) for workspace lifecycle and asynchronous file ingestion. Phase 1, **implemented**.
|
||||||
2. **MCP Server** (port 22091 internal, `/mcp/` via nginx on 23090) — exposes search, browse, and retrieval tools. Phase 5 of Mnemosyne's own roadmap, **implemented** with workspace-scoped access control via long-lived team JWTs. Consumed by Pallas FastAgents in production (Daedalus integration Phase 2, **implemented** — see [Phase 3 of this doc](#3-phase-3-long-lived-team-jwt-access-control-for-pallas-instances)).
|
2. **MCP Server** (port 22091 internal, `/mcp/` via nginx on 23090) — exposes search, browse, and retrieval tools. Phase 5 of Mnemosyne's own roadmap, **implemented** with workspace-scoped access control via long-lived team JWTs. Consumed by Pallas FastAgents in production (Daedalus integration Phase 2, **implemented** — see [Phase 3 of this doc](#3-phase-3-long-lived-team-jwt-access-control-for-pallas-instances)).
|
||||||
|
|
||||||
### Phase status
|
### Phase status
|
||||||
@@ -105,7 +105,7 @@ Auth is controlled by `MCP_REQUIRE_AUTH` in `.env`. Production sets it to `True`
|
|||||||
|
|
||||||
## 2. REST API for Daedalus
|
## 2. REST API for Daedalus
|
||||||
|
|
||||||
All endpoints require an `Authorization: Token <key>` header carrying the DRF token of the Mnemosyne user the workspace belongs to (surfaced on `/profile/settings/`). Workspaces are scoped to their creating user via the `Library.owner_username` property; cross-user access returns 404. They are consumed by the Daedalus FastAPI backend only — not by any frontend.
|
All endpoints require an `Authorization: Bearer <plaintext>` header carrying a `UserToken` belonging to the Mnemosyne user the workspace belongs to (minted at `/profile/tokens/`). Workspaces are scoped to their creating user via the `Library.owner_username` property; cross-user access returns 404. Anonymous requests get 401 with `WWW-Authenticate: Bearer`. These endpoints are consumed by the Daedalus FastAPI backend only — not by any frontend.
|
||||||
|
|
||||||
### Workspace lifecycle
|
### Workspace lifecycle
|
||||||
|
|
||||||
@@ -354,7 +354,7 @@ mnemosyne_s3_operations_total{operation,status} counter
|
|||||||
- [x] `GET /library/api/jobs/{job_id}/`, `POST .../retry/`, `GET /library/api/jobs/`
|
- [x] `GET /library/api/jobs/{job_id}/`, `POST .../retry/`, `GET /library/api/jobs/`
|
||||||
- [x] `library.tasks.ingest_from_daedalus` Celery task with content-hash-aware supersede logic
|
- [x] `library.tasks.ingest_from_daedalus` Celery task with content-hash-aware supersede logic
|
||||||
- [x] `library.services.daedalus_s3` cross-bucket fetch + copy
|
- [x] `library.services.daedalus_s3` cross-bucket fetch + copy
|
||||||
- [x] Per-user DRF token auth (`Authorization: Token <key>`); workspaces scoped to the owning user via `Library.owner_username`
|
- [x] Per-user `UserToken` auth (`Authorization: Bearer <plaintext>`, minted at `/profile/tokens/`); workspaces scoped to the owning user via `Library.owner_username`
|
||||||
|
|
||||||
### Phase 2 — MCP Server (Mnemosyne roadmap Phase 5) ✅ Implemented
|
### Phase 2 — MCP Server (Mnemosyne roadmap Phase 5) ✅ Implemented
|
||||||
- [x] `mcp_server/` module following the [Django MCP Pattern](Pattern_Django-MCP_V1-00.md)
|
- [x] `mcp_server/` module following the [Django MCP Pattern](Pattern_Django-MCP_V1-00.md)
|
||||||
|
|||||||
557
docs/ouranos.md
557
docs/ouranos.md
@@ -1,557 +0,0 @@
|
|||||||
# Ouranos Lab
|
|
||||||
|
|
||||||
Infrastructure-as-Code project managing the **Ouranos Lab** — a development sandbox at [ouranos.helu.ca](https://ouranos.helu.ca). Uses **Terraform** for container provisioning and **Ansible** for configuration management, themed around the moons of Uranus.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
| Component | Purpose |
|
|
||||||
|-----------|---------|
|
|
||||||
| **Terraform** | Provisions 10 specialised Incus containers (LXC) with DNS-resolved networking, security policies, and resource dependencies |
|
|
||||||
| **Ansible** | Deploys Docker, databases (PostgreSQL, Neo4j), observability stack (Prometheus, Grafana, Loki), and application runtimes across all hosts |
|
|
||||||
|
|
||||||
> **DNS Domain**: Incus resolves containers via the `.incus` domain suffix (e.g., `oberon.incus`, `portia.incus`). IPv4 addresses are dynamically assigned — always use DNS names, never hardcode IPs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Uranian Host Architecture
|
|
||||||
|
|
||||||
All containers are named after moons of Uranus and resolved via the `.incus` DNS suffix.
|
|
||||||
|
|
||||||
| Name | Role | Description | Nesting |
|
|
||||||
|------|------|-------------|---------|
|
|
||||||
| **ariel** | graph_database | Neo4j — Ethereal graph connections | ✔ |
|
|
||||||
| **caliban** | agent_automation | Agent S MCP Server with MATE Desktop | ✔ |
|
|
||||||
| **miranda** | mcp_docker_host | Dedicated Docker Host for MCP Servers | ✔ |
|
|
||||||
| **oberon** | container_orchestration | Docker Host — MCP Switchboard, RabbitMQ, Open WebUI | ✔ |
|
|
||||||
| **portia** | database | PostgreSQL — Relational database host | ❌ |
|
|
||||||
| **prospero** | observability | PPLG stack — Prometheus, Grafana, Loki, PgAdmin | ❌ |
|
|
||||||
| **puck** | application_runtime | Python App Host — JupyterLab, Django apps, Gitea Runner | ✔ |
|
|
||||||
| **rosalind** | collaboration | Gitea, LobeChat, Nextcloud, AnythingLLM | ✔ |
|
|
||||||
| **sycorax** | language_models | Arke LLM Proxy | ✔ |
|
|
||||||
| **titania** | proxy_sso | HAProxy TLS termination + Casdoor SSO | ✔ |
|
|
||||||
| **umbriel** | graph_database | Neo4j (Mnemosyne) — dedicated memory graph | ✔ |
|
|
||||||
|
|
||||||
### puck — Project Application Runtime
|
|
||||||
|
|
||||||
Shape-shifting trickster embodying Python's versatility.
|
|
||||||
This is the host that runs Python projects in the Ouranos sandbox.
|
|
||||||
It has an RDP server and is generally where application development happens.
|
|
||||||
Each project has a number that is used to determine port numbers.
|
|
||||||
|
|
||||||
- Docker engine
|
|
||||||
- JupyterLab (port 22071 via OAuth2-Proxy)
|
|
||||||
- Gitea Runner (CI/CD agent)
|
|
||||||
- Django Projects: Zelus (221), Angelia (222), Athena (224), Kairos (225), Icarlos (226), MCP Switchboard (227), Spelunker (228), Peitho (229), Mnemosyne (230)
|
|
||||||
- FastAgent Projects: Pallas (240)
|
|
||||||
- FastAPI Projects: Daedalus (200), Arke (201) Kernos (202), Rommie (203), Orpheus (204), Periplus (205), Nike (206), Stentor (207)
|
|
||||||
|
|
||||||
### caliban — Agent Automation
|
|
||||||
|
|
||||||
Autonomous computer agent learning through environmental interaction.
|
|
||||||
|
|
||||||
- Docker engine
|
|
||||||
- Agent S MCP Server (MATE desktop, AT-SPI automation)
|
|
||||||
- Kernos MCP Shell Server (port 22062)
|
|
||||||
- Rommie MCP Server (port 22061) — agent-to-agent GUI automation via Agent S
|
|
||||||
- FreeCAD Robust MCP Server (port 22063) — CAD automation via FreeCAD XML-RPC
|
|
||||||
- GPU passthrough
|
|
||||||
- RDP access (port 25521)
|
|
||||||
|
|
||||||
### oberon — Container Orchestration & Dockerized Shared Services
|
|
||||||
|
|
||||||
King of the Fairies orchestrating containers and managing MCP infrastructure.
|
|
||||||
|
|
||||||
- Docker engine
|
|
||||||
- MCP Switchboard (port 22781) — Django app routing MCP tool calls
|
|
||||||
- RabbitMQ message queue
|
|
||||||
- smtp4dev SMTP test server (port 22025)
|
|
||||||
|
|
||||||
### portia — Relational Database
|
|
||||||
|
|
||||||
Intelligent and resourceful — the reliability of relational databases.
|
|
||||||
|
|
||||||
- PostgreSQL 17 (port 5432)
|
|
||||||
- Databases: `arke`, `anythingllm`, `gitea`, `hass`, `lobechat`, `mcp_switchboard`, `mnemosyne`, `nextcloud`, `openwebui`, `periplus`, `spelunker`
|
|
||||||
|
|
||||||
### ariel — Graph Database
|
|
||||||
|
|
||||||
Air spirit — ethereal, interconnected nature mirroring graph relationships.
|
|
||||||
|
|
||||||
- Neo4j 5.26.0 (Docker)
|
|
||||||
- HTTP API: port 25554
|
|
||||||
- Bolt: port 7687 (reached as `ariel.incus:7687` on the internal network)
|
|
||||||
|
|
||||||
### umbriel — Graph Database (Mnemosyne)
|
|
||||||
|
|
||||||
Dusky melancholy sprite from Pope's *Rape of the Lock* — keeper of the Cave of
|
|
||||||
Spleen, naturally paired with Mnemosyne the Titan of memory. Dedicated Neo4j
|
|
||||||
instance so Mnemosyne's `Library`/`Collection`/`Item`/`Chunk`/`Concept` labels,
|
|
||||||
vector indexes, and schema migrations can't collide with another tenant's
|
|
||||||
graph on Ariel.
|
|
||||||
|
|
||||||
- Neo4j 5.26.0 (Docker)
|
|
||||||
- HTTP Browser: port 25555
|
|
||||||
- Bolt: port 7687 (reached as `umbriel.incus:7687` on the internal network)
|
|
||||||
|
|
||||||
### miranda — MCP Docker Host
|
|
||||||
|
|
||||||
Curious bridge between worlds — hosting MCP server containers.
|
|
||||||
|
|
||||||
- Docker engine (API exposed on port 2375 for MCP Switchboard)
|
|
||||||
- MCPO OpenAI-compatible MCP proxy 22071
|
|
||||||
- Argos MCP Server — web search via SearXNG (port 22062)
|
|
||||||
- Grafana MCP Server (port 22063)
|
|
||||||
- Neo4j MCP Server (port 22064)
|
|
||||||
- Gitea MCP Server (port 22065)
|
|
||||||
|
|
||||||
### prospero — Observability Stack
|
|
||||||
|
|
||||||
Master magician observing all events.
|
|
||||||
|
|
||||||
- PPLG stack via Docker Compose: Prometheus, Loki, Grafana, PgAdmin
|
|
||||||
- Internal HAProxy with OAuth2-Proxy for all dashboards
|
|
||||||
- AlertManager with Pushover notifications
|
|
||||||
- Prometheus metrics collection (`node-exporter`, HAProxy, Loki)
|
|
||||||
- Loki log aggregation via Alloy (all hosts)
|
|
||||||
- Grafana dashboard suite with Casdoor SSO integration
|
|
||||||
|
|
||||||
### rosalind — Third Party Applications for testing and evaluation
|
|
||||||
|
|
||||||
Witty and resourceful moon for PHP, Go, and Node.js runtimes.
|
|
||||||
|
|
||||||
- SearXNG privacy search (port 22083, behind OAuth2-Proxy)
|
|
||||||
- Gitea self-hosted Git (port 22082, SSH on 22022)
|
|
||||||
- LobeChat AI chat interface (port 22081)
|
|
||||||
- Nextcloud file sharing and collaboration (port 22083)
|
|
||||||
- AnythingLLM document AI workspace (port 22084)
|
|
||||||
- Nextcloud data on dedicated Incus storage volume
|
|
||||||
- Open WebUI LLM interface (port 22088, PostgreSQL backend on Portia
|
|
||||||
- Home Assistant (port 8123)
|
|
||||||
|
|
||||||
### sycorax — Language Models
|
|
||||||
|
|
||||||
Original magical power wielding language magic.
|
|
||||||
|
|
||||||
- Arke LLM API Proxy (port 25540)
|
|
||||||
- Multi-provider support (OpenAI, Anthropic, etc.)
|
|
||||||
- Session management with Memcached
|
|
||||||
- Database backend on Portia
|
|
||||||
|
|
||||||
### titania — Proxy & SSO Services
|
|
||||||
|
|
||||||
Queen of the Fairies managing access control and authentication.
|
|
||||||
|
|
||||||
- HAProxy 3.x with TLS termination (port 443)
|
|
||||||
- Let's Encrypt wildcard certificate via certbot DNS-01 (Namecheap)
|
|
||||||
- HTTP to HTTPS redirect (port 80)
|
|
||||||
- Gitea SSH proxy (port 22022)
|
|
||||||
- Casdoor SSO (port 22081, local PostgreSQL)
|
|
||||||
- Prometheus metrics at `:8404/metrics`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Port Numbering
|
|
||||||
|
|
||||||
Well-known ports running as a service may be used: Postgresql 5432, Prometheus Metrics 9100.
|
|
||||||
|
|
||||||
However inside a docker project, the number plan needs to be followed to avoid port conflicts and confusion:
|
|
||||||
XXXYZ
|
|
||||||
XXX Project Number or 220 for external project
|
|
||||||
Y Service: 0 reserved, 1-4 flexible, 5 database, 6 MCP, 7 API, 8 Web App, 9 Prometheus metrics
|
|
||||||
Z Instance: The running instance of this app on the same host, starting at 1. May also be used to handle exceptions.
|
|
||||||
|
|
||||||
255 Incus port forwarding: Ports in ths range are forwarded from the Incus host to Incus containers (defined in Terraform)
|
|
||||||
|
|
||||||
514ZZ is the syslog port. Docker containers send their syslog to an Alloy syslog collector port. ZZ is the application instance, they just need to be different on the same host and increment from 01.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Application Conventions
|
|
||||||
|
|
||||||
Standards that all services deployed in Ouranos MUST follow. For full logging standards and anti-patterns, see [red_panda_standards.md](red_panda_standards.md).
|
|
||||||
|
|
||||||
### Health Check Endpoints
|
|
||||||
|
|
||||||
All services MUST expose Kubernetes-style health endpoints:
|
|
||||||
|
|
||||||
| Endpoint | Purpose | Auth |
|
|
||||||
|----------|---------|------|
|
|
||||||
| `GET /live` | **Liveness** — process is running and accepting connections | None |
|
|
||||||
| `GET /ready` | **Readiness** — process is running AND all dependencies (DB, cache, upstream APIs) are healthy | None |
|
|
||||||
| `GET /metrics` | Prometheus metrics (see below) | IP-restricted |
|
|
||||||
|
|
||||||
- HAProxy checks `health_path` (typically `/ready/`) for backend health — return HTTP 200 when healthy
|
|
||||||
- Health endpoints MUST NOT require authentication (no JWT, no session)
|
|
||||||
- Third-party services use their native health paths (e.g., `/api/health`, `/api/healthz`, `/-/healthy`)
|
|
||||||
|
|
||||||
### Health Checks in Docker Compose
|
|
||||||
|
|
||||||
Use `curl -f` for Docker Compose healthchecks. Install curl in images if needed.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/live"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging Conventions
|
|
||||||
|
|
||||||
Log output flows through: **App → syslog (RFC3164) → Alloy → Loki → Grafana**
|
|
||||||
|
|
||||||
| Level | Usage |
|
|
||||||
|-------|-------|
|
|
||||||
| **ERROR** | Broken state requiring human action — always include `exc_info=True`, error type, and context |
|
|
||||||
| **WARNING** | Degraded but recovering — client disconnects, performance outliers, client-side exceptions, leaked markup |
|
|
||||||
| **INFO** | Lifecycle events — service start/stop, connections, requests completed, jobs finished |
|
|
||||||
| **DEBUG** | Diagnostic detail — SSE events, keepalive pings, health check 200 responses, negotiation steps |
|
|
||||||
|
|
||||||
**Health check responses MUST be logged at DEBUG only.** HAProxy and Prometheus probe endpoints every 15-30 seconds. Logging these at INFO floods syslog with thousands of identical `200 OK` lines per hour, burying real events.
|
|
||||||
|
|
||||||
### Protected vs Unprotected Endpoints
|
|
||||||
|
|
||||||
| Protected (require valid JWT) | Unprotected |
|
|
||||||
|-------------------------------|-------------|
|
|
||||||
| All `/api/v1/*` routes | `GET /live` |
|
|
||||||
| | `GET /ready` |
|
|
||||||
| | `GET /metrics` (IP-restricted to internal networks) |
|
|
||||||
| | `GET /api/auth/login-url` |
|
|
||||||
| | `POST /api/auth/token` |
|
|
||||||
| | `POST /api/v1/telemetry` (sendBeacon cannot set headers) |
|
|
||||||
|
|
||||||
### Prometheus Metrics
|
|
||||||
|
|
||||||
All services SHOULD expose `GET /metrics` in Prometheus exposition format, scraped by Prospero's Prometheus (default 15s interval).
|
|
||||||
|
|
||||||
- **IP-restricted** to internal networks only (`10.10.0.0/24`, `172.16.0.0/12`, `127.0.0.0/8`)
|
|
||||||
- Consider exposing: request counts/durations, error rates, active connections, queue depths, dependency health
|
|
||||||
|
|
||||||
### Browser Telemetry
|
|
||||||
|
|
||||||
Frontend/browser code MUST send telemetry data and errors back to the application's telemetry API:
|
|
||||||
|
|
||||||
- `POST /api/v1/telemetry` — unprotected (browser `sendBeacon` cannot set Authorization headers)
|
|
||||||
- Capture and report: JavaScript exceptions, performance metrics, user-facing errors
|
|
||||||
- Client-side exceptions should log as **WARNING** on the server (they indicate a problem but not a server-side failure)
|
|
||||||
|
|
||||||
### Docker Networking
|
|
||||||
|
|
||||||
- Use the **default Docker bridge network** for simple deployments
|
|
||||||
- Add additional named networks only when required (e.g., isolating database traffic) or explicitly requested
|
|
||||||
- Do not create custom network definitions for single-service Docker Compose stacks
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## External Access via HAProxy
|
|
||||||
|
|
||||||
Titania provides TLS termination and reverse proxy for all services.
|
|
||||||
|
|
||||||
- **Base domain**: `ouranos.helu.ca`
|
|
||||||
- **HTTPS**: port 443 (standard)
|
|
||||||
- **HTTP**: port 80 (redirects to HTTPS)
|
|
||||||
- **Certificate**: Let's Encrypt wildcard via certbot DNS-01
|
|
||||||
|
|
||||||
### Route Table
|
|
||||||
|
|
||||||
| Subdomain | Backend | Service |
|
|
||||||
|-----------|---------|---------|
|
|
||||||
| `ouranos.helu.ca` (root) | puck.incus:22281 | Angelia (Django) |
|
|
||||||
| `alertmanager.ouranos.helu.ca` | prospero.incus:443 (SSL) | AlertManager |
|
|
||||||
| `angelia.ouranos.helu.ca` | puck.incus:22281 | Angelia (Django) |
|
|
||||||
| `anythingllm.ouranos.helu.ca` | rosalind.incus:22084 | AnythingLLM |
|
|
||||||
| `arke.ouranos.helu.ca` | sycorax.incus:25540 | Arke LLM Proxy |
|
|
||||||
| `athena.ouranos.helu.ca` | puck.incus:22481 | Athena (Django) |
|
|
||||||
| `gitea.ouranos.helu.ca` | rosalind.incus:22082 | Gitea |
|
|
||||||
| `grafana.ouranos.helu.ca` | prospero.incus:443 (SSL) | Grafana |
|
|
||||||
| `hass.ouranos.helu.ca` | oberon.incus:8123 | Home Assistant |
|
|
||||||
| `id.ouranos.helu.ca` | titania.incus:22081 | Casdoor SSO |
|
|
||||||
| `icarlos.ouranos.helu.ca` | puck.incus:22681 | Icarlos (Django) |
|
|
||||||
| `jupyterlab.ouranos.helu.ca` | puck.incus:22071 | JupyterLab (OAuth2-Proxy) |
|
|
||||||
| `kairos.ouranos.helu.ca` | puck.incus:22581 | Kairos (Django) |
|
|
||||||
| `lobechat.ouranos.helu.ca` | rosalind.incus:22081 | LobeChat |
|
|
||||||
| `loki.ouranos.helu.ca` | prospero.incus:443 (SSL) | Loki |
|
|
||||||
| `mcp-switchboard.ouranos.helu.ca` | oberon.incus:22781 | MCP Switchboard |
|
|
||||||
| `nextcloud.ouranos.helu.ca` | rosalind.incus:22083 | Nextcloud |
|
|
||||||
| `openwebui.ouranos.helu.ca` | oberon.incus:22088 | Open WebUI |
|
|
||||||
| `peitho.ouranos.helu.ca` | puck.incus:22981 | Peitho (Django) |
|
|
||||||
| `periplus.ouranos.helu.ca` | puck.incus:20681 | Periplus (FastAPI + MCP via nginx) |
|
|
||||||
| `pgadmin.ouranos.helu.ca` | prospero.incus:443 (SSL) | PgAdmin 4 |
|
|
||||||
| `prometheus.ouranos.helu.ca` | prospero.incus:443 (SSL) | Prometheus |
|
|
||||||
| `searxng.ouranos.helu.ca` | oberon.incus:22073 | SearXNG (OAuth2-Proxy) |
|
|
||||||
| `smtp4dev.ouranos.helu.ca` | oberon.incus:22085 | smtp4dev |
|
|
||||||
| `spelunker.ouranos.helu.ca` | puck.incus:22881 | Spelunker (Django) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Infrastructure Management
|
|
||||||
|
|
||||||
### Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Provision containers
|
|
||||||
cd terraform
|
|
||||||
terraform init
|
|
||||||
terraform plan
|
|
||||||
terraform apply
|
|
||||||
|
|
||||||
# Start all containers
|
|
||||||
cd ../ansible
|
|
||||||
source ~/env/ouranos/bin/activate
|
|
||||||
ansible-playbook sandbox_up.yml
|
|
||||||
|
|
||||||
# Deploy all services
|
|
||||||
ansible-playbook site.yml
|
|
||||||
|
|
||||||
# Stop all containers
|
|
||||||
ansible-playbook sandbox_down.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
### Python Virtual Environment Setup
|
|
||||||
|
|
||||||
The Ansible automation requires a Python virtual environment with the `ansible` package installed. Create and activate the environment from the `~` directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create virtual environment
|
|
||||||
cd ~
|
|
||||||
python3 -m venv env/ouranos
|
|
||||||
|
|
||||||
# Activate environment
|
|
||||||
source ~/env/ouranos/bin/activate
|
|
||||||
|
|
||||||
# Install Ansible
|
|
||||||
pip install ansible
|
|
||||||
pip install ansible-core
|
|
||||||
pip install ansible-community.postgresql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ansible Playbook Syntax Check
|
|
||||||
|
|
||||||
Before running playbooks, use the `apsc.sh` utility (in PATH) to quickly validate YAML syntax:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From the ansible directory
|
|
||||||
apsc.sh
|
|
||||||
|
|
||||||
# This will check all YAML files in the current directory for syntax errors
|
|
||||||
```
|
|
||||||
|
|
||||||
### Terraform Workflow
|
|
||||||
|
|
||||||
1. **Define** — Containers, networks, and resources in `*.tf` files
|
|
||||||
2. **Plan** — Review changes with `terraform plan`
|
|
||||||
3. **Apply** — Provision with `terraform apply`
|
|
||||||
4. **Verify** — Check outputs and container status
|
|
||||||
|
|
||||||
### Terraform Import
|
|
||||||
|
|
||||||
When containers or other resources are created manually (outside Terraform) or need to be re-imported after recreation, use `terraform import` to sync the Terraform state with existing infrastructure.
|
|
||||||
|
|
||||||
#### Import Syntax
|
|
||||||
|
|
||||||
The correct import format for Incus resources requires quoting resource addresses with `for_each` keys and using the full ID including image fingerprints:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Import a container with correct syntax
|
|
||||||
terraform import 'incus_instance.uranian_hosts["<name>"]' ouranos/<name>,image=<fingerprint>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Getting Image Fingerprints
|
|
||||||
|
|
||||||
First, get the fingerprint of the image resource from Terraform state:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd terraform
|
|
||||||
terraform state show incus_image.noble | grep fingerprint
|
|
||||||
# Output: fingerprint = "75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644"
|
|
||||||
|
|
||||||
terraform state show incus_image.questing | grep fingerprint
|
|
||||||
# Output: fingerprint = "e78dd4a406b7fa3592ed0a6048862260b3d2e50c76e32a6169930245c0a13fdf"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Importing All Uranian Hosts
|
|
||||||
|
|
||||||
Replace containers missing from state (or re-import after manual recreation):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Containers using noble image
|
|
||||||
terraform import 'incus_instance.uranian_hosts["ariel"]' ouranos/ariel,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
|
|
||||||
terraform import 'incus_instance.uranian_hosts["miranda"]' ouranos/miranda,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
|
|
||||||
terraform import 'incus_instance.uranian_hosts["oberon"]' ouranos/oberon,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
|
|
||||||
terraform import 'incus_instance.uranian_hosts["portia"]' ouranos/portia,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
|
|
||||||
terraform import 'incus_instance.uranian_hosts["prospero"]' ouranos/prospero,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
|
|
||||||
terraform import 'incus_instance.uranian_hosts["rosalind"]' ouranos/rosalind,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
|
|
||||||
terraform import 'incus_instance.uranian_hosts["sycorax"]' ouranos/sycorax,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
|
|
||||||
terraform import 'incus_instance.uranian_hosts["titania"]' ouranos/titania,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
|
|
||||||
terraform import 'incus_instance.uranian_hosts["umbriel"]' ouranos/umbriel,image=75cde3e755b0e657c05f67e03a42683217b233b0339448be747845747df58644
|
|
||||||
|
|
||||||
# Containers using questing image
|
|
||||||
terraform import 'incus_instance.uranian_hosts["caliban"]' ouranos/caliban,image=e78dd4a406b7fa3592ed0a6048862260b3d2e50c76e32a6169930245c0a13fdf
|
|
||||||
terraform import 'incus_instance.uranian_hosts["puck"]' ouranos/puck,image=e78dd4a406b7fa3592ed0a6048862260b3d2e50c76e32a6169930245c0a13fdf
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Storage Bucket Import
|
|
||||||
|
|
||||||
For storage buckets, use the `<project>/<pool>/<name>` format:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
terraform import incus_storage_bucket.<name> ouranos/default/<bucket-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Common Issues
|
|
||||||
|
|
||||||
1. **Import ID format errors**: Use quotes around resource addresses with `for_each` keys: `'incus_instance.uranian_hosts["name"]'`
|
|
||||||
|
|
||||||
2. **Image replacement on import**: Importing without specifying the image fingerprint will cause Terraform to replace the container on next apply. Always include `image=<fingerprint>` in the import ID.
|
|
||||||
|
|
||||||
3. **Tainted state**: If a resource shows "will be created" but already exists, it may be tainted. Remove from state and re-import:
|
|
||||||
```bash
|
|
||||||
terraform state rm 'incus_instance.uranian_hosts["name"]'
|
|
||||||
terraform import 'incus_instance.uranian_hosts["name"]' ouranos/name,image=<fingerprint>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Verify Import
|
|
||||||
|
|
||||||
After importing, verify with `terraform plan`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
terraform plan
|
|
||||||
# Should show: Plan: 0 to add, 0 to change, 0 to destroy
|
|
||||||
# (Minor "update in-place" changes are normal for state sync of computed attributes)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ansible Workflow
|
|
||||||
|
|
||||||
1. **Bootstrap** — Update packages, install essentials (`apt_update.yml`)
|
|
||||||
2. **Agents** — Deploy Alloy (log/metrics) and Node Exporter on all hosts
|
|
||||||
3. **Services** — Configure databases, Docker, applications, observability
|
|
||||||
4. **Verify** — Check service health and connectivity
|
|
||||||
|
|
||||||
### Vault Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Edit secrets
|
|
||||||
ansible-vault edit inventory/group_vars/all/vault.yml
|
|
||||||
|
|
||||||
# View secrets
|
|
||||||
ansible-vault view inventory/group_vars/all/vault.yml
|
|
||||||
|
|
||||||
# Encrypt a new file
|
|
||||||
ansible-vault encrypt new_secrets.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## S3 Storage Provisioning
|
|
||||||
|
|
||||||
Terraform provisions Incus S3 buckets for services requiring object storage:
|
|
||||||
|
|
||||||
| Service | Host | Purpose |
|
|
||||||
|---------|------|---------|
|
|
||||||
| **Casdoor** | Titania | User avatars and SSO resource storage |
|
|
||||||
| **LobeChat** | Rosalind | File uploads and attachments |
|
|
||||||
|
|
||||||
> S3 credentials (access key, secret key, endpoint) are stored as sensitive Terraform outputs and managed in Ansible Vault with the `vault_*_s3_*` prefix.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ansible Automation
|
|
||||||
|
|
||||||
### Full Deployment (`site.yml`)
|
|
||||||
|
|
||||||
Playbooks run in dependency order:
|
|
||||||
|
|
||||||
| Playbook | Hosts | Purpose |
|
|
||||||
|----------|-------|---------|
|
|
||||||
| `apt_update.yml` | All | Update packages and install essentials |
|
|
||||||
| `alloy/deploy.yml` | All | Grafana Alloy log/metrics collection |
|
|
||||||
| `prometheus/node_deploy.yml` | All | Node Exporter metrics |
|
|
||||||
| `docker/deploy.yml` | Oberon, Ariel, Miranda, Puck, Rosalind, Sycorax, Caliban, Titania | Docker engine |
|
|
||||||
| `smtp4dev/deploy.yml` | Oberon | SMTP test server |
|
|
||||||
| `pplg/deploy.yml` | Prospero | Full observability stack + HAProxy + OAuth2-Proxy |
|
|
||||||
| `postgresql/deploy.yml` | Portia | PostgreSQL with all databases |
|
|
||||||
| `postgresql_ssl/deploy.yml` | Titania | Dedicated PostgreSQL for Casdoor |
|
|
||||||
| `neo4j/deploy.yml` | Ariel, Umbriel | Neo4j graph database (Umbriel is the dedicated Mnemosyne instance) |
|
|
||||||
| `searxng/deploy.yml` | Oberon | SearXNG privacy search |
|
|
||||||
| `haproxy/deploy.yml` | Titania | HAProxy TLS termination and routing |
|
|
||||||
| `casdoor/deploy.yml` | Titania | Casdoor SSO |
|
|
||||||
| `mcpo/deploy.yml` | Miranda | MCPO MCP proxy |
|
|
||||||
| `openwebui/deploy.yml` | Oberon | Open WebUI LLM interface |
|
|
||||||
| `hass/deploy.yml` | Oberon | Home Assistant |
|
|
||||||
| `gitea/deploy.yml` | Rosalind | Gitea self-hosted Git |
|
|
||||||
| `nextcloud/deploy.yml` | Rosalind | Nextcloud collaboration |
|
|
||||||
|
|
||||||
### Individual Service Deployments
|
|
||||||
|
|
||||||
Services with standalone deploy playbooks (not in `site.yml`):
|
|
||||||
|
|
||||||
| Playbook | Host | Service |
|
|
||||||
|----------|------|---------|
|
|
||||||
| `anythingllm/deploy.yml` | Rosalind | AnythingLLM document AI |
|
|
||||||
| `arke/deploy.yml` | Sycorax | Arke LLM proxy |
|
|
||||||
| `argos/deploy.yml` | Miranda | Argos MCP web search server |
|
|
||||||
| `caliban/deploy.yml` | Caliban | Agent S MCP Server |
|
|
||||||
| `certbot/deploy.yml` | Titania | Let's Encrypt certificate renewal |
|
|
||||||
| `gitea_mcp/deploy.yml` | Miranda | Gitea MCP Server |
|
|
||||||
| `gitea_runner/deploy.yml` | Puck | Gitea CI/CD runner |
|
|
||||||
| `grafana_mcp/deploy.yml` | Miranda | Grafana MCP Server |
|
|
||||||
| `jupyterlab/deploy.yml` | Puck | JupyterLab + OAuth2-Proxy |
|
|
||||||
| `kernos/deploy.yml` | Caliban | Kernos MCP shell server |
|
|
||||||
| `lobechat/deploy.yml` | Rosalind | LobeChat AI chat |
|
|
||||||
| `rommie/deploy.yml` | Caliban | Rommie MCP server (Agent S GUI automation) |
|
|
||||||
| `neo4j_mcp/deploy.yml` | Miranda | Neo4j MCP Server |
|
|
||||||
| `freecad_mcp/deploy.yml` | Caliban | FreeCAD Robust MCP Server |
|
|
||||||
| `rabbitmq/deploy.yml` | Oberon | RabbitMQ message queue |
|
|
||||||
|
|
||||||
### Lifecycle Playbooks
|
|
||||||
|
|
||||||
| Playbook | Purpose |
|
|
||||||
|----------|---------|
|
|
||||||
| `sandbox_up.yml` | Start all Uranian host containers |
|
|
||||||
| `sandbox_down.yml` | Gracefully stop all containers |
|
|
||||||
| `apt_update.yml` | Update packages on all hosts |
|
|
||||||
| `site.yml` | Full deployment orchestration |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Flow Architecture
|
|
||||||
|
|
||||||
### Observability Pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
All Hosts Prospero Alerts
|
|
||||||
Alloy + Node Exporter → Prometheus + Loki + Grafana → AlertManager + Pushover
|
|
||||||
collect metrics & logs storage & visualisation notifications
|
|
||||||
```
|
|
||||||
|
|
||||||
### Integration Points
|
|
||||||
|
|
||||||
| Consumer | Provider | Connection |
|
|
||||||
|----------|----------|-----------|
|
|
||||||
| All LLM apps | Arke (Sycorax) | `http://sycorax.incus:25540` |
|
|
||||||
| Open WebUI, Arke, Gitea, Nextcloud, LobeChat | PostgreSQL (Portia) | `portia.incus:5432` |
|
|
||||||
| Neo4j MCP | Neo4j (Ariel) | `ariel.incus:7687` (Bolt) |
|
|
||||||
| Mnemosyne | Neo4j (Umbriel) | `umbriel.incus:7687` (Bolt) — dedicated tenant |
|
|
||||||
| MCP Switchboard | Docker API (Miranda) | `tcp://miranda.incus:2375` |
|
|
||||||
| MCP Switchboard | RabbitMQ (Oberon) | `oberon.incus:5672` |
|
|
||||||
| Kairos, Spelunker | RabbitMQ (Oberon) | `oberon.incus:5672` |
|
|
||||||
| SMTP (all apps) | smtp4dev (Oberon) | `oberon.incus:22025` |
|
|
||||||
| All hosts | Loki (Prospero) | `http://prospero.incus:3100` |
|
|
||||||
| All hosts | Prometheus (Prospero) | `http://prospero.incus:9090` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
⚠️ **Alloy Host Variables Required** — Every host with `alloy` in its `services` list must define `alloy_log_level` in `inventory/host_vars/<host>.incus.yml`. The playbook will fail with an undefined variable error if this is missing.
|
|
||||||
|
|
||||||
⚠️ **Alloy Syslog Listeners Required for Docker Services** — Any Docker Compose service using the syslog logging driver must have a corresponding `loki.source.syslog` listener in the host's Alloy config template (`ansible/alloy/<hostname>/config.alloy.j2`). Missing listeners cause Docker containers to fail on start.
|
|
||||||
|
|
||||||
⚠️ **Local Terraform State** — This project uses local Terraform state (no remote backend). Do not run `terraform apply` from multiple machines simultaneously.
|
|
||||||
|
|
||||||
⚠️ **Nested Docker** — Docker runs inside Incus containers (nested), requiring `security.nesting = true` and `lxc.apparmor.profile=unconfined` AppArmor override on all Docker-enabled hosts.
|
|
||||||
|
|
||||||
⚠️ **Deployment Order** — Prospero (observability) must be fully deployed before other hosts, as Alloy on every host pushes logs and metrics to `prospero.incus`. Run `pplg/deploy.yml` before `site.yml` on a fresh environment.
|
|
||||||
@@ -686,6 +686,29 @@ def concept_graph(request, uid):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _job_for_user_or_none(job_id, username):
|
||||||
|
"""Load an ``IngestJob`` visible to ``username``, else ``None``.
|
||||||
|
|
||||||
|
Visibility: the job's ``library_uid`` must resolve to a Library with
|
||||||
|
``owner_username`` either null (global) or matching ``username``.
|
||||||
|
Callers translate ``None`` into a 404 with generic wording — cross-
|
||||||
|
user reads must not disclose existence.
|
||||||
|
"""
|
||||||
|
from library.models import IngestJob, Library
|
||||||
|
|
||||||
|
try:
|
||||||
|
job = IngestJob.objects.get(pk=job_id)
|
||||||
|
except IngestJob.DoesNotExist:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
lib = Library.nodes.get(uid=job.library_uid)
|
||||||
|
except Library.DoesNotExist:
|
||||||
|
return None
|
||||||
|
if lib.owner_username and lib.owner_username != username:
|
||||||
|
return None
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def ingest_create(request):
|
def ingest_create(request):
|
||||||
@@ -733,6 +756,17 @@ def ingest_create(request):
|
|||||||
status=status.HTTP_404_NOT_FOUND,
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Owner-scope (workspace-scoped libraries only) ---
|
||||||
|
# Global libraries (owner_username is null) stay shared; workspace
|
||||||
|
# libraries are visible only to their creating user. Cross-user
|
||||||
|
# callers get the same wording as the not-found branch above so the
|
||||||
|
# endpoint doesn't disclose existence across users.
|
||||||
|
if lib.owner_username and lib.owner_username != request.user.username:
|
||||||
|
return Response(
|
||||||
|
{"detail": f"Workspace '{workspace_id or library_uid}' not registered."},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
# --- Idempotency check on (library, source_ref, content_hash) ---
|
# --- Idempotency check on (library, source_ref, content_hash) ---
|
||||||
source_ref = data.get("source_ref") or ""
|
source_ref = data.get("source_ref") or ""
|
||||||
content_hash = data["content_hash"]
|
content_hash = data["content_hash"]
|
||||||
@@ -804,11 +838,8 @@ def ingest_create(request):
|
|||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def ingest_job_detail(request, job_id):
|
def ingest_job_detail(request, job_id):
|
||||||
"""Get the current status of an IngestJob."""
|
"""Get the current status of an IngestJob."""
|
||||||
from library.models import IngestJob
|
job = _job_for_user_or_none(job_id, request.user.username)
|
||||||
|
if job is None:
|
||||||
try:
|
|
||||||
job = IngestJob.objects.get(pk=job_id)
|
|
||||||
except IngestJob.DoesNotExist:
|
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND
|
{"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
@@ -820,12 +851,10 @@ def ingest_job_detail(request, job_id):
|
|||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def ingest_job_retry(request, job_id):
|
def ingest_job_retry(request, job_id):
|
||||||
"""Re-dispatch a failed IngestJob."""
|
"""Re-dispatch a failed IngestJob."""
|
||||||
from library.models import IngestJob
|
|
||||||
from library.tasks import ingest_from_daedalus
|
from library.tasks import ingest_from_daedalus
|
||||||
|
|
||||||
try:
|
job = _job_for_user_or_none(job_id, request.user.username)
|
||||||
job = IngestJob.objects.get(pk=job_id)
|
if job is None:
|
||||||
except IngestJob.DoesNotExist:
|
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND
|
{"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
@@ -852,10 +881,18 @@ def ingest_job_retry(request, job_id):
|
|||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def ingest_job_list(request):
|
def ingest_job_list(request):
|
||||||
"""List recent IngestJob rows, optionally filtered by status / library_uid."""
|
"""List recent IngestJob rows, optionally filtered by status / library_uid.
|
||||||
from library.models import IngestJob
|
|
||||||
|
|
||||||
qs = IngestJob.objects.all()
|
Scoped to libraries the caller owns (plus global libraries that have
|
||||||
|
no ``owner_username``). A ``library_uid`` query param the caller has
|
||||||
|
no access to silently returns an empty list — same wording as a
|
||||||
|
not-found job.
|
||||||
|
"""
|
||||||
|
from library.models import IngestJob
|
||||||
|
from library.utils import library_uids_for_user
|
||||||
|
|
||||||
|
visible_uids = library_uids_for_user(request.user.username)
|
||||||
|
qs = IngestJob.objects.filter(library_uid__in=visible_uids)
|
||||||
status_filter = request.query_params.get("status")
|
status_filter = request.query_params.get("status")
|
||||||
library_uid = request.query_params.get("library_uid")
|
library_uid = request.query_params.get("library_uid")
|
||||||
limit = min(int(request.query_params.get("limit", 50)), 200)
|
limit = min(int(request.query_params.get("limit", 50)), 200)
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ It uses the same Library node as a global library; the difference is that
|
|||||||
`workspace_id` is set, and search must filter on it.
|
`workspace_id` is set, and search must filter on it.
|
||||||
|
|
||||||
These endpoints are called by the Daedalus backend authenticated as the
|
These endpoints are called by the Daedalus backend authenticated as the
|
||||||
Mnemosyne user the workspace belongs to (per-user DRF token). The
|
Mnemosyne user the workspace belongs to (per-user ``UserToken``,
|
||||||
|
``Authorization: Bearer <plaintext>``, minted at ``/profile/tokens/``). The
|
||||||
workspace's owning user is recorded on the Library node as
|
workspace's owning user is recorded on the Library node as
|
||||||
``owner_username``; every read and mutation is scoped to that user.
|
``owner_username``; every read and mutation is scoped to that user.
|
||||||
Non-owners receive 404 so a workspace's existence isn't disclosed
|
Non-owners receive 404 so a workspace's existence isn't disclosed
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Authorization is expressed by the caller as a ``resolved_libraries``
|
# Authorization is expressed by the caller as a ``resolved_libraries``
|
||||||
# list — see §3.3 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. The
|
# list — see §3.3 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. The
|
||||||
# MCP auth middleware materializes it from the bearer token (opaque
|
# MCP auth middleware materializes it from the bearer token (opaque
|
||||||
# MCPToken.allowed_libraries, per-turn JWT ``libs`` claim, or live
|
# UserToken.allowed_libraries, per-turn JWT ``libs`` claim, or live
|
||||||
# ``Team → TeamWorkspaceAssignment → Library.workspace_id`` join) and
|
# ``Team → TeamWorkspaceAssignment → Library.workspace_id`` join) and
|
||||||
# trusted in-process callers (Django admin page, DRF session-auth'd
|
# trusted in-process callers (Django admin page, DRF session-auth'd
|
||||||
# search endpoint, ``manage.py search``) either pass the full set from
|
# search endpoint, ``manage.py search``) either pass the full set from
|
||||||
|
|||||||
@@ -23,6 +23,34 @@ def neo4j_available():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def library_uids_for_user(username: str) -> set[str]:
|
||||||
|
"""Return the UIDs of every Library this user may read on the REST surface.
|
||||||
|
|
||||||
|
A Library is visible to ``username`` if it has no ``owner_username``
|
||||||
|
(global / shared) or its ``owner_username`` matches. Used by the
|
||||||
|
ingest job endpoints to filter rows to the calling user's
|
||||||
|
workspaces — mirrors the owner-scoping on
|
||||||
|
``/library/api/workspaces/`` and ``/mcp_server/api/teams/``.
|
||||||
|
|
||||||
|
Returns the empty set when Neo4j is unreachable (fail-closed).
|
||||||
|
"""
|
||||||
|
if not neo4j_available():
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
from neomodel import db
|
||||||
|
|
||||||
|
rows, _ = db.cypher_query(
|
||||||
|
"MATCH (l:Library) "
|
||||||
|
"WHERE l.owner_username IS NULL OR l.owner_username = $u "
|
||||||
|
"RETURN l.uid",
|
||||||
|
{"u": username},
|
||||||
|
)
|
||||||
|
return {r[0] for r in rows if r[0]}
|
||||||
|
except Exception as exc: # pragma: no cover - Neo4j unreachable paths
|
||||||
|
logger.warning("Failed to enumerate library UIDs for user %s: %s", username, exc)
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
def all_library_uids() -> list[str]:
|
def all_library_uids() -> list[str]:
|
||||||
"""Return the UIDs of every ``Library`` node in Neo4j.
|
"""Return the UIDs of every ``Library`` node in Neo4j.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Three surfaces are exposed:
|
Three surfaces are exposed:
|
||||||
|
|
||||||
* :class:`MCPTokenAdmin` — read/edit opaque bearer tokens. Token
|
* :class:`UserTokenAdmin` — read/edit opaque bearer tokens. Token
|
||||||
creation still goes through the self-service dashboard so the
|
creation still goes through the self-service dashboard so the
|
||||||
plaintext can be shown exactly once; admin gets a filtered
|
plaintext can be shown exactly once; admin gets a filtered
|
||||||
``allowed_libraries`` picker so operators can scope an existing
|
``allowed_libraries`` picker so operators can scope an existing
|
||||||
@@ -12,7 +12,7 @@ Three surfaces are exposed:
|
|||||||
from Daedalus (``POST /mcp_server/api/teams/``) but the admin is the
|
from Daedalus (``POST /mcp_server/api/teams/``) but the admin is the
|
||||||
break-glass path when Daedalus is offline.
|
break-glass path when Daedalus is offline.
|
||||||
* :class:`LibraryMembershipAdmin` — manage who can grant each
|
* :class:`LibraryMembershipAdmin` — manage who can grant each
|
||||||
Neo4j-resident Library into a ``MCPToken.allowed_libraries``.
|
Neo4j-resident Library into a ``UserToken.allowed_libraries``.
|
||||||
|
|
||||||
See ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` for the overall model.
|
See ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` for the overall model.
|
||||||
"""
|
"""
|
||||||
@@ -25,9 +25,9 @@ from django.contrib import admin, messages
|
|||||||
from .models import (
|
from .models import (
|
||||||
LibraryMembership,
|
LibraryMembership,
|
||||||
MCPSigningKey,
|
MCPSigningKey,
|
||||||
MCPToken,
|
|
||||||
Team,
|
Team,
|
||||||
TeamWorkspaceAssignment,
|
TeamWorkspaceAssignment,
|
||||||
|
UserToken,
|
||||||
)
|
)
|
||||||
from .teams import TeamJWTError, mint_team_jwt
|
from .teams import TeamJWTError, mint_team_jwt
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class _LibraryPickerField(forms.MultipleChoiceField):
|
|||||||
Django ``ModelForm`` instantiates fields at class-definition time,
|
Django ``ModelForm`` instantiates fields at class-definition time,
|
||||||
but the set of grantable libraries depends on the request user.
|
but the set of grantable libraries depends on the request user.
|
||||||
We override :meth:`_bound_choices` indirectly by setting
|
We override :meth:`_bound_choices` indirectly by setting
|
||||||
``self.choices`` in :meth:`MCPTokenAdminForm.__init__`.
|
``self.choices`` in :meth:`UserTokenAdminForm.__init__`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -116,8 +116,8 @@ class _LibraryPickerField(forms.MultipleChoiceField):
|
|||||||
super().__init__(*args, choices=[], **kwargs)
|
super().__init__(*args, choices=[], **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class MCPTokenAdminForm(forms.ModelForm):
|
class UserTokenAdminForm(forms.ModelForm):
|
||||||
"""``MCPToken`` admin form with a membership-filtered library picker.
|
"""``UserToken`` admin form with a membership-filtered library picker.
|
||||||
|
|
||||||
The underlying field is a ``JSONField(list)``; the form substitutes
|
The underlying field is a ``JSONField(list)``; the form substitutes
|
||||||
a checkbox multi-select that writes the same JSON list shape. We
|
a checkbox multi-select that writes the same JSON list shape. We
|
||||||
@@ -129,7 +129,7 @@ class MCPTokenAdminForm(forms.ModelForm):
|
|||||||
allowed_libraries = _LibraryPickerField()
|
allowed_libraries = _LibraryPickerField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MCPToken
|
model = UserToken
|
||||||
fields = [
|
fields = [
|
||||||
"user",
|
"user",
|
||||||
"name",
|
"name",
|
||||||
@@ -159,9 +159,9 @@ class MCPTokenAdminForm(forms.ModelForm):
|
|||||||
return list(value)
|
return list(value)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(MCPToken)
|
@admin.register(UserToken)
|
||||||
class MCPTokenAdmin(admin.ModelAdmin):
|
class UserTokenAdmin(admin.ModelAdmin):
|
||||||
form = MCPTokenAdminForm
|
form = UserTokenAdminForm
|
||||||
|
|
||||||
list_display = [
|
list_display = [
|
||||||
"name",
|
"name",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
These endpoints are the Daedalus → Mnemosyne control plane described
|
These endpoints are the Daedalus → Mnemosyne control plane described
|
||||||
in §7 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. They are called
|
in §7 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. They are called
|
||||||
by Daedalus authenticated as the Mnemosyne user the team belongs to
|
by Daedalus authenticated as the Mnemosyne user the team belongs to
|
||||||
(per-user DRF token).
|
(per-user ``UserToken``, ``Authorization: Bearer …``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -233,6 +233,17 @@ def team_workspaces(request, team_id):
|
|||||||
def team_rotate(request, team_id):
|
def team_rotate(request, team_id):
|
||||||
"""Generate a fresh ``active_jti`` and JWT.
|
"""Generate a fresh ``active_jti`` and JWT.
|
||||||
|
|
||||||
|
Upsert-on-missing: if no ``Team`` exists for this id, create one
|
||||||
|
owned by the caller and mint its first JWT. This eliminates the
|
||||||
|
ordering trap where Daedalus calls rotate before its provisioning
|
||||||
|
flow has POSTed the team — the operator clicks "Rotate JWT" and
|
||||||
|
things just work. The placeholder ``name`` is the team id; an
|
||||||
|
operator can rename later via admin or the create endpoint.
|
||||||
|
|
||||||
|
A pre-existing team owned by a *different* user returns 409 (same
|
||||||
|
shape as ``team_create``'s collision branch) — never disclose
|
||||||
|
cross-user existence.
|
||||||
|
|
||||||
The previously-issued JWT stops validating immediately — the auth
|
The previously-issued JWT stops validating immediately — the auth
|
||||||
middleware compares the incoming ``jti`` against ``Team.active_jti``
|
middleware compares the incoming ``jti`` against ``Team.active_jti``
|
||||||
on every request.
|
on every request.
|
||||||
@@ -241,12 +252,27 @@ def team_rotate(request, team_id):
|
|||||||
returns 409 so the operator is forced to go through the explicit
|
returns 409 so the operator is forced to go through the explicit
|
||||||
create/readd flow rather than quietly resurrecting a team.
|
create/readd flow rather than quietly resurrecting a team.
|
||||||
"""
|
"""
|
||||||
team = _get_team(team_id, request.user)
|
team = Team.objects.filter(pk=team_id).first()
|
||||||
if team is None:
|
if team is None:
|
||||||
return Response(
|
team = Team.objects.create(
|
||||||
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
|
id=team_id,
|
||||||
|
name=str(team_id),
|
||||||
|
owner=request.user,
|
||||||
)
|
)
|
||||||
if not team.active:
|
logger.info(
|
||||||
|
"team_rotate upserted_missing team_id=%s owner=%s",
|
||||||
|
team.id, request.user.username,
|
||||||
|
)
|
||||||
|
elif team.owner_id != request.user.id:
|
||||||
|
logger.warning(
|
||||||
|
"team_rotate owner_conflict team_id=%s caller=%s",
|
||||||
|
team.id, request.user.id,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{"detail": "Team id is already in use."},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
|
elif not team.active:
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "Team is inactive; cannot rotate. Recreate it instead."},
|
{"detail": "Team is inactive; cannot rotate. Recreate it instead."},
|
||||||
status=status.HTTP_409_CONFLICT,
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""URL patterns for the ``/mcp_server/api/`` DRF control-plane API.
|
"""URL patterns for the ``/mcp_server/api/`` DRF control-plane API.
|
||||||
|
|
||||||
These endpoints are called by the Daedalus backend authenticated as
|
These endpoints are called by the Daedalus backend authenticated as
|
||||||
the owning Mnemosyne user (per-user DRF token). Every team is scoped
|
the owning Mnemosyne user (per-user ``UserToken``,
|
||||||
|
``Authorization: Bearer …``, minted at ``/profile/tokens/``). Every team is scoped
|
||||||
to its ``owner`` — cross-user access returns 404. End-user MCP traffic
|
to its ``owner`` — cross-user access returns 404. End-user MCP traffic
|
||||||
does NOT go through this surface — that's ``mnemosyne.asgi`` /
|
does NOT go through this surface — that's ``mnemosyne.asgi`` /
|
||||||
``mcp_server/server.py``.
|
``mcp_server/server.py``.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Three credential types are accepted — see
|
Three credential types are accepted — see
|
||||||
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.2 for the full model:
|
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.2 for the full model:
|
||||||
|
|
||||||
1. **Opaque ``MCPToken``** (long-lived, hashed at rest). Authorization
|
1. **Opaque ``UserToken``** (long-lived, hashed at rest). Authorization
|
||||||
scope is its ``allowed_libraries`` JSON list.
|
scope is its ``allowed_libraries`` JSON list.
|
||||||
2. **Per-turn signed JWT** (``iss=daedalus``, ≤10 min, legacy — retires
|
2. **Per-turn signed JWT** (``iss=daedalus``, ≤10 min, legacy — retires
|
||||||
in Phase 4 when Daedalus chat itself becomes a Pallas Team). Scope
|
in Phase 4 when Daedalus chat itself becomes a Pallas Team). Scope
|
||||||
@@ -34,12 +34,13 @@ from collections import OrderedDict
|
|||||||
|
|
||||||
import jwt as pyjwt
|
import jwt as pyjwt
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from fastmcp.server.dependencies import get_http_request
|
from fastmcp.server.dependencies import get_http_request
|
||||||
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
||||||
|
|
||||||
from .metrics import mcp_auth_failures_total
|
from .metrics import mcp_auth_failures_total
|
||||||
from .models import MCPSigningKey, MCPToken, Team, hash_token
|
from .models import MCPSigningKey, UserToken, Team, hash_token
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -88,18 +89,18 @@ class MCPAuthError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
def resolve_mcp_user(token_string: str):
|
def resolve_mcp_user(token_string: str):
|
||||||
"""Resolve an opaque bearer token to (user, MCPToken).
|
"""Resolve an opaque bearer token to (user, UserToken).
|
||||||
|
|
||||||
Hashes the incoming bearer and looks up by the hash — plaintext is never
|
Hashes the incoming bearer and looks up by the hash — plaintext is never
|
||||||
stored or compared directly.
|
stored or compared directly.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
token = (
|
token = (
|
||||||
MCPToken.objects
|
UserToken.objects
|
||||||
.select_related("user")
|
.select_related("user")
|
||||||
.get(token_hash=hash_token(token_string))
|
.get(token_hash=hash_token(token_string))
|
||||||
)
|
)
|
||||||
except MCPToken.DoesNotExist:
|
except UserToken.DoesNotExist:
|
||||||
raise MCPAuthError("Invalid MCP token.")
|
raise MCPAuthError("Invalid MCP token.")
|
||||||
|
|
||||||
if not token.is_active:
|
if not token.is_active:
|
||||||
@@ -341,7 +342,7 @@ class MCPAuthMiddleware(Middleware):
|
|||||||
:mod:`mcp_server.context`:
|
:mod:`mcp_server.context`:
|
||||||
|
|
||||||
* ``STATE_KEY_USER`` — Django user.
|
* ``STATE_KEY_USER`` — Django user.
|
||||||
* ``STATE_KEY_TOKEN`` — MCPToken row (opaque callers only).
|
* ``STATE_KEY_TOKEN`` — UserToken row (opaque callers only).
|
||||||
* ``STATE_KEY_CLAIMS`` — JWT claims dict (JWT callers only).
|
* ``STATE_KEY_CLAIMS`` — JWT claims dict (JWT callers only).
|
||||||
* ``STATE_KEY_RESOLVED_LIBRARIES`` — authorization-resolved Library
|
* ``STATE_KEY_RESOLVED_LIBRARIES`` — authorization-resolved Library
|
||||||
UID list. Tools read this; they never read ``STATE_KEY_CLAIMS``
|
UID list. Tools read this; they never read ``STATE_KEY_CLAIMS``
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ on the FastMCP ``Context``:
|
|||||||
|
|
||||||
* ``STATE_KEY_USER`` — the Django user the bearer resolved to (synthetic
|
* ``STATE_KEY_USER`` — the Django user the bearer resolved to (synthetic
|
||||||
service user for JWT callers, concrete ``mcp_tokens.user`` for opaque
|
service user for JWT callers, concrete ``mcp_tokens.user`` for opaque
|
||||||
MCPToken callers, ``None`` for team JWTs which are not tied to any
|
UserToken callers, ``None`` for team JWTs which are not tied to any
|
||||||
per-user account).
|
per-user account).
|
||||||
* ``STATE_KEY_TOKEN`` — the ``MCPToken`` row for opaque-token callers;
|
* ``STATE_KEY_TOKEN`` — the ``UserToken`` row for opaque-token callers;
|
||||||
``None`` for JWT callers.
|
``None`` for JWT callers.
|
||||||
* ``STATE_KEY_CLAIMS`` — the JWT claims dict for JWT callers; ``None``
|
* ``STATE_KEY_CLAIMS`` — the JWT claims dict for JWT callers; ``None``
|
||||||
for opaque-token callers. Intentionally exposed for debugging /
|
for opaque-token callers. Intentionally exposed for debugging /
|
||||||
|
|||||||
54
mnemosyne/mcp_server/drf_auth.py
Normal file
54
mnemosyne/mcp_server/drf_auth.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""DRF authentication class backed by :class:`mcp_server.models.UserToken`.
|
||||||
|
|
||||||
|
Wraps :func:`mcp_server.auth.resolve_mcp_user` so a single verification
|
||||||
|
routine serves both surfaces:
|
||||||
|
|
||||||
|
* the FastMCP middleware on ``/mcp/`` (via ``MCPAuthMiddleware``); and
|
||||||
|
* the Django REST surface on ``/library/api/*`` and
|
||||||
|
``/mcp_server/api/teams/*`` (via this class).
|
||||||
|
|
||||||
|
Scope: this class authenticates the caller — it does *not* honour the
|
||||||
|
token's ``allowed_libraries`` / ``allowed_tools`` fields. Those apply
|
||||||
|
only to the MCP tool surface. On the REST endpoints, access is gated by
|
||||||
|
``Team.owner`` and ``Library.owner_username`` rather than per-token
|
||||||
|
scope claims; treating ``allowed_libraries`` as authoritative there
|
||||||
|
would either force Daedalus to mint an effectively-unrestricted token
|
||||||
|
(redundant with the user identity) or invent a per-endpoint scope
|
||||||
|
mapping with no natural shape.
|
||||||
|
|
||||||
|
The accepted header is ``Authorization: Bearer <plaintext>``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from rest_framework import authentication, exceptions
|
||||||
|
|
||||||
|
from .auth import MCPAuthError, resolve_mcp_user
|
||||||
|
|
||||||
|
|
||||||
|
class UserTokenAuthentication(authentication.BaseAuthentication):
|
||||||
|
"""Authenticate DRF requests with a ``UserToken`` bearer."""
|
||||||
|
|
||||||
|
keyword = "Bearer"
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
header = authentication.get_authorization_header(request).decode("iso-8859-1")
|
||||||
|
if not header:
|
||||||
|
return None
|
||||||
|
parts = header.split()
|
||||||
|
if len(parts) < 2 or parts[0] != self.keyword:
|
||||||
|
# Not our scheme. Let other authenticators try.
|
||||||
|
return None
|
||||||
|
if len(parts) > 2:
|
||||||
|
raise exceptions.AuthenticationFailed(
|
||||||
|
"Invalid Authorization header: too many components."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user, token = resolve_mcp_user(parts[1])
|
||||||
|
except MCPAuthError as exc:
|
||||||
|
raise exceptions.AuthenticationFailed(str(exc))
|
||||||
|
return user, token
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
return self.keyword
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
"""Forms for the MCP token self-service dashboard.
|
"""Forms for the per-user API token self-service dashboard.
|
||||||
|
|
||||||
The dashboard is where humans mint their own opaque :class:`MCPToken`
|
The dashboard is where humans mint their own opaque :class:`UserToken`
|
||||||
rows for external MCP clients (Claude Desktop, Cline, ...). The
|
rows — used by MCP tool clients (Claude Desktop, Cline) on ``/mcp/``
|
||||||
plaintext is surfaced exactly once on the "created" page and never
|
and by the Daedalus REST integration on ``/library/api/*`` /
|
||||||
retrievable again — see ``mcp_server/views.py``.
|
``/mcp_server/api/teams/*``. The plaintext is surfaced exactly once on
|
||||||
|
the "created" page and never retrievable again — see ``views.py``.
|
||||||
|
|
||||||
Two pickers are rendered:
|
Two optional pickers ("Restrictions"; collapsed in the template) apply
|
||||||
|
only to the MCP surface:
|
||||||
|
|
||||||
* ``allowed_tools`` — multi-select over the FastMCP tool registry.
|
* ``allowed_tools`` — multi-select over the FastMCP tool registry.
|
||||||
Empty = all tools permitted. Backs ``MCPToken.allowed_tools``.
|
Empty = all tools permitted. Backs ``UserToken.allowed_tools``.
|
||||||
* ``allowed_libraries`` — multi-select over Neo4j Libraries the
|
* ``allowed_libraries`` — multi-select over Neo4j Libraries the
|
||||||
current request user has ``owner`` or ``manager`` membership on.
|
current request user has ``owner`` or ``manager`` membership on.
|
||||||
Empty = **zero** libraries (fail-closed), matching the semantics
|
Empty = **zero** libraries (fail-closed) *for MCP callers*, matching
|
||||||
in §4.1 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``.
|
the semantics in §4.1 of the integration design doc. On the REST
|
||||||
|
surface these fields are ignored; ``Team.owner`` and
|
||||||
|
``Library.owner_username`` enforce access there.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,7 +27,7 @@ import functools
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from .admin import _library_choices_for_user
|
from .admin import _library_choices_for_user
|
||||||
from .models import MCPToken
|
from .models import UserToken
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=1)
|
@functools.lru_cache(maxsize=1)
|
||||||
@@ -44,7 +48,7 @@ def _tool_choices() -> list[tuple[str, str]]:
|
|||||||
return [(name, name) for name in _registered_tool_names()]
|
return [(name, name) for name in _registered_tool_names()]
|
||||||
|
|
||||||
|
|
||||||
class MCPTokenCreateForm(forms.Form):
|
class UserTokenCreateForm(forms.Form):
|
||||||
"""Generate a new bearer token. The token value itself is server-generated."""
|
"""Generate a new bearer token. The token value itself is server-generated."""
|
||||||
|
|
||||||
name = forms.CharField(
|
name = forms.CharField(
|
||||||
@@ -82,7 +86,7 @@ class MCPTokenCreateForm(forms.Form):
|
|||||||
self.fields["allowed_libraries"].choices = _library_choices_for_user(user)
|
self.fields["allowed_libraries"].choices = _library_choices_for_user(user)
|
||||||
|
|
||||||
|
|
||||||
class MCPTokenEditForm(forms.ModelForm):
|
class UserTokenEditForm(forms.ModelForm):
|
||||||
"""Edit token metadata. The hashed token itself cannot be edited."""
|
"""Edit token metadata. The hashed token itself cannot be edited."""
|
||||||
|
|
||||||
allowed_tools = forms.MultipleChoiceField(
|
allowed_tools = forms.MultipleChoiceField(
|
||||||
@@ -100,7 +104,7 @@ class MCPTokenEditForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MCPToken
|
model = UserToken
|
||||||
fields = ["name", "is_active", "expires_at", "allowed_tools", "allowed_libraries"]
|
fields = ["name", "is_active", "expires_at", "allowed_tools", "allowed_libraries"]
|
||||||
widgets = {
|
widgets = {
|
||||||
"name": forms.TextInput(attrs={"class": "input input-bordered w-full"}),
|
"name": forms.TextInput(attrs={"class": "input input-bordered w-full"}),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ skips:
|
|||||||
* Workspace-scoped Libraries (``workspace_id`` is not null). Those
|
* Workspace-scoped Libraries (``workspace_id`` is not null). Those
|
||||||
belong to a Daedalus workspace and will be reachable via team JWTs
|
belong to a Daedalus workspace and will be reachable via team JWTs
|
||||||
once Phase 4 wires up ``TeamWorkspaceAssignment``. Granting them to
|
once Phase 4 wires up ``TeamWorkspaceAssignment``. Granting them to
|
||||||
a superuser MCPToken would silently widen the blast radius of a
|
a superuser UserToken would silently widen the blast radius of a
|
||||||
leaked token.
|
leaked token.
|
||||||
* Libraries that already have any :class:`LibraryMembership` row. We
|
* Libraries that already have any :class:`LibraryMembership` row. We
|
||||||
do not stack roles for idempotency.
|
do not stack roles for idempotency.
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from mcp_server.models import MCPToken
|
from mcp_server.models import UserToken
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Create an MCP token for a user and print the full token (shown once)."
|
help = "Create an API token for a user and print the full token (shown once)."
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -57,14 +57,14 @@ class Command(BaseCommand):
|
|||||||
raise CommandError("--expires-days must be at least 1.")
|
raise CommandError("--expires-days must be at least 1.")
|
||||||
expires_at = timezone.now() + timedelta(days=options["expires_days"])
|
expires_at = timezone.now() + timedelta(days=options["expires_days"])
|
||||||
|
|
||||||
token, plaintext = MCPToken.objects.create_token(
|
token, plaintext = UserToken.objects.create_token(
|
||||||
user=user,
|
user=user,
|
||||||
name=options["name"],
|
name=options["name"],
|
||||||
allowed_tools=allowed_tools,
|
allowed_tools=allowed_tools,
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("MCP token created"))
|
self.stdout.write(self.style.SUCCESS("API token created"))
|
||||||
self.stdout.write(f" Name: {token.name}")
|
self.stdout.write(f" Name: {token.name}")
|
||||||
self.stdout.write(f" User: {user}")
|
self.stdout.write(f" User: {user}")
|
||||||
if allowed_tools:
|
if allowed_tools:
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.2.13 on 2026-05-23 11:12
|
# Generated by Django 5.2.13 on 2026-05-23 15:12
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -29,25 +29,6 @@ class Migration(migrations.Migration):
|
|||||||
'ordering': ['-created_at'],
|
'ordering': ['-created_at'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
|
||||||
name='MCPToken',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
|
|
||||||
('name', models.CharField(max_length=100)),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
|
||||||
('expires_at', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('allowed_tools', models.JSONField(blank=True, default=list)),
|
|
||||||
('allowed_libraries', models.JSONField(blank=True, default=list)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mcp_tokens', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Team',
|
name='Team',
|
||||||
fields=[
|
fields=[
|
||||||
@@ -63,6 +44,27 @@ class Migration(migrations.Migration):
|
|||||||
'ordering': ['name'],
|
'ordering': ['name'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserToken',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('allowed_tools', models.JSONField(blank=True, default=list)),
|
||||||
|
('allowed_libraries', models.JSONField(blank=True, default=list)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'API Token',
|
||||||
|
'verbose_name_plural': 'API Tokens',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='LibraryMembership',
|
name='LibraryMembership',
|
||||||
fields=[
|
fields=[
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
This module defines every Postgres-backed row the Mnemosyne MCP surface
|
This module defines every Postgres-backed row the Mnemosyne MCP surface
|
||||||
relies on:
|
relies on:
|
||||||
|
|
||||||
* :class:`MCPToken` — opaque bearer tokens (SHA-256 hashed at rest).
|
* :class:`UserToken` — opaque bearer tokens (SHA-256 hashed at rest);
|
||||||
|
used on both the MCP surface and the Daedalus DRF REST surface.
|
||||||
* :class:`MCPSigningKey` — HMAC signing keys (``HS256``) for JWTs,
|
* :class:`MCPSigningKey` — HMAC signing keys (``HS256``) for JWTs,
|
||||||
keyed by ``kid``. Used by the legacy per-turn path *and* by team
|
keyed by ``kid``. Used by the legacy per-turn path *and* by team
|
||||||
JWTs minted in §7 of ``DAEDALUS_PALLAS_INTEGRATION_v1.md``.
|
JWTs minted in §7 of ``DAEDALUS_PALLAS_INTEGRATION_v1.md``.
|
||||||
@@ -54,7 +55,7 @@ class LibraryMembership(models.Model):
|
|||||||
Roles are ordered (owner > manager > reader) but not hierarchical
|
Roles are ordered (owner > manager > reader) but not hierarchical
|
||||||
in storage: a user with owner rights is represented by a single
|
in storage: a user with owner rights is represented by a single
|
||||||
row with ``role="owner"``, not multiple rows. Callers deciding
|
row with ``role="owner"``, not multiple rows. Callers deciding
|
||||||
whether a user may *grant* a Library into an ``MCPToken`` should
|
whether a user may *grant* a Library into a ``UserToken`` should
|
||||||
check for ``role__in=("owner", "manager")``.
|
check for ``role__in=("owner", "manager")``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ class LibraryMembership(models.Model):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class MCPTokenManager(models.Manager):
|
class UserTokenManager(models.Manager):
|
||||||
def create_token(
|
def create_token(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -124,24 +125,30 @@ class MCPTokenManager(models.Manager):
|
|||||||
return instance, plaintext
|
return instance, plaintext
|
||||||
|
|
||||||
|
|
||||||
class MCPToken(models.Model):
|
class UserToken(models.Model):
|
||||||
"""Bearer token for authenticating MCP tool calls.
|
"""Per-user opaque bearer token for authenticating to Mnemosyne.
|
||||||
|
|
||||||
Tokens are hashed at rest (SHA-256, 64-char hex). Plaintext exists only in
|
A single generic credential model used on both surfaces:
|
||||||
memory at creation time, on the wire to the client, and in the user's own
|
|
||||||
storage. A leaked database backup discloses no usable credentials.
|
|
||||||
|
|
||||||
``allowed_libraries`` is a JSON list of Library ``uid`` strings. It is
|
* **MCP (``/mcp/``)** — third-party tool clients (Claude Desktop, …).
|
||||||
the sole authorization axis for opaque-token callers: the auth
|
``allowed_tools`` / ``allowed_libraries`` scope the token; an empty
|
||||||
middleware materializes ``resolved_libraries = list(allowed_libraries)``
|
``allowed_libraries`` is fail-closed (the token sees nothing), not
|
||||||
on every request. An empty list is fail-closed (the token sees nothing),
|
an implicit "all".
|
||||||
not an implicit "all".
|
* **DRF REST (``/library/api/*``, ``/mcp_server/api/teams/*``)** —
|
||||||
|
Daedalus calls authenticated as the owning user. The scope fields
|
||||||
|
are ignored on this surface; ``Team.owner`` and
|
||||||
|
``Library.owner_username`` enforce access.
|
||||||
|
|
||||||
|
Tokens are hashed at rest (SHA-256, 64-char hex). Plaintext exists
|
||||||
|
only in memory at creation time, on the wire to the client, and in
|
||||||
|
the user's own storage. A leaked database backup discloses no usable
|
||||||
|
credentials.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="mcp_tokens",
|
related_name="api_tokens",
|
||||||
)
|
)
|
||||||
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
|
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
@@ -150,7 +157,8 @@ class MCPToken(models.Model):
|
|||||||
last_used_at = models.DateTimeField(null=True, blank=True)
|
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||||
allowed_tools = models.JSONField(default=list, blank=True)
|
allowed_tools = models.JSONField(default=list, blank=True)
|
||||||
|
|
||||||
# JSON list of Library.uid strings. Fail-closed: empty → zero libraries.
|
# JSON list of Library.uid strings. Fail-closed: empty → zero libraries
|
||||||
|
# *for MCP callers*; ignored on the REST surface (see class docstring).
|
||||||
# We cannot use a ``ManyToManyField(Library)`` because Library is a
|
# We cannot use a ``ManyToManyField(Library)`` because Library is a
|
||||||
# neomodel ``StructuredNode`` in Neo4j, not a Django ORM model.
|
# neomodel ``StructuredNode`` in Neo4j, not a Django ORM model.
|
||||||
allowed_libraries = models.JSONField(default=list, blank=True)
|
allowed_libraries = models.JSONField(default=list, blank=True)
|
||||||
@@ -158,10 +166,12 @@ class MCPToken(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
objects = MCPTokenManager()
|
objects = UserTokenManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
|
verbose_name = "API Token"
|
||||||
|
verbose_name_plural = "API Tokens"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.user})"
|
return f"{self.name} ({self.user})"
|
||||||
@@ -187,9 +197,9 @@ class MCPToken(models.Model):
|
|||||||
"""Token-id-style display for admin and dashboard.
|
"""Token-id-style display for admin and dashboard.
|
||||||
|
|
||||||
Plaintext is unrecoverable, so we display the first 8 chars of the
|
Plaintext is unrecoverable, so we display the first 8 chars of the
|
||||||
hash prefixed with `mcp_…`. Stable per token, never reveals plaintext.
|
hash prefixed with ``tok_…``. Stable per token, never reveals plaintext.
|
||||||
"""
|
"""
|
||||||
return f"mcp_…{self.token_hash[:8]}"
|
return f"tok_…{self.token_hash[:8]}"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{% extends "themis/base.html" %}
|
{% extends "themis/base.html" %}
|
||||||
|
|
||||||
{% block title %}Generate MCP Token — {{ themis_app_name }}{% endblock %}
|
{% block title %}Generate API Token — {{ themis_app_name }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<h1 class="text-2xl font-bold mb-6">Generate MCP Token</h1>
|
<h1 class="text-2xl font-bold mb-6">Generate API Token</h1>
|
||||||
|
|
||||||
<div class="alert alert-info mb-6">
|
<div class="alert alert-info mb-6">
|
||||||
<span>The token will be displayed once after creation. Save it before leaving the page — it cannot be recovered.</span>
|
<span>The token will be displayed once after creation. Save it before leaving the page — it cannot be recovered.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{% url 'mcp_server:mcp-token-create' %}">
|
<form method="post" action="{% url 'mcp_server:token-create' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="card bg-base-200 mb-6">
|
<div class="card bg-base-200 mb-6">
|
||||||
@@ -36,9 +36,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-200 mb-6">
|
<details class="card bg-base-200 mb-6">
|
||||||
<div class="card-body">
|
<summary class="card-body cursor-pointer">
|
||||||
<h2 class="card-title text-lg">Allowed Tools</h2>
|
<h2 class="card-title text-lg">Restrictions (optional)</h2>
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
These restrictions apply only when the token is used by an MCP
|
||||||
|
tool client (e.g. Claude Desktop). They are ignored by the
|
||||||
|
Daedalus REST API and the Mnemosyne web session — there the
|
||||||
|
token has the same access as your account.
|
||||||
|
</p>
|
||||||
|
</summary>
|
||||||
|
<div class="card-body pt-0">
|
||||||
|
<h3 class="font-semibold mt-2">Allowed Tools</h3>
|
||||||
<p class="text-sm opacity-60 mb-2">{{ form.allowed_tools.help_text }}</p>
|
<p class="text-sm opacity-60 mb-2">{{ form.allowed_tools.help_text }}</p>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{% for choice in form.allowed_tools %}
|
{% for choice in form.allowed_tools %}
|
||||||
@@ -52,10 +61,10 @@
|
|||||||
<div class="text-error text-sm mt-2">{{ form.allowed_tools.errors }}</div>
|
<div class="text-error text-sm mt-2">{{ form.allowed_tools.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<a href="{% url 'mcp_server:mcp-token-list' %}" class="btn btn-ghost">Cancel</a>
|
<a href="{% url 'mcp_server:token-list' %}" class="btn btn-ghost">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary">Generate Token</button>
|
<button type="submit" class="btn btn-primary">Generate Token</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{% extends "themis/base.html" %}
|
{% extends "themis/base.html" %}
|
||||||
|
|
||||||
{% block title %}MCP Token Created — {{ themis_app_name }}{% endblock %}
|
{% block title %}API Token Created — {{ themis_app_name }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<h1 class="text-2xl font-bold mb-2">MCP Token Created</h1>
|
<h1 class="text-2xl font-bold mb-2">API Token Created</h1>
|
||||||
<p class="opacity-60 mb-6">{{ token.name }}</p>
|
<p class="opacity-60 mb-6">{{ token.name }}</p>
|
||||||
|
|
||||||
<div class="alert alert-warning mb-6">
|
<div class="alert alert-warning mb-6">
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<a href="{% url 'mcp_server:mcp-token-list' %}" class="btn btn-primary">I’ve saved it — go to token list</a>
|
<a href="{% url 'mcp_server:token-list' %}" class="btn btn-primary">I’ve saved it — go to token list</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,15 +8,15 @@
|
|||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h1 class="text-2xl font-bold">{{ token.name }}</h1>
|
<h1 class="text-2xl font-bold">{{ token.name }}</h1>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="{% url 'mcp_server:mcp-token-edit' pk=token.pk %}" class="btn btn-ghost btn-sm">Edit</a>
|
<a href="{% url 'mcp_server:token-edit' pk=token.pk %}" class="btn btn-ghost btn-sm">Edit</a>
|
||||||
{% if token.is_active %}
|
{% if token.is_active %}
|
||||||
<form method="post" action="{% url 'mcp_server:mcp-token-revoke' pk=token.pk %}"
|
<form method="post" action="{% url 'mcp_server:token-revoke' pk=token.pk %}"
|
||||||
onsubmit="return confirm('Revoke this token? It will no longer authenticate MCP requests.');">
|
onsubmit="return confirm('Revoke this token? It will no longer authenticate MCP requests.');">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-warning btn-sm btn-outline">Revoke</button>
|
<button type="submit" class="btn btn-warning btn-sm btn-outline">Revoke</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{% url 'mcp_server:mcp-token-delete' pk=token.pk %}"
|
<form method="post" action="{% url 'mcp_server:token-delete' pk=token.pk %}"
|
||||||
onsubmit="return confirm('Delete this token permanently? This removes the audit trail.');">
|
onsubmit="return confirm('Delete this token permanently? This removes the audit trail.');">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-error btn-sm btn-outline">Delete</button>
|
<button type="submit" class="btn btn-error btn-sm btn-outline">Delete</button>
|
||||||
@@ -76,6 +76,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="{% url 'mcp_server:mcp-token-list' %}" class="btn btn-ghost btn-sm">← Back to Tokens</a>
|
<a href="{% url 'mcp_server:token-list' %}" class="btn btn-ghost btn-sm">← Back to Tokens</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<span>You can edit metadata below. The token value itself cannot be changed — generate a new token if needed.</span>
|
<span>You can edit metadata below. The token value itself cannot be changed — generate a new token if needed.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{% url 'mcp_server:mcp-token-edit' pk=token.pk %}">
|
<form method="post" action="{% url 'mcp_server:token-edit' pk=token.pk %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="card bg-base-200 mb-6">
|
<div class="card bg-base-200 mb-6">
|
||||||
@@ -36,9 +36,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-200 mb-6">
|
<details class="card bg-base-200 mb-6"{% if form.allowed_tools.value or form.allowed_libraries.value %} open{% endif %}>
|
||||||
<div class="card-body">
|
<summary class="card-body cursor-pointer">
|
||||||
<h2 class="card-title text-lg">Allowed Tools</h2>
|
<h2 class="card-title text-lg">Restrictions (optional)</h2>
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
These restrictions apply only when the token is used by an MCP
|
||||||
|
tool client (e.g. Claude Desktop). They are ignored by the
|
||||||
|
Daedalus REST API and the Mnemosyne web session.
|
||||||
|
</p>
|
||||||
|
</summary>
|
||||||
|
<div class="card-body pt-0">
|
||||||
|
<h3 class="font-semibold mt-2">Allowed Tools</h3>
|
||||||
<p class="text-sm opacity-60 mb-2">{{ form.allowed_tools.help_text }}</p>
|
<p class="text-sm opacity-60 mb-2">{{ form.allowed_tools.help_text }}</p>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{% for choice in form.allowed_tools %}
|
{% for choice in form.allowed_tools %}
|
||||||
@@ -49,10 +57,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<a href="{% url 'mcp_server:mcp-token-detail' pk=token.pk %}" class="btn btn-ghost">Cancel</a>
|
<a href="{% url 'mcp_server:token-detail' pk=token.pk %}" class="btn btn-ghost">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{% extends "themis/base.html" %}
|
{% extends "themis/base.html" %}
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
|
|
||||||
{% block title %}MCP Tokens — {{ themis_app_name }}{% endblock %}
|
{% block title %}API Tokens — {{ themis_app_name }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h1 class="text-2xl font-bold">MCP Tokens</h1>
|
<h1 class="text-2xl font-bold">API Tokens</h1>
|
||||||
<a href="{% url 'mcp_server:mcp-token-create' %}" class="btn btn-primary btn-sm">Generate Token</a>
|
<a href="{% url 'mcp_server:token-create' %}" class="btn btn-primary btn-sm">Generate Token</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm opacity-60 mb-4">
|
<p class="text-sm opacity-60 mb-4">
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<a href="{% url 'mcp_server:mcp-token-detail' pk=token.pk %}"
|
<a href="{% url 'mcp_server:token-detail' pk=token.pk %}"
|
||||||
class="font-semibold link link-hover">
|
class="font-semibold link link-hover">
|
||||||
{{ token.name }}
|
{{ token.name }}
|
||||||
</a>
|
</a>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<a href="{% url 'mcp_server:mcp-token-edit' pk=token.pk %}"
|
<a href="{% url 'mcp_server:token-edit' pk=token.pk %}"
|
||||||
class="btn btn-ghost btn-xs">Edit</a>
|
class="btn btn-ghost btn-xs">Edit</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="card bg-base-200">
|
<div class="card bg-base-200">
|
||||||
<div class="card-body items-center text-center py-12">
|
<div class="card-body items-center text-center py-12">
|
||||||
<p class="opacity-60 mb-4">No MCP tokens yet.</p>
|
<p class="opacity-60 mb-4">No API tokens yet.</p>
|
||||||
<a href="{% url 'mcp_server:mcp-token-create' %}" class="btn btn-primary btn-sm">Generate Your First Token</a>
|
<a href="{% url 'mcp_server:token-create' %}" class="btn btn-primary btn-sm">Generate Your First Token</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Covers all three credential types described in
|
Covers all three credential types described in
|
||||||
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.2:
|
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.2:
|
||||||
|
|
||||||
1. Opaque :class:`~mcp_server.models.MCPToken` — ``resolve_mcp_user``
|
1. Opaque :class:`~mcp_server.models.UserToken` — ``resolve_mcp_user``
|
||||||
+ ``MCPAuthMiddleware`` opaque branch.
|
+ ``MCPAuthMiddleware`` opaque branch.
|
||||||
2. Per-turn JWT (``iss=daedalus``, legacy) — ``resolve_mcp_jwt`` normal
|
2. Per-turn JWT (``iss=daedalus``, legacy) — ``resolve_mcp_jwt`` normal
|
||||||
path, ``_remember_jti`` replay cache, ``claims["libs"]``-derived
|
path, ``_remember_jti`` replay cache, ``claims["libs"]``-derived
|
||||||
@@ -42,7 +42,7 @@ from mcp_server.auth import (
|
|||||||
)
|
)
|
||||||
from mcp_server.models import (
|
from mcp_server.models import (
|
||||||
MCPSigningKey,
|
MCPSigningKey,
|
||||||
MCPToken,
|
UserToken,
|
||||||
Team,
|
Team,
|
||||||
TeamWorkspaceAssignment,
|
TeamWorkspaceAssignment,
|
||||||
)
|
)
|
||||||
@@ -51,7 +51,7 @@ User = get_user_model()
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Opaque MCPToken
|
# Opaque UserToken
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ class ResolveMCPUserTest(TestCase):
|
|||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="bob", email="bob@example.com", password="pw"
|
username="bob", email="bob@example.com", password="pw"
|
||||||
)
|
)
|
||||||
self.token, self.plaintext = MCPToken.objects.create_token(
|
self.token, self.plaintext = UserToken.objects.create_token(
|
||||||
user=self.user, name="t"
|
user=self.user, name="t"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ class ResolveMCPUserTest(TestCase):
|
|||||||
|
|
||||||
plaintext = self.plaintext
|
plaintext = self.plaintext
|
||||||
with connection.cursor() as cur:
|
with connection.cursor() as cur:
|
||||||
cur.execute("SELECT * FROM mcp_server_mcptoken")
|
cur.execute("SELECT * FROM mcp_server_usertoken")
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
for value in row:
|
for value in row:
|
||||||
@@ -190,7 +190,7 @@ class LooksLikeJWTTest(TestCase):
|
|||||||
self.assertFalse(looks_like_jwt("!!!.bbb.ccc"))
|
self.assertFalse(looks_like_jwt("!!!.bbb.ccc"))
|
||||||
|
|
||||||
def test_opaque_token_rejected(self):
|
def test_opaque_token_rejected(self):
|
||||||
# Real ``MCPToken.create_token`` plaintext is 48-byte base64, often
|
# Real ``UserToken.create_token`` plaintext is 48-byte base64, often
|
||||||
# contains dashes but never two dots.
|
# contains dashes but never two dots.
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
looks_like_jwt("CxGb3rThJ7_4jUGl0q2_fakey_fakey_fakey_fakey_fakey")
|
looks_like_jwt("CxGb3rThJ7_4jUGl0q2_fakey_fakey_fakey_fakey_fakey")
|
||||||
@@ -506,3 +506,37 @@ class ResolveJWTActorTest(TestCase):
|
|||||||
claims = {"typ": "team", "team_id": uuid.uuid4()}
|
claims = {"typ": "team", "team_id": uuid.uuid4()}
|
||||||
with self.assertRaises(MCPAuthError):
|
with self.assertRaises(MCPAuthError):
|
||||||
_resolve_jwt_actor(claims)
|
_resolve_jwt_actor(claims)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level import guards (regression tests)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AuthModuleImportsTest(TestCase):
|
||||||
|
"""Pin the imports the runtime depends on.
|
||||||
|
|
||||||
|
These are regressions waiting to happen: a quick `grep` doesn't
|
||||||
|
catch a missing import when the consuming code is only reached via
|
||||||
|
a runtime path (FastMCP middleware, async tool dispatch, …) that
|
||||||
|
the test suite doesn't exercise end-to-end.
|
||||||
|
|
||||||
|
Add a check here whenever production fails with a
|
||||||
|
``NameError: name 'X' is not defined`` that the test suite missed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_settings_is_importable(self):
|
||||||
|
"""``MCPAuthMiddleware.on_call_tool`` reads
|
||||||
|
``settings.MCP_REQUIRE_AUTH`` on every tool call (including
|
||||||
|
unauthenticated ``get_health`` polls from Pallas). Removing the
|
||||||
|
``from django.conf import settings`` import — as happened during
|
||||||
|
the v2 token-consolidation cleanup — surfaces as
|
||||||
|
``NameError: name 'settings' is not defined`` for *every* MCP
|
||||||
|
client. Keep this import alive.
|
||||||
|
"""
|
||||||
|
from django.conf import settings as dj_settings
|
||||||
|
|
||||||
|
from mcp_server import auth as auth_module
|
||||||
|
|
||||||
|
self.assertTrue(hasattr(auth_module, "settings"))
|
||||||
|
self.assertIs(auth_module.settings, dj_settings)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for the create_mcp_token management command."""
|
"""Tests for the create_user_token management command."""
|
||||||
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
@@ -7,12 +7,12 @@ from django.core.management import call_command
|
|||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from mcp_server.models import MCPToken
|
from mcp_server.models import UserToken
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class CreateMCPTokenCommandTest(TestCase):
|
class CreateUserTokenCommandTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="carol", email="carol@example.com", password="pw"
|
username="carol", email="carol@example.com", password="pw"
|
||||||
@@ -20,33 +20,33 @@ class CreateMCPTokenCommandTest(TestCase):
|
|||||||
|
|
||||||
def test_create_basic_token(self):
|
def test_create_basic_token(self):
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
call_command("create_mcp_token", user="carol@example.com", name="CLI", stdout=out)
|
call_command("create_user_token", user="carol@example.com", name="CLI", stdout=out)
|
||||||
self.assertEqual(MCPToken.objects.count(), 1)
|
self.assertEqual(UserToken.objects.count(), 1)
|
||||||
self.assertIn("CLI", out.getvalue())
|
self.assertIn("CLI", out.getvalue())
|
||||||
|
|
||||||
def test_lookup_by_username(self):
|
def test_lookup_by_username(self):
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
call_command("create_mcp_token", user="carol", name="CLI2", stdout=out)
|
call_command("create_user_token", user="carol", name="CLI2", stdout=out)
|
||||||
self.assertEqual(MCPToken.objects.count(), 1)
|
self.assertEqual(UserToken.objects.count(), 1)
|
||||||
|
|
||||||
def test_unknown_user_raises(self):
|
def test_unknown_user_raises(self):
|
||||||
with self.assertRaises(CommandError):
|
with self.assertRaises(CommandError):
|
||||||
call_command("create_mcp_token", user="nobody@x.com", name="x")
|
call_command("create_user_token", user="nobody@x.com", name="x")
|
||||||
|
|
||||||
def test_inactive_user_raises(self):
|
def test_inactive_user_raises(self):
|
||||||
self.user.is_active = False
|
self.user.is_active = False
|
||||||
self.user.save()
|
self.user.save()
|
||||||
with self.assertRaises(CommandError):
|
with self.assertRaises(CommandError):
|
||||||
call_command("create_mcp_token", user="carol", name="x")
|
call_command("create_user_token", user="carol", name="x")
|
||||||
|
|
||||||
def test_tool_whitelist_parsed(self):
|
def test_tool_whitelist_parsed(self):
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
call_command(
|
call_command(
|
||||||
"create_mcp_token",
|
"create_user_token",
|
||||||
user="carol",
|
user="carol",
|
||||||
name="Restricted",
|
name="Restricted",
|
||||||
tools="search,get_chunk",
|
tools="search,get_chunk",
|
||||||
stdout=out,
|
stdout=out,
|
||||||
)
|
)
|
||||||
token = MCPToken.objects.get(name="Restricted")
|
token = UserToken.objects.get(name="Restricted")
|
||||||
self.assertEqual(token.allowed_tools, ["search", "get_chunk"])
|
self.assertEqual(token.allowed_tools, ["search", "get_chunk"])
|
||||||
|
|||||||
131
mnemosyne/mcp_server/tests/test_drf_auth.py
Normal file
131
mnemosyne/mcp_server/tests/test_drf_auth.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Tests for ``mcp_server.drf_auth.UserTokenAuthentication``.
|
||||||
|
|
||||||
|
Authenticates DRF endpoints using a per-user ``UserToken`` carried as
|
||||||
|
``Authorization: Bearer <plaintext>``. The class wraps
|
||||||
|
``resolve_mcp_user``; these tests assert the DRF-side behaviour
|
||||||
|
(header parsing, error mapping, integration with ``IsAuthenticated``)
|
||||||
|
on top of the resolver's own coverage in ``test_auth.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import path
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from mcp_server.models import UserToken
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
# A tiny endpoint mounted only for these tests so we can exercise the
|
||||||
|
# DRF auth pipeline without coupling to any real app's view contract.
|
||||||
|
@api_view(["GET"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def _whoami(request):
|
||||||
|
return Response({"username": request.user.username})
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("__test_whoami__/", _whoami),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROOT_URLCONF=__name__)
|
||||||
|
class UserTokenAuthenticationTest(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = User.objects.create_user(username="alice", password="pw")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.token, self.plaintext = UserToken.objects.create_token(
|
||||||
|
user=self.user, name="t"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get(self, header=None):
|
||||||
|
kwargs = {}
|
||||||
|
if header is not None:
|
||||||
|
kwargs["HTTP_AUTHORIZATION"] = header
|
||||||
|
return self.client.get("/__test_whoami__/", **kwargs)
|
||||||
|
|
||||||
|
def test_no_header_returns_401_with_bearer_challenge(self):
|
||||||
|
resp = self._get()
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
# RFC 7235: anonymous request must include WWW-Authenticate so the
|
||||||
|
# client knows how to authenticate.
|
||||||
|
self.assertEqual(resp["WWW-Authenticate"], "Bearer")
|
||||||
|
|
||||||
|
def test_valid_bearer_authenticates(self):
|
||||||
|
resp = self._get(f"Bearer {self.plaintext}")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(resp.json(), {"username": "alice"})
|
||||||
|
|
||||||
|
def test_invalid_bearer_returns_401(self):
|
||||||
|
resp = self._get("Bearer not-a-real-token")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_inactive_token_returns_401(self):
|
||||||
|
self.token.is_active = False
|
||||||
|
self.token.save(update_fields=["is_active"])
|
||||||
|
resp = self._get(f"Bearer {self.plaintext}")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_expired_token_returns_401(self):
|
||||||
|
self.token.expires_at = timezone.now() - timedelta(hours=1)
|
||||||
|
self.token.save(update_fields=["expires_at"])
|
||||||
|
resp = self._get(f"Bearer {self.plaintext}")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_disabled_user_returns_401(self):
|
||||||
|
self.user.is_active = False
|
||||||
|
self.user.save(update_fields=["is_active"])
|
||||||
|
resp = self._get(f"Bearer {self.plaintext}")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_wrong_keyword_falls_through(self):
|
||||||
|
# ``Token <plaintext>`` is the old DRF authtoken keyword. The new
|
||||||
|
# class only accepts ``Bearer``; a stale ``Token`` header is not
|
||||||
|
# ours to consume — we return None and let the next auth class
|
||||||
|
# try. SessionAuthentication doesn't accept it either, so the
|
||||||
|
# request lands anonymous and IsAuthenticated returns 401.
|
||||||
|
resp = self._get(f"Token {self.plaintext}")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_malformed_header_too_many_parts_returns_401(self):
|
||||||
|
resp = self._get(f"Bearer {self.plaintext} extra")
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_request_auth_stashes_token(self):
|
||||||
|
# The auth class returns (user, token); DRF places the token on
|
||||||
|
# request.auth. Re-use a UserToken-aware endpoint to verify.
|
||||||
|
@api_view(["GET"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def echo_token(request):
|
||||||
|
return Response({"token_name": request.auth.name})
|
||||||
|
|
||||||
|
from django.urls import path as _path
|
||||||
|
|
||||||
|
with override_settings(ROOT_URLCONF=__name__):
|
||||||
|
# Mount the extra endpoint via a per-test urlpatterns swap.
|
||||||
|
# Simpler: just call the resolver directly to confirm the
|
||||||
|
# auth class returns the (user, token) tuple it should.
|
||||||
|
from mcp_server.drf_auth import UserTokenAuthentication
|
||||||
|
from django.test import RequestFactory
|
||||||
|
|
||||||
|
request = RequestFactory().get(
|
||||||
|
"/__test_whoami__/",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {self.plaintext}",
|
||||||
|
)
|
||||||
|
user, token = UserTokenAuthentication().authenticate(request)
|
||||||
|
self.assertEqual(user.pk, self.user.pk)
|
||||||
|
self.assertEqual(token.pk, self.token.pk)
|
||||||
@@ -3,24 +3,24 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from mcp_server.forms import MCPTokenCreateForm, MCPTokenEditForm
|
from mcp_server.forms import UserTokenCreateForm, UserTokenEditForm
|
||||||
from mcp_server.models import MCPToken
|
from mcp_server.models import UserToken
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class CreateFormTest(TestCase):
|
class CreateFormTest(TestCase):
|
||||||
def test_required_fields(self):
|
def test_required_fields(self):
|
||||||
form = MCPTokenCreateForm(data={})
|
form = UserTokenCreateForm(data={})
|
||||||
self.assertFalse(form.is_valid())
|
self.assertFalse(form.is_valid())
|
||||||
self.assertIn("name", form.errors)
|
self.assertIn("name", form.errors)
|
||||||
|
|
||||||
def test_name_only_is_valid(self):
|
def test_name_only_is_valid(self):
|
||||||
form = MCPTokenCreateForm(data={"name": "Test"})
|
form = UserTokenCreateForm(data={"name": "Test"})
|
||||||
self.assertTrue(form.is_valid(), form.errors)
|
self.assertTrue(form.is_valid(), form.errors)
|
||||||
|
|
||||||
def test_tool_choices_match_registered_tools(self):
|
def test_tool_choices_match_registered_tools(self):
|
||||||
form = MCPTokenCreateForm()
|
form = UserTokenCreateForm()
|
||||||
choices = {value for value, _ in form.fields["allowed_tools"].choices}
|
choices = {value for value, _ in form.fields["allowed_tools"].choices}
|
||||||
# These five must always be present per the FastMCP server.
|
# These five must always be present per the FastMCP server.
|
||||||
for expected in {"search", "get_chunk", "list_libraries", "list_collections", "list_items"}:
|
for expected in {"search", "get_chunk", "list_libraries", "list_collections", "list_items"}:
|
||||||
@@ -30,16 +30,16 @@ class CreateFormTest(TestCase):
|
|||||||
class EditFormTest(TestCase):
|
class EditFormTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="alice", password="pw")
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
self.token, _ = MCPToken.objects.create_token(
|
self.token, _ = UserToken.objects.create_token(
|
||||||
user=self.user, name="t", allowed_tools=["search"]
|
user=self.user, name="t", allowed_tools=["search"]
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_initial_allowed_tools_populated(self):
|
def test_initial_allowed_tools_populated(self):
|
||||||
form = MCPTokenEditForm(instance=self.token)
|
form = UserTokenEditForm(instance=self.token)
|
||||||
self.assertEqual(form.fields["allowed_tools"].initial, ["search"])
|
self.assertEqual(form.fields["allowed_tools"].initial, ["search"])
|
||||||
|
|
||||||
def test_save_updates_metadata(self):
|
def test_save_updates_metadata(self):
|
||||||
form = MCPTokenEditForm(
|
form = UserTokenEditForm(
|
||||||
data={
|
data={
|
||||||
"name": "Renamed",
|
"name": "Renamed",
|
||||||
"is_active": False,
|
"is_active": False,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Tests for the Team / LibraryMembership / TeamWorkspaceAssignment models.
|
"""Tests for the Team / LibraryMembership / TeamWorkspaceAssignment models.
|
||||||
|
|
||||||
``MCPToken``'s hash-at-rest semantics live in ``test_token.py``; this
|
``UserToken``'s hash-at-rest semantics live in ``test_token.py``; this
|
||||||
module exercises the new Phase 2 tables introduced by
|
module exercises the new Phase 2 tables introduced by
|
||||||
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §4 plus the
|
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §4 plus the
|
||||||
``allowed_libraries`` JSONField attached to the existing
|
``allowed_libraries`` JSONField attached to the existing
|
||||||
:class:`~mcp_server.models.MCPToken`.
|
:class:`~mcp_server.models.UserToken`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -18,7 +18,7 @@ from django.test import TestCase
|
|||||||
from mcp_server.models import (
|
from mcp_server.models import (
|
||||||
LibraryMembership,
|
LibraryMembership,
|
||||||
MCPSigningKey,
|
MCPSigningKey,
|
||||||
MCPToken,
|
UserToken,
|
||||||
Team,
|
Team,
|
||||||
TeamWorkspaceAssignment,
|
TeamWorkspaceAssignment,
|
||||||
)
|
)
|
||||||
@@ -92,26 +92,26 @@ class LibraryMembershipTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# MCPToken.allowed_libraries
|
# UserToken.allowed_libraries
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class MCPTokenAllowedLibrariesTest(TestCase):
|
class UserTokenAllowedLibrariesTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="u", password="pw")
|
self.user = User.objects.create_user(username="u", password="pw")
|
||||||
|
|
||||||
def test_defaults_to_empty_list(self):
|
def test_defaults_to_empty_list(self):
|
||||||
token, _ = MCPToken.objects.create_token(user=self.user, name="t")
|
token, _ = UserToken.objects.create_token(user=self.user, name="t")
|
||||||
self.assertEqual(token.allowed_libraries, [])
|
self.assertEqual(token.allowed_libraries, [])
|
||||||
|
|
||||||
def test_create_token_accepts_allowed_libraries(self):
|
def test_create_token_accepts_allowed_libraries(self):
|
||||||
token, _ = MCPToken.objects.create_token(
|
token, _ = UserToken.objects.create_token(
|
||||||
user=self.user, name="t", allowed_libraries=["lib-a", "lib-b"]
|
user=self.user, name="t", allowed_libraries=["lib-a", "lib-b"]
|
||||||
)
|
)
|
||||||
self.assertEqual(token.allowed_libraries, ["lib-a", "lib-b"])
|
self.assertEqual(token.allowed_libraries, ["lib-a", "lib-b"])
|
||||||
|
|
||||||
def test_allowed_libraries_round_trips(self):
|
def test_allowed_libraries_round_trips(self):
|
||||||
token, _ = MCPToken.objects.create_token(
|
token, _ = UserToken.objects.create_token(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
name="t",
|
name="t",
|
||||||
allowed_libraries=["lib-a", "lib-b", "lib-c"],
|
allowed_libraries=["lib-a", "lib-b", "lib-c"],
|
||||||
|
|||||||
@@ -385,13 +385,23 @@ class TeamRotateTest(_AuthenticatedAPITest):
|
|||||||
kwargs={"team_id": self.team.id},
|
kwargs={"team_id": self.team.id},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_unknown_team_returns_404(self):
|
def test_rotate_upserts_missing_team(self):
|
||||||
|
# Rotate is upsert-on-missing: if no Team row exists for this
|
||||||
|
# id, create one owned by the caller and mint its first JWT.
|
||||||
|
# Eliminates the create-before-rotate ordering trap Daedalus hit
|
||||||
|
# in production.
|
||||||
|
new_id = uuid.uuid4()
|
||||||
url = reverse(
|
url = reverse(
|
||||||
"mcp-server-api:team-rotate",
|
"mcp-server-api:team-rotate",
|
||||||
kwargs={"team_id": uuid.uuid4()},
|
kwargs={"team_id": new_id},
|
||||||
)
|
)
|
||||||
resp = self.client.post(url)
|
resp = self.client.post(url)
|
||||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("jwt", resp.data)
|
||||||
|
team = Team.objects.get(pk=new_id)
|
||||||
|
self.assertEqual(team.owner_id, self.user.id)
|
||||||
|
self.assertTrue(team.active)
|
||||||
|
self.assertIsNotNone(team.active_jti)
|
||||||
|
|
||||||
def test_rotate_returns_new_jwt_and_changes_active_jti(self):
|
def test_rotate_returns_new_jwt_and_changes_active_jti(self):
|
||||||
before = self.team.active_jti
|
before = self.team.active_jti
|
||||||
@@ -417,10 +427,16 @@ class TeamRotateTest(_AuthenticatedAPITest):
|
|||||||
resp.status_code, status.HTTP_503_SERVICE_UNAVAILABLE
|
resp.status_code, status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_rotate_by_non_owner_returns_404(self):
|
def test_rotate_by_non_owner_returns_409(self):
|
||||||
|
# The team row exists under Alice; Bob rotating it must not
|
||||||
|
# upsert (that would silently steal the id) and must not 404
|
||||||
|
# (would tell Bob the id is free). 409 is the right answer.
|
||||||
before = self.team.active_jti
|
before = self.team.active_jti
|
||||||
|
before_owner = self.team.owner_id
|
||||||
self.client.force_authenticate(user=self.other_user)
|
self.client.force_authenticate(user=self.other_user)
|
||||||
resp = self.client.post(self.url)
|
resp = self.client.post(self.url)
|
||||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
self.assertEqual(resp.status_code, status.HTTP_409_CONFLICT)
|
||||||
|
# Alice's team is untouched.
|
||||||
self.team.refresh_from_db()
|
self.team.refresh_from_db()
|
||||||
self.assertEqual(self.team.active_jti, before)
|
self.assertEqual(self.team.active_jti, before)
|
||||||
|
self.assertEqual(self.team.owner_id, before_owner)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for the MCPToken model."""
|
"""Tests for the UserToken model."""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
@@ -6,19 +6,19 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from mcp_server.models import MCPToken, hash_token
|
from mcp_server.models import UserToken, hash_token
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class MCPTokenModelTest(TestCase):
|
class UserTokenModelTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="alice", email="alice@example.com", password="pw"
|
username="alice", email="alice@example.com", password="pw"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_create_token_returns_plaintext_and_stores_hash(self):
|
def test_create_token_returns_plaintext_and_stores_hash(self):
|
||||||
token, plaintext = MCPToken.objects.create_token(user=self.user, name="t")
|
token, plaintext = UserToken.objects.create_token(user=self.user, name="t")
|
||||||
self.assertTrue(plaintext)
|
self.assertTrue(plaintext)
|
||||||
self.assertGreater(len(plaintext), 20)
|
self.assertGreater(len(plaintext), 20)
|
||||||
# Database stores hash, not plaintext
|
# Database stores hash, not plaintext
|
||||||
@@ -29,21 +29,21 @@ class MCPTokenModelTest(TestCase):
|
|||||||
def test_token_hash_never_equals_plaintext(self):
|
def test_token_hash_never_equals_plaintext(self):
|
||||||
# Regression guard: if anyone ever wires plaintext back into token_hash,
|
# Regression guard: if anyone ever wires plaintext back into token_hash,
|
||||||
# this fails.
|
# this fails.
|
||||||
token, plaintext = MCPToken.objects.create_token(user=self.user, name="t")
|
token, plaintext = UserToken.objects.create_token(user=self.user, name="t")
|
||||||
self.assertNotIn(plaintext, token.token_hash)
|
self.assertNotIn(plaintext, token.token_hash)
|
||||||
|
|
||||||
def test_active_token_is_valid(self):
|
def test_active_token_is_valid(self):
|
||||||
token, _ = MCPToken.objects.create_token(user=self.user, name="t")
|
token, _ = UserToken.objects.create_token(user=self.user, name="t")
|
||||||
self.assertTrue(token.is_valid)
|
self.assertTrue(token.is_valid)
|
||||||
|
|
||||||
def test_inactive_token_not_valid(self):
|
def test_inactive_token_not_valid(self):
|
||||||
token, _ = MCPToken.objects.create_token(user=self.user, name="t")
|
token, _ = UserToken.objects.create_token(user=self.user, name="t")
|
||||||
token.is_active = False
|
token.is_active = False
|
||||||
token.save()
|
token.save()
|
||||||
self.assertFalse(token.is_valid)
|
self.assertFalse(token.is_valid)
|
||||||
|
|
||||||
def test_expired_token_not_valid(self):
|
def test_expired_token_not_valid(self):
|
||||||
token, _ = MCPToken.objects.create_token(
|
token, _ = UserToken.objects.create_token(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
name="t",
|
name="t",
|
||||||
expires_at=timezone.now() - timedelta(hours=1),
|
expires_at=timezone.now() - timedelta(hours=1),
|
||||||
@@ -51,27 +51,27 @@ class MCPTokenModelTest(TestCase):
|
|||||||
self.assertFalse(token.is_valid)
|
self.assertFalse(token.is_valid)
|
||||||
|
|
||||||
def test_unrestricted_permits_all(self):
|
def test_unrestricted_permits_all(self):
|
||||||
token, _ = MCPToken.objects.create_token(user=self.user, name="t")
|
token, _ = UserToken.objects.create_token(user=self.user, name="t")
|
||||||
self.assertTrue(token.can_use_tool("anything"))
|
self.assertTrue(token.can_use_tool("anything"))
|
||||||
|
|
||||||
def test_tool_whitelist(self):
|
def test_tool_whitelist(self):
|
||||||
token, _ = MCPToken.objects.create_token(
|
token, _ = UserToken.objects.create_token(
|
||||||
user=self.user, name="t", allowed_tools=["search"]
|
user=self.user, name="t", allowed_tools=["search"]
|
||||||
)
|
)
|
||||||
self.assertTrue(token.can_use_tool("search"))
|
self.assertTrue(token.can_use_tool("search"))
|
||||||
self.assertFalse(token.can_use_tool("get_chunk"))
|
self.assertFalse(token.can_use_tool("get_chunk"))
|
||||||
|
|
||||||
def test_record_usage(self):
|
def test_record_usage(self):
|
||||||
token, _ = MCPToken.objects.create_token(user=self.user, name="t")
|
token, _ = UserToken.objects.create_token(user=self.user, name="t")
|
||||||
self.assertIsNone(token.last_used_at)
|
self.assertIsNone(token.last_used_at)
|
||||||
token.record_usage()
|
token.record_usage()
|
||||||
token.refresh_from_db()
|
token.refresh_from_db()
|
||||||
self.assertIsNotNone(token.last_used_at)
|
self.assertIsNotNone(token.last_used_at)
|
||||||
|
|
||||||
def test_masked_token_is_hash_prefix(self):
|
def test_masked_token_is_hash_prefix(self):
|
||||||
token, plaintext = MCPToken.objects.create_token(user=self.user, name="t")
|
token, plaintext = UserToken.objects.create_token(user=self.user, name="t")
|
||||||
masked = token.get_masked_token()
|
masked = token.get_masked_token()
|
||||||
self.assertTrue(masked.startswith("mcp_…"))
|
self.assertTrue(masked.startswith("tok_…"))
|
||||||
self.assertIn(token.token_hash[:8], masked)
|
self.assertIn(token.token_hash[:8], masked)
|
||||||
# Plaintext must never leak through the masked display
|
# Plaintext must never leak through the masked display
|
||||||
self.assertNotIn(plaintext, masked)
|
self.assertNotIn(plaintext, masked)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""View tests for the MCP token self-service dashboard."""
|
"""View tests for the per-user API token self-service dashboard."""
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from mcp_server.models import MCPToken
|
from mcp_server.models import UserToken
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ class TokenListViewTest(TestCase):
|
|||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="alice", email="alice@example.com", password="pw"
|
username="alice", email="alice@example.com", password="pw"
|
||||||
)
|
)
|
||||||
self.url = reverse("mcp_server:mcp-token-list")
|
self.url = reverse("mcp_server:token-list")
|
||||||
|
|
||||||
def test_login_required(self):
|
def test_login_required(self):
|
||||||
resp = self.client.get(self.url)
|
resp = self.client.get(self.url)
|
||||||
@@ -23,8 +23,8 @@ class TokenListViewTest(TestCase):
|
|||||||
|
|
||||||
def test_list_shows_only_own_tokens(self):
|
def test_list_shows_only_own_tokens(self):
|
||||||
other = User.objects.create_user(username="bob", password="pw")
|
other = User.objects.create_user(username="bob", password="pw")
|
||||||
MCPToken.objects.create_token(user=self.user, name="mine")
|
UserToken.objects.create_token(user=self.user, name="mine")
|
||||||
MCPToken.objects.create_token(user=other, name="theirs")
|
UserToken.objects.create_token(user=other, name="theirs")
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
resp = self.client.get(self.url)
|
resp = self.client.get(self.url)
|
||||||
self.assertContains(resp, "mine")
|
self.assertContains(resp, "mine")
|
||||||
@@ -33,19 +33,19 @@ class TokenListViewTest(TestCase):
|
|||||||
def test_empty_state(self):
|
def test_empty_state(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
resp = self.client.get(self.url)
|
resp = self.client.get(self.url)
|
||||||
self.assertContains(resp, "No MCP tokens yet.")
|
self.assertContains(resp, "No API tokens yet.")
|
||||||
|
|
||||||
|
|
||||||
class TokenCreateViewTest(TestCase):
|
class TokenCreateViewTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="alice", password="pw")
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
self.url = reverse("mcp_server:mcp-token-create")
|
self.url = reverse("mcp_server:token-create")
|
||||||
|
|
||||||
def test_get_renders_form(self):
|
def test_get_renders_form(self):
|
||||||
resp = self.client.get(self.url)
|
resp = self.client.get(self.url)
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
self.assertContains(resp, "Generate MCP Token")
|
self.assertContains(resp, "Generate API Token")
|
||||||
|
|
||||||
def test_post_creates_token_and_shows_plaintext_once(self):
|
def test_post_creates_token_and_shows_plaintext_once(self):
|
||||||
resp = self.client.post(self.url, {"name": "Claude Desktop"})
|
resp = self.client.post(self.url, {"name": "Claude Desktop"})
|
||||||
@@ -53,7 +53,7 @@ class TokenCreateViewTest(TestCase):
|
|||||||
self.assertContains(resp, "Save this token now")
|
self.assertContains(resp, "Save this token now")
|
||||||
# Pull the created row, verify the response contained a plaintext that
|
# Pull the created row, verify the response contained a plaintext that
|
||||||
# is NOT what we stored.
|
# is NOT what we stored.
|
||||||
token = MCPToken.objects.get(user=self.user, name="Claude Desktop")
|
token = UserToken.objects.get(user=self.user, name="Claude Desktop")
|
||||||
self.assertNotContains(resp, token.token_hash) # hash is not what we display
|
self.assertNotContains(resp, token.token_hash) # hash is not what we display
|
||||||
# And the detail page never renders the plaintext.
|
# And the detail page never renders the plaintext.
|
||||||
body = resp.content.decode()
|
body = resp.content.decode()
|
||||||
@@ -71,35 +71,35 @@ class TokenCreateViewTest(TestCase):
|
|||||||
self.assertEqual(hash_token(plaintext), token.token_hash)
|
self.assertEqual(hash_token(plaintext), token.token_hash)
|
||||||
# Detail page must NOT contain the plaintext.
|
# Detail page must NOT contain the plaintext.
|
||||||
detail_resp = self.client.get(
|
detail_resp = self.client.get(
|
||||||
reverse("mcp_server:mcp-token-detail", args=[token.pk])
|
reverse("mcp_server:token-detail", args=[token.pk])
|
||||||
)
|
)
|
||||||
self.assertNotContains(detail_resp, plaintext)
|
self.assertNotContains(detail_resp, plaintext)
|
||||||
|
|
||||||
def test_post_invalid_renders_form_again(self):
|
def test_post_invalid_renders_form_again(self):
|
||||||
resp = self.client.post(self.url, {"name": ""})
|
resp = self.client.post(self.url, {"name": ""})
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
self.assertContains(resp, "Generate MCP Token")
|
self.assertContains(resp, "Generate API Token")
|
||||||
self.assertEqual(MCPToken.objects.count(), 0)
|
self.assertEqual(UserToken.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
class TokenDetailViewTest(TestCase):
|
class TokenDetailViewTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="alice", password="pw")
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
self.token, _ = MCPToken.objects.create_token(user=self.user, name="t")
|
self.token, _ = UserToken.objects.create_token(user=self.user, name="t")
|
||||||
|
|
||||||
def test_renders_token(self):
|
def test_renders_token(self):
|
||||||
resp = self.client.get(
|
resp = self.client.get(
|
||||||
reverse("mcp_server:mcp-token-detail", args=[self.token.pk])
|
reverse("mcp_server:token-detail", args=[self.token.pk])
|
||||||
)
|
)
|
||||||
self.assertContains(resp, self.token.name)
|
self.assertContains(resp, self.token.name)
|
||||||
self.assertContains(resp, self.token.get_masked_token())
|
self.assertContains(resp, self.token.get_masked_token())
|
||||||
|
|
||||||
def test_cannot_view_other_users_token(self):
|
def test_cannot_view_other_users_token(self):
|
||||||
other = User.objects.create_user(username="bob", password="pw")
|
other = User.objects.create_user(username="bob", password="pw")
|
||||||
other_token, _ = MCPToken.objects.create_token(user=other, name="theirs")
|
other_token, _ = UserToken.objects.create_token(user=other, name="theirs")
|
||||||
resp = self.client.get(
|
resp = self.client.get(
|
||||||
reverse("mcp_server:mcp-token-detail", args=[other_token.pk])
|
reverse("mcp_server:token-detail", args=[other_token.pk])
|
||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, 404)
|
self.assertEqual(resp.status_code, 404)
|
||||||
|
|
||||||
@@ -108,11 +108,11 @@ class TokenEditViewTest(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="alice", password="pw")
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
self.token, _ = MCPToken.objects.create_token(user=self.user, name="t")
|
self.token, _ = UserToken.objects.create_token(user=self.user, name="t")
|
||||||
|
|
||||||
def test_post_updates_metadata(self):
|
def test_post_updates_metadata(self):
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
reverse("mcp_server:mcp-token-edit", args=[self.token.pk]),
|
reverse("mcp_server:token-edit", args=[self.token.pk]),
|
||||||
{
|
{
|
||||||
"name": "Renamed",
|
"name": "Renamed",
|
||||||
"is_active": "on",
|
"is_active": "on",
|
||||||
@@ -127,9 +127,9 @@ class TokenEditViewTest(TestCase):
|
|||||||
|
|
||||||
def test_cannot_edit_other_users_token(self):
|
def test_cannot_edit_other_users_token(self):
|
||||||
other = User.objects.create_user(username="bob", password="pw")
|
other = User.objects.create_user(username="bob", password="pw")
|
||||||
other_token, _ = MCPToken.objects.create_token(user=other, name="theirs")
|
other_token, _ = UserToken.objects.create_token(user=other, name="theirs")
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
reverse("mcp_server:mcp-token-edit", args=[other_token.pk]),
|
reverse("mcp_server:token-edit", args=[other_token.pk]),
|
||||||
{"name": "hacked"},
|
{"name": "hacked"},
|
||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, 404)
|
self.assertEqual(resp.status_code, 404)
|
||||||
@@ -139,19 +139,19 @@ class TokenRevokeViewTest(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="alice", password="pw")
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
self.token, _ = MCPToken.objects.create_token(user=self.user, name="t")
|
self.token, _ = UserToken.objects.create_token(user=self.user, name="t")
|
||||||
|
|
||||||
def test_revoke_sets_inactive_keeps_row(self):
|
def test_revoke_sets_inactive_keeps_row(self):
|
||||||
url = reverse("mcp_server:mcp-token-revoke", args=[self.token.pk])
|
url = reverse("mcp_server:token-revoke", args=[self.token.pk])
|
||||||
resp = self.client.post(url)
|
resp = self.client.post(url)
|
||||||
self.assertEqual(resp.status_code, 302)
|
self.assertEqual(resp.status_code, 302)
|
||||||
self.token.refresh_from_db()
|
self.token.refresh_from_db()
|
||||||
self.assertFalse(self.token.is_active)
|
self.assertFalse(self.token.is_active)
|
||||||
# Row still exists for audit trail.
|
# Row still exists for audit trail.
|
||||||
self.assertTrue(MCPToken.objects.filter(pk=self.token.pk).exists())
|
self.assertTrue(UserToken.objects.filter(pk=self.token.pk).exists())
|
||||||
|
|
||||||
def test_get_not_allowed(self):
|
def test_get_not_allowed(self):
|
||||||
url = reverse("mcp_server:mcp-token-revoke", args=[self.token.pk])
|
url = reverse("mcp_server:token-revoke", args=[self.token.pk])
|
||||||
resp = self.client.get(url)
|
resp = self.client.get(url)
|
||||||
self.assertEqual(resp.status_code, 405)
|
self.assertEqual(resp.status_code, 405)
|
||||||
|
|
||||||
@@ -160,18 +160,18 @@ class TokenDeleteViewTest(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="alice", password="pw")
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
self.token, _ = MCPToken.objects.create_token(user=self.user, name="t")
|
self.token, _ = UserToken.objects.create_token(user=self.user, name="t")
|
||||||
|
|
||||||
def test_delete_removes_row(self):
|
def test_delete_removes_row(self):
|
||||||
url = reverse("mcp_server:mcp-token-delete", args=[self.token.pk])
|
url = reverse("mcp_server:token-delete", args=[self.token.pk])
|
||||||
resp = self.client.post(url)
|
resp = self.client.post(url)
|
||||||
self.assertEqual(resp.status_code, 302)
|
self.assertEqual(resp.status_code, 302)
|
||||||
self.assertFalse(MCPToken.objects.filter(pk=self.token.pk).exists())
|
self.assertFalse(UserToken.objects.filter(pk=self.token.pk).exists())
|
||||||
|
|
||||||
def test_cannot_delete_other_users_token(self):
|
def test_cannot_delete_other_users_token(self):
|
||||||
other = User.objects.create_user(username="bob", password="pw")
|
other = User.objects.create_user(username="bob", password="pw")
|
||||||
other_token, _ = MCPToken.objects.create_token(user=other, name="theirs")
|
other_token, _ = UserToken.objects.create_token(user=other, name="theirs")
|
||||||
url = reverse("mcp_server:mcp-token-delete", args=[other_token.pk])
|
url = reverse("mcp_server:token-delete", args=[other_token.pk])
|
||||||
resp = self.client.post(url)
|
resp = self.client.post(url)
|
||||||
self.assertEqual(resp.status_code, 404)
|
self.assertEqual(resp.status_code, 404)
|
||||||
self.assertTrue(MCPToken.objects.filter(pk=other_token.pk).exists())
|
self.assertTrue(UserToken.objects.filter(pk=other_token.pk).exists())
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""URL routes for the per-user MCP token self-service dashboard.
|
"""URL routes for the per-user API token self-service dashboard.
|
||||||
|
|
||||||
Mounted at ``/profile/mcp-tokens/…``. Humans use this surface to mint
|
Mounted at ``/profile/tokens/…``. Humans use this surface to mint
|
||||||
opaque :class:`mcp_server.models.MCPToken` rows for third-party MCP
|
opaque :class:`mcp_server.models.UserToken` rows that authenticate to
|
||||||
clients (Claude Desktop, Cline, etc.).
|
Mnemosyne — used by MCP tool clients (Claude Desktop, Cline) on
|
||||||
|
``/mcp/`` and by the Daedalus REST integration on
|
||||||
|
``/library/api/*`` / ``/mcp_server/api/teams/*``.
|
||||||
|
|
||||||
Other MCP-server surfaces live elsewhere:
|
Other MCP-server surfaces live elsewhere:
|
||||||
|
|
||||||
@@ -23,10 +25,10 @@ app_name = "mcp_server"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Self-service token dashboard (human-facing).
|
# Self-service token dashboard (human-facing).
|
||||||
path("profile/mcp-tokens/", views.mcp_token_list, name="mcp-token-list"),
|
path("profile/tokens/", views.token_list, name="token-list"),
|
||||||
path("profile/mcp-tokens/add/", views.mcp_token_create, name="mcp-token-create"),
|
path("profile/tokens/add/", views.token_create, name="token-create"),
|
||||||
path("profile/mcp-tokens/<int:pk>/", views.mcp_token_detail, name="mcp-token-detail"),
|
path("profile/tokens/<int:pk>/", views.token_detail, name="token-detail"),
|
||||||
path("profile/mcp-tokens/<int:pk>/edit/", views.mcp_token_edit, name="mcp-token-edit"),
|
path("profile/tokens/<int:pk>/edit/", views.token_edit, name="token-edit"),
|
||||||
path("profile/mcp-tokens/<int:pk>/revoke/", views.mcp_token_revoke, name="mcp-token-revoke"),
|
path("profile/tokens/<int:pk>/revoke/", views.token_revoke, name="token-revoke"),
|
||||||
path("profile/mcp-tokens/<int:pk>/delete/", views.mcp_token_delete, name="mcp-token-delete"),
|
path("profile/tokens/<int:pk>/delete/", views.token_delete, name="token-delete"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Self-service dashboard for MCP bearer tokens.
|
"""Self-service dashboard for per-user API tokens.
|
||||||
|
|
||||||
Mirrors the Themis API-keys flow visually but stores hashed tokens. Plaintext
|
Mirrors the Themis API-keys flow visually but stores hashed tokens.
|
||||||
is shown to the user exactly once (on the create-success page) and never
|
Plaintext is shown to the user exactly once (on the create-success
|
||||||
persisted.
|
page) and never persisted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,24 +13,24 @@ from django.http import HttpRequest, HttpResponse
|
|||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||||
|
|
||||||
from .forms import MCPTokenCreateForm, MCPTokenEditForm
|
from .forms import UserTokenCreateForm, UserTokenEditForm
|
||||||
from .models import MCPToken
|
from .models import UserToken
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_GET
|
@require_GET
|
||||||
def mcp_token_list(request: HttpRequest) -> HttpResponse:
|
def token_list(request: HttpRequest) -> HttpResponse:
|
||||||
tokens = MCPToken.objects.filter(user=request.user).order_by("-created_at")
|
tokens = UserToken.objects.filter(user=request.user).order_by("-created_at")
|
||||||
return render(request, "mcp_server/tokens/list.html", {"tokens": tokens})
|
return render(request, "mcp_server/tokens/list.html", {"tokens": tokens})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def mcp_token_create(request: HttpRequest) -> HttpResponse:
|
def token_create(request: HttpRequest) -> HttpResponse:
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = MCPTokenCreateForm(request.POST, user=request.user)
|
form = UserTokenCreateForm(request.POST, user=request.user)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
token, plaintext = MCPToken.objects.create_token(
|
token, plaintext = UserToken.objects.create_token(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
name=form.cleaned_data["name"],
|
name=form.cleaned_data["name"],
|
||||||
allowed_tools=form.cleaned_data.get("allowed_tools") or [],
|
allowed_tools=form.cleaned_data.get("allowed_tools") or [],
|
||||||
@@ -43,25 +43,25 @@ def mcp_token_create(request: HttpRequest) -> HttpResponse:
|
|||||||
{"token": token, "plaintext": plaintext},
|
{"token": token, "plaintext": plaintext},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
form = MCPTokenCreateForm(user=request.user)
|
form = UserTokenCreateForm(user=request.user)
|
||||||
|
|
||||||
return render(request, "mcp_server/tokens/create.html", {"form": form})
|
return render(request, "mcp_server/tokens/create.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_GET
|
@require_GET
|
||||||
def mcp_token_detail(request: HttpRequest, pk: int) -> HttpResponse:
|
def token_detail(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
|
token = get_object_or_404(UserToken, pk=pk, user=request.user)
|
||||||
return render(request, "mcp_server/tokens/detail.html", {"token": token})
|
return render(request, "mcp_server/tokens/detail.html", {"token": token})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def mcp_token_edit(request: HttpRequest, pk: int) -> HttpResponse:
|
def token_edit(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
|
token = get_object_or_404(UserToken, pk=pk, user=request.user)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = MCPTokenEditForm(request.POST, instance=token, user=request.user)
|
form = UserTokenEditForm(request.POST, instance=token, user=request.user)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
instance = form.save(commit=False)
|
instance = form.save(commit=False)
|
||||||
instance.allowed_tools = form.cleaned_data.get("allowed_tools") or []
|
instance.allowed_tools = form.cleaned_data.get("allowed_tools") or []
|
||||||
@@ -69,10 +69,10 @@ def mcp_token_edit(request: HttpRequest, pk: int) -> HttpResponse:
|
|||||||
form.cleaned_data.get("allowed_libraries") or []
|
form.cleaned_data.get("allowed_libraries") or []
|
||||||
)
|
)
|
||||||
instance.save()
|
instance.save()
|
||||||
messages.success(request, "MCP token updated.")
|
messages.success(request, "Token updated.")
|
||||||
return redirect("mcp_server:mcp-token-detail", pk=token.pk)
|
return redirect("mcp_server:token-detail", pk=token.pk)
|
||||||
else:
|
else:
|
||||||
form = MCPTokenEditForm(instance=token, user=request.user)
|
form = UserTokenEditForm(instance=token, user=request.user)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request, "mcp_server/tokens/edit.html", {"form": form, "token": token}
|
request, "mcp_server/tokens/edit.html", {"form": form, "token": token}
|
||||||
@@ -81,19 +81,19 @@ def mcp_token_edit(request: HttpRequest, pk: int) -> HttpResponse:
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def mcp_token_revoke(request: HttpRequest, pk: int) -> HttpResponse:
|
def token_revoke(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
|
token = get_object_or_404(UserToken, pk=pk, user=request.user)
|
||||||
token.is_active = False
|
token.is_active = False
|
||||||
token.save(update_fields=["is_active", "updated_at"])
|
token.save(update_fields=["is_active", "updated_at"])
|
||||||
messages.success(request, f"Revoked “{token.name}”. The token can no longer be used.")
|
messages.success(request, f"Revoked “{token.name}”. The token can no longer be used.")
|
||||||
return redirect("mcp_server:mcp-token-detail", pk=token.pk)
|
return redirect("mcp_server:token-detail", pk=token.pk)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def mcp_token_delete(request: HttpRequest, pk: int) -> HttpResponse:
|
def token_delete(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
|
token = get_object_or_404(UserToken, pk=pk, user=request.user)
|
||||||
name = token.name
|
name = token.name
|
||||||
token.delete()
|
token.delete()
|
||||||
messages.success(request, f"Deleted “{name}”.")
|
messages.success(request, f"Deleted “{name}”.")
|
||||||
return redirect("mcp_server:mcp-token-list")
|
return redirect("mcp_server:token-list")
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.humanize",
|
"django.contrib.humanize",
|
||||||
# Third-party
|
# Third-party
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"rest_framework.authtoken",
|
|
||||||
"storages",
|
"storages",
|
||||||
"django_neomodel",
|
"django_neomodel",
|
||||||
"django_prometheus",
|
"django_prometheus",
|
||||||
@@ -295,9 +294,11 @@ EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
|||||||
# --- Django REST Framework ---
|
# --- Django REST Framework ---
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||||
|
# Bearer first: unauthenticated requests get 401 + WWW-Authenticate: Bearer
|
||||||
|
# (RFC-correct). SessionAuthentication still runs after; it picks up
|
||||||
|
# browser session cookies when no Authorization header is present.
|
||||||
|
"mcp_server.drf_auth.UserTokenAuthentication",
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
"rest_framework.authentication.SessionAuthentication",
|
||||||
"rest_framework.authentication.TokenAuthentication",
|
|
||||||
"rest_framework.authentication.BasicAuthentication",
|
|
||||||
],
|
],
|
||||||
"DEFAULT_PERMISSION_CLASSES": [
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
"rest_framework.permissions.IsAuthenticated",
|
"rest_framework.permissions.IsAuthenticated",
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ urlpatterns = [
|
|||||||
# LLM Manager
|
# LLM Manager
|
||||||
path("llm/", include("llm_manager.urls")),
|
path("llm/", include("llm_manager.urls")),
|
||||||
# MCP server — two surfaces:
|
# MCP server — two surfaces:
|
||||||
# /profile/mcp-tokens/… — per-user self-service token dashboard (HTML, session auth)
|
# /profile/tokens/… — per-user self-service token dashboard (HTML, session auth)
|
||||||
# /mcp_server/api/… — Daedalus-facing team control plane (DRF, Basic auth)
|
# /mcp_server/api/… — Daedalus-facing team control plane (DRF, UserToken auth)
|
||||||
# The MCP bearer-auth surface itself (tool calls) is mounted by
|
# The MCP bearer-auth surface itself (tool calls) is mounted by
|
||||||
# mnemosyne.asgi at /mcp/ and is not routed here.
|
# mnemosyne.asgi at /mcp/ and is not routed here.
|
||||||
path("", include("mcp_server.urls")),
|
path("", include("mcp_server.urls")),
|
||||||
|
|||||||
@@ -39,13 +39,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'mcp_server:mcp-token-list' %}">
|
<a href="{% url 'mcp_server:token-list' %}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none"
|
||||||
viewBox="0 0 24 24" stroke="currentColor">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
</svg>
|
</svg>
|
||||||
MCP Tokens
|
API Tokens
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<div class="divider my-0"></div>
|
<div class="divider my-0"></div>
|
||||||
|
|||||||
@@ -141,31 +141,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- API Token — separate form, outside the settings form -->
|
<div class="text-sm opacity-70 mt-4">
|
||||||
<div class="card bg-base-200 mb-6">
|
Looking for API tokens? Manage them at
|
||||||
<div class="card-body">
|
<a class="link" href="{% url 'mcp_server:token-list' %}">/profile/tokens/</a>.
|
||||||
<h2 class="card-title text-lg">API Token</h2>
|
|
||||||
<p class="text-sm opacity-70 mb-4">
|
|
||||||
Authenticates programmatic clients (Daedalus, scripts, IDE
|
|
||||||
integrations) to Mnemosyne. Has the same access as your web
|
|
||||||
session — keep it secret.
|
|
||||||
</p>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<code class="font-mono bg-base-300 px-3 py-2 rounded flex-1 break-all select-all text-sm">{{ api_token.key }}</code>
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-ghost btn-sm"
|
|
||||||
onclick="navigator.clipboard.writeText('{{ api_token.key }}').then(() => { this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy', 2000); }).catch(() => {})">
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3">
|
|
||||||
<form method="post" action="{% url 'themis:api-token-regenerate' %}"
|
|
||||||
onsubmit="return confirm('Regenerate token? Any client using the current token will stop working until updated.')">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="btn btn-warning btn-sm">Regenerate</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ class UserProfileAPITest(APITestCase):
|
|||||||
self.assertEqual(len(response.data), 3) # apiuser, admin, otherapi
|
self.assertEqual(len(response.data), 3) # apiuser, admin, otherapi
|
||||||
|
|
||||||
def test_unauthenticated_denied(self):
|
def test_unauthenticated_denied(self):
|
||||||
"""Unauthenticated requests are denied."""
|
"""Unauthenticated requests are denied with 401 + Bearer challenge."""
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
def test_retrieve_own_profile(self):
|
def test_retrieve_own_profile(self):
|
||||||
"""User can retrieve their own profile."""
|
"""User can retrieve their own profile."""
|
||||||
@@ -205,9 +205,9 @@ class UserAPIKeyAPITest(APITestCase):
|
|||||||
self.assertTrue(UserAPIKey.objects.filter(pk=self.other_key.pk).exists())
|
self.assertTrue(UserAPIKey.objects.filter(pk=self.other_key.pk).exists())
|
||||||
|
|
||||||
def test_unauthenticated_denied(self):
|
def test_unauthenticated_denied(self):
|
||||||
"""Unauthenticated requests are denied."""
|
"""Unauthenticated requests are denied with 401 + Bearer challenge."""
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
class IsOwnerOrAdminPermissionTest(APITestCase):
|
class IsOwnerOrAdminPermissionTest(APITestCase):
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ urlpatterns = [
|
|||||||
path("live/", views.live, name="live"),
|
path("live/", views.live, name="live"),
|
||||||
# Profile settings
|
# Profile settings
|
||||||
path("profile/settings/", views.profile_settings, name="profile-settings"),
|
path("profile/settings/", views.profile_settings, name="profile-settings"),
|
||||||
path("profile/api-token/regenerate/", views.api_token_regenerate, name="api-token-regenerate"),
|
|
||||||
# API key management
|
# API key management
|
||||||
path("profile/keys/", views.key_list, name="key-list"),
|
path("profile/keys/", views.key_list, name="key-list"),
|
||||||
path("profile/keys/add/", views.key_create, name="key-create"),
|
path("profile/keys/add/", views.key_create, name="key-create"),
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ from django.shortcuts import get_object_or_404, redirect, render
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||||
|
|
||||||
from rest_framework.authtoken.models import Token
|
|
||||||
|
|
||||||
from themis.encryption import encrypt_value
|
from themis.encryption import encrypt_value
|
||||||
from themis.forms import APIKeyCreateForm, APIKeyEditForm, ProfileSettingsForm
|
from themis.forms import APIKeyCreateForm, APIKeyEditForm, ProfileSettingsForm
|
||||||
from themis.models import UserAPIKey, UserNotification
|
from themis.models import UserAPIKey, UserNotification
|
||||||
@@ -65,7 +63,6 @@ def live(request):
|
|||||||
def profile_settings(request):
|
def profile_settings(request):
|
||||||
"""Display and update user profile preferences."""
|
"""Display and update user profile preferences."""
|
||||||
profile = request.user.profile
|
profile = request.user.profile
|
||||||
api_token, _ = Token.objects.get_or_create(user=request.user)
|
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = ProfileSettingsForm(request.POST, instance=profile)
|
form = ProfileSettingsForm(request.POST, instance=profile)
|
||||||
@@ -76,17 +73,7 @@ def profile_settings(request):
|
|||||||
else:
|
else:
|
||||||
form = ProfileSettingsForm(instance=profile)
|
form = ProfileSettingsForm(instance=profile)
|
||||||
|
|
||||||
return render(request, "themis/profile/settings.html", {"form": form, "api_token": api_token})
|
return render(request, "themis/profile/settings.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_POST
|
|
||||||
def api_token_regenerate(request):
|
|
||||||
"""Delete and recreate the user's DRF API token."""
|
|
||||||
Token.objects.filter(user=request.user).delete()
|
|
||||||
Token.objects.create(user=request.user)
|
|
||||||
messages.success(request, "API token regenerated.")
|
|
||||||
return redirect("themis:profile-settings")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user