Compare commits
2 Commits
55551fe9af
...
409da7d109
| Author | SHA1 | Date | |
|---|---|---|---|
| 409da7d109 | |||
| 7296b8c42f |
188
CLAUDE.md
Normal file
188
CLAUDE.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
## 🐾 Red Panda Approval™
|
||||||
|
|
||||||
|
The standard every change is judged against. Don't satisfy a checklist —
|
||||||
|
satisfy the red pandas. Ask of each change: *does this earn approval?*
|
||||||
|
|
||||||
|
1. **Fresh Migration Test** — migrations apply cleanly from an empty database.
|
||||||
|
2. **Elegant Simplicity** — no unnecessary complexity; the obvious solution, done well.
|
||||||
|
3. **Observable & Debuggable** — proper logging; failures say what broke and why.
|
||||||
|
4. **Consistent Patterns** — follows Django conventions and the patterns already in this repo.
|
||||||
|
5. **Actually Works** — passes all checks *and* serves a real user need.
|
||||||
|
|
||||||
|
Criteria 1 and 5 are **externally verifiable** — migrations apply or they
|
||||||
|
don't; checks pass or they don't. Verify them, don't assert them. Criteria
|
||||||
|
2–4 are judgement calls: when in doubt, match what the repo already does
|
||||||
|
rather than grading your own elegance.
|
||||||
|
|
||||||
|
> If a paw print isn't leading the response, the rest of this file probably
|
||||||
|
> isn't being honoured either. Lead with one. 🐾
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions (always-on)
|
||||||
|
|
||||||
|
These are the rubric made concrete for the common case — writing models,
|
||||||
|
views, forms, templates, and queries.
|
||||||
|
|
||||||
|
### Models
|
||||||
|
- Names: singular PascalCase (`User`, `BlogPost`, `OrderItem`).
|
||||||
|
- Every model defines `__str__` and `get_absolute_url`.
|
||||||
|
- Every model has `created_at = DateTimeField(auto_now_add=True)` and
|
||||||
|
`updated_at = DateTimeField(auto_now=True)`.
|
||||||
|
- `TextChoices` for status fields.
|
||||||
|
- `related_name` on every `ForeignKey`; plural snake_case with correct
|
||||||
|
English pluralisation.
|
||||||
|
- Public-facing models: consider `UUIDField` primary key and
|
||||||
|
`is_active` for soft deletes.
|
||||||
|
|
||||||
|
### Field naming
|
||||||
|
- Foreign keys: singular, no `_id` suffix (`author`, `category`, `parent`).
|
||||||
|
- Booleans: prefixed (`is_active`, `has_permission`, `can_edit`).
|
||||||
|
- Dates: suffixed (`created_at`, `updated_at`, `published_on`).
|
||||||
|
- No abbreviations (`description`, not `desc`).
|
||||||
|
|
||||||
|
### Views
|
||||||
|
- **Function-based views exclusively.** Explicit logic over implicit
|
||||||
|
inheritance. Extract shared logic into utility functions.
|
||||||
|
- Business logic lives in service functions, not views and not `save()`.
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
- `ModelForm` with an explicit `fields` list — never `__all__`, never `exclude`.
|
||||||
|
- Validate at the boundary; never trust client-side validation alone.
|
||||||
|
|
||||||
|
### Queries
|
||||||
|
- `select_related()` for FKs; `prefetch_related()` for reverse and M2M.
|
||||||
|
- No queries inside loops (N+1). No `.all()` when you need a subset.
|
||||||
|
- `.only()` / `.defer()` for large models. Comment non-obvious querysets.
|
||||||
|
|
||||||
|
### URLs & identifiers
|
||||||
|
- Public URLs use 12-char short UUIDs via `shortuuid`. Never expose
|
||||||
|
sequential IDs (enumeration risk). Internal refs may use PKs.
|
||||||
|
- Resource-based, namespaced URL names per app, trailing slashes, flat
|
||||||
|
structure preferred.
|
||||||
|
|
||||||
|
### Docstrings
|
||||||
|
- **Google style.** Document public classes, functions, methods, modules.
|
||||||
|
- Imperative one-line summary. `Args:`/`Returns:`/`Raises:` only when the
|
||||||
|
signature doesn't already convey it. Don't restate type hints in prose.
|
||||||
|
- Skip obvious one-liners and standard Django overrides.
|
||||||
|
|
||||||
|
### Code organisation
|
||||||
|
- PEP 8 import ordering (stdlib, third-party, local). Type hints on params.
|
||||||
|
- CSS and JS in external files only — no inline styles, `<style>`,
|
||||||
|
inline handlers, or `<script>` blocks.
|
||||||
|
- File length: split by domain concept past ~500 lines; hard ceiling 1000.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Django `TestCase` (not pytest). Separate files per module:
|
||||||
|
`test_models.py`, `test_views.py`, `test_forms.py`.
|
||||||
|
|
||||||
|
An app isn't done until it's reachable
|
||||||
|
django-admin startapp builds an island. A complete-from-its-own-boundary
|
||||||
|
app — models, views, urls, templates, tests all present and passing — is
|
||||||
|
|
||||||
|
# Add to always-on Django CLAUDE.md — Conventions section
|
||||||
|
|
||||||
|
Insert this block under "Conventions (always-on)", as its own subsection.
|
||||||
|
It is the universal Django definition-of-done. It fires for *every* app,
|
||||||
|
not just registered tools.
|
||||||
|
|
||||||
|
### An app isn't done until it's reachable
|
||||||
|
|
||||||
|
`django-admin startapp` builds an **island**. A complete-from-its-own-boundary
|
||||||
|
app — models, views, urls, templates, tests all present and passing — is
|
||||||
|
still *unfinished* if nothing in the running site links to it. "It works in
|
||||||
|
isolation" is not done; **"a user can reach it from the running site" is done.**
|
||||||
|
|
||||||
|
Before reporting a new app complete, wire it into the site:
|
||||||
|
|
||||||
|
1. **`INSTALLED_APPS`** — add the app's config.
|
||||||
|
2. **Root URLconf** — `include()` the app's `urls.py` in `config/urls.py`.
|
||||||
|
An app whose URLconf isn't included has unreachable views, full stop.
|
||||||
|
3. **Navigation / discovery** — register the app so it surfaces wherever
|
||||||
|
this project expects apps to appear. This project uses an **app
|
||||||
|
registry** (see Project Setup): the app registers itself in its own
|
||||||
|
`apps.py.ready()` and the navigation template tag picks it up. Do **not**
|
||||||
|
hand-edit nav templates or central list views — they read from the
|
||||||
|
registry.
|
||||||
|
4. **Verify reachability** — confirm the app's main page actually loads
|
||||||
|
from the running site (not just that its tests pass). Per Red Panda
|
||||||
|
criterion 5, this is externally verifiable: load the page, don't assert
|
||||||
|
it works.
|
||||||
|
Why this rule exists: an LLM reasons locally and closes the visible task at
|
||||||
|
the app's own boundary. The wiring that makes an app reachable lives in
|
||||||
|
*other* files (`config/urls.py`, `INSTALLED_APPS`, the registry) with no
|
||||||
|
signal inside the new app pointing to them. Without this rule, the
|
||||||
|
near-certain result is a fully-built, completely inaccessible app. The
|
||||||
|
registry exists precisely so that "surface it" happens *inside* the app's
|
||||||
|
own boundary (a `register()` call in `ready()`) — collapsing the wiring
|
||||||
|
into the one place local reasoning will actually look.
|
||||||
|
|
||||||
|
> The same principle generalises beyond Django: a new route that isn't
|
||||||
|
> mounted, a CLI subcommand not added to the dispatcher, a handler not
|
||||||
|
> registered — all the same failure. Done means *connected*, not *written*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Always-on anti-patterns
|
||||||
|
|
||||||
|
The cross-cutting tripwires worth carrying everywhere. File-specific
|
||||||
|
landmines (nginx, compose, broker) are in path-scoped rules.
|
||||||
|
|
||||||
|
- **Models:** no `.get()` without handling `DoesNotExist`; no `null=True`
|
||||||
|
on `CharField`/`TextField` (use `blank=True, default=""`); always specify
|
||||||
|
`on_delete`; don't override `save()` for business logic; no
|
||||||
|
`Meta.ordering` on large tables.
|
||||||
|
- **Security:** secrets via env vars, never in `settings.py`; never commit
|
||||||
|
`.env`; never `DEBUG=True` in production; never `mark_safe()` on
|
||||||
|
user-supplied content; never disable CSRF.
|
||||||
|
- **Templates:** `{% url %}` not `{{ variable }}` for URLs; no logic in
|
||||||
|
templates; `{% csrf_token %}` in every form.
|
||||||
|
- **Imports/style:** no `import *`; no mutable default args; no bare
|
||||||
|
`except:`; don't silence linter warnings without a documented reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- Virtual environment: `~/env/PROJECT/bin/activate` (replace PROJECT).
|
||||||
|
- `pyproject.toml` for config — no `setup.py`, no `requirements.txt`.
|
||||||
|
- Dependencies floor-pinned with ceiling (`Django>=5.2,<6.0`). Exact `==`
|
||||||
|
pins only in application lock files, never in reusable packages.
|
||||||
|
- Dev DB: SQLite. Production DB: PostgreSQL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path-scoped rules to create (`.claude/rules/`)
|
||||||
|
|
||||||
|
These hold the landmines extracted from the standards doc. Each loads only
|
||||||
|
when its `paths` match, keeping this file lean. Frontmatter shown.
|
||||||
|
|
||||||
|
- **`nginx.md`** — `paths: ["nginx/**", "**/*.conf"]` — reverse-proxy
|
||||||
|
reference config: Docker DNS resolver + variable `proxy_pass`,
|
||||||
|
`$proxy_x_forwarded_proto` map, access-log filtering, RFC1918 allowlists
|
||||||
|
(all four ranges), `always` security headers.
|
||||||
|
- **`docker-compose.md`** — `paths: ["docker-compose*.y*ml", ".env*"]` —
|
||||||
|
per-service `environment:` scoping (no shared `env_file:`), `${VAR}`
|
||||||
|
interpolation, `.env.example` annotation convention, the `repr()` parse
|
||||||
|
diagnostic.
|
||||||
|
- **`celery-tasks.md`** — `paths: ["**/tasks.py"]` — idempotency, retry
|
||||||
|
logic, pass IDs not instances, synchronous-by-default, broker URL
|
||||||
|
percent-encoding, progress pattern `{app}:task:{task_id}:progress`.
|
||||||
|
- **`migrations.md`** — `paths: ["**/migrations/**"]` — never edit deployed
|
||||||
|
migrations; `RunPython` needs a reverse; no non-nullable field without a
|
||||||
|
default; meaningful `--name`; test forward and backward.
|
||||||
|
- **`memcached.md`** — `paths: ["**/settings.py", ".env*"]` — bind
|
||||||
|
`0.0.0.0` not localhost; container can't reach `127.0.0.1`; LAN hostname
|
||||||
|
in `KVDB_LOCATION`; key pattern `{app}:{model}:{identifier}:{field}`.
|
||||||
|
- **`frontend.md`** — `paths: ["**/templates/**", "**/static/**"]` — DaisyUI+
|
||||||
|
Tailwind for new projects / Bootstrap 5 for existing; extend
|
||||||
|
`themis/base.html`; no inline styles or scripts.
|
||||||
|
|
||||||
|
## Reference docs (consult on demand, don't inline)
|
||||||
|
|
||||||
|
- `docs/` gotcha writeups: broker-URL/Kombu parsing, env-file parsing
|
||||||
|
differences, nginx IP-caching. State the rule in the rule file; link the
|
||||||
|
*why* here.
|
||||||
|
- Preferred-packages list and per-app architecture: keep in `docs/`, not in
|
||||||
|
this always-on file.
|
||||||
@@ -367,9 +367,12 @@ Mnemosyne validates the JWT against `MCPSigningKey` keyed by `kid`.
|
|||||||
|
|
||||||
## 7. REST API — Mnemosyne team lifecycle
|
## 7. REST API — Mnemosyne team lifecycle
|
||||||
|
|
||||||
All endpoints live under `/mcp_server/api/teams/` and are protected
|
All endpoints live under `/mcp_server/api/teams/` and are authenticated
|
||||||
by the existing `daedalus-service` HTTP Basic account (same auth as
|
as the Mnemosyne user the team belongs to via a per-user DRF token
|
||||||
`/library/api/workspaces/` and `/library/api/ingest/`).
|
(`Authorization: Token <key>`, surfaced on `/profile/settings/`). Each
|
||||||
|
team has an `owner` FK; non-owners receive 404 (never 403) so a team's
|
||||||
|
existence isn't disclosed across users. `/library/api/workspaces/` and
|
||||||
|
`/library/api/ingest/` use the same per-user auth model.
|
||||||
|
|
||||||
### 7.1 `POST /mcp_server/api/teams/`
|
### 7.1 `POST /mcp_server/api/teams/`
|
||||||
Create a team.
|
Create a team.
|
||||||
@@ -733,7 +736,8 @@ escape hatch for hard compartmentalization.
|
|||||||
* `TeamWorkspaceAssignment` PUT is idempotent and replaces, not
|
* `TeamWorkspaceAssignment` PUT is idempotent and replaces, not
|
||||||
unions.
|
unions.
|
||||||
* `/mcp_server/api/teams/` endpoints: create, delete, rotate,
|
* `/mcp_server/api/teams/` endpoints: create, delete, rotate,
|
||||||
workspaces PUT, all authenticated as `daedalus-service`.
|
workspaces PUT, all authenticated with a per-user DRF token and
|
||||||
|
scoped to the team's `owner` (non-owner requests return 404).
|
||||||
|
|
||||||
### 14.2 Daedalus test surface
|
### 14.2 Daedalus test surface
|
||||||
* `on_pallas_registered` populates `team_jwt_encrypted` and transitions
|
* `on_pallas_registered` populates `team_jwt_encrypted` and transitions
|
||||||
|
|||||||
@@ -92,14 +92,6 @@ docker compose -f /srv/mnemosyne/docker-compose.yaml \
|
|||||||
docker compose -f /srv/mnemosyne/docker-compose.yaml \
|
docker compose -f /srv/mnemosyne/docker-compose.yaml \
|
||||||
run --rm app setup
|
run --rm app setup
|
||||||
|
|
||||||
# Create the daedalus-service user (HTTP Basic auth for ingest API)
|
|
||||||
# Pass --password from vault; idempotent if user already exists.
|
|
||||||
docker compose -f /srv/mnemosyne/docker-compose.yaml \
|
|
||||||
run --rm app \
|
|
||||||
python manage.py ensure_service_user \
|
|
||||||
--username daedalus-service \
|
|
||||||
--password "{{ vault_mnemosyne_daedalus_service_password }}"
|
|
||||||
|
|
||||||
# Seed the MCPSigningKey used to sign long-lived Pallas team JWTs.
|
# Seed the MCPSigningKey used to sign long-lived Pallas team JWTs.
|
||||||
# --retire-other deactivates any previously-active key. The hex
|
# --retire-other deactivates any previously-active key. The hex
|
||||||
# emitted to stdout is persisted in Mnemosyne's database and is
|
# emitted to stdout is persisted in Mnemosyne's database and is
|
||||||
@@ -321,13 +313,16 @@ curl -f http://puck.incus:23181/healthz
|
|||||||
curl http://puck.incus:23181/metrics | head -5
|
curl http://puck.incus:23181/metrics | head -5
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verify the daedalus-service account
|
### Verify Daedalus auth (per-user API token)
|
||||||
|
|
||||||
|
Daedalus now authenticates as a Mnemosyne user via the DRF token shown
|
||||||
|
on `/profile/settings/`. To smoke-test from a deploy host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -u daedalus-service:<password> \
|
curl -H "Authorization: Token <user-api-token>" \
|
||||||
https://mnemosyne.ouranos.helu.ca/library/api/workspaces/ \
|
https://mnemosyne.ouranos.helu.ca/library/api/workspaces/ws_smoke/ \
|
||||||
-o /dev/null -w "%{http_code}"
|
-o /dev/null -w "%{http_code}"
|
||||||
# Expect: 200
|
# Expect: 200 if the workspace exists for that user, 404 otherwise.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verify MCP connectivity (from a client with a valid MCPToken)
|
### Verify MCP connectivity (from a client with a valid MCPToken)
|
||||||
@@ -401,6 +396,5 @@ will report as a failure.
|
|||||||
| `vault_daedalus_s3_read_secret` | `DAEDALUS_S3_SECRET_ACCESS_KEY` |
|
| `vault_daedalus_s3_read_secret` | `DAEDALUS_S3_SECRET_ACCESS_KEY` |
|
||||||
| `vault_rabbitmq_password` | embedded in `CELERY_BROKER_URL` |
|
| `vault_rabbitmq_password` | embedded in `CELERY_BROKER_URL` |
|
||||||
| `vault_mnemosyne_llm_encryption_key` | `LLM_API_SECRETS_ENCRYPTION_KEY` |
|
| `vault_mnemosyne_llm_encryption_key` | `LLM_API_SECRETS_ENCRYPTION_KEY` |
|
||||||
| `vault_mnemosyne_daedalus_service_password` | passed to `ensure_service_user --password` |
|
|
||||||
| `vault_mnemosyne_casdoor_client_id` | `CASDOOR_CLIENT_ID` |
|
| `vault_mnemosyne_casdoor_client_id` | `CASDOOR_CLIENT_ID` |
|
||||||
| `vault_mnemosyne_casdoor_client_secret` | `CASDOOR_CLIENT_SECRET` |
|
| `vault_mnemosyne_casdoor_client_secret` | `CASDOOR_CLIENT_SECRET` |
|
||||||
|
|||||||
@@ -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 (HTTP Basic auth, service account `daedalus-service`) 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 DRF token (`Authorization: Token <key>`, surfaced on `/profile/settings/`) 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 HTTP Basic auth as `daedalus-service`. They are consumed by the Daedalus FastAPI backend only — not by any frontend.
|
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.
|
||||||
|
|
||||||
### 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] HTTP Basic auth via `daedalus-service` user
|
- [x] Per-user DRF token auth (`Authorization: Token <key>`); 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)
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ A "workspace" in Mnemosyne is a Library scoped to a Daedalus workspace UUID.
|
|||||||
It uses the same Library node as a global library; the difference is that
|
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 (HTTP Basic auth as
|
These endpoints are called by the Daedalus backend authenticated as the
|
||||||
the `daedalus-service` user). Daedalus owns the workspace_id; Mnemosyne
|
Mnemosyne user the workspace belongs to (per-user DRF token). The
|
||||||
just persists what Daedalus tells it.
|
workspace's owning user is recorded on the Library node as
|
||||||
|
``owner_username``; every read and mutation is scoped to that user.
|
||||||
|
Non-owners receive 404 so a workspace's existence isn't disclosed
|
||||||
|
across users.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -72,6 +75,17 @@ def workspace_create(request):
|
|||||||
existing = None
|
existing = None
|
||||||
|
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
|
if existing.owner_username and existing.owner_username != request.user.username:
|
||||||
|
# Same workspace_id under a different owner. Don't leak the
|
||||||
|
# collision shape; surface a generic conflict.
|
||||||
|
logger.warning(
|
||||||
|
"workspace_create owner_conflict workspace_id=%s caller=%s",
|
||||||
|
data["workspace_id"], request.user.username,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{"detail": "Workspace id is already in use."},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
if existing.library_type != data["library_type"]:
|
if existing.library_type != data["library_type"]:
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@@ -98,6 +112,7 @@ def workspace_create(request):
|
|||||||
library_type=data["library_type"],
|
library_type=data["library_type"],
|
||||||
description=data.get("description", ""),
|
description=data.get("description", ""),
|
||||||
workspace_id=data["workspace_id"],
|
workspace_id=data["workspace_id"],
|
||||||
|
owner_username=request.user.username,
|
||||||
chunking_config=defaults["chunking_config"],
|
chunking_config=defaults["chunking_config"],
|
||||||
embedding_instruction=defaults["embedding_instruction"],
|
embedding_instruction=defaults["embedding_instruction"],
|
||||||
reranker_instruction=defaults["reranker_instruction"],
|
reranker_instruction=defaults["reranker_instruction"],
|
||||||
@@ -127,21 +142,26 @@ def workspace_detail_or_delete(request, workspace_id):
|
|||||||
"""
|
"""
|
||||||
from library.models import Library
|
from library.models import Library
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
try:
|
try:
|
||||||
lib = Library.nodes.get(workspace_id=workspace_id)
|
lib = Library.nodes.get(workspace_id=workspace_id)
|
||||||
except Library.DoesNotExist:
|
except Library.DoesNotExist:
|
||||||
|
lib = None
|
||||||
|
|
||||||
|
# Cross-user reads/writes look like "not found" — don't disclose
|
||||||
|
# existence across users.
|
||||||
|
if lib is not None and lib.owner_username != request.user.username:
|
||||||
|
lib = None
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
if lib is None:
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "Workspace not found."},
|
{"detail": "Workspace not found."},
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(WorkspaceStatusSerializer(_serialize_workspace(lib)).data)
|
return Response(WorkspaceStatusSerializer(_serialize_workspace(lib)).data)
|
||||||
|
|
||||||
# DELETE — idempotent: a missing workspace returns 204.
|
# DELETE — idempotent: a missing (or unowned) workspace returns 204.
|
||||||
try:
|
if lib is None:
|
||||||
lib = Library.nodes.get(workspace_id=workspace_id)
|
|
||||||
except Library.DoesNotExist:
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
library_uid = lib.uid
|
library_uid = lib.uid
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ class Library(StructuredNode):
|
|||||||
# libraries. Unique-indexed so a workspace cannot have two libraries.
|
# libraries. Unique-indexed so a workspace cannot have two libraries.
|
||||||
workspace_id = StringProperty(unique_index=True, required=False)
|
workspace_id = StringProperty(unique_index=True, required=False)
|
||||||
|
|
||||||
|
# For workspace-scoped libraries: the Mnemosyne username that owns
|
||||||
|
# the workspace. Mutations via the workspaces API are restricted to
|
||||||
|
# this user. Null for global libraries.
|
||||||
|
owner_username = StringProperty(required=False, index=True)
|
||||||
|
|
||||||
# Content-type configuration
|
# Content-type configuration
|
||||||
chunking_config = JSONProperty(default={})
|
chunking_config = JSONProperty(default={})
|
||||||
embedding_instruction = StringProperty(default="")
|
embedding_instruction = StringProperty(default="")
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
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 the ``daedalus-service`` account, not by end users or bearer
|
by Daedalus authenticated as the Mnemosyne user the team belongs to
|
||||||
tokens.
|
(per-user DRF token).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
Mounted under ``/mcp_server/api/teams/`` — see §7 of
|
Mounted under ``/mcp_server/api/teams/`` — see §7 of
|
||||||
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. Every endpoint is
|
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. Every endpoint is
|
||||||
``IsAuthenticated``-gated against the ``daedalus-service`` HTTP Basic
|
``IsAuthenticated``-gated and scoped to ``request.user``: the user that
|
||||||
account (same surface as ``/library/api/workspaces/``). Endpoints are
|
created a team via ``POST /`` is the only user that can read, mutate,
|
||||||
designed to be idempotent where possible:
|
or rotate it. Non-owners receive 404 (not 403) so a team's existence
|
||||||
|
isn't disclosed across users. Endpoints are designed to be idempotent
|
||||||
|
where possible:
|
||||||
|
|
||||||
* ``POST /`` — create a team by UUID; a second POST with
|
* ``POST /`` — create a team by UUID; a second POST with
|
||||||
the same id returns 200 without a new JWT.
|
the same id returns 200 without a new JWT.
|
||||||
@@ -46,9 +48,14 @@ logger = logging.getLogger(__name__)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _get_team(team_id):
|
def _get_team(team_id, user):
|
||||||
"""Load a team by UUID or None. Callers 404 on None."""
|
"""Load a team by UUID *for this user* or None.
|
||||||
return Team.objects.filter(pk=team_id).first()
|
|
||||||
|
Returns None both when the team does not exist and when it exists
|
||||||
|
but is owned by another user. Callers 404 on None — they must not
|
||||||
|
distinguish the two cases.
|
||||||
|
"""
|
||||||
|
return Team.objects.filter(pk=team_id, owner=user).first()
|
||||||
|
|
||||||
|
|
||||||
def _mint_with_fresh_jti(team: Team) -> str:
|
def _mint_with_fresh_jti(team: Team) -> str:
|
||||||
@@ -75,8 +82,19 @@ def team_create(request):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
|
|
||||||
team = _get_team(data["id"])
|
existing = Team.objects.filter(pk=data["id"]).first()
|
||||||
if team is not None:
|
if existing is not None:
|
||||||
|
if existing.owner_id != request.user.id:
|
||||||
|
# Same id, different owner. Surfacing existence would leak
|
||||||
|
# one user's team id to another; treat as a generic conflict.
|
||||||
|
logger.warning(
|
||||||
|
"team_create owner_conflict team_id=%s caller=%s",
|
||||||
|
existing.id, request.user.id,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{"detail": "Team id is already in use."},
|
||||||
|
status=status.HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
# Idempotent: surface current state without a new JWT. If the
|
# Idempotent: surface current state without a new JWT. If the
|
||||||
# caller wants to reactivate a soft-deleted team they must do
|
# caller wants to reactivate a soft-deleted team they must do
|
||||||
# so explicitly via the admin or a future endpoint; re-POST is
|
# so explicitly via the admin or a future endpoint; re-POST is
|
||||||
@@ -84,15 +102,17 @@ def team_create(request):
|
|||||||
# on every retry storm.
|
# on every retry storm.
|
||||||
logger.info(
|
logger.info(
|
||||||
"team_create idempotent_hit team_id=%s name=%s active=%s",
|
"team_create idempotent_hit team_id=%s name=%s active=%s",
|
||||||
team.id, team.name, team.active,
|
existing.id, existing.name, existing.active,
|
||||||
)
|
)
|
||||||
return Response(
|
return Response(
|
||||||
TeamPublicSerializer(team).data, status=status.HTTP_200_OK
|
TeamPublicSerializer(existing).data, status=status.HTTP_200_OK
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
team = Team.objects.create(id=data["id"], name=data["name"])
|
team = Team.objects.create(
|
||||||
|
id=data["id"], name=data["name"], owner=request.user
|
||||||
|
)
|
||||||
jwt_string = _mint_with_fresh_jti(team)
|
jwt_string = _mint_with_fresh_jti(team)
|
||||||
except TeamJWTError as exc:
|
except TeamJWTError as exc:
|
||||||
# Rolling back the create is fine — we have no signing key yet
|
# Rolling back the create is fine — we have no signing key yet
|
||||||
@@ -130,7 +150,7 @@ def team_detail(request, team_id):
|
|||||||
in the database for audit; call POST ``/`` with the same id to
|
in the database for audit; call POST ``/`` with the same id to
|
||||||
re-materialize a fresh team if needed (operator decision).
|
re-materialize a fresh team if needed (operator decision).
|
||||||
"""
|
"""
|
||||||
team = _get_team(team_id)
|
team = _get_team(team_id, request.user)
|
||||||
if team is None:
|
if team is None:
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
|
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
|
||||||
@@ -163,7 +183,7 @@ def team_workspaces(request, team_id):
|
|||||||
Diff is computed in-DB so we don't thrash rows that already match —
|
Diff is computed in-DB so we don't thrash rows that already match —
|
||||||
only the net-add and net-remove rows are touched.
|
only the net-add and net-remove rows are touched.
|
||||||
"""
|
"""
|
||||||
team = _get_team(team_id)
|
team = _get_team(team_id, request.user)
|
||||||
if team is None:
|
if team is None:
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
|
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
|
||||||
@@ -221,7 +241,7 @@ 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)
|
team = _get_team(team_id, request.user)
|
||||||
if team is None:
|
if team is None:
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
|
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""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 (HTTP Basic auth
|
These endpoints are called by the Daedalus backend authenticated as
|
||||||
as ``daedalus-service``). End-user MCP traffic does NOT go through
|
the owning Mnemosyne user (per-user DRF token). Every team is scoped
|
||||||
this surface — that's ``mnemosyne.asgi`` / ``mcp_server/server.py``.
|
to its ``owner`` — cross-user access returns 404. End-user MCP traffic
|
||||||
|
does NOT go through this surface — that's ``mnemosyne.asgi`` /
|
||||||
|
``mcp_server/server.py``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ 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
|
||||||
@@ -558,29 +557,39 @@ class MCPAuthMiddleware(Middleware):
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_jwt_actor(claims: dict):
|
def _resolve_jwt_actor(claims: dict):
|
||||||
"""Resolve the synthetic actor for a JWT-authenticated turn.
|
"""Resolve the acting user for a JWT-authenticated turn.
|
||||||
|
|
||||||
Returns the system service user (``MCP_JWT_SERVICE_USERNAME``, default
|
For ``typ=team`` JWTs (the only kind we mint), the actor is the
|
||||||
``daedalus-service``). The user must exist and be active. JWT tokens
|
``Team.owner`` — the Mnemosyne user that created the team. Usage
|
||||||
are not tied to per-user accounts — claims encode all authorization.
|
accounting and the audit trail attribute the turn to that user.
|
||||||
|
|
||||||
Used for both per-turn and team JWTs. The service user is a hook for
|
For legacy per-turn JWTs (``iss=daedalus``, retiring in Phase 4), no
|
||||||
usage accounting (LLMUsage / search metrics) and for the audit trail;
|
user binding exists in the claims; we cannot attribute the turn to a
|
||||||
authorization does not depend on it.
|
Mnemosyne user and the path is rejected. If a deployment still
|
||||||
|
accepts per-turn JWTs, that work needs to land first.
|
||||||
"""
|
"""
|
||||||
from django.contrib.auth import get_user_model
|
if claims.get("typ") != "team":
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
username = getattr(settings, "MCP_JWT_SERVICE_USERNAME", "daedalus-service")
|
|
||||||
try:
|
|
||||||
user = User.objects.get(username=username)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
raise MCPAuthError(
|
raise MCPAuthError(
|
||||||
f"JWT service user {username!r} does not exist; provision via management command."
|
"Per-turn JWTs are no longer accepted; mint a team JWT."
|
||||||
)
|
)
|
||||||
if not user.is_active:
|
|
||||||
raise MCPAuthError(f"JWT service user {username!r} is disabled.")
|
# resolve_mcp_jwt has already parsed the team UUID out of ``sub`` and
|
||||||
return user
|
# stashed it as ``team_id`` for the team branch.
|
||||||
|
team_id = claims.get("team_id")
|
||||||
|
if team_id is None:
|
||||||
|
raise MCPAuthError("Team JWT missing team_id claim.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
team = Team.objects.select_related("owner").get(pk=team_id)
|
||||||
|
except Team.DoesNotExist:
|
||||||
|
raise MCPAuthError("Team JWT references a team that no longer exists.")
|
||||||
|
if not team.active:
|
||||||
|
raise MCPAuthError("Team JWT references an inactive team.")
|
||||||
|
if not team.owner.is_active:
|
||||||
|
raise MCPAuthError(
|
||||||
|
f"Team owner {team.owner.username!r} is disabled."
|
||||||
|
)
|
||||||
|
return team.owner
|
||||||
|
|
||||||
|
|
||||||
def _resolved_libraries_for_jwt(claims: dict) -> list[str]:
|
def _resolved_libraries_for_jwt(claims: dict) -> list[str]:
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
"""Idempotently create the service user that JWT-authenticated MCP requests act as.
|
|
||||||
|
|
||||||
Daedalus mints per-turn JWTs whose claims encode all authorization (workspace,
|
|
||||||
allowed libraries). The Django ``user`` field on the request still needs to
|
|
||||||
point at *something* — the service user is that something. It owns no data
|
|
||||||
and does not log in via the dashboard.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Idempotently create or reactivate the JWT service user (default 'daedalus-service')."
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument("--username", default="daedalus-service")
|
|
||||||
parser.add_argument("--email", default="daedalus-service@local")
|
|
||||||
parser.add_argument(
|
|
||||||
"--password",
|
|
||||||
default=None,
|
|
||||||
help=(
|
|
||||||
"Password for HTTP Basic auth (Daedalus REST calls). "
|
|
||||||
"Omit to set a random unusable password (JWT-only mode)."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
User = get_user_model()
|
|
||||||
username = options["username"]
|
|
||||||
email = options["email"]
|
|
||||||
password = options["password"] or secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
user, created = User.objects.get_or_create(
|
|
||||||
username=username,
|
|
||||||
defaults={"email": email, "is_active": True},
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
user.set_password(password)
|
|
||||||
user.save(update_fields=["password"])
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Created service user {username!r}"))
|
|
||||||
else:
|
|
||||||
changed = False
|
|
||||||
if not user.is_active:
|
|
||||||
user.is_active = True
|
|
||||||
changed = True
|
|
||||||
if user.email != email:
|
|
||||||
user.email = email
|
|
||||||
changed = True
|
|
||||||
if options["password"]:
|
|
||||||
user.set_password(password)
|
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
user.save(update_fields=["is_active", "email", "password"])
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Updated service user {username!r}"))
|
|
||||||
else:
|
|
||||||
self.stdout.write(f"Service user {username!r} already provisioned")
|
|
||||||
36
mnemosyne/mcp_server/migrations/0002_team_owner.py
Normal file
36
mnemosyne/mcp_server/migrations/0002_team_owner.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Add ``owner`` FK to ``Team``.
|
||||||
|
|
||||||
|
The Daedalus integration moved from a shared ``daedalus-service`` HTTP Basic
|
||||||
|
account to per-user DRF tokens. Each team is now scoped to the Mnemosyne
|
||||||
|
user that created it.
|
||||||
|
|
||||||
|
No production Team rows exist at the time of this migration, so the FK is
|
||||||
|
added as non-null without a backfill. Any pre-existing row in a dev /
|
||||||
|
staging database will break this migration — drop the ``mcp_server_team``
|
||||||
|
table (and its dependent ``mcp_server_teamworkspaceassignment``) before
|
||||||
|
applying, or recreate the database from scratch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("mcp_server", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="team",
|
||||||
|
name="owner",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="teams",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -266,12 +266,21 @@ class Team(models.Model):
|
|||||||
auth middleware compares the incoming ``jti`` against this value.
|
auth middleware compares the incoming ``jti`` against this value.
|
||||||
|
|
||||||
``active=False`` soft-deletes the team — every validation will
|
``active=False`` soft-deletes the team — every validation will
|
||||||
reject tokens whose ``sub`` resolves to this row, so revocation
|
reject tokens for an inactive team, so revocation survives restart
|
||||||
survives restart without needing a cache purge.
|
without needing a cache purge.
|
||||||
|
|
||||||
|
``owner`` is the Mnemosyne user that created the team. Team
|
||||||
|
management endpoints scope by ``owner`` so that one user cannot
|
||||||
|
manage another user's teams.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id = models.UUIDField(primary_key=True, editable=False)
|
id = models.UUIDField(primary_key=True, editable=False)
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="teams",
|
||||||
|
)
|
||||||
active = models.BooleanField(default=True)
|
active = models.BooleanField(default=True)
|
||||||
active_jti = models.UUIDField(null=True, blank=True)
|
active_jti = models.UUIDField(null=True, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from mcp_server.auth import (
|
|||||||
MCPAuthError,
|
MCPAuthError,
|
||||||
_libraries_for_team,
|
_libraries_for_team,
|
||||||
_remember_jti,
|
_remember_jti,
|
||||||
|
_resolve_jwt_actor,
|
||||||
_resolved_libraries_for_jwt,
|
_resolved_libraries_for_jwt,
|
||||||
looks_like_jwt,
|
looks_like_jwt,
|
||||||
resolve_mcp_jwt,
|
resolve_mcp_jwt,
|
||||||
@@ -314,11 +315,16 @@ class PerTurnReplayCacheTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ResolveTeamJWTTest(TestCase):
|
class ResolveTeamJWTTest(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner = User.objects.create_user(username="alice", password="pw")
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.key = _make_signing_key()
|
self.key = _make_signing_key()
|
||||||
self.team = Team.objects.create(
|
self.team = Team.objects.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
name="Pallas-Harper",
|
name="Pallas-Harper",
|
||||||
|
owner=self.owner,
|
||||||
active=True,
|
active=True,
|
||||||
active_jti=uuid.uuid4(),
|
active_jti=uuid.uuid4(),
|
||||||
)
|
)
|
||||||
@@ -362,10 +368,15 @@ class ResolveTeamJWTTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class LibrariesForTeamTest(TestCase):
|
class LibrariesForTeamTest(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner = User.objects.create_user(username="alice", password="pw")
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.team = Team.objects.create(
|
self.team = Team.objects.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
name="T",
|
name="T",
|
||||||
|
owner=self.owner,
|
||||||
active=True,
|
active=True,
|
||||||
active_jti=uuid.uuid4(),
|
active_jti=uuid.uuid4(),
|
||||||
)
|
)
|
||||||
@@ -415,6 +426,10 @@ class LibrariesForTeamTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ResolvedLibrariesForJWTDispatcherTest(TestCase):
|
class ResolvedLibrariesForJWTDispatcherTest(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner = User.objects.create_user(username="alice", password="pw")
|
||||||
|
|
||||||
def test_per_turn_claims_use_libs(self):
|
def test_per_turn_claims_use_libs(self):
|
||||||
claims = {"libs": ["one", "two"]}
|
claims = {"libs": ["one", "two"]}
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -425,6 +440,7 @@ class ResolvedLibrariesForJWTDispatcherTest(TestCase):
|
|||||||
team = Team.objects.create(
|
team = Team.objects.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
name="T",
|
name="T",
|
||||||
|
owner=self.owner,
|
||||||
active=True,
|
active=True,
|
||||||
active_jti=uuid.uuid4(),
|
active_jti=uuid.uuid4(),
|
||||||
)
|
)
|
||||||
@@ -442,3 +458,51 @@ class ResolvedLibrariesForJWTDispatcherTest(TestCase):
|
|||||||
)
|
)
|
||||||
out = _resolved_libraries_for_jwt(claims)
|
out = _resolved_libraries_for_jwt(claims)
|
||||||
self.assertEqual(out, ["lib-team"])
|
self.assertEqual(out, ["lib-team"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _resolve_jwt_actor — team JWTs resolve to the team's owner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveJWTActorTest(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner = User.objects.create_user(username="alice", password="pw")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.team = Team.objects.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="T",
|
||||||
|
owner=self.owner,
|
||||||
|
active=True,
|
||||||
|
active_jti=uuid.uuid4(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_team_jwt_resolves_to_team_owner(self):
|
||||||
|
claims = {"typ": "team", "team_id": self.team.id}
|
||||||
|
self.assertEqual(_resolve_jwt_actor(claims), self.owner)
|
||||||
|
|
||||||
|
def test_inactive_team_rejected(self):
|
||||||
|
self.team.deactivate()
|
||||||
|
claims = {"typ": "team", "team_id": self.team.id}
|
||||||
|
with self.assertRaises(MCPAuthError):
|
||||||
|
_resolve_jwt_actor(claims)
|
||||||
|
|
||||||
|
def test_disabled_owner_rejected(self):
|
||||||
|
self.owner.is_active = False
|
||||||
|
self.owner.save(update_fields=["is_active"])
|
||||||
|
claims = {"typ": "team", "team_id": self.team.id}
|
||||||
|
with self.assertRaises(MCPAuthError):
|
||||||
|
_resolve_jwt_actor(claims)
|
||||||
|
|
||||||
|
def test_per_turn_jwt_rejected(self):
|
||||||
|
# No more service-user fallback: per-turn JWTs can't be attributed
|
||||||
|
# to a Mnemosyne user, so the resolver refuses them.
|
||||||
|
with self.assertRaises(MCPAuthError):
|
||||||
|
_resolve_jwt_actor({"libs": ["x"]})
|
||||||
|
|
||||||
|
def test_unknown_team_rejected(self):
|
||||||
|
claims = {"typ": "team", "team_id": uuid.uuid4()}
|
||||||
|
with self.assertRaises(MCPAuthError):
|
||||||
|
_resolve_jwt_actor(claims)
|
||||||
|
|||||||
@@ -128,15 +128,21 @@ class MCPTokenAllowedLibrariesTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TeamTest(TestCase):
|
class TeamTest(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner = get_user_model().objects.create_user(
|
||||||
|
username="alice", password="pw"
|
||||||
|
)
|
||||||
|
|
||||||
def test_create_with_explicit_uuid(self):
|
def test_create_with_explicit_uuid(self):
|
||||||
tid = uuid.uuid4()
|
tid = uuid.uuid4()
|
||||||
team = Team.objects.create(id=tid, name="Harper")
|
team = Team.objects.create(id=tid, name="Harper", owner=self.owner)
|
||||||
self.assertEqual(team.id, tid)
|
self.assertEqual(team.id, tid)
|
||||||
self.assertTrue(team.active)
|
self.assertTrue(team.active)
|
||||||
self.assertIsNone(team.active_jti)
|
self.assertIsNone(team.active_jti)
|
||||||
|
|
||||||
def test_rotate_jti_installs_fresh_uuid(self):
|
def test_rotate_jti_installs_fresh_uuid(self):
|
||||||
team = Team.objects.create(id=uuid.uuid4(), name="t")
|
team = Team.objects.create(id=uuid.uuid4(), name="t", owner=self.owner)
|
||||||
first = team.rotate_jti()
|
first = team.rotate_jti()
|
||||||
self.assertIsInstance(first, uuid.UUID)
|
self.assertIsInstance(first, uuid.UUID)
|
||||||
self.assertEqual(team.active_jti, first)
|
self.assertEqual(team.active_jti, first)
|
||||||
@@ -146,14 +152,14 @@ class TeamTest(TestCase):
|
|||||||
self.assertEqual(team.active_jti, second)
|
self.assertEqual(team.active_jti, second)
|
||||||
|
|
||||||
def test_rotate_jti_persists(self):
|
def test_rotate_jti_persists(self):
|
||||||
team = Team.objects.create(id=uuid.uuid4(), name="t")
|
team = Team.objects.create(id=uuid.uuid4(), name="t", owner=self.owner)
|
||||||
team.rotate_jti()
|
team.rotate_jti()
|
||||||
# Reload from DB and make sure the UUID was committed.
|
# Reload from DB and make sure the UUID was committed.
|
||||||
reloaded = Team.objects.get(pk=team.id)
|
reloaded = Team.objects.get(pk=team.id)
|
||||||
self.assertEqual(reloaded.active_jti, team.active_jti)
|
self.assertEqual(reloaded.active_jti, team.active_jti)
|
||||||
|
|
||||||
def test_deactivate_clears_active_jti(self):
|
def test_deactivate_clears_active_jti(self):
|
||||||
team = Team.objects.create(id=uuid.uuid4(), name="t")
|
team = Team.objects.create(id=uuid.uuid4(), name="t", owner=self.owner)
|
||||||
team.rotate_jti()
|
team.rotate_jti()
|
||||||
self.assertTrue(team.active)
|
self.assertTrue(team.active)
|
||||||
team.deactivate()
|
team.deactivate()
|
||||||
@@ -171,8 +177,16 @@ class TeamTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TeamWorkspaceAssignmentTest(TestCase):
|
class TeamWorkspaceAssignmentTest(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner = get_user_model().objects.create_user(
|
||||||
|
username="alice", password="pw"
|
||||||
|
)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.team = Team.objects.create(id=uuid.uuid4(), name="t")
|
self.team = Team.objects.create(
|
||||||
|
id=uuid.uuid4(), name="t", owner=self.owner
|
||||||
|
)
|
||||||
|
|
||||||
def test_unique_team_workspace_pair(self):
|
def test_unique_team_workspace_pair(self):
|
||||||
TeamWorkspaceAssignment.objects.create(
|
TeamWorkspaceAssignment.objects.create(
|
||||||
@@ -185,7 +199,9 @@ class TeamWorkspaceAssignmentTest(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_same_workspace_different_teams_allowed(self):
|
def test_same_workspace_different_teams_allowed(self):
|
||||||
other = Team.objects.create(id=uuid.uuid4(), name="t2")
|
other = Team.objects.create(
|
||||||
|
id=uuid.uuid4(), name="t2", owner=self.owner
|
||||||
|
)
|
||||||
TeamWorkspaceAssignment.objects.create(
|
TeamWorkspaceAssignment.objects.create(
|
||||||
team=self.team, workspace_id="ws-a"
|
team=self.team, workspace_id="ws-a"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Tests for the ``/mcp_server/api/teams/`` REST control plane.
|
"""Tests for the ``/mcp_server/api/teams/`` REST control plane.
|
||||||
|
|
||||||
This is the Daedalus-facing surface described in §7 of
|
This is the Daedalus-facing surface described in §7 of
|
||||||
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. We do NOT exercise HTTP
|
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. We do NOT exercise the
|
||||||
Basic auth here (that's part of DRF / the project's session auth
|
DRF Token / Session auth machinery here (that's covered by DRF
|
||||||
stack); instead we use :meth:`APIClient.force_authenticate` to focus
|
itself); instead we use :meth:`APIClient.force_authenticate` to focus
|
||||||
on the endpoints' own idempotence and state-transition rules.
|
on the endpoints' own idempotence, ownership, and state-transition
|
||||||
|
rules.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -36,20 +37,23 @@ def _seed_signing_key() -> MCPSigningKey:
|
|||||||
|
|
||||||
|
|
||||||
class _AuthenticatedAPITest(TestCase):
|
class _AuthenticatedAPITest(TestCase):
|
||||||
"""Shared ``APIClient`` authenticated as the service user.
|
"""Shared ``APIClient`` authenticated as a regular user.
|
||||||
|
|
||||||
The real deployment has Daedalus hit these endpoints as
|
The endpoints scope by ``request.user`` (every team has an
|
||||||
``daedalus-service`` over HTTP Basic, but the view decorator is a
|
``owner``); ``self.user`` is the team owner and ``self.other_user``
|
||||||
plain ``IsAuthenticated`` — so for unit-test purposes we use
|
is used for cross-user access tests.
|
||||||
``force_authenticate`` with any active user.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
@classmethod
|
||||||
self.service_user = User.objects.create_user(
|
def setUpTestData(cls):
|
||||||
username="daedalus-service", password="pw"
|
cls.user = User.objects.create_user(username="alice", password="pw")
|
||||||
|
cls.other_user = User.objects.create_user(
|
||||||
|
username="bob", password="pw"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
self.client = APIClient()
|
self.client = APIClient()
|
||||||
self.client.force_authenticate(user=self.service_user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -106,6 +110,29 @@ class TeamCreateTest(_AuthenticatedAPITest):
|
|||||||
self.assertEqual(decoded["sub"], f"team:{tid}")
|
self.assertEqual(decoded["sub"], f"team:{tid}")
|
||||||
self.assertEqual(decoded["jti"], str(team.active_jti))
|
self.assertEqual(decoded["jti"], str(team.active_jti))
|
||||||
|
|
||||||
|
def test_create_sets_request_user_as_owner(self):
|
||||||
|
tid = uuid.uuid4()
|
||||||
|
self.client.post(
|
||||||
|
self.url, {"id": str(tid), "name": "Harper"}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(Team.objects.get(pk=tid).owner_id, self.user.id)
|
||||||
|
|
||||||
|
def test_same_id_under_other_owner_409s(self):
|
||||||
|
tid = uuid.uuid4()
|
||||||
|
# Alice creates the team first.
|
||||||
|
self.client.post(
|
||||||
|
self.url, {"id": str(tid), "name": "Harper"}, format="json"
|
||||||
|
)
|
||||||
|
# Bob then tries to create with the same id — must be a generic
|
||||||
|
# conflict, not idempotent and not 200.
|
||||||
|
self.client.force_authenticate(user=self.other_user)
|
||||||
|
resp = self.client.post(
|
||||||
|
self.url, {"id": str(tid), "name": "Bob's"}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_409_CONFLICT)
|
||||||
|
# Owner unchanged.
|
||||||
|
self.assertEqual(Team.objects.get(pk=tid).owner_id, self.user.id)
|
||||||
|
|
||||||
def test_idempotent_on_same_id_returns_200_without_jwt(self):
|
def test_idempotent_on_same_id_returns_200_without_jwt(self):
|
||||||
tid = uuid.uuid4()
|
tid = uuid.uuid4()
|
||||||
first = self.client.post(
|
first = self.client.post(
|
||||||
@@ -152,7 +179,11 @@ class TeamDetailTest(_AuthenticatedAPITest):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.team = Team.objects.create(
|
self.team = Team.objects.create(
|
||||||
id=uuid.uuid4(), name="t", active=True, active_jti=uuid.uuid4()
|
id=uuid.uuid4(),
|
||||||
|
name="t",
|
||||||
|
owner=self.user,
|
||||||
|
active=True,
|
||||||
|
active_jti=uuid.uuid4(),
|
||||||
)
|
)
|
||||||
TeamWorkspaceAssignment.objects.create(
|
TeamWorkspaceAssignment.objects.create(
|
||||||
team=self.team, workspace_id="ws-a"
|
team=self.team, workspace_id="ws-a"
|
||||||
@@ -197,6 +228,18 @@ class TeamDetailTest(_AuthenticatedAPITest):
|
|||||||
resp = self.client.delete(self.url)
|
resp = self.client.delete(self.url)
|
||||||
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
|
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
def test_get_by_non_owner_returns_404(self):
|
||||||
|
self.client.force_authenticate(user=self.other_user)
|
||||||
|
resp = self.client.get(self.url)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
def test_delete_by_non_owner_returns_404_and_no_op(self):
|
||||||
|
self.client.force_authenticate(user=self.other_user)
|
||||||
|
resp = self.client.delete(self.url)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
# Original team is still active — non-owner couldn't soft-delete it.
|
||||||
|
self.assertTrue(Team.objects.get(pk=self.team.id).active)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PUT /mcp_server/api/teams/{id}/workspaces/
|
# PUT /mcp_server/api/teams/{id}/workspaces/
|
||||||
@@ -207,7 +250,11 @@ class TeamWorkspacesTest(_AuthenticatedAPITest):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.team = Team.objects.create(
|
self.team = Team.objects.create(
|
||||||
id=uuid.uuid4(), name="t", active=True, active_jti=uuid.uuid4()
|
id=uuid.uuid4(),
|
||||||
|
name="t",
|
||||||
|
owner=self.user,
|
||||||
|
active=True,
|
||||||
|
active_jti=uuid.uuid4(),
|
||||||
)
|
)
|
||||||
self.url = reverse(
|
self.url = reverse(
|
||||||
"mcp-server-api:team-workspaces",
|
"mcp-server-api:team-workspaces",
|
||||||
@@ -308,6 +355,14 @@ class TeamWorkspacesTest(_AuthenticatedAPITest):
|
|||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_put_by_non_owner_returns_404(self):
|
||||||
|
self.client.force_authenticate(user=self.other_user)
|
||||||
|
resp = self.client.put(
|
||||||
|
self.url, {"workspace_ids": ["ws-x"]}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
self.assertEqual(self._ws_ids(), [])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /mcp_server/api/teams/{id}/rotate/
|
# POST /mcp_server/api/teams/{id}/rotate/
|
||||||
@@ -319,7 +374,11 @@ class TeamRotateTest(_AuthenticatedAPITest):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
_seed_signing_key()
|
_seed_signing_key()
|
||||||
self.team = Team.objects.create(
|
self.team = Team.objects.create(
|
||||||
id=uuid.uuid4(), name="t", active=True, active_jti=uuid.uuid4()
|
id=uuid.uuid4(),
|
||||||
|
name="t",
|
||||||
|
owner=self.user,
|
||||||
|
active=True,
|
||||||
|
active_jti=uuid.uuid4(),
|
||||||
)
|
)
|
||||||
self.url = reverse(
|
self.url = reverse(
|
||||||
"mcp-server-api:team-rotate",
|
"mcp-server-api:team-rotate",
|
||||||
@@ -357,3 +416,11 @@ class TeamRotateTest(_AuthenticatedAPITest):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
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):
|
||||||
|
before = self.team.active_jti
|
||||||
|
self.client.force_authenticate(user=self.other_user)
|
||||||
|
resp = self.client.post(self.url)
|
||||||
|
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
self.team.refresh_from_db()
|
||||||
|
self.assertEqual(self.team.active_jti, before)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import jwt as pyjwt
|
import jwt as pyjwt
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from mcp_server.models import MCPSigningKey, Team
|
from mcp_server.models import MCPSigningKey, Team
|
||||||
@@ -24,10 +25,11 @@ def _make_key(kid: str = "k", is_active: bool = True) -> MCPSigningKey:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_team(**overrides) -> Team:
|
def _make_team(owner, **overrides) -> Team:
|
||||||
data = dict(
|
data = dict(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
name="t",
|
name="t",
|
||||||
|
owner=owner,
|
||||||
active=True,
|
active=True,
|
||||||
active_jti=uuid.uuid4(),
|
active_jti=uuid.uuid4(),
|
||||||
)
|
)
|
||||||
@@ -36,9 +38,15 @@ def _make_team(**overrides) -> Team:
|
|||||||
|
|
||||||
|
|
||||||
class MintTeamJWTHappyPathTest(TestCase):
|
class MintTeamJWTHappyPathTest(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner = get_user_model().objects.create_user(
|
||||||
|
username="alice", password="pw"
|
||||||
|
)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.key = _make_key("k-1")
|
self.key = _make_key("k-1")
|
||||||
self.team = _make_team()
|
self.team = _make_team(self.owner)
|
||||||
|
|
||||||
def test_returns_signed_jwt_string(self):
|
def test_returns_signed_jwt_string(self):
|
||||||
token = mint_team_jwt(self.team)
|
token = mint_team_jwt(self.team)
|
||||||
@@ -88,15 +96,21 @@ class MintTeamJWTHappyPathTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class MintTeamJWTFailureModesTest(TestCase):
|
class MintTeamJWTFailureModesTest(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner = get_user_model().objects.create_user(
|
||||||
|
username="alice", password="pw"
|
||||||
|
)
|
||||||
|
|
||||||
def test_no_signing_key_raises(self):
|
def test_no_signing_key_raises(self):
|
||||||
team = _make_team()
|
team = _make_team(self.owner)
|
||||||
with self.assertRaises(TeamJWTError) as ctx:
|
with self.assertRaises(TeamJWTError) as ctx:
|
||||||
mint_team_jwt(team)
|
mint_team_jwt(team)
|
||||||
self.assertIn("signing", str(ctx.exception).lower())
|
self.assertIn("signing", str(ctx.exception).lower())
|
||||||
|
|
||||||
def test_missing_active_jti_raises(self):
|
def test_missing_active_jti_raises(self):
|
||||||
_make_key()
|
_make_key()
|
||||||
team = _make_team(active_jti=None)
|
team = _make_team(self.owner, active_jti=None)
|
||||||
with self.assertRaises(TeamJWTError) as ctx:
|
with self.assertRaises(TeamJWTError) as ctx:
|
||||||
mint_team_jwt(team)
|
mint_team_jwt(team)
|
||||||
# Should name the thing the caller forgot to do.
|
# Should name the thing the caller forgot to do.
|
||||||
@@ -106,6 +120,6 @@ class MintTeamJWTFailureModesTest(TestCase):
|
|||||||
MCPSigningKey.objects.create(
|
MCPSigningKey.objects.create(
|
||||||
kid="broken", secret_hex="not-hex!!", is_active=True
|
kid="broken", secret_hex="not-hex!!", is_active=True
|
||||||
)
|
)
|
||||||
team = _make_team()
|
team = _make_team(self.owner)
|
||||||
with self.assertRaises(TeamJWTError):
|
with self.assertRaises(TeamJWTError):
|
||||||
mint_team_jwt(team)
|
mint_team_jwt(team)
|
||||||
|
|||||||
@@ -141,14 +141,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Daedalus API Token — separate form, outside the settings form -->
|
<!-- API Token — separate form, outside the settings form -->
|
||||||
<div class="card bg-base-200 mb-6">
|
<div class="card bg-base-200 mb-6">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-lg">Daedalus API Token</h2>
|
<h2 class="card-title text-lg">API Token</h2>
|
||||||
<p class="text-sm opacity-70 mb-4">
|
<p class="text-sm opacity-70 mb-4">
|
||||||
Used by Daedalus to authenticate with Mnemosyne. Set
|
Authenticates programmatic clients (Daedalus, scripts, IDE
|
||||||
<code class="font-mono text-xs">DAEDALUS_MNEMOSYNE_API_KEY</code>
|
integrations) to Mnemosyne. Has the same access as your web
|
||||||
in your Daedalus environment to this value.
|
session — keep it secret.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-3">
|
<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>
|
<code class="font-mono bg-base-300 px-3 py-2 rounded flex-1 break-all select-all text-sm">{{ api_token.key }}</code>
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<form method="post" action="{% url 'themis:api-token-regenerate' %}"
|
<form method="post" action="{% url 'themis:api-token-regenerate' %}"
|
||||||
onsubmit="return confirm('Regenerate token? Daedalus will stop working until you update DAEDALUS_MNEMOSYNE_API_KEY.')">
|
onsubmit="return confirm('Regenerate token? Any client using the current token will stop working until updated.')">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-warning btn-sm">Regenerate</button>
|
<button type="submit" class="btn btn-warning btn-sm">Regenerate</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user