One trust domain

Everything downstream of a login trusts whatever token or session Othala hands it, so the first question is what can reach the two Ory admin APIs at all. The answer is one process. The portal runs a public listener the edge reaches and a second, internal-network admin listener the edge never touches, so every admin-API caller stays inside one trust domain instead of becoming a service-to-service authorization problem of its own.

ARCHITECTURE.md § Trust boundary has the enforcement; the Hardening guide's Admin-API isolation section walks the control end to end.

The trust boundary: what the network can actually reach TRUST BOUNDARY · WHAT THE NETWORK CAN ACTUALLY REACH OTHALA THE NETWORK SEES THREE PORTS BROWSER reaches one hostname per app EDGE PROXY one hostname, TLS — routes to three ports self-service, sessions public SSR authorize, token, discovery ONE TRUST DOMAIN · THE ADMIN APIS NEVER LEAVE IT admin API · no route from the edge KRATOS Self-service login, recovery, settings THE APP PUBLIC SSR challenge providers, UI ADMIN · INTERNAL admin-bind mux — not the Astro handler HYDRA OAuth2 / OIDC — issues tokens, no UI admin API admin API POSTGRES separate databases — Kratos, Hydra ADMIN-API ISOLATION Admin APIs are reachable only from the app — never from the edge. Enforced by the Ory config render (fails when the admin bind-address token is unset), requireAdminSession (portal/src/lib/admin.ts), and pinned by d-admin-aal2-stepup.spec.ts + f-aal2-bypass.spec.ts.
Three ports on the network; the admin APIs are not among them

How a login becomes a token

A relying party, here Grafana, redirects the browser to Hydra. Hydra ships no login UI of its own, so it opens a login challenge on the portal, and with no session yet the portal sends the browser to Kratos.

Kratos runs the passkey ceremony, sets the session cookie and resumes the authorize request. A remembered subject skips the passkey but never the gate: Hydra's memory of a past login is never trusted without a live Kratos session behind it.

At consent the portal gates again, then maps groups to the claims the token carries. Hydra hands Grafana a code to exchange server-side. The Per-app OIDC guide walks one relying party through it; ARCHITECTURE.md § The OIDC path has the sequencing.

