Config surface — per-project config introspection, coherence verification, and
audited mutation without destroying NCL structure (ADR-008):
- crates/ontoref-daemon/src/config.rs — typed DaemonNclConfig (parse-at-boundary
pattern); all section structs derive ConfigFields + config_section(id, ncl_file)
emitting inventory::submit!(ConfigFieldsEntry{...}) at link time
- crates/ontoref-derive/src/lib.rs — #[derive(ConfigFields)] proc-macro; serde
rename support; serde_rename_of() helper extracted to fix excessive_nesting
- crates/ontoref-daemon/src/main.rs — 3-tuple bootstrap block (nickel_import_path,
loaded_ncl_config: Option<DaemonNclConfig>, stdin_raw); apply_ui_config takes
&UiConfig; NATS call site typed; resolve_asset_dir cfg(feature = "ui")
- crates/ontoref-daemon/src/api.rs — config GET/PUT endpoints, quickref, coherence,
cross-project comparison; index_section_fields() extracted (excessive_nesting)
- crates/ontoref-daemon/src/config_coherence.rs — multi-consumer coherence;
merge_meta_into_section() extracted; and() replaces unnecessary and_then
NCL contracts for ontoref's own config:
- .ontoref/contracts.ncl — LogConfig (LogLevel, LogRotation, PositiveInt) and
DaemonConfig (Port, optional overrides); std.contract.from_validator throughout
- .ontoref/config.ncl — log | C.LogConfig applied
- .ontology/manifest.ncl — contracts_path, log/daemon contract refs, daemon section
with DaemonRuntimeConfig consumer and 7 declared fields
Protocol:
- adrs/adr-008-ncl-first-config-validation-and-override-layer.ncl — NCL contracts
as single validation gate; Rust structs are contract-trusted; override-layer
mutation writes {section}.overrides.ncl + _overrides_meta, never touches source
on+re update:
- .ontology/core.ncl — config-surface node (28 practices); adr-lifecycle extended
to adr-007 + adr-008; 6 new edges (ManifestsIn daemon, DependsOn ontology-crate,
Complements api-catalog-surface/dag-formalized/self-describing/adopt-ontoref)
- .ontology/state.ncl — protocol-maturity blocker and self-description-coverage
catalyst updated for session 2026-03-26
- README.md / CHANGELOG.md updated
127 lines
9.9 KiB
XML
127 lines
9.9 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 = { tag = '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 = { tag = '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 = { tag = '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 = { tag = '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.",
|
|
},
|
|
}
|