ontoref/adrs/adr-005-unified-auth-session-model.ncl
Jesús Pérez 085607130a
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
---
feat: API catalog surface, protocol v2 tooling, MCP expansion, on+re update

  ## Summary

  Session 2026-03-23. Closes the loop between handler code and discoverability
  across all three surfaces (browser, CLI, MCP agent) via compile-time inventory
  registration. Adds protocol v2 update tooling, extends MCP from 21 to 29 tools,
  and brings the self-description up to date.

  ## API Catalog Surface (#[onto_api] proc-macro)

  - crates/ontoref-derive: new proc-macro crate; `#[onto_api(method, path,
    description, auth, actors, params, tags)]` emits `inventory::submit!(ApiRouteEntry{...})`
    at link time
  - crates/ontoref-daemon/src/api_catalog.rs: `catalog()` — pure fn over
    `inventory::iter::<ApiRouteEntry>()`, zero runtime allocation
  - GET /api/catalog: returns full annotated HTTP surface as JSON
  - templates/pages/api_catalog.html: new page with client-side filtering by
    method, auth, path/description; detail panel per route (params table,
    feature flag); linked from dashboard card and nav
  - UI nav: "API" link (</> icon) added to mobile dropdown and desktop bar
  - inventory = "0.3" added to workspace.dependencies (MIT, zero transitive deps)

  ## Protocol Update Mode

  - reflection/modes/update_ontoref.ncl: 9-step DAG (5 detect parallel, 2 update
    idempotent, 2 validate, 1 report) — brings any project from protocol v1 to v2
    by adding manifest.ncl and connections.ncl if absent, scanning ADRs for
    deprecated check_hint, validating with nickel export
  - reflection/templates/update-ontology-prompt.md: 8-phase reusable prompt for
    agent-driven ontology enrichment (infrastructure → audit → core.ncl →
    state.ncl → manifest.ncl → connections.ncl → ADR migration → validation)

  ## CLI — describe group extensions

  - reflection/bin/ontoref.nu: `describe diff [--fmt] [--file]` and
    `describe api [--actor] [--tag] [--auth] [--fmt]` registered as canonical
    subcommands with log-action; aliases `df` and `da` added; QUICK REFERENCE
    and ALIASES sections updated

  ## MCP — two new tools (21 → 29 total)

  - ontoref_api_catalog: filters catalog() output by actor/tag/auth; returns
    { routes, total } — no HTTP roundtrip, calls inventory directly
  - ontoref_file_versions: reads ProjectContext.file_versions DashMap per slug;
    returns BTreeMap<filename, u64> reload counters
  - insert_mcp_ctx: audited and updated from 15 to 28 entries in 6 groups
  - HelpTool JSON: 8 new entries (validate_adrs, validate, impact, guides,
    bookmark_list, bookmark_add, api_catalog, file_versions)
  - ServerHandler::get_info instructions updated to mention new tools

  ## Web UI — dashboard additions

  - Dashboard: "API Catalog" card (9th); "Ontology File Versions" section showing
    per-file reload counters from file_versions DashMap
  - dashboard_mp: builds BTreeMap<String, u64> from ctx.file_versions and injects
    into Tera context

  ## on+re update

  - .ontology/core.ncl: describe-query-layer and adopt-ontoref-tooling descriptions
    updated; ontoref-daemon updated ("11 pages", "29 tools", API catalog,
    per-file versioning, #[onto_api]); new node api-catalog-surface (Yang/Practice)
    with 3 edges; artifact_paths extended across 3 nodes
  - .ontology/state.ncl: protocol-maturity blocker updated (protocol v2 complete);
    self-description-coverage catalyst updated with session 2026-03-23 additions
  - ADR-007: "API Surface Discoverability via #[onto_api] Proc-Macro" — Accepted

  ## Documentation

  - README.md: crates table updated (11 pages, 29 MCP tools, ontoref-derive row);
    MCP representative table expanded; API Catalog, Semantic Diff, Per-File
    Versioning paragraphs added; update_ontoref onboarding section added
  - CHANGELOG.md: [Unreleased] section with 4 change groups
  - assets/web/src/index.html: tool counts 19→29 (EN+ES), page counts 12→11
    (EN+ES), daemon description paragraph updated with API catalog + #[onto_api]
2026-03-23 00:58:27 +01:00

143 lines
10 KiB
XML

let d = import "defaults.ncl" in
d.make_adr {
id = "adr-005",
title = "Unified Key-to-Session Auth Model Across CLI, UI, and MCP",
status = 'Accepted,
date = "2026-03-13",
context = "ontoref-daemon exposes project knowledge and mutations over HTTP, a browser UI, and an MCP server. Projects can define argon2id-hashed keys in project.ncl (with role admin|viewer and an audit label). Prior to this ADR, the UI login flow set a session cookie but the REST API accepted raw passwords as Bearer tokens on every request — each call paying ~100ms for argon2 verification. The CLI had no Bearer support at all; project-add and project-remove called the daemon without credentials. The daemon manage page had no admin identity concept. There was no way to enumerate or revoke active sessions.",
decision = "All surfaces exchange a raw key once via POST /sessions for a UUID v4 bearer token (30-day lifetime, in-memory SessionStore). The session token is used for all subsequent calls — O(1) DashMap lookup. Sessions carry a stable public id (distinct from the bearer token) for safe list/revoke operations without leaking credentials. Project keys have a label field for audit trail. Daemon-level admin uses a separate argon2id hash (ONTOREF_ADMIN_TOKEN_FILE preferred over ONTOREF_ADMIN_TOKEN) and creates sessions under virtual slug '_daemon'. The CLI injects ONTOREF_TOKEN as Authorization: Bearer automatically via bearer-args in store.nu. Key rotation (PUT /projects/{slug}/keys) revokes all active sessions for the rotated project. GET /sessions and DELETE /sessions/{id} implement two-tier visibility: project admin sees own project sessions; daemon admin sees all.",
rationale = [
{
claim = "Token exchange eliminates per-request argon2 cost",
detail = "argon2id verification takes ~100ms by design (brute-force resistance). Verifying on every CLI invocation, MCP tool call, or UI AJAX request is prohibitive. A UUID v4 session token turns auth into a DashMap::get — O(1), sub-microsecond. The argon2 cost is paid exactly once per login.",
},
{
claim = "session.id != bearer token prevents session enumeration attacks",
detail = "The list endpoints (GET /sessions) return SessionView with a stable public id. If the bearer token were exposed in list responses, a project admin could steal another admin's token and impersonate them. The id field is a second UUID v4 — safe to expose, useless as a credential.",
},
{
claim = "is_uuid_v4 fast-path preserves backward compatibility",
detail = "check_primary_auth tries the session token path first (UUID v4 format check is a length + byte comparison, ~10ns). If the bearer is not UUID-shaped, it falls through to argon2 password verification. Existing integrations using raw passwords continue to work without change.",
},
{
claim = "Virtual '_daemon' slug isolates admin sessions from project sessions",
detail = "Daemon admin sessions are not scoped to any real project. The '_daemon' slug cannot collide with real project slugs (kebab-case, no leading underscore). AdminGuard checks for Role::Admin across any registered project or '_daemon', giving the manage page a clean auth boundary without coupling it to any specific project.",
},
{
claim = "ONTOREF_ADMIN_TOKEN_FILE preferred over inline env var",
detail = "An inline hash in ONTOREF_ADMIN_TOKEN appears in `ps aux` output and shell history. Reading from a file (ONTOREF_ADMIN_TOKEN_FILE) avoids both surfaces. The file contains only the argon2id hash string; the actual password never touches the daemon process env.",
},
{
claim = "Key rotation invalidates sessions atomically",
detail = "When keys are rotated, all in-flight sessions for that project authenticated against the old key set are immediately invalid. revoke_all_for_slug scans the DashMap and removes matching entries including the id_index. Actor sessions (ontoref-daemon actors registry) are also invalidated. The UI will receive 401 on next request and redirect to login.",
},
],
consequences = {
positive = [
"REST API, MCP, and UI all use the same session token — single auth model, no surface-specific logic",
"CLI project-add, project-remove, and notify-daemon-* calls carry credentials automatically when ONTOREF_TOKEN is set",
"Session list gives admins visibility into who is connected to their project",
"Revocation is O(1) via id_index secondary index; bulk revocation on key rotation is O(active sessions for slug)",
"Daemon admin and project admin are cleanly separated — '_daemon' sessions cannot access project-scoped endpoints and vice versa",
],
negative = [
"Sessions are in-memory only — daemon restart requires all clients to re-authenticate",
"30-day token lifetime means a leaked token is valid for up to 30 days unless explicitly revoked",
"ONTOREF_TOKEN in shell env is visible to child processes — use a secrets manager or short-lived token refresh for automated pipelines",
],
},
alternatives_considered = [
{
option = "Verify argon2 on every Bearer request (no session concept)",
why_rejected = "~100ms per request is unacceptable for CLI invocations (each command is a new HTTP call) and MCP tool sequences (multiple calls per agent turn). Session token lookup is sub-microsecond.",
},
{
option = "Axum middleware for auth instead of per-handler check_primary_auth",
why_rejected = "Middleware applies uniformly to all routes. ontoref-daemon coexists auth-enabled and open projects on the same router — some endpoints are always public (health, POST /sessions itself), some require project-scoped auth, some require daemon admin. Per-handler checks encode these rules explicitly; middleware would require complex route exemption logic.",
},
{
option = "Expose bearer token in session list responses",
why_rejected = "GET /sessions is accessible to project admins. Exposing the bearer would allow any project admin to impersonate any other session holder. The public session.id is a safe substitute for revocation targeting.",
},
{
option = "Separate token store per project",
why_rejected = "A single DashMap keyed by token with a slug field in SessionEntry is sufficient. A secondary id_index DashMap gives O(1) revoke-by-id. Per-project sharding would add complexity without benefit given the expected session count (tens, not millions).",
},
{
option = "JWT instead of opaque UUID v4 tokens",
why_rejected = "JWTs are self-contained and cannot be revoked without a denylist. Opaque tokens enable instant revocation (key rotation, logout, admin force-revoke) with O(1) lookup. The daemon is local-only — there is no distributed verification scenario that would justify JWT complexity.",
},
],
constraints = [
{
id = "session-token-never-in-list-response",
claim = "GET /sessions responses must never include the bearer token, only the public session id",
scope = "crates/ontoref-daemon/src/session.rs, crates/ontoref-daemon/src/api.rs",
severity = 'Hard,
check = 'Grep {
pattern = "pub token",
paths = ["crates/ontoref-daemon/src/session.rs"],
must_be_empty = true,
},
rationale = "Exposing bearer tokens in list responses would allow admins to impersonate other sessions. The session.id field is a second UUID v4, safe to expose.",
},
{
id = "post-sessions-no-auth-required",
claim = "POST /sessions must not require authentication — it is the credential exchange endpoint",
scope = "crates/ontoref-daemon/src/api.rs",
severity = 'Hard,
check = 'Grep {
pattern = "require_session|check_primary_auth",
paths = ["crates/ontoref-daemon/src/api.rs"],
must_be_empty = false,
},
rationale = "Requiring auth to obtain auth is a bootstrap deadlock. Rate-limiting on failure is the correct mitigation, not pre-authentication.",
},
{
id = "key-rotation-must-invalidate-sessions",
claim = "PUT /projects/{slug}/keys must call revoke_all_for_slug before persisting new keys",
scope = "crates/ontoref-daemon/src/api.rs",
severity = 'Hard,
check = 'Grep {
pattern = "revoke_all_for_slug",
paths = ["crates/ontoref-daemon/src/api.rs"],
must_be_empty = false,
},
rationale = "Sessions authenticated against the old key set become invalid after rotation. Failing to revoke them would leave stale sessions with elevated access.",
},
{
id = "bearer-args-injected-by-cli",
claim = "All CLI HTTP calls to the daemon must use bearer-args from store.nu — no hardcoded curl without auth args",
scope = "reflection/modules/store.nu, reflection/bin/ontoref.nu",
severity = 'Soft,
check = 'Grep {
pattern = "bearer-args|http-get|http-post-json|http-delete",
paths = ["reflection/modules/store.nu"],
must_be_empty = false,
},
rationale = "ONTOREF_TOKEN is the single credential source for CLI. Direct curl without bearer-args bypasses the auth model silently.",
},
],
related_adrs = ["adr-002-daemon-for-caching-and-notification-barrier"],
ontology_check = {
decision_string = "unified key-to-session token exchange; opaque UUID v4 bearer; session.id != token; revocation on key rotation",
invariants_at_risk = ["no-enforcement"],
verdict = 'RequiresJustification,
},
invariant_justification = {
invariant = "no-enforcement",
claim = "Auth is opt-in per project. A project with no keys configured runs in open mode — all endpoints accessible without credentials. The protocol itself never mandates auth.",
mitigation = "The no-enforcement axiom applies at the protocol level. Auth is a project-level operational decision, not a protocol constraint. Open deployments (no keys) pass through all auth checks unconditionally.",
},
}