The OIDC login happy path OIDC LOGIN · THE HAPPY PATH OTHALA EXAMPLE RELYING PARTY: GRAFANA BROWSER HYDRA OTHALA APP KRATOS 1 · /oauth2/authorize (via Grafana's 302) 2 · GET /oauth2/login — login challenge 3 · no session yet — 302 to Kratos login 4 · passkey challenge — passkey is the primary credential 5 · session cookie set — resume /oauth2/authorize 6 · resume /oauth2/authorize 7 · GET /oauth2/consent — consent challenge 8 · accept — gate again; groups → claims (claims.ts) 9 · 302 with code → Grafana SIGNED IN Grafana exchanges the code for tokens, server-side. A remembered subject (Hydra skip=true) takes a different step 3 — it re-verifies a live Kratos session before honouring the skip. See "Hydra remembers; Kratos authenticates." Pinned by e2e/playwright/tests/t-access-model.spec.ts and i-passkey-register-login.spec.ts.
One code exchange, four parties, one live Kratos session underneath it

The apps that speak no OIDC

Not every app in the house can run an OAuth2 redirect, so the edge asks a question instead. Before any proxying, it sends a sub-request to the portal carrying the session cookie and a group name that the edge's own config sets, never an inbound header.

The portal verifies the session, then re-resolves the identity through the Kratos admin API, because group metadata is not in whoami. The gate fails closed at every step: an inactive identity, a non-canonical stored group or a missing group each refuse.

The answer is a status, and each status means one thing. 401 is the only denial an edge may turn into a login redirect. 503 means Kratos never answered; folding it into 403 would turn an outage into an endless bounce. ARCHITECTURE.md § The forward-auth request path has the contract.

The forward-auth request path: browser, edge, Caddy, portal, Kratos, app A browser carrying the apex-scoped Kratos session cookie reaches the estate edge, which hands the request to the service's own Caddy. Caddy does not proxy it yet: it issues a sub-request to the portal's /api/forward-auth endpoint with the required group as a static query parameter set by the edge config. The portal reads that group, verifies the Kratos session from the request's own cookie, re-resolves the identity through the Kratos admin API because metadata_admin.groups is absent from whoami, and applies enforceGroupGate — identity active, every stored group canonical, and in the named group — failing closed at every step. The portal answers one of four statuses: 200 proxy through, 401 no usable session, 403 refused on the merits (inactive identity, a non-canonical stored group, a group miss, or a missing or malformed group parameter), and 503 when Kratos or its admin API did not answer. Caddy proxies to the app only on 200, setting X-Kratos-Authenticated-Identity-Id from the server-verified session id; every other status is a denial, and whether a denial becomes a login redirect is decided by the edge's own status matcher, not by the portal. 503 exists so that an outage cannot be matched into a login loop against the same portal that is down. The app never learns any of this; it sees only the header. The session check is why session.whoami.required_aal must stay aal1. REQUEST PATH · AN APP THAT CANNOT SPEAK OIDC OTHALA THE REQUEST BROWSER carries the apex-scoped session cookie ESTATE EDGE L4 SNI routing — nothing decrypted SERVICE CADDY · THE DOOR forward_auth, before any reverse_proxy THE APP no OIDC support. It sees only the header. proxied only after 200 denied — the edge's matcher decides THE GATE · A SUB-REQUEST, NOT A REDIRECT 200 / 401 / 403 / 503 ?group=audiobookshelf-access PORTAL · /api/forward-auth 1 · the group comes from the query param the edge config sets — never from an inbound header 2 · verify the Kratos session cookie 3 · re-resolve the identity, admin API 4 · enforceGroupGate: active, every stored group canonical, and in the named group FAIL CLOSED AT EVERY STEP KRATOS whoami gives the identity id — pinned aal1, or this gate locks out admin API gives state and metadata_admin .groups — not in whoami no answer → a 503 STATUS CONTRACT · CADDY KEYS ON THE STATUS, NEVER THE BODY 200 proxy through, with X-Kratos-Authenticated-Identity-Id set from the server-verified session id. 401 no usable session, and nothing else. This is the status an edge can safely turn into a login redirect. 403 refused on the merits: inactive identity, non-canonical stored group, group miss — plus a bad ?group= param. 503 Kratos or its admin API did not answer. Denies like a 403 — but must never be matched into a redirect.
One sub-request decides the request, before the app ever sees it

What actually happens on sign-out

Hydra and Kratos each remember something about a session. When Hydra says it remembers a subject, the login handler still checks for a live Kratos session belonging to that same identity before honouring the skip. No match means signing in again.

Revoking a subject is one in-process fan-out that cuts the Kratos sessions and both of Hydra's remembered sessions in a fixed order, whatever the trigger. It stops sessions, not credentials, so a lost-device eviction deletes the credential first; theHardening guide has that procedure.

Hydra remembers; Kratos authenticates Two panels. Left: when Hydra reports skip=true because it remembers a subject, the login handler does not honour that alone — it re-verifies that a live Kratos session exists whose identity.id equals the remembered subject. No live match forces interactive re-authentication at Kratos's own login UI; a match honours the skip and accepts login with the subject and acr taken from that live session. Skip alone is not enough, pinned by the FIX-A cases in portal/scripts/oauth2-handlers.test.mjs. Right: revokeSubject is the one in-process revocation fan-out, triggered by admin force-logout, user sign-out-everywhere, or deactivate. On deactivate only, Kratos state flips to inactive first. Then, for every trigger: Kratos sessions are revoked, Hydra's login-session is revoked to cut the remember window, and Hydra's consent-session is revoked. This cuts sessions and remembered logins but not enrolled credentials — the full eviction procedure audits credentials, deletes the affected credential type, and only then revokes, specified by e2e/playwright/tests/p-eviction.spec.ts but not yet live (every case there is fixme). SESSIONS · HYDRA REMEMBERS, KRATOS AUTHENTICATES OTHALA (A) LOGIN SKIP · RE-VERIFY BEFORE HONOURING (B) REVOKE · THE FAN-OUT HYDRA · skip = true remembers this subject (login.subject) re-verify KRATOS SESSION CHECK session.active AND session.identity.id === login.subject no match match Hydra remembered the subject; no live Kratos session matches it FORCE RE-AUTH session.active and its identity match the remembered subject HONOUR SKIP Skip alone is not enough. Pinned by the FIX-A cases in portal/scripts/oauth2-handlers.test.mjs. ADMIN LOGOUT SIGN-OUT ALL DEACTIVATE revokeSubject(subject) 1 · state → inactive (deactivate trigger only, must precede step 2) 2 · Kratos sessions revoked (404 = nothing to revoke = success) 3 · Hydra login-session revoked cuts the remember window 4 · Hydra consent-session revoked STEPS 1–2 ARE THE HARD CUT — ORDER FIXED Sessions, not credentials. Eviction order — audit, delete the credential type, then revoke — specified by p-eviction.spec.ts, not yet live.
Hydra remembers; Kratos authenticates, and revoking cuts both

How the first credential gets in the door

There is no self-service registration. An admin creates each identity through the admin API with no credentials block at all, then mints a recovery link and a one-time code together and hands them over on a conversation that already exists with that person.

The person redeems the code in their own browser, which is where a password and a passkey are first set. Neither ever leaves it.

One branch refuses outright. When the target carries the admin marker, recovery is refused before Kratos is ever asked, so an admin's own recovery stays at the console.

How a first credential arrives without a password ever crossing a wire An admin creates the identity through the admin API with no credentials block at all, then mints a one-time recovery link and code together and hands both over on a conversation that already exists with that person; link and code stay apart from any password. The recipient's own browser is where a password and a passkey first get set. One branch refuses outright: when the target carries the admin marker, shouldRefuseRecovery refuses before Kratos is ever asked, and the refusal is console-only. Pinned by portal/scripts/admin-recovery-gate.test.mjs and e2e/playwright/tests/zh-admin-create-identity.spec.ts. ENROLMENT · NO PASSWORD EVER TRANSITS A CHANNEL OTHALA THE ADMIN CREATES THE IDENTITY createIdentity — admin-api.ts NO credentials block, ever HAND-DELIVERY getRecoveryLink link + code minted together, one request handed over on a conversation that already exists with that person admin-marked target? REFUSED console-only — shouldRefuseRecovery RECIPIENT'S OWN BROWSER redeems the code, sets a password and a passkey THE INVARIANT Not "no password exists" — no password ever transits a channel. Link and code stay apart from any password. Pinned by portal/scripts/admin-recovery-gate.test.mjs and e2e/playwright/tests/zh-admin-create-identity.spec.ts.
Not "no password exists": no password ever transits a channel

One group gets you in; the other decides what you can do

Every manifest binds a group twice, and the two bindings do different jobs on purpose. presentation.access is entry: hold it and the gate admits you.consent.group_role_map is role: it elevates someone already admitted and grants nothing on its own, so holding only an app's admin group gets a person nowhere.

Keeping the two questions on two bindings is what makes an admin group safe to have at all. The Per-app OIDCguide has the manifest shape; ARCHITECTURE.md § The access model has the resolution order.

Group, binding, decision: what a group actually does in Othala ACCESS MODEL · WHAT A GROUP ACTUALLY DOES OTHALA 1 · THE IDENTITY HOLDS 2 · THE MANIFEST BINDS 3 · THE CODE DECIDES metadata_admin.groups everyone audiobookshelf-access audiobookshelf-admin Admin-API only. Not in the public whoami response. ENTRY BINDING presentation.access: - audiobookshelf-access Only the access group. Read by parseAccessGroups. ROLE BINDING consent.group_role_map: audiobookshelf-admin: admin audiobookshelf-access: user Rank is the declaration order of consent.roles. ENTRY admitted gate.ts enforceAccessGate — OR over the bound list. ROLE admin claims.ts lookupRole — the highest-ranked match wins, in any stored order. THE NAME IS CONVENTION · THE BINDING IS THE MECHANISM audiobookshelf-admin, held alone presentation.access does not list it, so it admits nobody. Admin never implies access. NO ENTRY a group nothing binds group-registry.ts types a group from its bindings. With none, it is inert however it is spelled. GRANTS NOTHING bananas, if a manifest binds it the only rule on a group name is a safe charset. No code anywhere requires the access- prefix. GRANTS ENTRY
The identity holds · the manifest binds · the code decides

That is the shape of it end to end. ARCHITECTURE.md has the rest of the reasoning, DECISIONS.md has the rulings no change may reverse, and the repositoryhas the code and the tests these diagrams are drawn from.