yuzu

Auth Architecture

Reference for the authentication and authorization features implemented in the Yuzu server. CLAUDE.md keeps only the hard invariants; this document is the implementation history and feature inventory.

Transport and identity

Certificate setup instructions: scripts/Certificate Instructions.txt.

Login and session management

Durable operator sessions (HA WS-1/1a, ADR-2002 §4)

Operator sessions are durable Postgres rows so a session survives a core-replica restart/failover and — with DB-clock authority (#3715, below) — validates identically on any replica under arbitrary cross-host clock skew, a prerequisite of the HA “safe-to-scale” gate. The design:

DB-clock authority (ADR-2002 §4, completed — WS-1/1a, #3715). Durable timestamps are authored from PostgreSQL now() (in the store, in SQL — SessionStore::create/set_elevation/mark_mfa/touch_activity), NOT the calling replica’s system_clock, so every replica dates a session in the ONE DB clock domain — the fix for cross-host skew. Reads (find) return db_now_ms in the same atomic SELECT; AuthManager adjudicates absolute expiry / idle / elevation / MFA against db_now_ms and then derives a local monotonic (steady_clock) remaining-duration deadline at cache-populate (derive_session_deadlines), so the per-request hot path touches neither the DB nor the local wall clock and is immune to local skew/steps.

A backward now() on the DB primary is the residual the reversal admits (ADR-2002 §4). Four layered defences bound it: (1) every derived remaining is CLAMPED so a lowered now cannot inflate the LIVED duration past its authored maximum — elevation against elevation_issued + kMaxElevationWindow, the base session against created_at, MFA against its window ceiling; (2) the authored-width ceilings still REJECT an over-kMaxElevationWindow (24 h) elevation and a future-dated MFA proof outright; (3) a generous kWallSanitySkew (5 min) wall-clock ceiling backstops steady_clock’s suspend-blindness (a suspended replica’s monotonic clock pauses); (4) DB-primary clock integrity is monitored — reap_expired() reads now() itself and declines + raises yuzu_auth_session_reap_clock_anomaly_total (distinct from the local host-clock yuzu_auth_local_clock_backward_total) on a backward step, alerted by YuzuSessionReapClockAnomaly. So “validates identically on any replica” now holds under arbitrary cross-host skew (the steady deadline is derived from the shared DB clock), with the wall ceiling assuming replicas stay roughly NTP-synced for the suspend backstop only.

Cookie-session writes require a same-site Origin or Referer, and so do the guarded reads, the cookie-session requests that dispatch work, persist state or force a refresh of server state (for those, a Sec-Fetch-Site header that is present decides alone). One decision, evaluate_same_site_write (same_site_write_policy.hpp), is applied by one caller, apply_same_site_write_gate (same_site_write_gate.hpp), invoked once from server.cpp’s pre-routing handler.

Account lockout (SOC 2 CC6.3)

Brute-force / credential-stuffing protection on the local-password login path (POST /login). OIDC delegates throttling to the IdP; the MFA code-verification path has its own per-token failure rate-limit — neither is in scope here.

Local password change and reset (#5342, #5274)

Two REST v1 routes write a local account’s password; the dashboard’s Settings → User Management buttons call them with fetch() (no hx-on — CSP). Full wire contract: docs/user-manual/rest-api.md “Users”.

One writer, one transaction. CredentialChangeOwner (credential_change_owner.{hpp,cpp}, #5342 Gate 8) is the only writer of password_hash on an existing row — AuthDB has no credential-write method (upsert_user is INSERT-only — ON CONFLICT DO NOTHING; the first-boot seed paths only INSERT a new row). It is an ADR-0012 §3 query owner (see that ADR’s 2026-10-04 Update): one pool lease, one with_txn_for, schema-qualified SQL across auth, session_store and audit_store. Both routes reach it through AuthManager::commit_password_change, which on success refreshes only this process’s caches (users_ hash/salt, sessions_).

The invariant. A local account’s credential, its session set, its provisional MFA state, its lockout state (admin reset) and the audit evidence for the change commit or abort TOGETHER, in one transaction holding the auth.users row lock first — and every LOCAL session mint re-reads that row under the same lock after persisting (below). Nothing proven under a retired credential is honoured. Every enrolment write is anchored the same way (F4, below), so the store never honours a credential proven before the change; the only remaining window is a request a replica admitted on a session the change is revoking before its caches saw the revocation — the generic session-revocation propagation window (R14), not a credential-specific one — and no compensating write exists.

The transaction, in order: set_config('lock_timeout', …, true) (every lock wait bounded, txn-scoped); SELECT … FROM auth.users WHERE username=$1 AND is_active FOR UPDATE — classification under the lock: no row → 404; non-local identity or provisioning source → 409 not_local; the self route’s verified hash (VerifiedCredential::hash_hex, from the lockout-accounted verify_password — no second PBKDF2) no longer the stored one → 409 conflict. Then one guarded UPDATE auth.users (new hash/salt, updated_at, the provisional TOTP secret wiped, and — admin reset only — the lockout columns cleared); DELETE FROM session_store.sessions WHERE username=$1 (session_store_sql_helpers.hpp, the ONE copy, shared with SessionStore::invalidate_user); the success audit row(s) via AuditStore::log_in_txn — user.password_change / user.password_reset (detail sessions_revoked=N provisional_mfa_cleared=…) and, for an admin reset of an account that had a lockout (locked_until set — even if the lock has since expired — or a non-zero failure count), auth.lockout.cleared (detail password_reset); and LAST the session_meta write-generation bump (same helper file). An audit INSERT failure aborts everything → 503 audit_unavailable + Sec-Audit-Failed; any other failure (lease, lock timeout, statement, COMMIT) → 503 store_unavailable. A refusal changes NOTHING — the caller’s session, the target’s sessions and the lockout are exactly as they were. The 503 bodies say what is true: nothing was changed unless the server lost contact with the database at commit; a successful sign-in with the new password confirms the change, which is audited either way (the audit row is in the same commit).

Lock order (documented in the owner header; a change inverting it can deadlock): the auth.users row → session_store.sessions rows → audit_store.audit_events (INSERT) → the session_store.session_meta write_generation row → COMMIT. The generation row is last because every session create and revoke in the fleet takes it: holding it while waiting on anything else (formerly the audit INSERT, behind a lock someone held on audit_events) stalled every unrelated sign-in and sign-out until the owner’s lock_timeout (T1′). Nothing holds session_meta or a sessions row and then waits on auth.users or audit_events: no session_store transaction touches another schema, no audit writer touches session_store, and the post-mint re-read takes auth.users only after its session INSERT committed. RbacAdminAuthorityOwner takes principal_roles → auth.users and never a session/audit lock. The owner requires AuthDB, SessionStore and AuditStore on ONE pool/database (ADR-0006; server.cpp builds all three on *pg_pool_); a split fails every statement closed, never a partial write.

The locking post-mint read. create_local_session(username, role, mfa_verified, expected_hash_hex) takes the verified hash (no default; the MFA pending entry carries it across the TOTP round trip). After the session is persisted, post_mint_role_recheck re-reads the row with AuthDB::recheck_role_locked (SELECT … FOR UPDATE, bounded lock_timeout (deliberately longer than the owner’s own lock_timeout, so a same-account mint queued behind a stalled change waits out its abort instead of failing closed — R22e)) and revokes-and-denies when the role or the stored hash diverged. Because the read is LOCKING, a credential change whose UPDATE has executed but not yet committed makes the mint wait for that commit and then see the new hash; a change that has not yet taken the lock will DELETE the (already committed) session row; one that committed earlier is simply seen. A plain read here would see the old hash past an uncommitted UPDATE whose session DELETE had already run — the Gate 8 defect this closes. An empty anchor denies.

MFA. The transaction wipes a provisional (never-enrolled) TOTP secret on both routes, so an enrolment begun under the retired credential cannot be finished and the account’s next enrolment gets a fresh secret (F1). Every enrolment WRITE is anchored to a credential (F2/F4): the mint in mfa_init_enrollment (… WHERE mfa_totp_secret IS NULL AND mfa_enrolled_at IS NULL AND password_hash = <anchor>) and the enrol in mfa_verify_enrollment (… AND password_hash = <anchor>), both with a REQUIRED anchor argument. The login-enforcement path passes the hash its password was PROVEN against — the bootstrap init in POST /login and the confirm in POST /login/mfa/enroll (the pending entry carries it); Settings passes std::nullopt, which the store resolves to the hash it read in the SAME call. A stale proven anchor is refused before any reveal or mint, and a write that queued behind the credential change’s row lock re-evaluates against the committed row and lands as AuthDBError::CredentialChanged — never a secret written, or an enrolment completed, over the change. At login that is the generic 401 with nothing revealed and no pending token kept; in Settings the fragment says “Your password changed during enrolment — sign in again”; every site audits mfa.enroll.failed result error detail credential_changed. (The IS NULL pair also stops a slow init overwriting a secret a concurrent init or verify just wrote — it re-reveals a concurrent init’s secret instead.) An ENROLLED second factor and its recovery codes are not touched by a change or reset (F3); clearing one is yuzu-server --mfa-reset (an optional admin-reset flag is a follow-up).

Accepted residual (R11). The admin route’s durable-Administrator gate runs before the transaction; an Administrator grant revoked in the PBKDF2 window between the gate and the commit lets that one in-flight reset complete under the revoked authority (the next request is refused) — the same lock-free regime check the A2/A1 handlers accept.

Accepted residual (R14). A Settings MFA init or verify admitted on a session that a credential change is revoking — on the same replica between the change’s COMMIT and its erase of the cached session, on another replica before its next session-generation refresh (≤1 s), or a request admitted before the COMMIT whose own store reads land after it, bounded by the store’s read timeout plus its one retry, ≈4.2 s — mint only — anchors to the NEW hash it reads and so can mint and, with a second such request, enrol a secret. A mint alone makes the account’s next enrolment re-reveal that provisional secret, so a secret the revoked session’s holder saw can be enrolled by the legitimate user. The same window also covers POST /api/settings/mfa/recovery-codes and /disable. That is the generic session-revocation propagation window, not a credential-specific one. The controls are the compromise runbook (yuzu-server --mfa-reset) and the mfa.enroll.* audit rows adjacent to user.password_reset; full closure is a fifth credential-change owner step or a current-password field on the Settings enrolment form (#5392).

Route gates (rest_api_v1.cpp register_password_routes): interactive cookie sessions only — any MCP tier, service-scoped token, engine principal or non-interactive auth_source is 403 token_session; the self route additionally requires auth_source == "local" (409 not_local for OIDC/SAML). CSRF: Origin/Referer must be same-site (origin_is_same_site, honouring --csrf-trusted-origin) and a cookie request lacking both headers is refused. Both are decided centrally, in server.cpp’s pre-routing handler (same_site_write_gate.hpp, after authentication and before any route; see Same-site gate for cookie-session writes); the per-route copies, this one included, are defence in depth. Invariants: the header banner of same_site_write_policy.hpp. Content-Type must be application/json (415). The admin route then runs the canonical durable-Administrator gate — is_rbac_administrator(kRest) (rbac_admin_predicate.hpp, the A2/A1 predicate) followed by check_caller_authorized_under_current_regime, REPLACING perm_fn, for every target: RBAC off, the caller’s own local account must hold the admin role (an OIDC session whose admin role came from IdP group mapping does not — its auth.users row says user); RBAC on, the caller must hold a user-principal Administrator grant. A JIT elevation or a custom role holding UserManagement:Write is 403 durable_admin_required; a store that cannot confirm is 503 admin_gate_unavailable. It refuses a self-target (403 self_target, via the shared is_self_target) so a hijacked admin cookie cannot re-key its own account without the current password, and the configured break-glass account (403 break_glass_target) so a compromised IdP cannot invalidate the sealed escape hatch — both before step-up. The body is parsed with a non-throwing, type-guarded parse and never echoed; body/policy 400s are not audited; passwords, hashes and lengths never reach a log or audit row. Every audit row both routes write names the principal captured at the start of the request (refusals via AuthRoutes::audit_log_for_principal; the in-transaction success rows from make_audit_event_for_principal’s template). (The session-revoke routes DELETE /api/v1/sessions/me and DELETE /api/v1/sessions?username= still write their row after revoking the caller’s own session, with an empty principal — #5358.)

Lockout. The self route’s current-password check runs through AuthRoutes::verify_password_with_lockout — the striped login_lock_for lockout section extracted from /login, behaviour-preserving, and the ONE copy (no second striped-lock section). A wrong current password records a failed login exactly like /login; a locked account answers the SAME 403 “current password is incorrect” as a wrong password (audit detail account_locked vs wrong_current records the truth). Its store-unavailable branches count under yuzu_auth_secret_unavailable_total{route="password_change"} / yuzu_auth_read_degrade_total{route="password_change",reason}. Under --auth-mode=sso-only the same section runs /login’s hardened-mode gate (AuthRoutes::sso_only_local_password_gate) before PBKDF2: only an ARMED break-glass account may change its password; a disarmed one gets 403 sso_only_local_disabled with no password evaluated and no strike. A verified current password clears a non-zero failure counter exactly as a successful login does (auth.lockout.cleared, detail reset_on_password_change) — before the write, so it stays cleared even if the write is then refused. Response timing can still tell a locked account from a wrong password, on /login and here alike (#5364).

Sessions and lockout. The self route issues no replacement session: on success it answers with a clearing Set-Cookie (Max-Age=0) and session_reissued:false — the user signs in again with the new password (a refusal never clears the cookie: nothing changed). The admin reset clears the target’s lockout inside the same transaction; lockout_cleared is true only when a lockout existed and was cleared (then an auth.lockout.cleared row is in the same commit). It reports api_tokens_active (ApiTokenStore::list_active_for_principal_checked; null + api_tokens_unknown when the read fails) — tokens are deliberately not auto-revoked.

#5274 — the config file can no longer shadow the AuthDB. Before this change find_user_or_hydrate was cache-first, and users_ is seeded from yuzu-server.cfg at boot (before set_auth_db) and never overwritten by a hydrate; the post-verify recheck compared role/active, never the hash. So after a password change (a) every restart re-seeded the OLD cfg hash, which then won, and (b) a second replica kept its cached old hash until restart. Now, with an AuthDB wired, find_user_or_hydrate reads the AuthDB and never consults users_ for credentials (the get_user_role “always authoritative” precedent); users_ remains the store only in cfg-only (no-AuthDB) mode. And recheck_role_locked’s row-locked SELECT ... FOR UPDATE now also returns password_hash; the credential check denies if it differs from the hash just verified: verify_password returns kCredentialChanged (a change committed mid-login), which is transient — /login answers 503 + Retry-After with no lockout strike and no auth.login_failed row, and the user retries; no re-verify. Counted by yuzu_auth_credential_changed_during_verify_total. The cfg file is therefore seed-only by construction: an operator can no longer reset a password by editing it, and a password rotated that way before this change reverts on upgrade to the stored one (behaviour change — see docs/user-manual/upgrading.md). The Windows server installer refuses a password on an upgrade for the same reason (exit code 11, docs/user-manual/server-admin.md). cfg-only non-admin accounts are a separate gap, mitigated by #5343: only the cfg admin is provisioned on a fresh database, so the second first-run account and the installer’s /OPERATOR_USER could never sign in on Postgres, and both generators were removed (a hand-written cfg-only entry still cannot sign in, and is named in a boot warning) (first-run setup creates the administrator only; further accounts come from Settings → User Management). A cfg entry that has no auth.users row is named in a warning at boot (AuthManager::report_stale_cfg_credentials; no metric).

The cfg file is still a bootstrap credential. It seeds the first administrator whenever the auth schema is empty, so an empty-database rebuild (a lost or recreated Postgres, a restore onto a fresh database) re-seeds whatever password hash the file holds — typically the original first-boot password, not the current one. Protect the file like the credential it is (the Windows installer locks it to Administrators and SYSTEM), and change the administrator password in the product after any rebuild. At boot, after set_auth_db, AuthManager::report_stale_cfg_credentials compares each cfg entry’s hash with its auth.users row and logs a WARN naming every account whose stored credential differs (“#5274 cfg is seed-only; stored credential wins”), setting the gauge yuzu_auth_cfg_credentials_stale to their count. It writes no audit row (#5361).

Password policy. password_policy.hpp is the ONE length policy: kMinPasswordBytes = 12, kMaxPasswordBytes = 1024 (bytes). It replaces the three hand-copied < 12 checks; /login answers an over-max password like a wrong one without running PBKDF2. Body cap: kBodyCapTable row /api/v1/users (any method, 16 KiB, path_class="users"). The Windows server installer and generate-config.ps1 enforce the same 1024-byte maximum (UTF-8) on a fresh install; the Compose Wizard does not yet (#5363).

No MCP twin (ADR-1005 parity-ledger exception: rows, docs/api-parity-ledger.md). Self-service change is permanently excluded: an MCP caller is a token, not the human, so a token able to change its owner’s password converts token compromise into account takeover. Admin reset is deferred: UserManagement:Write is approval-gated at the supervised MCP tier, and an approval ticket persists the tool’s canonical_args in plain text (approvals.scope_expression) and renders them to approvers — a password argument would be stored and displayed. The intended follow-up is a temp-password MCP tool that takes no password argument (a server-generated temporary password plus a must-change-at-next-sign-in flag, auth schema v3 — #5357).

Metric. yuzu_auth_password_changes_total{kind="self"|"admin", result="ok"|"denied"|"error"}.

Inactivity (idle) session timeout (SOC 2 CC6.3)

/auth-and-authz skill gap matrix P1 #8. A sliding idle window that invalidates an operator dashboard cookie session after a period of inactivity, under the absolute 8-hour session lifetime (kSessionDuration). Wires the previously-reserved sessions.last_activity_at column end-to-end.

MFA / TOTP (v0.12+, SOC 2 CC6.6)

Full design: docs/auth-mfa-design.md. Summary:

MFA step-up chokepoint

Whether a mutating route needs a fresh MFA proof is decided in one place, not by each handler.

Hard invariants live in §“Hard invariants” of docs/auth-mfa-design.md — do not regress them when shipping PR 2 / PR 3.

Approver assurance (maker-checker, #6061)

Reviewing an approval ticket is a human-review act, so approve and reject carry more than the Approval:Approve permission. POST /api/approvals/{id}/approve, POST /api/approvals/{id}/reject, the dashboard Approvals page (which posts to the same routes) and the MCP tools approve_request / reject_request require all of:

Consequences, each deliberate:

Operator-facing description: docs/user-manual/instructions.md, “Who can approve”.

JIT admin elevation (SOC 2 CC6.3/CC6.6)

/auth-and-authz skill gap matrix P1 #9. Reduce standing privilege: a pre-authorized operator holds a non-admin base role day-to-day and activates admin just-in-time for a bounded, justified, MFA-gated window, then auto-reverts — so a compromised everyday session is not a standing admin session.

Hardened mode (sso-only) + break-glass (SOC 2 CC6.3/CC6.6)

/auth-and-authz skill gap matrix P0 #3. Closes Workstream B “Disable local-password fallback in hardened mode (or tightly constrain break-glass account policy)” — this ships both halves.

Implementation: gate at auth_routes.cpp POST /login (between the lockout pre-check and verify_password); accessors AuthDB::break_glass_status / arm_break_glass (single UPDATE ... RETURNING, no sqlite3_changes() — #1033); the boot guard’s testable core is sso_only_boot_guard_ok (sso_boot_guard.{hpp,cpp}, shared with server.cpp via saml_config_complete), called from a thin wrapper in main.cpp alongside the flags + arm one-shot; Config::auth_mode / break_glass_user / break_glass_window_secs in server.hpp. Tests: tests/unit/server/test_auth_break_glass.cpp (DB accessors) + test_auth_routes_hardened.cpp (login-gate wire path) + test_sso_boot_guard.cpp (boot-guard predicate — OIDC/SAML/HTTPS/platform).

RBAC group provisioning (#1832)

Every OIDC /auth/callback login reconciles the IdP’s groups claim into the RBAC store (RbacStore::reconcile_idp_memberships), so group-scoped role assignments made against an IdP-sourced group stay in sync with the IdP’s current membership. Mirrors the break-glass metric/audit treatment above.

Implementation: RbacStore::reconcile_idp_memberships / namespaced_group_name (rbac_store.{hpp,cpp}); the callback gate in auth_routes.cpp /auth/callback; the claim parsing + gating decision in oidc_provider.{hpp,cpp} (IdTokenClaims::groups_claim_present / groups_overage, groups_claim_reconcilable). Tests: tests/unit/server/test_rbac_store.cpp, test_oidc_provider.cpp.

SAML now rides this same reconcile path (source "saml"). The /saml/acs handler reconciles --saml-group-attribute’s asserted values the same way, before minting the session — see docs/user-manual/authentication.md “SAML Fine-Grained RBAC” for the operator-facing detail. Two SAML-specific differences from the OIDC shape above: (1) SAML has no groups_claim_present/groups_overage equivalent — it cannot distinguish “attribute absent” from “attribute present, zero values” — so an EMPTY asserted set skips reconciliation unconditionally (never reconciles zero groups), rather than gating on a presence signal; (2) the cap is enforced by the SAML verifier itself (saml::kMaxGroupValues, 200 — aligned with, and independent of, RbacStore::kMaxIdpGroupsPerLogin) via a group_cap_truncated flag on the parsed assertion, and a truncated assertion denies the login before reconcile_idp_memberships is ever called (never a partial/truncated reconcile).

Stable principal vs. display name (#1837)

Session carries two separate identity fields with different jobs:

Why this split exists. Before #1837, create_oidc_session keyed username on claims.name (falling back to claims.email) — an IdP-editable, non-unique display label. Two different SSO users who happened to share a display name (a common collision in orgs with duplicate names, or after a legal name change re-used an old name) collided onto ONE authorization principal. Once #1832 shipped reconcile_idp_memberships — which deletes a principal’s un-reasserted IdP-sourced group memberships on every login — that collision became destructive: user B’s login could silently delete user A’s group memberships (and, in the group-membership window between the two logins, inherit A’s roles).

The fix. OIDC sessions now key username on:

"oidc:" + iss + "#" + sub

sub (the OIDC subject claim) is only guaranteed unique per issuer (RFC 7519) — a bare sub from two different IdPs (or two tenants of the same IdP product) could theoretically collide, so iss (the token issuer) scopes it. This is an OPAQUE id — never render it as a human name. display_name is set to claims.name (falling back to claims.email), exactly as username used to be computed, and is free to change on every login without touching the stable principal or the RBAC memberships/roles bound to it.

sub is now authorization-load-bearing (it is half of the RBAC principal), so OidcProvider::validate_claims — the same chokepoint that already rejects iss/aud/exp/nonce mismatches — also rejects a token whose sub is empty, contains a control character (byte < 0x20 or == 0x7F, including CR/LF/tab), or exceeds 255 bytes, and rejects an empty iss defensively. Without this, an IdP token that omits sub would collapse every such login onto the single principal oidc:<iss># (destructive under the #1832 reconcile), and a control/newline byte in sub would corrupt the audit principal column. Rejection is fail-closed — the login is denied (auth.oidc_login_failed), no session minted.

Recovering a human name without a live session. There is no persistent principal→display-name directory. The audit principal column is the authoritative stable id (oidc:<iss>#<sub>) for every SSO audit row; the human name for that same login is carried alongside it in that row’s detail field (display=<sanitized name>;email=<sanitized email> — see /auth/callback’s audit_log_for_principal calls, which attach this to every SSO audit action: auth.oidc_login, auth.oidc_login_failed, auth.sso_group_provision). A live session’s Session::display_name is the other source, for the currently-signed-in user only. Rendering a principal for which no session is live and no audit row is being inspected (e.g. an admin-facing user list keyed on raw username strings) has no name to show today — a durable principal→display-name directory (persistent, survives restart, joinable outside an audit row) is tracked as a fast-follow in issue #1852. An earlier revision of this section documented an in-memory AuthManager::sso_identities_ resolution map upserted on every create_oidc_session call; it had zero production callers (every render site used a live session or an audit-row detail instead) and was removed as dead code in the #1837 governance hardening round.

Render sites. GET /api/me (the “who am I” read, consumed by the app shell’s script, app_shell.cpp, which fills nav-user/context-user on every page) and GET /api/v1/me both now return display_name alongside the stable username; the shell script shows display_name, falling back to username for a legacy/local session predating this field.

SAML was unaffected this slice; since resolved (ADR-2001 PR4a). At the time of the #1837 hardening round documented above, create_saml_session still keyed username on the raw NameID (display_name set to the same value, purely for render-site parity) — SAML did not sync to rbac_store yet (dropped in #1827; it now does, under source "saml", see #1832 above), so the collision risk this fix closes was dormant there. ADR-2001 PR4a has since closed the SAML side of the same gap: create_saml_session now keys username on the stable saml_principal_id(entity_id, name_id) ("saml:" + entity_id + "#" + name_id, saml_principal.hpp), mirroring the OIDC split above byte-for-byte; display_name still carries the raw NameID for render-site parity. This unlocks force-logout (DELETE /api/v1/sessions?username= saml:<entity_id>#<NameID>, is_valid_principal accepts the saml: reserved prefix on the same wider SSO charset as oidc:) and SCIM deprovision-time session revocation for linked SAML identities (see docs/user-manual/scim-provisioning.md “SCIM ↔ SAML identity linkage”). JIT elevation is not part of this fix — no auth.db users row is provisioned for a SAML principal, so provision_sso_identity is still not wired to SAML (AuthManager::provision_sso_identity’s docstring) and a SAML session still cannot elevate.

Audit-detail-field injection defense (sanitize_detail_value). Every IdP-supplied value that reaches an audit detail string or an emit_event JSON attribute — name/email/sub (and the derived display) — is untrusted: a hostile or misconfigured IdP, or a user-editable IdP profile field, controls it. detail is a flat "k=v;k=v" string parsed by SIEM tooling, so an unsanitised value could inject ;/= to forge additional fields or \r/\n/other control bytes to inject fake log lines. detail::sanitize_detail_value (auth_routes.hpp/.cpp) is the single chokepoint that neutralises this: it replaces ;, =, \r, \n, and any control byte (incl. DEL) with _, then truncates to 128 bytes on a UTF-8 code-point boundary. Every display=/email=/oidc_sub/name value attached in /auth/callback is run through it before concatenation. It is audit-detail-field-injection defense only — it does NOT strip HTML markup and is not a stored-XSS defense by itself. A value with no control bytes (e.g. a display name containing <script>) passes through unchanged; if that value is later rendered into HTML, the render layer’s own escaping (html_escape) is what neutralises it. See the docstring on detail::sanitize_detail_value in auth_routes.hpp for the full contract.

Migration. RbacStore legacy SQLite schema v3 (distinct from the PG schema’s own v3, ADR-0041 — different migration sequences, same file) deletes every group_members row belonging to an IdP-sourced group (groups.source != 'local') on upgrade — those rows were keyed on the OLD display-name principal and would otherwise be BOTH orphaned (never re-referenced by the new stable-keyed principal) AND a resurrected confused-deputy hazard: a LOCAL user who later takes the old display name as their username would silently inherit whatever roles the stale row’s group grants. The purge is additive/safe — only group_members rows for non-local groups are removed; groups/roles/role_permissions/local memberships are untouched, and IdP membership re-populates under the new stable key on each affected user’s next SSO login via reconcile_idp_memberships.

Resolved — durable SSO identity restores OIDC JIT admin elevation (#1852). AuthDB::set_elevation_eligible/is_elevation_eligible key on users.username and were gated through is_valid_username, which allows only alphanumerics plus . _ - and explicitly rejects : (comment: “prevent config file format injection”). The stable OIDC principal shape (oidc:<iss>#<sub>) contains both : and #, so it failed that check unconditionally — POST /api/v1/users/{name}/elevation-eligibility 400’d before reaching the store, and is_elevation_eligible(session->username) for a live OIDC session returned InvalidUsername, treated as fail-closed/denied by the caller. Widening is_valid_username’s charset alone would not have been sufficient: an OIDC login provisioned no users row at all — elevation_eligible is a column on users, and there was no row for an SSO principal to set it on. Both halves are now fixed:

Governance hardening round (2026-07-03) — see docs/security-reviews/sso-durable-identity-2026-07-03.md for the full record. Four fixes on top of the base restoration above:

SAML still cannot elevate, though the reason has narrowed since ADR-2001 PR4a (see “SAML was unaffected this slice; since resolved” above): create_saml_session now keys username on the reserved-prefix stable principal saml:<entity_id>#<NameID>, so is_valid_principal DOES recognise it as an SSO principal (unlocking force-logout, per that section) — but provision_sso_identity is still never called from the SAML ACS handler, so no auth.db users row is provisioned for a SAML principal. A SAML session therefore still cannot elevate — not because of a missing MFA proof specifically, and no longer because is_valid_principal rejects the identity shape, but because there is no durable identity row to hold elevation_eligible on in the first place. Wiring provision_sso_identity into the SAML ACS handler is a tracked follow-up, separate from PR4a.

This is not a regression of a previously-supported flow. Before #1837, Session::username for an OIDC session was the mutable display name (claims.name, falling back to claims.email), and is_elevation_eligible looked that string up directly in users.username. Elevation for an SSO operator therefore only ever “worked” by accident, and only when both of two coincidences held: the IdP-asserted display name had to consist solely of is_valid_username’s narrow charset (no space, no @ — so most real display names/emails already failed this silently), and it had to exactly match an existing local users.username. When both held, the SSO login’s principal silently borrowed that local principal’s elevation_eligible flag and mfa_totp_secret enrollment — a latent cross-principal grant with zero cryptographic binding between the SSO identity and the local account it happened to name-collide with: anyone the IdP would authenticate under that same display name inherited that local account’s admin-elevation path. #1837 severs this borrowing as a direct, correct consequence of closing the display-name collision it rode on (see above) — it is closing an unsafe accident, not breaking a supported capability. #1852 is the deliberate, durable restoration.

Session-revocation-by-username is a narrower, structurally different gap. The operationally-critical kill step (AuthManager::invalidate_user_sessions’s in-memory sweep, keyed by plain string equality over sessions_) carries no is_valid_username gate and revokes any principal string, oidc:<iss>#<sub> included — there is no missing-row problem here, unlike elevation. DELETE /api/v1/sessions?username=’s own query-parameter check does still run is_valid_username at the REST layer, so an admin typing the literal oidc:<iss>#<sub> string still gets a 400 today from that endpoint — but that charset-only validator predates #1837 and would already have rejected most OIDC display-name-keyed usernames (spaces, @) before this change too, so this is a narrow, pre-existing, easily-widened validator gap, not a new #1837 regression and not gated on #1852. Separately, the historical auth.db-persisted half of revocation (AuthDB::invalidate_all_sessions’s DELETE FROM sessions, surfaced as db_persisted=false for an SSO target) was already a no-op for OIDC before #1837 too: AuthDB::create_session was never called for an OIDC login — SSO sessions have always been in-memory-only and were never written to auth.db’s sessions table in the first place. This whole distinction is now moot for every login path, not just OIDC: the ADR-0006 Postgres cutover dropped AuthDB::invalidate_all_sessions/create_session and the sessions table entirely (see “AuthDB — persistent authentication store” below) — invalidate_user_sessions today is purely the in-memory sweep described at the top of this bullet, for every auth_source.

Implementation: Session::username/display_name (auth.hpp), AuthManager::create_oidc_session (auth.cpp), the stable-id construction and per-audit-row display=/email= detail attachment in auth_routes.cpp /auth/callback, RbacStore legacy SQLite migration v3 (rbac_store.cpp; distinct from the PG schema’s own v3, ADR-0041). Tests: tests/unit/server/test_oidc_principal_key.cpp.

SAML 2.0 SP

/auth-and-authz skill gap matrix P1 #6. Thin first slice: SP-initiated login against a single, statically-pinned IdP. Mirrors the OIDC SSO session seam (same cookie / auth_source / RBAC funnel) but uses the SAML 2.0 protocol. Linux and macOS only — SAML is unsupported on Windows server builds and fails closed there (a Windows server logs an error at startup and does not enable SAML regardless of flag values). This is a deliberate non-gap, not unfinished work: running the Yuzu server on Windows is out of scope, so a Windows-server SAML port is not planned. (The Yuzu agent on managed Windows endpoints is a separate binary and is unaffected.)

Configuration

SAML is enabled only when all five required flags below are set; supplying any subset produces a startup warning (naming the missing flag) and SAML is disabled (fail-closed). All five are validated at startup as one unit — a partial configuration never yields a “SAML SP initialized” log. The two group→role flags are optional and independent of the enable gate (see Group→role mapping below).

FlagEnv varDescription
--saml-idp-entity-idYUZU_SAML_IDP_ENTITY_IDEntity ID URI of the IdP (e.g. https://idp.example.com/saml)
--saml-idp-sso-urlYUZU_SAML_IDP_SSO_URLIdP’s HTTP-Redirect SSO endpoint URL
--saml-idp-certYUZU_SAML_IDP_CERTFilesystem path to the IdP’s signing certificate (PEM)
--saml-sp-entity-idYUZU_SAML_SP_ENTITY_IDEntity ID URI the SP advertises to the IdP
--saml-sp-acs-urlYUZU_SAML_SP_ACS_URLFull URL of this server’s Assertion Consumer Service (POST /saml/acs)
--saml-group-attribute (optional)YUZU_SAML_GROUP_ATTRIBUTE<Attribute Name="..."> in the assertion’s <AttributeStatement> whose <AttributeValue>s are group identifiers (e.g. Entra’s http://schemas.microsoft.com/ws/2008/06/identity/claims/groups)
--saml-admin-group (optional)YUZU_SAML_ADMIN_GROUPGroup value (from --saml-group-attribute) that grants role=admin
--saml-name-attribute (optional)YUZU_SAML_NAME_ATTRIBUTE<Attribute Name="..."> whose first <AttributeValue> is the user’s human display name (e.g. Entra’s http://schemas.microsoft.com/identity/claims/displayname). Empty leaves the display as the raw NameID. See “Attribute-based display” below
--saml-email-attribute (optional)YUZU_SAML_EMAIL_ATTRIBUTE<Attribute Name="..."> whose first <AttributeValue> is the user’s email (e.g. Entra’s http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress). Used only as a display fallback and logged
--saml-sp-key (optional)YUZU_SAML_SP_KEYFilesystem path to an SP AuthnRequest signing private key (PEM, RSA only); when set, AuthnRequests are signed (see AuthnRequest signing below)

Example startup:

./yuzu-server \
  --https-cert         /etc/yuzu/server.crt \
  --https-key          /etc/yuzu/server.key \
  --saml-idp-entity-id "https://idp.example.com/saml" \
  --saml-idp-sso-url   "https://idp.example.com/saml/sso" \
  --saml-idp-cert      /etc/yuzu/idp-signing.pem \
  --saml-sp-entity-id  "https://yuzu.example.com" \
  --saml-sp-acs-url    "https://yuzu.example.com/saml/acs"

HTTPS is required. The __Host-yuzu_saml_bind browser-binding cookie is Secure-only; browsers silently drop Secure cookies over plain HTTP. If --https-cert/--https-key are not configured, the server logs an error at startup and leaves SAML disabled (fail-closed). Do not run SAML over HTTP.

Login flow

SP-initiated via HTTP-Redirect binding; assertion consumed via HTTP-POST binding.

  1. The operator navigates to GET /auth/saml/start, or clicks the single-sign-on button on the sign-in page (/home), which links there when SAML is enabled and no OIDC provider is.
  2. The server builds a <samlp:AuthnRequest> (SP entity ID, ACS URL, ID=random, IssueInstant, ForceAuthn=false) and redirects the browser to the IdP’s SSO URL via HTTP-Redirect binding (deflate-compressed, URL-encoded SAMLRequest query parameter). When --saml-sp-key is configured, the request is signed (see AuthnRequest signing below); otherwise it is unsigned — the IdP must be configured to accept unsigned requests in that case.
  3. The user authenticates at the IdP.
  4. The IdP POSTs a <samlp:Response> containing a signed <saml:Assertion> to the ACS endpoint (POST /saml/acs).
  5. The server validates the response (see Security posture below) and, on success, mints an ephemeral session cookie.
Browser           Yuzu Server               IdP
  |                    |                          |
  |-- GET /auth/saml/start -->                    |
  |                    |-- 302 SAMLRequest? ---->|
  |                    |                          |
  |                    |      (user authenticates)|
  |                    |                          |
  |                    |<--- POST /saml/acs ------|
  |<-- Set-Cookie -----|                          |

Session

The minted session is durable — since HA WS-1/1a (ADR-2002 §4) AuthManager::create_saml_session write-throughs to SessionStore exactly as OIDC does, so it survives a server restart (for Postgres-backed deployments; config-file-only deployments keep the old in-memory-only behavior). It has the identical lifetime to OIDC sessions — 8-hour absolute, subject to --session-inactivity-secs. See docs/auth-architecture.md → “Durable operator sessions (HA WS-1/1a, ADR-2002 §4)”. Session fields:

Group→role mapping

SAML admin access is available via IdP-attested group membership, mirroring the OIDC --oidc-admin-group mechanism:

Security: admin is granted only from the configured group attribute’s values — never from NameID, email, or display name, all of which are attacker-controlled fields that ride in the same assertion (mirrors the C3 fix in create_oidc_session). Group values are parsed from the same XSW-verified assertion node that NameID is read from — never a second, unverified document-wide search — so a signature-wrapping attack cannot inject groups the IdP didn’t attest to. Parsing is capped at 64 <AttributeValue> entries (across however many <Attribute> elements carry the configured Name) as a DoS guard; values beyond the cap are silently ignored rather than rejecting the assertion.

Attribute-based display (name / email)

By default a SAML session’s display_name is the raw NameID — often an opaque persistent identifier. --saml-name-attribute and --saml-email-attribute let the operator name the <Attribute> elements that carry the user’s display name and email, so the dashboard and audit rows show a human name instead. The session display is derived exactly like OIDC’s (create_oidc_session): name attribute → email attribute → raw NameID.

PII in operational logs (data-handling note, CC6.7). When configured, the parsed display name and email appear in the operational log (spdlog) at info on session creation — the same posture OIDC already has (its create_oidc_session logs the resolved display + email identically). These are IdP-attested values, are not written to the audit store (the SAML audit row keys on the stable NameID-derived principal only), and are not otherwise persisted. Operational logs inherit the deployment’s general log-access and retention controls; a first-customer questionnaire asking “where does user PII appear in logs” is answered here for both SSO paths. Tightening this to a hashed/omitted form across OIDC and SAML consistently is tracked as a follow-up.

Because role is computed fresh at every session mint (no persisted mapping), there is no schema or migration involved, and changing --saml-admin-group takes effect on the next login after a server restart (see Rotating the IdP signing certificate below for the general “no hot-reload” caveat that also applies to these two flags).

Audit actions

ActionResultWhen
auth.saml_loginokACS validation passed; session minted
auth.saml_login_failederrorACS validation failed (signature, audience, expiry, replay, etc.)

Security posture

MFA enforcement with SAML

MFA step-up is not supported for SAML sessions in this release. A SAML session hitting any step-up-gated route (the 39 listed in the REST API reference under “Step-up envelope on high-risk endpoints”: token, session, engine-principal and RBAC lifecycle, Guardian rule and guard writes, software deploy, approval approve/reject, user delete/role change, MFA recovery-code regeneration and disable, JIT elevation) receives a 403 with "MFA step-up is not available for SAML sessions in this release", regardless of --mfa-enforcement mode (the one exception is a disabled window: with --mfa-step-up-window-secs at 0 or less, SAML sessions pass the 37 routes enforced by the central check, but not the three JIT-elevation routes, which own their gate and substitute a 300 s window, so those still deny SAML). Approval approve/reject are among the 37 central routes, so with the window enabled the central check answers a SAML session first with this generic 403, and the handler never runs. They also carry their own, stricter approver gate (#6079), which refuses a SAML session in every configuration; the typed approver_assurance_required:saml_mfa_unsupported is what it answers with --mfa-step-up-window-secs=0 (where the central check passes SAML through), and the generic 403 above answers first otherwise. The step-up decision (decide_step_up in step_up_gate.cpp, shared by the chokepoint and require_mfa_step_up in mfa_step_up.cpp) exits with an honest denial before reaching the local mfa_status lookup, which would fail anyway (SAML users have no local users row). This means:

A SAML user cannot approve or reject approval tickets in any configuration (the generic 403 above with the window above 0, approver_assurance_required:saml_mfa_unsupported with it at 0; see “Approver assurance” above).

Recommendation: Use --mfa-enforcement=optional or --mfa-enforcement=admin-only when SAML is in use, and configure your IdP to enforce MFA at login time. Avoid required unless you are prepared for SAML users to be denied at all step-up gates. The recommended pattern for a SAML deployment is optional with IdP-side MFA enforcement.

--auth-mode=sso-only covers SAML (SOC 2 CC6.3)

A SAML-only deployment can run under --auth-mode=sso-only: the boot guard accepts a complete SAML SP config (all five --saml-* fields) with HTTPS enabled as an SSO path, exactly as it accepts a complete OIDC config — see the Hardened-mode boot-guard bullet above for the full predicate. A dual OIDC+SAML deployment satisfies it via either provider. On Windows the SAML provider is a stub, so a Windows server still needs OIDC for sso-only (running the server on Windows is out of scope regardless).

Two limitations a SAML-only sso-only operator should know (neither is new to this change; both are pre-existing SAML properties that simply become more visible without a local-password fallback):

OIDC browser binding

The OIDC sign-in is bound to the browser that started it, in the same way the SAML binding cookie above binds the ACS POST. GET /auth/oidc/start sets a 10 minute HttpOnly cookie (__Host-yuzu_oidc_bind on an HTTPS listener, yuzu_oidc_bind on plain HTTP) holding a random 256-bit secret; the pending flow keeps only its SHA-256 digest, compared in constant time at GET /auth/callback. Deliberate differences from SAML: SameSite=Lax rather than None (the OIDC return is a top-level GET, and the server requests the default query response mode); the cookie name follows the server’s own HTTPS flag, as the session cookie does, instead of being HTTPS-only; and a refusal is non-consuming, so the pending flow stays available to the initiating browser. A digest failure fails closed (browser_binding_unavailable, or a 500 with no cookie at start). The cookie is host-bound, so the sign-in must start on the host name of --oidc-redirect-uri. Operator-facing detail: docs/user-manual/authentication.md “Browser binding”.

HA / multi-replica

Pending AuthnRequest state (the random ID stored for replay protection) is kept in process memory. In a multi-replica deployment, load-balancer sticky sessions (session affinity) must be configured on GET /auth/saml/start and POST /saml/acs so the ACS POST for a given request is always routed to the replica that generated it. Without affinity, approximately (N−1)/N of logins fail as “unsolicited” (no matching pending ID). OIDC shares this limitation via its in-process PKCE state and its browser-binding digest.

Rotating the IdP signing certificate

Update --saml-idp-cert and restart the server — there is no hot-reload for the IdP cert in this release.

AuthnRequest signing

Optional and independent of the five-flag enable gate: --saml-sp-key points at a filesystem PEM containing the SP’s AuthnRequest signing private key. Design:

Deferred items (not in this slice)

SCIM v2 provisioning (SOC 2 CC6.2/CC6.7/CC6.8)

/auth-and-authz skill gap matrix P1 #7. RFC 7643 (Core Schema) / RFC 7644 (Protocol) — lets an enterprise IdP (Okta/Entra/OneLogin) auto-provision and auto-deprovision Yuzu operators via its SCIM connector, rather than an admin managing accounts by hand. Slice 1 (Users) shipped every SCIM-provisioned account at the fixed, floor-privilege user role; slice 2 (this section’s “Groups → role mapping” below, issue #2021, SOC 2 CC6.7) lets an IdP grant role=admin to members of a configured admin group.

Configuration (fail-closed)

FlagEnv varDescription
--scim-enableYUZU_SCIM_ENABLEEnable the /scim/v2/* route surface. Default false — SCIM is entirely inert when disabled (no routes registered, no boot-log line).
--scim-tokenYUZU_SCIM_TOKENThe bearer credential the IdP’s SCIM connector authenticates with. Secret — treat like a password.
--scim-admin-groupYUZU_SCIM_ADMIN_GROUPdisplayName of the SCIM group whose members are granted role=admin. Default empty — no SCIM group grants admin; every SCIM-provisioned user stays role=user. The value is whitespace-trimmed at load. Mirrors --saml-admin-group. Requires a server restart to take effect — there is no Settings-UI/live-reload path; renaming the group’s displayName in the IdP likewise requires updating this flag and restarting.

Prefer YUZU_SCIM_TOKEN over --scim-token. A value passed on argv is visible to any local user via ps//proc/<pid>/cmdline; the environment variable does not appear there. YUZU_SCIM_TOKEN is the recommended delivery method — the flag remains supported for parity with the other --scim-* options and local/manual testing.

Two fail-closed startup guards, both refusing to start (EXIT_FAILURE):

The active SCIM posture (enabled/disabled, and — if enabled — that a token is configured) is logged once at boot as CC6.2 evidence, the same pattern as the sso-only boot-posture banner.

Auth

Every /scim/v2/* route — including the discovery endpoints — requires Authorization: Bearer <token>, validated constant-time (CRYPTO_memcmp) against the SHA-256 hash stored at --scim-enable time (mirrors ApiTokenStore’s token-hashing pattern). A missing, malformed, or invalid token is a 401 + WWW-Authenticate: Bearer — the same envelope regardless of why it failed (no oracle distinguishing “wrong token” from “no token configured”). SCIM tokens are their own credential type, entirely distinct from operator API tokens and session cookies — there is no cookie, no session, no shared token store. Every authenticated SCIM request maps to a fixed scim-service audit principal (there is no notion of “which operator” made a SCIM call; it is the IdP connector, always).

Endpoints

All responses (and the accepted PATCH/PUT bodies) use Content-Type: application/scim+json.

ServiceProviderConfig advertises etag.supported: false — there is no conditional-write enforcement (If-Match/If-None-Match are not honored); the ETag/meta.version a client sees back is informational only, not a concurrency-control token.

EndpointPurpose
GET /scim/v2/ServiceProviderConfigDiscovery — capability document
GET /scim/v2/ResourceTypesDiscovery — describes the User and Group resource types
GET /scim/v2/SchemasDiscovery — the core User and Group schemas
POST /scim/v2/UsersProvision a new user
GET /scim/v2/Users/{id}Read a single user (404 if unknown)
GET /scim/v2/Users?filter=userName eq "x"&startIndex=&count=Existence check + pagination (SCIM ListResponse)
PUT /scim/v2/Users/{id}Replace (externalId, active)
PATCH /scim/v2/Users/{id}Primary lifecycle path — deactivate / reactivate
DELETE /scim/v2/Users/{id}Deprovision
POST /scim/v2/GroupsCreate a group (displayName, optional members[])
GET /scim/v2/Groups/{id}Read a single group
GET /scim/v2/Groups?filter=displayName eq "x"&startIndex=&count=List groups (SCIM ListResponse, pagination)
PUT /scim/v2/Groups/{id}Replace a group
PATCH /scim/v2/Groups/{id}Add/remove members (RFC 7644 §3.5.2 PatchOp on members)
DELETE /scim/v2/Groups/{id}Delete a group

Group is now advertised alongside User in /scim/v2/ResourceTypes and /scim/v2/Schemas (schema urn urn:ietf:params:scim:schemas:core:2.0:Group).

Provisioning model

POST /scim/v2/Users creates the account with a discarded CSPRNG-generated password — a SCIM-provisioned user never authenticates with a local password; they sign in via the IdP/SSO. Role defaults to user and is recomputed immediately against the current --scim-admin-group membership (see Groups → role mapping below) — so a user who already appears in the admin group’s members[] at the moment they are provisioned is created role=admin, not user-then-promoted. A duplicate userName is rejected 409 (scim_type=uniqueness) rather than silently upserting. Success is 201 + Location (the resource’s canonical URL) + ETag (the resource’s etag_version, bumped on every mutation).

Reprovisioning a returning employee works. If the userName collision is against an existing SCIM-provisioned account that is currently deactivated, POST revives that account rather than rejecting 409 — this is the “left, then rejoined” case an IdP connector replays automatically when a person is re-added and re-assigned the app. The 409 uniqueness rejection is reserved for a userName collision against a currently-active account.

userName charset — must be a slug, not an email address

Yuzu account usernames are slug-shaped: alphanumeric plus ., _, - only — no @. Stock Okta/Entra provisioning defaults userName to the user’s email address, which contains @ and is rejected 400 at POST /scim/v2/Users (and at any subsequent identity-touching PUT/PATCH). Operators must remap userName to a non-email slug attribute in the IdP’s provisioning/attribute-mapping configuration before assigning any users — the 400 response carries an error-message hint calling out the rejected character(s) to speed up this diagnosis. Native support for an email-shaped userName (deriving a slug server-side) is a planned follow-up — see Deferred below.

GET .../Users?filter=userName eq "x" is the connector’s standard existence-check-before-create call. Only the userName eq "..." filter is supported (attribute + operator case-insensitive, value double-quoted); any other attribute or operator is rejected 400 (scim_type=invalidFilter) rather than silently ignored or partially matched.

PUT /scim/v2/Users/{id} replaces the mutable identity fields (externalId, active). A userName change is rejected 400 (scim_type=mutability) — rename is out of scope this slice; deleting and re-provisioning is the only supported path for a IdP-side username change today.

Deprovision and reactivation

PATCH /scim/v2/Users/{id} is the primary lifecycle path (both the pathless {"value":{"active":false}} and explicit {"path":"active","value":false} PatchOp forms are accepted). PUT /scim/v2/Users/{id} triggers the identical deactivate/reactivate semantics whenever its body’s active value differs from the account’s current state — some IdP connectors issue a full PUT rather than a PATCH for lifecycle changes, and that path gets the same audit behavior described below, not a silent no-op:

DELETE /scim/v2/Users/{id} is the equivalent one-shot deprovision (204), for IdPs that issue a hard delete rather than a PATCH/PUT-to-inactive.

All lifecycle audit writes are set-and-proceed; evidence integrity is enforced by an alert on the failure metric. Every action (provision, update, deactivate, reactivate, delete) completes even if its audit write fails, and each failure unconditionally bumps yuzu_scim_audit_write_failures_total. A fail-closed 500 on a termination audit-write failure was considered and rejected: because the account is already mutated, the IdP’s retry re-reads the terminated post-state, takes the non-termination (no-op / 404) branch, and never re-attempts the missing audit — the 500 fails the request without ever re-landing the evidence row it was meant to guarantee. The correct CC6.8 control is therefore an alert on yuzu_scim_audit_write_failures_total (same-day follow-up on a missed termination record); deploy that rule when you enable SCIM.

🔴 Provenance guard (the load-bearing security invariant)

SCIM may only ever mutate accounts that SCIM itself provisioned, and only while they are still floor-privilege. Every deactivate / reactivate / delete / update re-verifies both provisioning_source == "scim" (a users column, AuthDB::set_provisioning_source/get_provisioning_source; originally SQLite auth.db migration v7, now a column on the Postgres auth.users schema) and role == "user" immediately before touching the auth account — not at lookup time, at the mutation site. Either mismatch (the target scim_id maps to a username whose provisioning_source is local or anything else, or whose role has since been elevated, e.g. by a dashboard admin promoting a former SCIM user) refuses with 404, never 403 — a 403 would be an existence oracle (it confirms the resource exists but is protected); a 404 is indistinguishable from “no such SCIM resource” — plus a scim.user.provenance_denied audit row so the attempt is still recorded even though the caller sees a plain not-found.

This is the invariant that makes it safe to point a third-party IdP connector at this endpoint at all: a locally-created admin account, or the --break-glass-user break-glass account, can never be deactivated by an IdP push — even if an attacker who compromises the IdP (or a misconfigured connector) knows or guesses that account’s SCIM-facing id. The role half of the check adds a second belt: an operator-elevated SCIM account is not SCIM’s to tear down — once a SCIM-provisioned account has been promoted (e.g. to admin) by a human through the dashboard, it drops out of SCIM’s write authority entirely, the same as if it had never been SCIM-provisioned. The one code path from a SCIM request to role=admin is Groups → role mapping (below), and it is bounded by design: admin is granted only while the user is a current member of the single group named by --scim-admin-group — there is no other field or code path by which a SCIM request can set role=admin, so a compromised IdP can elevate a SCIM account only as far as that one configured group, never past it, and (via this guard) can still never touch a local or already-elevated-and-thus- out-of-scope account, whether that account was always local or was originally SCIM-provisioned and later promoted.

Groups → role mapping (SOC 2 CC6.7, issue #2021)

/scim/v2/Groups lets an IdP push SCIM Group resources (POST/GET/PUT/PATCH/DELETE, list with startIndex/count pagination and an optional filter=displayName eq "..."; PATCH follows RFC 7644 §3.5.2 PatchOp semantics on the members path). Group is advertised alongside User in /scim/v2/ResourceTypes and /scim/v2/Schemas (schema urn urn:ietf:params:scim:schemas:core:2.0:Group).

Role resolution reuses the existing machinery, not a parallel implementation. Because the auth store’s role model (auth.users.role) is binary (admin | user), granting admin via group membership is a parity check, not a richer mapping: a SCIM-provisioned user is role=admin iff they are currently a member of the group whose displayName equals --scim-admin-group (YUZU_SCIM_ADMIN_GROUP, default empty — no SCIM group grants admin). This calls the same resolve_role_from_groups (server/core/include/yuzu/server/auth.hpp) helper SAML (#1826) and OIDC already use for their own --saml-admin-group/--oidc-admin-group mapping — one role-resolution function, three callers, not three divergent implementations. Role is recomputed (never left stale) at every point membership could have changed: on user creation, and on any Group POST/PUT/PATCH/DELETE that could add or remove a user from the admin group. Removing a user from the admin group demotes them back to user on the next recomputation — there is no separate “apply pending role change” step.

The provenance guard is preserved, not bypassed, for group-driven role changes. A role recomputation triggered by a Group mutation only ever writes to accounts with provisioning_source == "scim" — exactly the same guard that already protects deactivate/reactivate/delete (see Provenance guard above). If a Group’s members[] includes the SCIM id of a non-SCIM account (a locally-created admin, or the --break-glass-user account), that member is silently skipped for role purposes — never role-changed. A compromised or misconfigured IdP therefore cannot use Group push to elevate, demote, or otherwise touch a local principal, even by naming its SCIM-facing id in a group’s membership list.

deprovision_role_ok is a demote-before-delete ordering gate, not a “manual elevation is protected” guarantee. deprovision_role_ok (server/core/src/scim_routes.cpp) refuses to delete/deactivate any account whose DB-authoritative role is not user, returning 404 (never a 403 existence oracle) — regardless of how that account came to be non-user (a dashboard promotion or Groups → role mapping). Its job is purely sequencing: SCIM must never be able to deprovision an elevated account without an explicit, auditable demotion happening first. It is not a promise that a manually-elevated account stays elevated — see Model A below for what actually governs durability of a manual role change on a SCIM account. Because Groups → role mapping can now put a SCIM-provisioned user at role=admin through ordinary group membership (not just a manual dashboard promotion), a user who is admin via group membership cannot be SCIM-deprovisioned until the IdP first removes them from the admin group — that removal demotes them back to user via the next recompute_scim_user_role call, at which point the next deactivate/delete call proceeds normally. If that user is the last protected administrator the demotion is refused instead: the role stays admin and the 404 persists until another active local administrator account (role admin) exists AND the user’s role is demoted by hand, signed in as that other administrator (POST /api/settings/users/{username}/role with {"role":"user"}; a re-send of the Group change cannot do it: the member has already left the group) or by a later change that names the user (see docs/user-manual/rbac.md, “Last-Administrator guard for account deactivation and demotion” and “Group role recompute”). This was evaluated and accepted rather than special-cased: standard Okta/Entra offboarding already removes a departing employee from all group assignments (including any admin group) as part of unassigning the app, ahead of or alongside deactivating the user, so the required ordering matches the normal IdP-side offboarding flow rather than imposing an unusual one. Operator-facing guidance is docs/user-manual/scim-provisioning.md “Deprovisioning order matters for group-granted admins”.

Model A — IdP group membership is authoritative for a SCIM account’s role. For any SCIM-provisioned account, --scim-admin-group membership, not a manual dashboard edit, is the durable source of truth for role. A manual (out-of-band, e.g. dashboard) role change to a SCIM-provisioned account is reverted to the group-derived role on the next event that recomputes that user’s membership — i.e. a Group POST/PUT/PATCH/ DELETE that resolves to this user via recompute_scim_user_role, or a User reprovision (POST reviving a deactivated account) — not on a plain deactivate/delete (deprovision_role_ok blocks those against a non-user account outright, so they never reach a recompute), and not on server restart or on changing --scim-admin-group itself (both require a subsequent Group mutation to actually re-evaluate membership — see Recovery/alerting in docs/user-manual/scim-provisioning.md). Residual (carried from the Users slice, not new here): a manually-promoted SCIM account that is a member of no SCIM group is neither reverted by this mechanism nor SCIM-deprovisionable (deprovision_role_ok still blocks it) until an operator demotes it by hand — there is no membership-recompute event that would ever touch it, because recompute_scim_user_role only acts on users a Group mutation or reprovision actually resolves to.

Architectural constraints, by design (not gaps):

Threat model addition: a compromised IdP cannot elevate a local account. The provenance guard bounds Groups → role mapping to SCIM’s own accounts (see above); the group-parity design bounds it to exactly one configured group (see Role resolution above). Combined, a compromised or malicious IdP connector can grant admin only to accounts it itself provisioned, only via the single group an operator explicitly configured as --scim-admin-group, and never to a local admin or the break-glass account regardless of what it puts in a group’s membership list. Full change record: docs/security-reviews/scim-groups-role-2026-07-13.md.

Audit actions

Audit result is one of success | failure | denied — not ok/error. success is a completed mutation; denied is a guard refusing an otherwise-well-formed request (409 uniqueness, provenance/role mismatch, bad bearer token); failure is an internal error after the request was otherwise accepted (e.g. an audit-write or transaction failure that triggers a 500).

ActionResultWhen
scim.user.provisionedsuccessPOST /scim/v2/Users succeeds (new account, or a revived reprovision)
scim.user.provisioneddeniedPOST rejected 409 — userName collision against a currently-active account
scim.user.provisionedfailurePOST rolls back after a 500 (e.g. the account-creation transaction fails)
scim.user.updatedsuccess / failurePUT /scim/v2/Users/{id} succeeds / fails 500
scim.user.deactivatedsuccess / failurePATCH/PUT/DELETE sets the account inactive; failure (set-and-proceed) if the audit write could not persist. (#4966) A guarded deactivation that hit a store failure is also failure, answered 503, with detail starting account_state=unknown credentials_revoked=true (the linked credentials were revoked; the account may or may not be deactivated), and a failed durable session wipe after the deactivation is failure with account deactivated; durable session wipe failed (500)
scim.user.reactivatedsuccess / failurePATCH or PUT sets active:true. failure causes (#4966): the tombstone’s role could not be read (500, tombstone role unreadable before reactivation), the Administrator-grant read was unavailable (503, administrator grant check unavailable), or the account or mirror write failed (500). An elevated tombstone or a name holding an Administrator grant is a scim.user.provenance_denied (404), not a reactivated row
scim.user.deletedsuccess / failureDELETE /scim/v2/Users/{id} succeeds / audit-write failure (set-and-proceed). (#4966) A guarded deactivation that hit a store failure is also failure, answered 503, with detail starting account_state=unknown credentials_revoked=true (the linked credentials were revoked; the account may or may not be deactivated), and a failed durable session wipe after the deactivation is failure with account deactivated; durable session wipe failed (500)
scim.user.provenance_denieddeniedA deactivate/reactivate/delete/update targets an account whose provisioning_source != "scim", or whose current role != "user"; or (#4966) a POST /Users or an active:true reactivation names a username that already holds an Administrator grant or a deactivated record whose role is not user. Counted on yuzu_scim_provenance_denied_total for the same causes (including the group-recompute provenance skip, which writes no audit row; a FAILED provenance read in the recompute is not a provenance refusal and is recorded as an undetermined member instead)
scim.user.last_administrator_refuseddenied / partial(#4966) The last-Administrator guard refused a deactivation, delete, POST with active:false or group-derived demotion (409 mutability on the Users routes; a refused group-derived demotion is not a 409: the Group request keeps its 2xx and the member keeps the role). partial for a LATE refusal (the linked credentials were revoked before the guard refused); denied for an early refusal and for a refused demotion. Every refusal also raises a critical analytics event. Counted on yuzu_server_rbac_last_admin_guard_refused_total{transport="scim"}
scim.user.compensation_failedfailure(#4966) The guarded undo of a half-finished create/revive was refused or failed (outcome = guard_refused_last_administrator / deactivated_but_session_wipe_failed / store_failure). Written with a critical analytics event and yuzu_scim_compensation_failed_total{outcome}; the account is left active only for the guard refusal
scim.auth.denieddeniedBearer-auth validation fails (missing/malformed/wrong token) on any /scim/v2/* route, including discovery
scim.group.createdsuccess / denied / failurePOST /scim/v2/Groups succeeds / rejected 409 - displayName collision against an existing group / rolls back 500. On success, detail is recompute=complete or recompute=incomplete
scim.group.updatedsuccess / denied / failurePUT/PATCH /scim/v2/Groups/{id} succeeds / rejected 409 - rename onto an existing displayName / fails 500. On success, detail is recompute=complete or recompute=incomplete
scim.group.deletedsuccess / failureDELETE /scim/v2/Groups/{id} succeeds / audit-write failure (set-and-proceed). On success, detail is recompute=complete or recompute=incomplete
scim.group.recompute_incompletepartial(#4966, target type Group) A Group request’s role recompute did not apply every member as intended (a refused or failed demotion, an undetermined member whose read failed, or promotions skipped after a failed guarded write): the member count and, per class, the count and up to 20 usernames (the first 20 are named; the count is exact). Counted on yuzu_scim_group_recompute_skipped_total{cause}. The Group’s created/updated/deleted success row then says recompute=incomplete (recompute=complete otherwise)
scim.user.role_changedsuccess / failureA user’s role is recomputed to a new value (user create, or a Group create/replace/patch/delete affecting admin-group membership); records old_role→new_role, reason=group; a failure row for a role that WAS applied but whose durable session wipe failed carries role_changed=true session_wipe_failed=true, and a failure row for a guarded write whose outcome is not known (it may have committed) carries role_applied=unknown

All rows carry principal = "scim-service", principal_role = "scim-service". target_type is "User" for the user-lifecycle and role_changed rows, "Group" for the group-lifecycle rows.

Metrics

Prometheus counters (all in the yuzu_scim_* namespace):

MetricLabelsMeaning
yuzu_scim_requests_totalop, statusEvery /scim/v2/Users and now /scim/v2/Groups request, by operation (op = create|get|list|replace|patch|delete — the literal wire values scim_routes.cpp passes to record_request) and outcome bucket (status = 2xx|4xx|5xx). The three discovery documents (ServiceProviderConfig/ResourceTypes/Schemas) are NOT counted here (they carry no lifecycle op) — a rejected bearer against them still surfaces via yuzu_scim_auth_failures_total.
yuzu_scim_auth_failures_total—Bearer-auth failures on any /scim/v2/* route; pairs with scim.auth.denied audit rows.
yuzu_scim_audit_write_failures_total—An audit-log write itself failed. All lifecycle actions (provision, update, deactivate, reactivate, delete, group create/update/delete, role change) are set-and-proceed on this failure mode — the SCIM call still succeeded and the bump is the only record that the evidence row is missing; alert on this counter rather than expecting a 500 (see “All lifecycle audit writes are set-and-proceed” above).
yuzu_scim_provenance_denied_total—Provenance- or role-guard refusals; pairs with scim.user.provenance_denied audit rows.
yuzu_scim_role_changes_total—A user’s role was recomputed to a new value via Groups → role mapping; pairs with scim.user.role_changed audit rows (success).
yuzu_scim_group_recompute_skipped_totalcause(#4966) Group role-recompute members not applied in one request: promotion (skipped after the first failed guarded write; demotions are always attempted) or undetermined (a read the decision needed failed, so the role was left untouched); pairs with one scim.group.recompute_incomplete audit row per request.
yuzu_scim_role_change_failures_total-recompute_scim_user_role’s AuthManager::update_role_checked call reported a genuine AuthDB write failure, a role change that was decided but did not complete: the guarded write failed (the role may not have changed), or the role did change but its durable session wipe failed; pairs with scim.user.role_changed audit rows (failure). A sustained non-zero rate means role changes are silently not taking effect, alert on it distinctly from yuzu_scim_audit_write_failures_total (that counter covers a lost evidence row for an otherwise-successful mutation; this one covers the mutation itself failing).

Critical analytics events (#4966). Besides the audit rows, the last-Administrator work raises critical analytics events: scim.user.last_administrator_refused (a refused deprovision or group-derived demotion), scim.user.role_demotion_incomplete (a group-derived demotion that failed with a store failure or a failed durable session wipe) and scim.user.compensation_failed. The scim.user.role_demotion_incomplete event carries the fields role_applied and session_wipe_failed, each true, false or the string "unknown" (unknown when the guarded write may have committed and its outcome is not known; the matching audit row then carries role_applied=unknown). They are a no-op under --no-analytics; the audit rows and the counters are written every time.

Storage

SHIPPED (ADR-0006 Wave 3): ScimStore is now its own born-on-Postgres store, schema scim_store, alongside AuthDB’s auth schema — not a table riding inside auth.db any more. ScimStore’s resource-mapping table (scim_resources — the IdP-facing id/externalId ↔ Yuzu username mapping), its bearer-token table (scim_tokens — sha256 hashes only), and its Group tables (scim_groups/scim_group_members) all live under the scim_store Postgres schema, migrated by ScimStore’s own PgMigrationRunner instance, independent of (but constructed alongside) AuthDB’s auth-schema migration. ScimStore(pg::PgPool&) takes the same shared server PgPool AuthDB does — a distinct schema, not a distinct database or connection identity. This closed out the former SQLite-era auth.db-cohabitation design (see history below) as part of the same migration that moved AuthDB itself.

History (pre-migration; kept for context). Before ADR-0006 Wave 3, scim_resources/scim_tokens were a deliberate, dated, eyes-open exception to ADR-0006’s “no new server-side SQLite store” rule — they rode the existing SQLite auth.db file (their own "scim" MigrationRunner component) rather than standing up a new Postgres store, pending auth.db’s own eventual Postgres cutover. That cutover has now happened for both stores in lockstep — the exception is retired, not carried forward. See docs/postgres-migration-ladder.md’s auth/SCIM row for the shipped record.

SCIM ↔ OIDC identity linkage for deprovision (ADR-2001, CC6.8)

docs/adr/2001-scim-oidc-identity-linkage.md (Accepted). Closes a gap in the CC6.8 termination control above: a SCIM-provisioned user and their OIDC login identity are two disjoint auth.users rows (SCIM provisions username=<slug>; OIDC login always mints username="oidc:" + iss + "#" + sub and never adopts the slug), and every API/MCP token a federated user holds is minted on the oidc: principal, never the slug. Deprovisioning the slug alone (the pre-ADR-2001 behavior) therefore revoked zero of a federated user’s tokens while reporting a clean success — the exact silent-under-revocation gap this ADR closes. PR1+PR2+PR3 of the ADR’s delivery plan are all shipped (link formation, the revoke seam, D1, D2, and the deny-at-login backstop, ADR §4) — a deprovisioned linked identity can no longer re-authenticate via OIDC and mint a fresh session; see “Deny-at-login backstop” below for the exact deny sites, the fail-closed store-unavailable posture, and the honest (narrowed-not-eliminated) scope of the in-flight-deprovision race it self-heals.

Join key: --oidc-scim-link-claim. Configures which validated ID-token claim is compared against a SCIM resource’s externalId to form the link at login. Default sub; boot rejects any value outside the allow-list {sub, oid} fail-closed (main.cpp, CLI::IsMember) — never a silent fallback to an unvalidated claim. This is an operator decision per IdP, not a universal default:

IdPTypical externalId sourceCorrect --oidc-scim-link-claim
OktaThe OIDC sub claimsub (default — no flag needed)
Microsoft Entra IDThe AAD object id, which rides the ID token as the oid claim, not suboid (--oidc-scim-link-claim=oid)

Getting this wrong is not silent: it is exactly the condition the D2 detector (below) exists to surface. An IdP whose SCIM externalId shares no value with any OIDC claim Yuzu validates cannot have its federated tokens revoked by SCIM at all — there is no join key available for any --oidc-scim-link-claim setting to select; this is a fundamental limitation of the design (single trusted-issuer join on IdP-asserted claims only, ADR constraint 3), not a configuration mistake, and it too is surfaced via D2 rather than failing silently.

Link formation (login-time, fail-open). On a successful OIDC login, Yuzu compares the configured claim’s value against scim_resources .external_id. A link is recorded (in ScimStore’s dedicated identity_links table, keyed (iss, sub) unique, secondary-indexed on scim_id) only when exactly one active SCIM resource matches — zero matches is normal (no link, nothing to do); more than one match is treated as no link, not an arbitrary pick (ScimStore:: find_unique_active_by_external_id; the mis-link-prevention guard, ADR §2). The link write itself is fail-open — a write failure never fails the login, because a missing link is caught by the D2 detector below. Independently of whether a link formed, every OIDC login also records a durable observation of the claim value it presented (ScimStore:: record_login_observation) — this is what makes D2 possible at all.

Deprovision-time revoke. SCIM active:false (PATCH/PUT), SCIM DELETE, and the dashboard’s DELETE /api/settings/users/{username} all resolve the full principal set — the slug plus every oidc:<iss>#<sub> identity currently linked to it — and revoke API tokens (ApiTokenStore:: revoke_for_principal) and sessions for each principal in that set, before the account is marked inactive/deleted. On any revoke that fails to persist, the caller does not report a clean success: SCIM returns 500 (so the IdP retries) and the audit result is partial; the dashboard delete likewise refuses to proceed to remove_user. This mirrors /me’s own api_tokens_revoked=N/sessions_revoked=N detail-string pattern. Implementation: deprovision_revoke.{hpp,cpp} (the shared resolver/orchestrator, deliberately not a reuse of session_revoke_fn, which carries unrelated caller=self|admin metric semantics) and oidc_principal.hpp (the single oidc_principal_id(iss, sub) builder every call site uses — a hand-built format would silently miss every token for a principal built the “wrong” way).

The last-Administrator guard sits after the revoke (#4966). The account removal itself is AuthManager::remove_user_checked, which runs the guarded soft delete (RbacStore::deactivate_account) AFTER the credentials-first revoke above. A best-effort, lock-free early check (would_strand_last_administrator) runs BEFORE the revoke and, when it already shows the account is the last member of a protected administrator population, refuses with nothing revoked. When that check passes (or cannot answer) and the guard then refuses, the outcome is a late refusal: the credentials were revoked, the account stays active, the response and audit row say so (SCIM 409 mutability, dashboard 409 and a partial audit result). In both forms the person the identity provider terminated is not terminated here: after an early refusal their API tokens and sessions are still live, after a late one the account remains active in Yuzu, so any credential the identity provider still accepts for it works. A refused SCIM deprovision therefore raises a critical analytics event and the operator revokes manually or lets the identity provider retry once another active local account holds the Administrator grant; see docs/user-manual/rbac.md, “Last-Administrator guard for account deactivation and demotion”. The guard stays a recovery rule, not a proof that anyone can sign in.

The ~60s residual, stated honestly. A previously-issued API/MCP token may keep validating for up to ApiTokenStore’s validate-cache TTL (~60s) after the underlying revoke_for_principal call has already persisted. The revoke is durable, but that cache is process-local (token_cache_ / revoke_generation_), and revoke_token / revoke_for_principal invalidate only on the replica that served the call — so on that replica this is a narrow race against the eviction, while on every other replica holding a cached entry it is a deterministic window lasting out that replica’s own TTL. Add the (irreducible) IdP→SCIM propagation lag on top. Cookie sessions are not instant either, since HA WS-1a: validate_session routes to validate_session_durable, a generation-gated cache bounded by the 1 s generation refresh (kSessionGenRefreshMs) and widening to the 30 s stale-serve ceiling (kSessionGenStaleServeBoundMs) for as long as a Postgres brownout blocks refreshes. The honest guarantee is “revoked within ~60s of the deprovision reaching Yuzu,” not instant — do not describe this as instantaneous revocation.

D1 — a SCIM slug elevated to admin outside SCIM (the #2021 guard interaction). deprovision_role_ok still refuses (404, per the provenance/role guard above) to deprovision a slug whose current role is not user — including when that elevation happened via Groups→role mapping or a manual dashboard promotion. Post-linkage, that refusal now has a new consequence: the linked federated identity’s tokens are not auto-revoked either (auto-revoking on the IdP’s unilateral say-so would reopen exactly what #2021 defends against — a compromised or racing IdP tearing down an admin). ADR-2001 D1 keeps the refusal, but makes it loud whenever a linked identity actually exists to be missed: a human must terminate the federated identity manually (revoke its tokens from the dashboard, or demote-then-redeprovision). What actually fires, precisely (this is a real divergence from the ADR’s kCritical shorthand worth naming explicitly — see below):

  1. An AuditStore row, action scim.user.deprovision_role_refused_with_link, result="failure" — always, and no different in kind from any other audit row on this surface. AuditEvent has no severity column; a D1 audit row cannot itself be “critical” any more than a break-glass-login audit row can (same pattern there).
  2. The Prometheus counter yuzu_scim_deprovision_role_refused_with_active_link_total — always, unconditionally, alongside the audit row.
  3. A Severity::kCritical AnalyticsEvent (emit_scim_critical_event, scim_routes.cpp) — only when AnalyticsEventStore is wired, i.e. only when analytics event collection is enabled (--no-analytics is opt-out, so this is on by default unless explicitly disabled, but it is a product-analytics pipeline, not a dedicated security-alert channel, and a deployment that disables analytics loses this signal entirely).

Operator guidance: alert on (1)+(2), the metric and the result="failure" audit row — that is the primary, always-on D1 signal regardless of the analytics-collection setting. The kCritical analytics event is enrichment on top for a deployment that has analytics wired, not the detection mechanism itself. Do not build a detection rule that assumes an audit row can itself carry a severity level — filter on action= "scim.user.deprovision_role_refused_with_link" (or the metric), not on any notion of a “critical audit.”

D2 — the fail-loud detector for a mismatched/misconfigured link claim. Every OIDC login records both the sub and oid candidate claim values it observed, not only the value of the claim --oidc-scim-link-claim is currently configured to use. When a deprovision resolves a principal set of size 1 (slug only — no linked identity) but a recorded login observation shows the slug’s externalId matches either candidate value at some prior OIDC login, that is a real signal: the user did authenticate via OIDC, but the link never formed — almost always a misconfigured --oidc-scim-link-claim (or, per the worked-examples table above, an IdP whose externalId has no matching OIDC claim at all). Recording both candidates (rather than only the configured one) is what makes D2 able to catch the specific, common failure mode where the wrong claim is configured — e.g. an Entra deployment left on the default sub whose externalId actually matches oid — instead of a case where the configured-but-wrong claim’s value happens never to have been observed at all. This bumps yuzu_scim_deprovision_unlinked_total (ScimRoutes::maybe_flag_d2_unlinked). A non-zero rate here means some federated population’s tokens are NOT being revoked by SCIM deprovision today — investigate the flag value before trusting the CC6.8 claim for that population. D2 is a detection signal conditioned on a login-then- deprovision pair actually occurring in the observed window, not a standing guarantee — a zero rate means “nothing detected yet,” not “every federated user is provably linked.”

Deny-at-login backstop (ADR-2001 §4, PR3 — shipped). An OIDC login whose linked SCIM resource is deprovisioned is refused. ScimStore:: linked_resource_active(iss, sub) resolves the identity in one query — a LEFT JOIN from identity_links to scim_resources — and returns a LinkedResourceState{scim_id, active} tri-state: store-unavailable (the query itself could not be answered) is treated identically to a resolved inactive/orphaned link — fail-closed, deny; no identity_links row at all is a genuine non-match — proceed; a linked row whose scim_resources counterpart is gone (hard-DELETEd by a SCIM DELETE — identity_links is not FK-cascaded) or explicitly active=false — deny, naming the scim_id that drove it; a linked row with active=true — proceed. The LEFT JOIN is load-bearing: an INNER join would collapse the orphaned-link case into “no rows,” which reads as “no link” and would let a fully-deprovisioned identity re-authenticate — exactly the bypass this join shape exists to close.

oidc_login_denied_deprovisioned(scim_store, iss, sub) (oidc_scim_link.{hpp,cpp}) is the single pure decision function both call sites in /auth/callback share:

  1. Primary check, immediately after the OIDC principal is built and strictly before any mutation below it (group reconcile, session mint, provision_sso_identity, the ADR-2001 §2 link/observation writes, MFA amr seeding) — a denied login leaves no side effect behind.
  2. Post-mint re-check, run again immediately after create_oidc_session and strictly before the Set-Cookie header is written. If a concurrent SCIM deactivate/DELETE landed in the window between the primary check and the mint, this re-check catches it: it calls AuthManager::invalidate_user_sessions on the session just minted and denies — self-healing the check-then-mint race without holding a cross-store lock over the mint (which would violate the “never hold one store’s pool lease while calling another” discipline in §3 above).

Both deny sites emit the byte-identical /login?error=sso_failed redirect the existing token-exchange-failure branch uses (no “deprovisioned” wording reaches the browser — no oracle), a server-side audit row auth.oidc.deprovisioned_denied (result=failure, principal = the OIDC username), and increment the pre-seeded counter yuzu_auth_oidc_deprovisioned_denied_total. detail distinguishes the two denial causes rather than folding them into one reason: reason= linked_scim_resource_inactive plus ;scim_id=<id> when an actually resolved (deactivated or orphaned) SCIM resource drove the denial, versus reason=scim_store_unavailable (no scim_id — there is no resource to name; the store itself could not be asked) on the fail-closed store-unavailable path — this path denies every OIDC login while it persists, not only deprovisioned ones (docs/user-manual/scim-provisioning.md “Availability: a ScimStore/Postgres outage denies ALL OIDC logins”). On the post-mint re-check path only, detail additionally carries ;post_mint_recheck=true;sessions_invalidated=<N>, and ;db_persisted=false if the session-revoke write itself did not persist.

The honest guarantee — read this before describing CC6.8 as fully closed. Deny-at-login fully closes the dominant case: a re-login against an already-completed deprovision is refused, unconditionally — there is no window left to race once the deprovision itself has landed. It narrows, but does not eliminate by construction, the rarer in-flight-deprovision race: a login that authenticates and mints/refreshes its link strictly inside the gap between the primary check and the mint, concurrently with a deprovision landing in that same gap, is caught by the post-mint re-check in the overwhelming majority of timings — but a microsecond check-then-mint window remains theoretically possible and is deliberately not closed by lock-serialization (the cross-store-lock deadlock hazard above). That residual’s bound differs by credential kind — the two must not be collapsed into one figure. An API/MCP token caught in the race is bounded by the existing ~60s ApiTokenStore validate-cache window. A session that slips through is not on the same clock: AuthManager::validate_session re-checks only the session’s own expiry/idle timeout on every request, never SCIM-linked deprovision state, so a slipped session remains valid for up to the session’s own TTL (the absolute kSessionDuration, 8h by default, or a shorter configured --session-inactivity-secs idle timeout) — it is cut short early only if a subsequent deprovision call happens to land against the same identity, which an IdP is not guaranteed to send again once it believes the resource is already deactivated. Do not describe a slipped session as bounded by ~60s, and do not describe this residual overall as “the race has nothing left to win” — that overclaims what a lock-free, cross-store design can guarantee; see docs/adr/2001-scim-oidc-identity-linkage.md “Known residuals” for the full statement, including the forward caveat on single-primary Postgres reads (this guarantee assumes no read-replica routing).

New audit actions (ADR-2001)

ActionResultWhen
scim.user.deprovision_role_refused_with_linkfailureD1: a role-refused deprovision (deprovision_role_ok 404) for a slug with ≥1 active linked OIDC identity that was NOT auto-revoked
auth.oidc.deprovisioned_deniedfailure§4/PR3: an OIDC login was refused because its linked SCIM resource resolved deprovisioned (deactivated, orphaned) or because ScimStore could not answer at all (fail-closed). Emitted from /auth/callback, not a /scim/v2/* route. detail carries reason=linked_scim_resource_inactive;scim_id=<id> when an actually resolved resource drove the denial, or reason=scim_store_unavailable (no scim_id) when the store itself could not be asked, and on the post-mint re-check path only, post_mint_recheck=true;sessions_invalidated=<N> (+db_persisted=false if that revoke itself failed to persist)

The existing scim.user.deactivated/.deleted rows (see Audit actions above) now also carry api_tokens_revoked=N sessions_revoked=N principals=N in detail on success, and partial is now a possible result for those two actions specifically (a non-persisted credential revoke — see “Deprovision-time revoke” above), in addition to the existing success/failure. When the detail lists the revoked principals (revoked_principals=a,b,c), each principal string is tokenized before it is written (#4966, CWE-117): ,, space, =, control bytes and every byte

= 0x80 become _, and a string over 256 bytes is shortened. The tokenization is lossy: two distinct principals (for example two OIDC subjects differing only in a non-ASCII character) can collapse into one token, so a SIEM rule must not treat the list as an identity key; the exact identities are in the identity_links rows.

New metrics (ADR-2001)

MetricMeaningOperator action on non-zero
yuzu_scim_deprovision_role_refused_with_active_link_totalD1: a deprovision was refused (role != user) for a slug with an active linked federated identity — that identity’s tokens were NOT auto-revokedA human must terminate the linked federated identity’s credentials manually (revoke its tokens, or demote the account then let the next deprovision proceed normally). Alert on this alongside the existing yuzu_scim_provenance_denied_total.
yuzu_scim_deprovision_unlinked_totalD2: a deprovision found a login observation matching the slug’s externalId but resolved no formed link — almost certainly a misconfigured --oidc-scim-link-claim, or an IdP whose externalId has no corresponding OIDC claim (see the worked-examples table)Re-check --oidc-scim-link-claim against your IdP (Okta: sub; Entra: oid). If neither matches, this population’s federated tokens are not reachable by SCIM revoke by design — treat their manual revocation as a required step of the offboarding runbook until a shared claim exists.
yuzu_auth_oidc_deprovisioned_denied_total§4/PR3: an OIDC login was denied at /auth/callback because its linked SCIM resource resolved deprovisioned (deactivated, orphaned, or the store degraded) — the deny-at-login backstop actually firingA deprovisioned federated identity attempted to re-authenticate; confirm the deprovision was intentional. A sustained non-zero rate against one identity may indicate a termination the user (or their IdP session) has not yet noticed, or a store-degrade making the check fail closed — correlate with ScimStore/Postgres availability.

Residual risks / deferred (next slice)

Implementation: server/core/include/yuzu/server/scim_store.hpp + server/core/src/scim_store.cpp (storage layer), server/core/include/yuzu/ server/scim_json.hpp + server/core/src/scim_json.cpp (JSON codec + discovery documents), server/core/src/scim_routes.{hpp,cpp} (HTTP routes). Tests: tests/unit/server/test_scim_store.cpp, test_scim_json.cpp, test_scim_routes.cpp.

SAML ↔ SCIM identity linkage (ADR-2001 PR4a+PR4b, CC6.8)

docs/adr/2001-scim-oidc-identity-linkage.md (Accepted, SAML addendum). The SAML analogue of “SCIM ↔ OIDC identity linkage for deprovision” above, shipped as PR4a (link formation + deprovision-time revoke — the SAML counterpart of that section’s PR1+PR2) and PR4b (deny-at-login — the SAML counterpart of that section’s §4/PR3). Before PR4a, a SAML login’s session was keyed on the raw NameID alone, and no link to any SCIM resource was ever recorded, so a SCIM deprovision could not reach a SAML-authenticated identity’s session at all.

Stable principal: saml:<entity_id>#<NameID>. AuthManager:: create_saml_session now keys the session’s username (the authorization/ audit/revoke principal) on saml::saml_principal_id(entity_id, name_id) (server/core/src/saml_principal.hpp) — "saml:" + entity_id + "#" + name_id, mirroring oidc_principal_id(iss, sub)’s shape byte-for-byte and built through the same kind of single shared builder (both the session-mint site and the deprovision resolver route through it, so a hand-built copy at either site cannot drift from the other and silently miss a session on revoke). display_name stays the raw NameID — human-readable rendering only (dashboard, audit detail), never the authorization key. Both the NameID and the entity_id are sanitised at the ACS handler (non-empty, ≤255 bytes, no control bytes — the same rule OidcProvider::validate_claims applies to sub/oid) before either value enters the principal string or the link store; a malformed value fails the SAML login outright (redirect /login?error=saml, no session minted) — fail-closed, the same posture OIDC takes for the same class of durable-join-key input.

Single-IdP precondition — stronger than the OIDC side. SAML’s join key is the assertion’s NameID; unlike OIDC’s --oidc-scim-link-claim (which selects among candidate claims), there is exactly one candidate value and no per-issuer partitioning question, because Yuzu accepts assertions from exactly one pinned IdP (--saml-idp-cert + --saml-idp-entity-id, both already required — SamlProvider::is_enabled()). --saml-idp-entity-id is now additionally load-bearing for the principal build itself: it is the entity_id half of every saml:<entity_id>#<NameID> string, verified by SamlProvider::validate_response to equal the assertion’s signed <saml:Issuer> before the ACS handler ever reads it. This single-pinned-IdP shape is what makes a bare NameID→externalId match safe by construction — the multi-IdP partitioning caveat the OIDC section’s constraint 5 states does not apply here as written, because there is only ever one IdP to partition against.

The NameID Format contract — a NameID is a safe join key ONLY when it is STABLE and equals the SCIM externalId. SAML’s <NameID> element carries an optional Format attribute; SamlProvider::validate_response now reads it into SamlAssertion::name_id_format from the same XSW-verified assertion node as the NameID itself. A link to a SCIM resource forms only when the Format is one of the two STABLE URIs Yuzu treats as safe — urn:oasis:names:tc:SAML:2.0:nameid-format:persistent or the SAML 1.1 urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress (saml::is_linkable_name_id_format, saml_scim_link.hpp). A transient Format — re-minted per login by design — or an unspecified/missing Format is conservatively treated as not linkable; Yuzu never coerces or normalizes an unstable NameID into a linkable one. This is an operator configuration obligation, not something Yuzu can enforce on the IdP’s behalf: the operator must configure their IdP to emit a NameID that is both a stable Format and equal to the SCIM externalId it provisions that same user with. A NameID that is stable-Format but numerically different from the SCIM externalId (or an IdP left on transient) simply never forms a link — the SAML login still succeeds, but that identity is unlinkable, and therefore unrevocable via SCIM deprovision. This is the direct SAML analogue of the OIDC section’s D2 case (a mismatched/misconfigured join claim), and — as of #3072 (2026-08-14), SHIPPED — SAML now has its own D2-style observability, described in “SAML D2 observability (#3072)” below. Unlike OIDC there is still no --saml-scim-link-claim knob to misconfigure among several candidates (SAML has exactly one candidate join key, the NameID itself), so #3072’s detector shape necessarily differs from OIDC’s D2 — see that section for the honest scope of what it can and cannot attribute.

Link formation (login-time, fail-open). On a successful SAML login with a linkable NameID Format, Yuzu compares the NameID against scim_resources.external_id using the same find_unique_active_by_external_id exactly-one-active-match rule the OIDC side uses (ScimStore) — zero matches is normal (no link), more than one match forms no link (never an arbitrary pick). A formed link is recorded in a dedicated saml_identity_links table (ScimStore migration v4 — a separate table from OIDC’s identity_links, deliberately not a generalization of it, keeping this PR off the OIDC linkage schema surface), keyed (entity_id, name_id) unique with a secondary index on scim_id. The link write itself is fail-open — a write failure never fails the login, mirroring the OIDC side’s posture exactly (saml::link_saml_login_to_scim, saml_scim_link.{hpp,cpp}).

Residual — rotating the pinned trust anchor orphans existing links. Because a link row is keyed on the pinned IdP identifier (entity_id for SAML, iss for OIDC — part of the principal identity itself), rotating --saml-idp-entity-id (or, symmetrically, the OIDC --oidc-issuer) leaves every existing saml_identity_links/identity_links row keyed on the old value. saml_linked_resource_active(new_entity_id, name_id) then matches zero rows and reads as “no link → proceed”, so the deny-at-login backstop does not fire for an already-deprovisioned identity logging in under the new identifier. Crucially the backstop cannot re-arm itself via SCIM for such an identity: link_saml_login_to_scim re-forms a link only against an active SCIM resource (find_unique_active_by_external_id), and a deprovisioned resource is inactive — so the login keeps proceeding, and re-running SCIM deprovision does nothing (there is no active resource to re-link to). This is a deliberate, documented residual of the single-pinned-IdP linkage model, in the same family as the unstable-NameID residual below: within the designed envelope — one stable pinned identifier — the control holds; rotating that identifier is re-establishing the trust anchor. Across such a rotation the operator relies on the primary termination control — the IdP no longer issuing assertions for a terminated user — since the deny-at-login backstop (which exists precisely for the case where SCIM deprovision and IdP de-authorization are decoupled or lagging) is blind to a pre-rotation-deprovisioned identity until it is re-provisioned and re-links on a subsequent login. It is not a code gap the deny logic can close without abandoning the entity_id/iss-scoped link key (which is what keeps a SAML saml: principal and an OIDC oidc: principal from ever colliding on a shared NameID/subject).

Deprovision-time revoke: SAML has no API tokens, so revoke = session invalidation. resolve_deprovision_principals (deprovision_revoke.cpp) now runs a second pass alongside the existing OIDC one: for every row ScimStore::saml_links_for_scim_id(scim_id) returns, it adds saml::saml_principal_id(entity_id, name_id) to the principal set a deprovision revokes — fail-closed on that lookup’s own nullopt, exactly like the OIDC pass (a store blip must never be read as “no linked SAML identity”). Because SAML never mints API/MCP tokens (there is no revoke_for_principal call site keyed on a saml: principal — only create_saml_session mints anything for one), the practical effect of resolving a saml: principal into the revoke set is session invalidation only: the linked SAML session (if still live) is torn down; there are no SAML-keyed tokens to revoke.

Deny-at-login backstop — PR4b (#3066), SHIPPED. PR4a alone closed only the deprovision-time gap: an existing SAML session for a deprovisioned, linked identity is revoked. PR4b closes the login-time gap the same way §4/PR3 closes it for OIDC above — a deprovisioned SAML identity is now refused at /saml/acs, not merely torn down on the next deprovision pass. ScimStore::saml_linked_resource_active(entity_id, name_id) is the SAML analogue of linked_resource_active(iss, sub): a LEFT JOIN from saml_identity_links to scim_resources in one query, reusing the OIDC side’s LinkedResourceState tri-state shape — store-unavailable is fail-closed (deny), no linked row is a genuine non-match (proceed), an orphaned link (the scim_resources row hard-deleted) denies unless an active reprovision sibling exists for the same NameID (find_unique_active_by_external_id, the same reprovision rule §4 uses), and an explicitly-deactivated link denies unconditionally. saml:: saml_login_denied_deprovisioned(scim_store, entity_id, name_id) (saml_scim_link.{hpp,cpp}) is the single pure decision function both call sites in /saml/acs share — a primary check immediately after saml_principal is built and strictly before any mutation (link formation, session mint), and a post-mint re-check immediately after create_saml_session and strictly before Set-Cookie, which invalidates the just-minted session via AuthManager::invalidate_user_sessions and denies if a concurrent deprovision landed in the check-then-mint window — the identical self-healing shape §4 uses for OIDC. Unlike the OIDC side there is no separate link-claim parameter: SAML’s join key is always the NameID itself (see the NameID Format contract above), so the orphaned-branch reprovision check resolves against name_id directly.

Both deny sites emit the byte-identical /login?error=saml redirect every other SAML failure branch uses (no oracle), a server-side audit row auth.saml.deprovisioned_denied (result=failure, principal = the saml:<entity_id>#<NameID> string), and increment the pre-seeded counter yuzu_auth_saml_deprovisioned_denied_total. detail distinguishes the two denial causes exactly like the OIDC row: reason= linked_scim_resource_inactive;scim_id=<id> when a resolved (deactivated or orphaned-not-reprovisioned) SCIM resource drove the denial, versus reason= scim_store_unavailable (no scim_id) on the fail-closed store-unavailable path; the post-mint re-check path additionally carries ;post_mint_recheck= true;sessions_invalidated=<N> (+;db_persisted=false if that session invalidation itself failed to persist). PR4b inherits the --scim-enable gate for free — /saml/acs reads the same AuthRoutes::scim_store_ member the SCIM routes already null-check, so with SCIM off the decision function receives a null store and unconditionally proceeds; there is no new feature-off SAML login outage, only the same store-availability coupling §4 already documents for OIDC while --scim-enable is on.

The honest guarantee — read this before describing SAML CC6.8 as fully closed, exactly as the OIDC section above asks. Deny-at-login fully closes the dominant case for SAML too: a re-login against an already-completed deprovision is refused, unconditionally. It narrows, but does not eliminate by construction, the same in-flight-deprovision race described for OIDC above, for the identical reason (the cross-store-lock deadlock hazard) — the post-mint re-check self-heals the overwhelming majority of timings, and a microsecond check-then-mint gap remains theoretically possible. SAML mints no API/MCP tokens, so there is no ~60s validate-cache bound to lean on for a slipped session on this side; a SAML session that does slip through the race is bounded only by the session’s own TTL (the absolute kSessionDuration or a configured --session-inactivity-secs), the identical residual the OIDC section states for a slipped session. Test coverage caveat, shared by both providers, stated once: this codebase has no mock-IdP integration harness exercising a live /saml/acs or /auth/callback round trip end to end — saml_login_denied_deprovisioned and oidc_login_denied_deprovisioned are each covered by direct unit tests against the decision function (test_saml_scim_link.cpp, test_oidc_scim_link.cpp), and each call site’s wiring into its route handler (ordering relative to link formation, mint, and Set-Cookie; the audit-detail construction; the redirect) is verified by code inspection rather than an end-to-end test — an existing limitation of both backstops, not something PR4b newly introduced. See docs/adr/2001-scim-oidc-identity-linkage.md’s SAML addendum item 8 for the full statement.

Implementation: server/core/src/saml_principal.hpp (the single saml_principal_id(entity_id, name_id) builder), server/core/src/ saml_scim_link.{hpp,cpp} (login-site link orchestration + the NameID Format gate), server/core/include/yuzu/server/scim_store.hpp + scim_store.cpp (the saml_identity_links table, migration v4), server/core/src/ deprovision_revoke.cpp (the SAML second pass), server/core/src/ saml_provider.{hpp,cpp} (SamlAssertion::name_id_format extraction). Tests: tests/unit/server/test_saml_principal.cpp, test_saml_scim_link.cpp, test_saml_provider.cpp, test_saml_routes.cpp, test_scim_store_pg.cpp, test_scim_routes.cpp, test_auth_sso_identity.cpp.

SAML D2 observability (#3072, SHIPPED 2026-08-14)

Before #3072, SAML’s version of D2 was a genuine, stated gap: unlike OIDC — which has a --oidc-scim-link-claim knob an operator can misconfigure among several candidate claims, and therefore a candidate to detect a mismatch against — SAML has exactly one join key (the NameID), so there was nothing to record a “should-have-matched” observation about. #3072 closes that gap with a SAML-shaped detector, not a copy of OIDC’s: it splits the signal across login time (three new, always-on, observe-and-proceed signals) and deprovision time (one D2-style tripwire), because the two catch genuinely different failure shapes on SAML — see “Honest scope” below.

New table: saml_login_observations (ScimStore migration v5). The SAML analogue of oidc_login_observations (§“D2” above) — keyed (entity_id, name_id, name_id_format) unique, secondary-indexed on name_id (saml_observation_matches looks up by name_id alone, which the 3-column unique key does not serve). name_id_format is deliberately part of the uniqueness key rather than folded into (entity_id, name_id) alone: a later login presenting the same NameID value under a stable Format must not silently overwrite — and so erase — an earlier observation recorded under an unstable Format; each (entity_id, name_id, format) triple is its own row. Bounded upsert (seen_at refreshed on every login, one row per distinct NameID+Format pair) — no GC/retention obligation, matching oidc_login_observations’ no-GC posture.

ScimStore::record_saml_login_observation(entity_id, name_id, name_id_format) records every SAML login’s NameID observation, including an unstable-Format one — it is called before the linkable-Format gate in link_saml_login_to_scim, unconditionally. Observe-only: the NameID is never normalized here, and this call never influences whether a link forms. ScimStore::saml_observation_matches(name_id) returns a tri-state std::optional<bool> mirroring observation_matches’ OIDC contract: nullopt means the store could not answer (closed, lease timeout, failed statement) and the caller must skip rather than false-positive a “never seen”; engaged true/false are a genuine seen/not-seen answer.

find_unique_active_by_external_id_checked — the store-error-aware lookup #3072 needed to build the login-time signals. ScimStore::find_unique_active_by_external_id (used by both OIDC and SAML link formation) always collapsed “zero matches”, “ambiguous (>1) matches”, and “the store could not answer” into the same nullopt — sufficient for link formation’s fail-open posture, but not enough to drive a distinct audit verb per cause. The new find_unique_active_by_external_id_checked returns a 4-state ActiveExternalIdLookupResult{status, resource} — matched / no_match / ambiguous / store_error — with the identical underlying query and the identical ADR-2001 §2 mis-link guard (no LIMIT 1; more than one row is ambiguous, never an arbitrary pick). The plain find_unique_active_by_external_id is now a thin, byte-unchanged compatibility wrapper over it (matched → the resource, every other status → nullopt) — every pre-existing caller (OIDC link formation, both providers’ orphan/reprovision checks, SAML link formation, deny-at-login) keeps its exact prior behaviour.

link_saml_login_to_scim now returns a typed SamlScimLinkOutcome (saml_scim_link.hpp) instead of void — not_linkable / linked / no_active_match / ambiguous_match / lookup_store_error / link_write_error — so POST /saml/acs can drive per-outcome audit/metric signals without re-deriving the cause. Every value is still a proceed outcome for the login itself: login-time linking stays fail-open by contract, unchanged from before #3072 (the PR4b deny-at-login backstop above is the only SAML-side path that can refuse the login).

Two new login-time audit verbs at POST /saml/acs, observe-and-proceed — these are NOT denies. The SAML login still succeeds on every branch below; only the audit trail and a counter change:

::linked, ::not_linkable, and ::link_write_error keep the pre-#3072 behaviour — no new login-time audit row (link_write_error still bumps the pre-existing yuzu_scim_saml_link_write_failures_total inside link_saml_login_to_scim itself, unchanged).

Deprovision-time D2: maybe_flag_saml_d2_unlinked. The SAML analogue of maybe_flag_d2_unlinked (§“D2” above) — fires when a deprovisioned resource’s externalId has zero linked SAML identities (ScimStore::saml_links_for_scim_id) but a recorded SAML login observation shows a NameID matching that externalId. It queries saml_links_for_scim_id specifically — never OIDC’s links_for_scim_id or a principals.size() proxy — mirroring the PR4a C2 lesson maybe_flag_d2_unlinked already learned: an OIDC link coexisting on the same scim_id must never mask a missing SAML link, and vice versa. Bumps the new yuzu_scim_deprovision_saml_unlinked_total counter; a store-error saml_links_for_scim_id/saml_observation_matches read is skipped rather than risking a false positive off an unconfirmed read (the identical fail-safe posture maybe_flag_d2_unlinked takes).

Honest scope — read this before treating #3072 as SAML’s D2 in full. The login-time signals and the deprovision-time D2 tripwire are complementary, not overlapping, because SAML’s single-candidate-NameID model forces a split OIDC’s multi-candidate-claim model does not need:

Two further residuals (governance hardening round, UP-2/UP-3). UP-2 — the observation write is fail-open, like every other write on this path. If record_saml_login_observation itself fails (a ScimStore blip during the login window), the login still proceeds and that login is simply never recorded, so a later deprovision’s D2 tripwire cannot fire for it — the same shape as a missed link write. Not a security regression versus pre-#3072 (D2 is a detective control; deprovision-time revocation itself is unaffected), and it is itself surfaced: the observation write and the link write share yuzu_scim_saml_link_write_failures_total, so a sustained non-zero rate there is the honest signal that D2 coverage — not only linkage — is degraded for that window. UP-3 — no GC, and the deprovision-time match is entity/format-agnostic. saml_login_observations rows are never pruned, a deliberate choice mirroring oidc_login_observations: the table is a bounded upsert keyed on distinct identities, not one row per login event, and it is durable CC6.8 evidence rather than regenerable scratch data — the ResultSetStore pruning model does not apply here. Separately, saml_observation_matches matches on name_id value alone (WHERE name_id = $1, no entity_id/name_id_format predicate) — safe under the single-pinned-IdP precondition already stated for this addendum, but worth naming explicitly: maybe_flag_saml_d2_unlinked therefore fires on any recorded NameID-value match regardless of the Format it was observed under, which makes the tripwire slightly broader than “catches the unstable-Format-but-value-matches case” alone — an under-statement in the framing above, not a false guarantee, since the detector is strictly more protective than described (it would also, for example, catch a stable-Format value-match whose link write itself failed). A future multi-entity_id SAML deployment would need an entity_id predicate added to saml_observation_matches before this match stays safe, the same way constraint 5’s forward caveat already requires for OIDC’s issuer partitioning. yuzu_scim_deprovision_saml_unlinked_total remains a review signal, not a hard alarm with one root cause.

New metrics: yuzu_scim_saml_link_unmatched_total, yuzu_scim_saml_link_ambiguous_total, yuzu_scim_saml_link_lookup_failures_total, yuzu_scim_deprovision_saml_unlinked_total — see docs/user-manual/metrics.md “SCIM deprovision-linkage metrics” for the operator-facing description of each, and docs/user-manual/rest-api.md’s SCIM audit-actions table for the two new audit verbs.

Implementation: server/core/include/yuzu/server/scim_store.hpp + scim_store.cpp (saml_login_observations table migration v5, record_saml_login_observation, saml_observation_matches, find_unique_active_by_external_id_checked), server/core/src/ saml_scim_link.{hpp,cpp} (SamlScimLinkOutcome, the unconditional observation write), server/core/src/auth_routes.cpp (the two login-time audit/metric branches at /saml/acs), server/core/src/scim_routes.cpp (maybe_flag_saml_d2_unlinked), server/core/src/server.cpp (the four counter registrations). Tests: tests/unit/server/test_saml_scim_link.cpp, test_scim_routes.cpp, test_scim_store_pg.cpp.

Granular RBAC (Phase 3)

ProductPack securable seeding fix (#4029)

ProductPack was used as an RBAC securable-type string by the shipped /api/product-packs* routes (workflow_routes.cpp, perm_fn(req, res, "ProductPack", "Read"/"Write"/"Delete")) but was never seeded into RbacStore::seed_defaults()’s types[] array or mcp_server.cpp’s mirrored kRbacSecurables[]. role_permissions.securable_type carries a hard FK to securable_types(name), so no role — not even Administrator — could ever be granted ProductPack:* while RBAC was enabled; the routes were reachable only via the RBAC-off legacy fallback or an elevated-session bypass. Fixed as part of #4029 (the same class of gap #2376’s EnginePrincipal cut fixed for a different securable, see above): types[] now includes ProductPack (Administrator gets full CRUD for free via the existing cross-type loop), and Read is additionally granted to Operator/PlatformEngineer/Viewer — the same population that already holds InstructionDefinition:Read, for consistency across the content/catalog domain. Write/Delete stay Administrator-only (the issue’s ask was scoped to the read twins; a follow-up could widen this if an operator role needs to author/uninstall packs directly). #4032 tracks the identical gap for Workflow and Directory, fixed by sibling issues in their own PRs — this fix touches ProductPack only.

The authorization topology floor (#2376)

The defect. RbacStore::rbac_enabled_ defaults false, so a fresh install runs with RBAC off — the default posture, not an edge case. With RBAC off, AuthRoutes::require_permission/require_scoped_permission fall through to a legacy branch (server/core/src/auth_routes.cpp, both functions, :713/:919) that allows every Read to any authenticated non-engine session (see the rbac_enforcement_in_effect gate at ~:699, which is the branch above the legacy fallback — a live RBAC grant is always consulted first and returns before the legacy branch is ever reached; the legacy branch is reachable only when RBAC is disabled or the store is unwired). On a default install that gave a plain user session read access to the authorization TOPOLOGY itself: the fleet-wide access-review export (the surface that exists to be SOC 2 CC6.2 evidence — see “Periodic access reviews” below), GET /api/v1/rbac/roles (the RBAC role graph), and the engine-principal grant graph (GET /api/v1/engine-principals* and its MCP twins).

The fix has three parts, all keyed off the single server/core/src/authz_topology_floor.hpp chokepoint:

  1. A new EnginePrincipal securable (Read only, so far), cut away from the over-broad Security:Read. The engine-principal inventory and grant-graph reads moved onto it: REST GET /api/v1/engine-principals, GET /api/v1/engine-principals/{id}, GET /api/v1/engine-principals/{id}/roles, and the MCP twins list_engine_principals, get_engine_principal, list_engine_roles. This is the same move #2324 made cutting AccessReview away from AuditLog:* (see “Periodic access reviews” → “#2225 round 2” below) — the same shape of over-broad-securable defect, closed the same way. Seeded (RbacStore::seed_defaults()) to Administrator (full CRUD via the existing cross-type loop) and Viewer (Read) — exactly the two built-in roles that reached these routes via Security:Read before the cut, so no built-in role’s authority widens or narrows under RBAC-enabled enforcement; only the RBAC-off legacy posture changes (see below).
  2. The topology floor itself: {AccessReview:Read, UserManagement:Read, EnginePrincipal:Read} — plus, as of #4028, {TlsConfig:Read, PluginSigning:Read, ServerConfig:Read, AnalyticsConfig:Read} (see “Settings read-twins” below), as of #4031, {Enrollment:Read, OidcConfig:Read}, as of Wave 7 PR7.2, {Forensics:Read}, and as of #5771, {AuditLog:Read} (see “The audit trail” below) — require the admin session role regardless of the RBAC on/off toggle, via authz_topology_floor.hpp’s topology_floor_applies(). The groups are different categories that happen to share this one mechanism: the original three are authorization-topology reads (the RBAC role graph, the engine-principal grant graph, the access-review export) that intentionally stay reachable by an admin-owned session on ANY transport, MCP tokens included, per the legacy-role-fallback note below; the #4028 four and #4031 two are server-administration reads that #520 additionally excludes from every MCP tier outright (mcp_policy.hpp’s tier_allows()), so an admin-owned MCP token cannot reach them even though it would otherwise satisfy this same floor check. AuditLog:Read is a further class — evidence-grade data, neither topology nor a gate migration — and, like the original three and unlike the #4028/#4031 reads, is NOT excluded from MCP tiers: an admin-owned MCP token still reads it through query_audit_log where its tier already allowed that. (Directory deliberately has no floor entry, since it was never admin_fn_-gated to begin with — list_directory_users and most of get_directory_status’s payload stay reachable at Viewer role and readonly MCP tier. One field is the exception: groups[].mapped_role on get_directory_status — the AD-group -> Yuzu-role authorization map, the same data class as the floored OidcConfig admin_group field (colleague review on #4176 caught this inconsistency: mapped_role was newly MCP-reachable at readonly tier and newly Viewer-reachable under RBAC-on with no floor treatment at all, despite the sibling OidcConfig field being floored in this same PR). Flooring all of Directory:Read was rejected — it would also demote list_directory_users (lower-sensitivity PII, not authorization topology) to admin-only under RBAC-off. Instead directory_status_json’s reveal_mapped_role parameter redacts just that field to the empty string for a non-admin caller, checked via the same auth::effective_role(session) == auth::Role::admin test the topology floor itself uses — REST v1, the legacy route, and the MCP tool all compute it independently at their own call site, since the floor mechanism only gates whole-route (securable, operation) pairs, not individual response fields.) It is consulted only inside the legacy (RBAC-off) fallback of require_permission/require_scoped_permission — never ahead of, or instead of, the live-RBAC branch. That ordering is load-bearing, not incidental: #2324 cut the dedicated AccessReview securable specifically so a non-admin Reviewer role could be seeded AccessReview:Read and reach the export without being admin. If the floor ran ahead of the live-RBAC branch (or replaced it), it would deny that seeded Reviewer grant and destroy the entire reason the AccessReview securable exists. Because the live-RBAC branch in both functions always returns (true or false) before the legacy branch’s code is reached, “floor only in the legacy branch” is structural, not a convention that could silently drift — there is only one path into the floor check.
  3. Observability. A floored denial gets a distinct audit reason — "topology floor: non-admin role denied <securable>:<operation>" on the auth.permission_required/auth.scoped_permission_required audit actions, result=denied — instead of the generic "non-admin role denied ..." an ordinary legacy write/delete/execute/approve denial gets. It is also counted separately in yuzu_auth_topology_floor_denied_total{permission}, so an operator can alert on floored denials specifically rather than parsing audit-log text.

The audit trail (#5771). AuditLog:Read was the one evidence store left open by default: with RBAC off, any signed-in session read the full trail through seven surfaces — GET /api/audit (which also returns the actor’s role and source IP), GET /api/v1/audit, GET /api/v1/audit/auth-sample, GET /api/v1/engine-principals/audit/no-admin, MCP query_audit_log / audit_engine_no_admin, and the yuzu://audit/recent resource. All seven gate on the same ("AuditLog", "Read") pair through require_permission, so the single floor entry covers them; there is no per-route change and no second list. Engine principals and service-scoped tokens were already refused with RBAC off and are unchanged. With RBAC on nothing changes: every seeded holder (Administrator, PlatformEngineer, Operator, ITServiceOwner, Viewer) and any custom role granted the pair is admitted, and — as before — a holder reads every row, because the audit surfaces are not yet management-group confined (ADR-0017 lists them as a later step). Deliberately NOT done here: a dedicated securable for the sensitive fields (source IP, actor role) so a Reviewer-style role could read audit evidence under RBAC, and field-level redaction by caller role — both are separate, separately-decidable follow-ons.

Deliberately NOT floored, on purpose:

The floor is not configurable — there is no toggle that widens it back open; a footgun that can re-open a security floor is not a feature. See docs/security-reviews/authz-topology-floor-2026-08-05.md for the full recorded decision, including why the floor is keyed on (securable, operation) rather than route path (an MCP tool call and an unrelated MCP tool call share the same /mcp/v1/ wire path, so a route-keyed floor cannot distinguish list_engine_roles from list_issued_certs), and why Viewer keeps EnginePrincipal:Read under RBAC-enabled enforcement (a narrower RBAC-on role model is a separate decision from closing the RBAC-off gap, not taken here).

No schema migration. seed_defaults() runs unconditionally on every RbacStore construction and its seed loops are INSERT OR IGNORE, so an existing deployment picks up the EnginePrincipal securable and its grants on the next boot with no migration required — the same mechanism that introduced AccessReview and SoftwareLicensing. A migration is needed only when a change cannot be expressed as an idempotent additive re-seed (rbac_store.cpp’s legacy SQLite v4 migration deletes rows, which is why it needed one — distinct from the PG schema’s own migration sequence, ADR-0041, currently at v3).

Render-time permission probes (capability hints)

Six AuthRoutes methods answer a permission question without writing a denial row for an interactive sign-in, for pages that decide what to DRAW: has_permission (the verdict only), probe_permission (the verdict with the gate’s own response kept: it receives the 401/403/503 the gate wrote, and the 503 appears only where the gate itself writes one, such as an engine principal with the store not open; an RBAC read that fails (breaker open, pool timeout, a failed role or permission query) is a 403 here and cannot be told apart from a refusal), probe_permission_unavailable_aware (probe_permission with one difference: such a failed RBAC read is answered with a retryable 503, retry_after_ms 5000 and no permission field. It exists for the one consumer whose correctness depends on telling “could not determine” from “definitively denied”, the Forensics-tier resolver, where a collapsed answer would serve a 200 with the Forensics rows silently missing; a new caller needs the same justification, and require_permission itself stays a 403 on a degrade, ADR-0041), probe_admin (require_admin’s decision), probe_fleet_read (require_fleet_read’s decision and scope) and probe_global_list_read (require_list_read’s decision, with a management-group- confined admission refused as a denial, for a resource that is not agent-scoped; its loud twin require_global_list_read is the gate of the plugin-config REST list). Each runs the gate’s own code under the thread-local AuditProbeScope. require_auth, the first step of every gate, records the session the gate authenticated in that scope, and audit_log withholds a row only when that session is an interactive cookie sign-in (denial_row_may_be_withheld: local, oidc or saml and not an engine principal). The decision rests on the one lookup that decided the verdict, never on a second one; the role.elevation.expired lifecycle row, written through audit_log_for_principal, is still written. A request whose gate session is a token or an engine principal keeps its row.

What is withheld is the audit row. The denial counters (yuzu_auth_topology_floor_denied_total, yuzu_auth_service_scope_default_denied_total, yuzu_server_rbac_read_degrade_total) still tick, and the store’s own read telemetry is untouched.

A probe is a rendering hint unless a route deliberately binds it as its gate: the Dashboard panel fragments and the Plugins and updates fragments do (a refused sign-in is shown a note and leaves no row), while an action route must re-check through require_permission or require_scoped_permission. An earlier design that threaded a Quiet argument through require_permission was dropped because nothing called it and it did not cover require_fleet_read.

Settings read-twins (#4028, api-parity programme #2146)

Eight /fragments/settings/* dashboard sub-areas (TLS, HTTPS, gateway, server-config, MCP, data-retention, analytics, plugin-signing) were gated only by AuthRoutes::require_admin — a whole-route role check, not an RBAC securable/operation pair, with no REST v1 twin and no RBAC-off fallback at all. #4028 migrated all eight onto four new securables, split along sensitivity lines rather than one blanket Settings:Read (the same reasoning EnginePrincipal above was cut for):

Each is Read-only today, seeded to Administrator only (via the existing cross-type CRUD loop in seed_defaults() — matching every other admin-only securable’s precedent, e.g. PluginConfig, PluginSecret, UploadGrant, PowerManagement above), deliberately absent from Viewer’s blanket read-list and every other role’s explicit grant list — this is a mechanical RBAC-ification of an already-admin-only gate, not a broadening. All four (securable, "Read") pairs are also added to authz_topology_floor.hpp’s kTopologyFloor[] (see that file’s own doc comment for why: migrating an admin-only gate onto a Read securable without flooring it would silently widen every one of these eight routes from admin-only to any-authenticated-user on an RBAC-off install, the out-of-the-box default) — extending that file’s floor beyond its original “authorization topology” framing to a second, related case: preserving an existing admin-only posture across the RBAC-off toggle.

The MCP question (#520). require_admin’s own comment states the deliberate design this issue had to resolve explicitly, not silently override: “MCP tokens are for fleet management (queries, instruction execution) and must not be used to administer the server itself (settings, users, TLS, OIDC).” #4028 ships all eight sub-areas REST-only — no MCP tool touches any of them — treating read-only settings visibility as a meaningfully different exposure than the fleet-query surface #520 was written to keep MCP confined to, but one that still requires its own reviewed amendment to #520 rather than a side effect of a routine twin PR. See MCP Server for the policy itself.

Shipping no MCP tool is not, by itself, sufficient to preserve #520’s intent, because these eight routes are also reachable over REST, and an MCP token can call any REST route its tier and role admit — an MCP tool registration and an MCP token’s REST reach are two independent things. The topology floor above requires admin role in the RBAC-off legacy fallback, but require_permission does not otherwise distinguish an admin-owned MCP token from an ordinary admin session: an MCP token carries its creator’s real legacy role there by design (see “The authorization topology floor” above), so an admin-owned MCP token — at any tier, including readonly — would satisfy the floor exactly like an interactive admin session would, unless something stops it earlier. Hardening this route off require_admin (which rejected every mcp_tier token outright, regardless of role) onto require_permission — now GET /api/v2/agent/plugin-policy, split from the still-require_admin-gated, deprecated /api/v1/ shape by #4144 — would have silently reopened it to admin-owned MCP tokens without an explicit second control. #4028’s actual enforcement point is mcp_policy.hpp’s tier_allows(): TlsConfig, PluginSigning, ServerConfig, and AnalyticsConfig are denied at every tier there (readonly/operator/supervised) for every operation, so require_permission’s tier check 403s an MCP token before it ever reaches the topology-floor/legacy-role fallback that would otherwise admit it. This deliberately puts these four securables in a different category from the pre-existing three topology-floor pairs (AccessReview/UserManagement/EnginePrincipal), which keep the admin-owned-MCP-token reachability described above by design (see the topology floor section) — those are authorization-topology reads, not server-administration reads, and #520’s language is specific to the latter. Coverage: test_mcp_server.cpp (“no tier admits the #4028 server-administration securables”) pins tier_allows() directly; test_auth_routes.cpp (“no MCP tier … reaches the #4028 settings-administration securables”) exercises the same guarantee through the real require_permission() end-to-end path, with an admin-owned readonly-tier token and RBAC disabled — the exact combination that would otherwise have passed.

Analytics also fixed a leak, not just added RBAC. render_analytics_fragment (and the new GET /api/v1/settings/analytics twin) previously rendered the ClickHouse URL verbatim, masking only the separate clickhouse_password field — a URL with embedded userinfo credentials (clickhouse://user:pass@host:9000/db) leaked the credential regardless. The shared builder now strips URL userinfo unconditionally (settings_model::sanitize_url_userinfo) before either surface ever sees it, and never reads the raw password into a response at all — only a clickhouse_password_set bool. Fix-round hardening (governance Gate 2-8, three rounds, plus two external adversarial-review passes, /home/<user>/advrev-4028) found the initial strip itself incomplete: an unescaped @, /, or ? inside the userinfo let part or all of a credential through, and a query-string credential form (?password=..., no @ at all) was not modeled. A first attempt tried to locate the authority boundary (the path-starting /) and search for @ only within it, widening past an embedded / via a “does this look like a host[:port]” heuristic — governance re-review found that unfixable (a digit-only password segment before the / is lexically identical to a real port, so the heuristic cannot tell them apart) and it shipped a regression on top of the bypass it was meant to close. A second design deleted that heuristic entirely — the userinfo delimiter became simply the LAST @ anywhere in the URL, with the query string or fragment dropped afterward — which deliberately over-strips when the path itself contains a later, harmless @ (.../db@table becomes .../table), an accepted trade-off since the alternative is a heuristic that can be fooled into leaving a real credential in place. That second design itself shipped with two further bypasses an adversarial-review round found: a schemeless URL whose query string contains a nested :// could fool scheme-boundary detection into skipping the strip entirely, and a query string that itself contains an @ could make the query-strip (which ran against the already-userinfo-stripped string) miss its own delimiter and leave a password fragment exposed.

Current (third) design closes both, plus one further gap the fix itself introduced. sanitize_url_userinfo bounds the scheme scan to a genuine RFC 3986 §3.1 grammar match (ALPHA *(ALPHA/DIGIT/"+"/"-"/".") immediately followed by "://", evaluated from position 0 only — never an unbounded search), and computes both the userinfo-ending @ and the query/fragment-start cut points against the ORIGINAL string, unioning the two removal ranges rather than mutating sequentially; when the two ranges overlap (an inherently ambiguous shape — a real ?-corrupted password vs. no userinfo at all with the query containing its own @), it over-strips to the scheme prefix, the same conservative resolution the design already applies elsewhere. A subsequent adversarial-review round of this exact fix (finding CDX-01) found the scheme scan itself accepted a DIGIT (or +/-/.) as the first scheme byte, contrary to RFC 3986’s ALPHA-first requirement — a schemeless credential URL whose “username” happened to be scheme-shaped and digit-led (e.g. 9name://pass@host:9000/db) had that digit-led prefix wrongly preserved as if it were a real scheme. Requiring the first scheme byte be a letter closed this. All three findings (g8b-sanitizer-scheme-boundary, g8b-sanitizer-query-at-ordering, and CDX-01) are closed and verified — compiled, linked against the real object, and exercised against every adversarial shape either external reviewer or this fix round constructed, including a 2,000,000-case fuzz run under ASan/UBSan. Regression tests for all three shapes live in tests/unit/server/test_settings_model.cpp. Treat this sanitizer as a hand-rolled, adversarially verified deny-list transform, not an RFC-3986-conformant parser — it has been through four design iterations and three rounds of independent review specifically because ad hoc string surgery on URLs is a narrow, easy-to-misjudge problem; a future editor changing this function should read its full round-by-round history in settings_model.cpp’s header comment before touching it again.

On-behalf-of assertions rejected (ADR-1005 Interim rules)

Until server-verifiable delegation ships (ADR-1005 auth follow-up), the server accepts no on-behalf-of assertion on any ingress surface — any such header or metadata key is rejected, not ignored. Five names are reserved (case-insensitive; source of truth server/core/src/on_behalf_guard.hpp, and the list only ever grows): On-Behalf-Of, X-On-Behalf-Of, X-Yuzu-On-Behalf-Of, X-Yuzu-Delegated-Operator, X-Yuzu-Delegation-Artifact.

Enforcement: HTTP — checked in the pre-routing chokepoint (server.cpp) before auth, the allowlist, and the rate limiter, so REST, MCP, dashboard fragments, and static files all reject with 403 + the A4 error envelope. Recorded exception (the only one): the four liveness/readiness probe paths (/livez, /readyz, /health, /api/health) are exempt — a mesh/SSO proxy that stamps a reserved header on every request must not be able to fail the probes and crash-loop the server (governance CH-3/UP-5); a probe performs no identity-bearing action and nothing consumes the header on that path. The same four paths are also served (GET and HEAD only, no request body) on the plaintext HTTP→HTTPS redirect listener, with session resolution bound to none there: /health and /api/health always return the unauthenticated shape, no on-behalf-of guard runs on that listener either, and every other request 301s to the guarded main listener. This exception is recorded in ADR-1005’s exception ledger (2026-07-07, amended 2026-10-15). gRPC — a single server interceptor on the one ServerBuilder (grpc_on_behalf_interceptor.hpp) covers the agent, management, and gateway-upstream services and every future RPC method by construction; a call carrying a reserved metadata key is cancelled via ServerContext::TryCancel() (client observes CANCELLED). TryCancel() alone does not stop a handler from running, so it is paired with an enforcement seam (grpc_on_behalf_enforce.hpp): the first statement of every RPC handler on AgentServiceImpl and GatewayUpstreamServiceImpl independently re-derives the same reserved-key check from context->client_metadata() (not ServerContext::IsCancelled() — its propagation from TryCancel() is not synchronous with handler dispatch, confirmed by an end-to-end test) — the call is a true no-op on that surface, not merely a wrong status code on an already-committed side effect. Gateway note: the Erlang gateway’s own agent-facing listener reads only its known metadata keys and mints fresh upstream calls, so a reserved key sent to the gateway is dropped rather than rejected — tracked as a follow-up (Erlang-side reject, #1974) rather than an exception. Consequence for GatewayUpstreamServiceImpl’s own enforcement: today’s gateway never forwards the agent’s original gRPC metadata onward, so an agent cannot smuggle a reserved key through it — the ProxyRegister/etc. handlers’ onbehalf::enforce() guard is currently defense-in-depth against the gateway’s own client, not a proven end-to-end control against relayed agent metadata. If a future change adds metadata passthrough (e.g. for tracing), it must re-derive the reserved-key check against the forwarded metadata explicitly — the guard does not do this automatically. Rejections are counted in yuzu_onbehalf_rejected_total{surface,event="security"}; there is deliberately no audit row (the rejection fires pre-auth, so there is no resolved principal to attribute — the metric is the signal). Accepted evidence limit: the warn log is sampled (1 per 100 rejections per surface, a deliberate pre-rate-limiter flood defence), so the counter proves how many attempts occurred while only ~1% carry a forensic log line with timestamp/source — state this plainly if asked “can you show every attempt”. When delegation ships it will use a server-issued artifact, never a client-asserted header, so these names stay rejected on client ingress permanently.

API tokens and automation

Device tokens (DeviceTokenStore, ADR-0052, capability 18.8) — currently dormant, docs/user-manual/rest-api.md “Device Tokens”

Standing invariant: token presenter MUST equal token subject — a device token’s device_id (the agent it is bound to) is the only identity validate_token checks a presenter against; principal_id (the operator who issued it) is never part of that check. #823’s re-registration defence — every token bound to an agent’s device_id is revoked when that agent re-registers, closing the window an mTLS-disabled impersonation (#779) could otherwise exploit to replay a stolen token — is keyed on this same device_id column (revoke_by_device, #3401). Fails closed on a genuine revoke failure (ADR-0012 §1): the registration is refused, not merely logged.

Service-scoped token fleet-wide confinement — durable default-deny (guardian-confinement-2298 PR 3, “the flip”)

A service-scoped API token is bound to one IT service’s agents (session->token_scope_service non-empty on the resolved session) — created so an integration’s credential reaches only the devices tagged to its own service, not the whole fleet. A recurring gap closed across several earlier branches: a confinement check keyed on username, role, or resource ownership never actually consulted the token’s own service-tag scope, so a service-scoped token could reach fleet-wide data or, on a few mutating surfaces, fleet-wide actions. Those earlier fixes (PR 1 role cap, PR 2 Phase 0 primitives, PR 2 gate renames) capped the blast radius and built the primitives; this PR flips the underlying security posture from admit-by-default to deny-by-default, closing the pattern structurally rather than instance-by-instance.

Two authority axes compose, never supersede. Management-group visibility (RbacStore::authorize_list_read, ADR-0017) and service-scope visibility are independent — a session’s effective reach is the intersection, via authz::meet, never one axis overriding the other. Service scope is never left unfiltered because “some other axis already checked.”

The default-deny flip, AuthRoutes::require_permission’s service-scoped branch: ITServiceOwner remains the authority ceiling — a service token can never exceed what that role grants — but is no longer the sole gate. A (securable_type, operation) pair must also clear the seeded-empty kServiceScopeGlobalSafe allow-list (service_scope_policy.hpp) to be exercised fleet-wide/unconfined; everything else 403s by default now. Branch order is fixed and load-bearing — elevated → engine → mcp_tier → service → RBAC-enforced-legacy → legacy — and must not be reordered: the elevated branch is guarded (is_elevated && token_scope_service.empty()) so an elevated service-scoped session can’t bypass the flip; the engine branch gets its own belt-and-braces default-deny consult (mint already rejects a non-empty scope_service on an engine token — this guards a corrupted row only); and the mcp_tier branch never return trues on its own — it only denies or falls through into the service branch below it, so a token carrying both mcp_tier and scope_service still hits the same default-deny check (verified during PR 3 review — see the commit history on this file for the specific trace). A small testonly override seam (AuthRoutes::set_service_scope_global_safe_override_for_test) exists because the allow-list ships empty, which would otherwise leave the admit branch of both this check and MCP’s mirror (below) untestable dead code.

AuthRoutes::require_scoped_permission’s service branch and AuthRoutes::require_fleet_read both apply the same standardized rbac_enforcement_in_effect predicate (not raw is_rbac_enabled()) and the same RBAC-off hard-403 posture for a service-scoped session — require_fleet_read deliberately does NOT narrow to a tag-scoped view when RBAC is off; it denies outright, matching require_permission’s posture rather than diverging from it.

Two deliberately separate route classes (closes #3218 — do not merge these gates): require_list_read is the sole gate on fleet-wide rollup routes and refuses a service-scoped session outright (no per-service slice to narrow a rollup to). require_fleet_read REPLACES require_permission (never pairs with it — see its own doc comment’s BLOCKING falsifier) on routes migrating toward real per-request confinement (Phase 2 — see below); its own doc comment and require_list_read’s cross-reference each other’s route class so a future reader doesn’t reach for the wrong one. require_fleet_read never consults kServiceScopeGlobalSafe — a migrated route serves confined service-scoped tokens directly through the gate’s own meet(management-group, service-scope) composition, instead of the flip’s allow-list route.

MCP’s mirror: the C8 ServiceScopeClass chokepoint (§3c). Every tools/call dispatch consults a third field on the tool’s kToolSecurity row — denied (the default), confined (a real downstream confinement mechanism exists — deny_fleet_wide_service_scoped, a scoped_perm_fn check, a fail-closed exec_visible derivation, or (as of #3290) fleet_read_fn_/require_fleet_read’s meet(management-group, service-scope) composition), or global_safe (boot-validated against the same kServiceScopeGlobalSafe table require_permission reads). confined is not a claim that the tool is actually usable by a service-scoped caller — most confined tools still deny downstream via their own perm_fn/scoped_perm_fn under the seeded-empty allow-list; it only means the C8 layer itself doesn’t short-circuit before the tool’s own (pre-existing) confinement runs. query_installed_software and, as of #1700, get_agent_details are the tools so far where confined means real usability, not just a non-short-circuiting label — see the Phase 2 progress note below. resources/read bypasses C8 structurally but calls perm_fn directly, so it is covered by the flip the same way any REST route is.

Explicit denies for gate-less routes (§3e). A route that never calls require_permission/require_scoped_permission/C8 at all is untouched by the flip regardless of how the flip itself is tuned — these needed their own deny. AuthRoutes::deny_service_scoped_session is server.cpp’s shared gate for this shape (health-summary fragment, the legacy /events SSE stream, the instructions-list fragment, and the six result-set HTMX fragments); ComplianceRoutes and WorkflowRoutes carry their own file-local equivalents for the same reason every other route-owner class in this list does (below). A residual grep sweep of every auth_fn/perm_fn call site in rest_api_v1.cpp (74 sites) and mcp_server.cpp (3 sites) found four more real instances — three sharing one shape (management-groups/{id}/roles GET/POST/DELETE, plus the POST /api/v1/tokens service-scope minting check, all bypassing require_permission via a direct rbac_store->check_permission call) and the eight-route /api/v1/result-sets* REST family (the twin of the HTMX fragments above) — plus one distinct, more severe, not service-scope-specific bug in the same pass: POST /api/v1/result-sets/from-inventory-query had no authorization check of any kind (CWE-862), missed by an earlier fix that gated its three dispatch siblings. Full inventory, including document-only dispositions and the sweep’s own accounting: docs/security-reviews/service-scope-flip-route-inventory-2026-08.md.

The per-file deny_service_scoped_*/deny_fleet_wide_service_scoped helpers are now a SECOND, largely-redundant layer, not the primary defense. For any route that also calls require_permission/require_scoped_permission (the vast majority), the flip above already denies a service-scoped token structurally — four of the remaining original in-handler deny sites (list_schedules, list_dex_perf_devices, compare_app_perf_versions, list_network_devices) are still double-denies, kept for now and scheduled for Phase 2 retirement. Two are retired: query_installed_software’s in the first Phase 2 migration (below) and get_dex_group_app_perf’s in #3290 Phase 2 bucket 1a (both were provably dead, fired after their route’s own perm_fn). A THIRD group — GET /api/v1/dex/signals/{obs_type}+GET /api/v1/dex/app+GET /api/v1/dex/overview (REST) and their MCP twins get_dex_signal_detail/get_dex_app/get_dex_overview — stays on perm_fn + deny_fleet_wide_service_scoped (WS-A4 PR-1): these three routes’ fields are all fleet-wide aggregates not yet confined per caller (ADR-0017 INV-3), so they stay global-only rather than moving onto AuthRoutes::require_fleet_read — see docs/presentation-core-split-delivery-matrix.md’s WS-A4 row; their deny_fleet_wide_service_scoped call sites are therefore LIVE again, fired BEFORE perm_fn/tier_allows, same class as the four sites below. The four sites that remain are live-but-redundant (their deny fires BEFORE perm_fn, so retiring them changes the observable response, not just deletes unreachable code — a different, more cautious backlog item). Same bucket-1a pass also retired deny_service_scoped_schedule (schedule_routes.{hpp,cpp}) entirely — its four call sites (schedule.create/.list/.delete/.enable) all fired after their route’s own require_permission. The remaining per-file helpers (below) stay in place; they remain load-bearing only for routes with no other RBAC gate call at all (the §3e class above) — EXTEND the pattern, do not fork a new copy still applies to that subset. Current call sites, including this PR’s additions: deny_service_scoped_/deny_service_scoped_mutation_ (guardian_routes.{hpp,cpp}), deny_service_scoped_ (dex_routes.{hpp,cpp}, deployment_routes.{hpp,cpp}, preflight_routes.{hpp,cpp}, and the new compliance_routes.{hpp,cpp}), deny_service_scoped_schedule_list + the new deny_service_scoped_scope_estimate (both local lambdas in workflow_routes.cpp), the shared deny_fleet_wide_service_scoped lambda in rest_api_v1.cpp (now covering the result-set REST family too) and mcp_server.cpp, the new AuthRoutes::deny_service_scoped_session (server.cpp’s shared gate for its own gate-less routes), plus inline checks in device_routes.cpp / network_routes.cpp / inventory_routes.cpp / echo_tree_routes.cpp.

Phase 2 progress (#3290). The first migration landed: GET /api/v1/inventory/software + its MCP twin query_installed_software are now on require_fleet_read — both surfaces’ deny_fleet_wide_service_scoped/blanket-deny call sites are retired for this tool pair (the REST call was already provably dead, firing after perm_fn; the MCP call was live and is now gone). require_fleet_read itself gained the elevated/engine/mcp_tier caller-class branches it was missing at Phase 0 (mirroring require_list_read’s ladder — see its own doc comment). Prioritization for this and future migrations is documented reasoning, not the metric — no production fleet exists yet, so yuzu_auth_service_scope_default_denied_total has no real traffic to rank by; the criterion-1 substitute and the ranked backlog live in docs/security-reviews/service-scope-phase2-migrations-2026-08.md. The §3d authorize_list_read supersede→intersect migration (below) is a separate, not-yet-started stream.

Second migration (#1712/#1700, this ADR-0017 continuation). Closes the “World A” gap this ADR flagged (Fail-closed list-read gate (HIGH) finding) for three readers that previously had NO per-agent filter at all: dashboard /fragments/results, the workflow executions-drawer detail route (its responses section AND its per-agent status grid/table — both read per-agent data, from ResponseStore and ExecutionTracker respectively, and the route’s gate migration admits the same newly-in-scope caller to both), and MCP get_agent_details — all three routes migrated onto require_fleet_read as their sole gate, replacing perm_fn/perm_fn_/tier_allows+perm_fn outright. get_agent_details is reclassified from ServiceScopeClass::denied to confined (kToolSecurityRows, mcp_server.cpp) since it now has this same real confinement mechanism; a correctly-confined service-scoped token gets a real filtered/404-collapsed answer instead of a blanket 403.

Out of scope for this migration, flagged not fixed (none newly reachable by this migration’s admission change — unlike the status grid above, these were already reachable by their current caller class before #1712, so leaving them unmigrated does not widen anything): MCP list_agents (same unscoped-fleet-read shape get_agent_details had), the dashboard results workflow’s sidecar routes — /fragments/results/filter-bar, /fragments/create-group-form, and POST /api/dashboard/group-from-results (all still on flat perm_fn_, reading unscoped ResponseStore facet queries — tracked as #3489, #3525 tracked the same finding and was closed as its duplicate; see ADR-0017’s “In scope” Responses bullet for the detailed reachability analysis distinguishing filter-bar’s latent-only exposure from group-from-results’ real, pre-existing ManagementGroup:Write-vs-Response:Read privilege-crossing risk — this same risk is why the dashboard’s “Create Group from N Agents” button is now withheld from a confined caller entirely, #1712 hardening round), REST GET /api/v1/execution-statistics/agents (was on the same flat perm_fn(Execution,Read) gate, returning per-agent execution stats with no confinement — found by Gate 4 consistency-auditor’s re-derivation of the sibling-handler check on this PR’s exact (securable, operation) pairs, tracked as #3526; now migrated onto fleet_read_fn, see “Execution-statistics per-agent rollup (#3526)” below; the workflow executions LIST fragment /fragments/executions, noted in the same issue, has since been migrated onto require_fleet_read too - see the Sixth migration paragraph below). /sse/executions/{id} and REST /api/v1/events (formerly tracked here as #3699) are now migrated — see the Third migration paragraph below.

Third migration (#1634, this ADR-0017 continuation). Closes the systemic gap #1634 tracked since 2026-06-23: the legacy REST /api/responses/{id}/aggregate//export/catch-all-list routes (server.cpp), MCP query_responses/aggregate_responses, and REST GET /api/v1/executions/{id}/visualization all carried a per-row post-filter (response_agent_in_scope/response_scope_fn) that sat BEHIND a still-flat global gate — the exact “inert filter” shape #1711/#1550 shipped and this ADR names as the systemic defect. All five migrated onto require_fleet_read/fleet_read_fn_ as their sole gate; the response list/export routes and query_responses additionally push the resolved visible-agent set into ResponseStore::query/query_by_execution as SQL agent_id = ANY(...) before LIMIT/OFFSET (a new optional scope parameter, ADR-0017 INV-3) rather than post-filtering a capped/paginated read — the INV-3-compliant fix an adversarial review (Kimi + Codex) found the first migration pass had missed. GET /api/v1/executions/{id} (REST final-state lookup), GET /api/v1/events (REST SSE), GET /sse/executions/{id} (dashboard SSE — closing the #3699 gap above), and MCP get_execution_status/list_executions also migrated: all four collapse an invisible execution to the same 404/not-found shape a genuinely nonexistent one gets (no existence oracle), and a confined non-dispatcher’s view is a redacted projection (recomputed per-agent counts from only the visible agent-status rows, scope_expression/parameter_values replaced with a fixed placeholder) — dispatcher ownership admits VISIBILITY only (avoids a false 404 on a just-dispatched execution with zero responses yet) and never bypasses this projection, a distinction the same adversarial-review round caught the first attempt getting wrong. list_executions has a weaker mechanism than the others — execution rows carry no single agent_id to filter by, so a confined caller is restricted to dispatched_by = session->username (ExecutionQuery::dispatched_by) rather than a real visible-agent intersection. As of the PR #3793 review round, agents_targeted/agents_responded on this route ARE now projected (a batched, non-N+1 get_agent_statuses_for_executions call) the same way get_execution_status projects them, closing that specific gap; the confinement AXIS itself remains own-dispatches-only, not a real visible-agent intersection. Disclosure gap (found in the same review round, not fixed): dispatched_by = session->username is scoped to the minting principal, not intersected against a service-scoped token’s own service-tag scope — an admitted service token sees its minting user’s full dispatch-history metadata (status/timing/lineage, no agent identities), not narrowed to its own service. Recorded here as disclosed, not fixed. The two SSE routes share one event projector (execution_event_scope.hpp): agent-transition events are filtered by agent_id, execution-progress is dropped for confined subscribers (execution-wide counts, no agent_id), and execution-completed is sanitized (real status field preserved, counts stripped) rather than dropped, so the client still closes its stream. query_responses/aggregate_responses/get_execution_status/list_executions are reclassified ServiceScopeClass::confined (kToolSecurityRows), matching the real fleet_read_fn_ mechanism they now have.

Fourth migration (#3789, this ADR-0017 continuation). Closes the gap #1634’s own Gate 2 review found and deliberately deferred: the legacy pre-v1 /api/executions (list), /{id} (detail), /{id}/summary, /{id}/agents, /{id}/children, POST /{id}/rerun, and POST /{id}/cancel routes (execution_routes.cpp as of #2542 PR-7, extracted from server.cpp) carried NO management-group confinement at all — a bare require_permission(Execution, Read/Execute), unlike every other execution-reading surface migrated above. All five GET routes now gate on require_fleet_read(..., "Read"), mirroring the GET /api/v1/executions/{id} shape: an invisible or nonexistent execution collapses to one 404 (no oracle) for EVERY caller — but the audit row is written only when the caller’s scope was actually engaged (gate.scope). A confined caller failing this check writes execution.read/denied; an unconfined caller’s genuinely-nonexistent id is ordinary “not found”, not a confinement decision, and writes no row at all — auditing it as denied would have inflated the CC7.2 denial-rate metric with routine 404 traffic (compliance-officer, #3789 Gate 6 finding F2, corrected before merge). The caller-visible 404 response is identical either way; only the server-side audit trail distinguishes the two. A confined view redacts scope_expression/parameter_values and recomputes the four agent counts from only the in-scope, terminal-status rows. /agents — the worst pre-migration leak, returning raw agent identities and error_detail fleet-wide — filters its row list to authz::in_scope. /children does NOT inherit the detail route’s “keep lineage truthful” precedent: a visible parent does not authorize enumerating separate child execution records, so each child is checked against the same visibility predicate independently. (#2146 A2-R1 gave /children a REST v1 twin, GET /api/v1/executions/{id}/children, and an MCP twin, get_execution_children - both reuse this exact confinement logic via execution_scope_rules.hpp, never re-derived, and share a new execution_child_row_json builder with the legacy route.) The LIST route goes further than every sibling migrated above (including list_executions, next paragraph): rather than a post-fetch drop or an own-dispatches-only filter, the confinement predicate is pushed into the WHERE clause itself, before LIMIT (ADR-0017 INV-3) — dispatched_by = $owner OR EXISTS (SELECT 1 FROM agent_exec_status WHERE agent_id = ANY($visible)), one statement, no N+1 (ExecutionTracker::query_executions_checked’s new ExecutionScope parameter). The owner disjunct is load-bearing, not cosmetic: agent_exec_status rows are written only on response arrival, so an agent-membership-only predicate would make a caller’s own just-dispatched execution invisible to them until the first agent replies.

The two mutating routes (rerun/cancel) keep require_permission(Execution, Execute) ahead of the fleet gate — require_fleet_read structurally rejects any operation but Read (its legacy-open AdmitAll branch has no MCP approval-ticket check, so a mutation must never reach it) — then additionally take require_fleet_read(..., "Read") purely for scope. Mutation admission is stricter than read visibility: an adversarial review (external model, Sol/gpt-5.6-sol) found that “every EXISTING agent-status row is in scope” is a false-admission path, because those rows are response-arrival-seeded — an execution targeting agents A and B can have only A’s row while B, still pending, might be out of scope. The rule implemented instead: zero status rows (the just-dispatched window) admits ONLY the dispatcher; one or more rows requires the row count to equal agents_targeted (a complete ledger) AND every row’s agent to be in scope — dispatcher ownership is not a bypass once rows exist. Nonexistent, zero-visible, partial-visibility, and incomplete-ledger all collapse to the identical 404 + non-distinguishing audit detail (a distinct status for “partial visibility” would itself be a hidden-cohort disclosure). mark_cancelled’s pre-existing false-success-on-nonexistent-id behavior (an UPDATE matching zero rows still reports PGRES_COMMAND_OK) is fixed as a side effect: an explicit existence check now runs before every cancel attempt, for confined and unconfined callers alike.

New degrade-distinguishable tracker accessors (get_execution_checked, get_children_checked, get_agent_statuses_for_executions_checked, query_executions_checked) fail closed (503, retry_after_ms) on a transient read failure rather than collapsing it into “absent”/“empty” the way their pre-#3789 plain twins do — required because these five routes now write denied audit rows and 404 responses off these results, which the old degrade-collapsing methods cannot safely feed. execution_scope_rules.hpp’s execution_visible and admit_confined_mutation deliberately scan every status row with no early exit, even after a match is found — an early return makes wall-clock time a function of WHERE in the row list the first in-scope agent falls, a residual timing side-channel on the admit path (cpp-expert, #3789 Gate 3). Behavior changes accepted, not oversights: /summary’s nonsense zero-filled-200 response for an unknown id is now a 404 for every caller, confined or not; /agents and /children similarly now 404 on an unknown id instead of an empty-array 200; a caller holding Execution:Execute but not Execution:Read can no longer rerun/cancel (the mutation routes need both); gate-failure responses on a degraded RBAC/management-group store are now 503 (retry_after_ms) where the old flat gate failed closed 403. Coverage: tests/unit/server/test_legacy_executions_scope_authz.cpp (pure-function unit tests for the shared execution_scope_rules.hpp predicates, a real-rig PG confinement test, and per-route source tripwires) plus new [pg][execution_tracker] cases in test_execution_tracker.cpp for the SQL pushdown and the new checked accessors.

Deferred, not fixed here (flagged for a follow-up, not swept into #3789): MCP list_executions still uses the weaker own-dispatches-only axis this migration’s LIST route improves on — worth backporting the same SQL-pushdown shape; workflow_routes.cpp still calls the degrade-collapsing plain get_agent_statuses at one site.

Still open after this migration (unrelated files, tracked separately, not swept into #1634): the #3526 surfaces named in this section are migrated and no longer open here (REST GET /api/v1/execution-statistics/agents and MCP get_execution_statistics_by_agent were migrated afterwards, see “Execution-statistics per-agent rollup (#3526)” below, and the workflow executions LIST fragment is migrated, see the Sixth migration paragraph below); three reliability gaps on the workflow detail route — legacy-fallback starvation, untested store-degrade banner, get_execution error-vs-absent ambiguity (#3527); /fragments/results has no audit trail at all (#3528); no [pg] end-to-end test proves the real require_fleet_read/RbacStore/ManagementGroupStore composition for the #1712 call sites (#3529). Staleness of an already-open SSE stream (compliance/sre finding, this round): require_fleet_read/fleet_read_fn is evaluated once, at subscribe time — neither /sse/executions/{id} nor /api/v1/events re-checks the caller’s management-group scope for the life of the connection, so a scope narrowed (or a session revoked) mid-stream via invalidate_session does not disconnect an already-open subscriber; invalidate_session (auth.cpp) only erases the session record, it never reaches an open httplib connection, and no lever exists today to force-disconnect one live stream short of a full server restart. This is the same one-time-admission shape require_fleet_read’s non-streaming callers already have (a scope change doesn’t retroactively alter an in-flight response either), but a held-open stream widens the exposure window from one request to as long as the tab/worker stays connected. Accepted for this migration (real-time revocation is a separate, larger change to the SSE subsystem, not a #1634 scope-pushdown fix); worth a security runbook line if this becomes an operational concern before it is addressed.

Execution-statistics per-agent rollup (#3526). REST GET /api/v1/execution-statistics/agents and the MCP twin get_execution_statistics_by_agent returned one row per agent across every execution of every operator behind a bare perm_fn(Execution, Read), so a caller holding the flat permission saw every agent’s counts, success rate and last_execution_at with no per-agent filter. Both now gate SOLELY on fleet_read_fn (the admit-then-filter chokepoint, like GET /api/v1/executions and MCP list_executions) and thread the resulting VisibleSet into ExecutionStatsQuery::visible_agents, which ExecutionTracker::get_agent_statistics applies as a.agent_id = ANY(...) in SQL BEFORE the GROUP BY and LIMIT (present-empty is deny-all and answers an empty page, never “no filter”). A confined caller sees only its visible agents; a global grant is unchanged. The old gate admitted only a holder of a global grant, so a management-group-scoped-only operator received 403; the fleet-read gate admits that operator as a confined 200, which widens admission and narrows what is read. The gate fails closed: when the RBAC or management-group store is not open the route answers a retryable 503 (retry_after_ms 5000) even to a global admin, where the bare check answered 200. Confinement here is effective only with RBAC enforcement ON: with RBAC off (the shipped default) the rollup is unfiltered for every authenticated non-service, non-engine caller whose tier allows it (an engine principal is refused with 403), identical to GET /api/v1/executions, and a service-scoped token is refused with 403 on both surfaces. The MCP tool stays denied for a service-scoped token (the generic C8 gate refuses it before the handler runs), so this does not widen service-scoped reach; on REST a service-scoped token now gets the chokepoint’s confined view where the bare gate used to refuse it outright, the same posture as every other fleet_read_fn route (so, like the others, only with RBAC enforcement ON and subject to the ITServiceOwner ceiling described below). REST also rejects a negative since with 400. Two siblings keep the plain Execution:Read gate on purpose and are NOT confined: GET /api/v1/execution-statistics/definitions (and MCP get_execution_statistics_by_definition) and the fleet summary GET /api/v1/execution-statistics return definition-level and fleet-level aggregates with no agent identity, although the fleet summary carries active_agents, a fleet-wide count.

GET/PUT/DELETE /api/agents/:id/properties[/:key] — found in this same governance re-review, bare require_permission(Infrastructure,Read/Write) with no per-agent scope filter at all (including a WRITE path) — is now fixed (#3700): all three routes migrated to require_scoped_permission("Infrastructure", "Read"/"Write", agent_id), the same per-target gate the Tag routes (/api/tags/set, /api/tags/delete) use. RBAC-off behavior is unchanged, since Infrastructure is not in kTopologyFloor. Coverage: tests/unit/server/test_agent_properties_scope_authz.cpp.

Fifth migration (#4031 hardening round, adversarial review of the branch before push). GET /api/v1/enrollment/pending-agents — a new REST v1 route, not a legacy one — was gated on bare require_permission(Enrollment, Read) (perm_fn) in an earlier revision of this branch, the exact anti-pattern this ADR’s Consequences section names as forbidden for a new list/fan-out read of per-agent data: pending_agent_row_json rows carry a genuine agent_id plus hostname/os/arch/agent_version (auth::PendingAgent). Migrated onto require_fleet_read("Enrollment", "Read") as the route’s sole gate, filtering with authz::in_scope(gate.scope, agent.agent_id) — the same idiom GET /api/v1/inventory/software uses. The defect this closed was under-admission, not disclosure: require_permission’s ordinary RBAC branch resolves authority via RbacStore::collect_roles (direct/group-role grants only) and never consults ManagementGroupStore, so a caller holding only a management-group-scoped Enrollment:Read grant — a combination the product’s own docs (docs/user-manual/rbac.md) instruct operators to configure — was denied outright (403) rather than admitted with a real, correctly confined result. That result is typically empty under the intended enrollment workflow (a pending/not-yet-approved agent normally has no management-group membership yet — group assignment follows approval), but this is not enforced by the data model: management_group_members.agent_id carries no enrollment/agent-registry foreign key, and POST /api/v1/management-groups/{id}/members accepts any non-empty caller-supplied id with no existence check, so a pre-assigned membership row yields a non-empty, correctly-confined result via the same filter — never a widening. No live exploit path existed under the default seed (Enrollment:Read grants to Administrator only, resolved via check_permission’s global-grant branch regardless of which primitive gates the route), which is why this passed the branch’s 7-pass, multi-agent governance run before an adversarial review of the branch caught it.

This migration also closed a second, load-bearing gap it exposed: Enrollment:Read is the first kTopologyFloor-floored securable ever routed through require_fleet_read. Unlike require_permission’s legacy (RBAC-off) branch, require_fleet_read’s subordinate primitive, RbacStore::authorize_list_read, has no floor concept of its own by design (a data-layer primitive must not depend on Session) — its own legacy-open branch returns AdmitAll unconditionally once RBAC enforcement is off, with no way to distinguish an admin session from any other authenticated one. Every prior require_fleet_read caller (Inventory/Execution/Response/Schedule:Read) is unfloored, so this gap was latent, not exercised, until now — and require_list_read’s own doc comment (auth_routes.hpp) had already named the identical risk as something “a FUTURE floored securable… must re-apply explicitly.” Fixed inside require_fleet_read itself (authz_gates.cpp), not as a route-level bolt-on: a pre-check mirroring require_permission’s own legacy-branch floor logic (same audit-reason prefix "topology floor: ", same yuzu_auth_topology_floor_denied_total counter) now runs before the management-group axis whenever RBAC enforcement is off, denying a non-admin session on any floored pair. Coverage: tests/unit/server/test_authz_gates.cpp (RBAC-off + floored securable, non-admin-denied and admin-admitted); tests/unit/server/test_enrollment_directory_routes.cpp (route-level scope-filtering, including the admitted-empty-not-403 case that is the whole point of the fix).

Sixth migration (the #3526 workflow executions LIST fragment and MCP summarize_working_set kind=execution, #4753): hardening plus a capability change, NOT the closure of a reachable confinement leak. An author-run reachability probe (run by a subagent of the authoring session, not independently adjudicated; the JIT-elevated case was checked by code reading only; committed as evidence: docs/security-reviews/3526-confinement-reachability-probe-2026-10-05.md, with its .patch and .results.txt) drove the real require_permission/require_fleet_read and found nothing served through the old gate on either surface that the unconfined GET /api/v1/executions would not serve the same principal, with RBAC on or off (with RBAC off every non-service, non-engine caller whose tier allows it is unconfined by design; an engine principal is refused with 403); the committed tests below prove only the NEW behaviour. Confinement on these two surfaces is effective only with RBAC enforcement ON: with RBAC off (the shipped default) both are unconfined for every authenticated non-service, non-engine caller whose tier allows it (an engine principal is refused with 403 with RBAC off), identical to GET /api/v1/executions, and a service-scoped token is refused (403 on the REST and MCP surfaces; the fragment renders the permission note described below). The analysis is that both sat behind a plain (Execution, Read) gate that only a principal holding a GLOBAL grant passes (it reads principal_roles/group membership and never joins management-group assignments), and a global grant is unfiltered under authorize_list_read (#1715(b)) in any case, so every caller the old gate admitted already received exactly what the v1 twin serves. Earlier wording in the GET /api/v1/executions source comment (rest_api_v1.cpp) and in its served OpenAPI description called the fragment “a real, documented confinement gap”; that overstated it. What existed was a capability gap (a group-scoped-only operator got 403 where the v1 twin served them a confined view) and a latent trap (if #2665, a group DENY overriding a global ALLOW, is ever scheduled, “plain-admit” stops being a subset of “admit-all” and the unconfined read would become live). Both surfaces now use fleet_read_fn/fleet_read_fn_ (require_fleet_read) as the SOLE gate on that pair, with the execution_scope_rules.hpp owner-or-visible rule and confined_projection counts the v1 twin uses. The fragment pushes the scope into SQL before its LIMIT (query_executions_checked), recomputes counters and last_error_detail (preview AND title= attribute) from in-scope agent rows, and answers a store failure with an honest degrade note, never “No executions yet” (rendered at HTTP 200 with a data-degraded attribute, because the dashboard htmx config drops 4xx/5xx bodies and a refusal left the panel on “Loading…”). When the gate itself refuses, it still writes its audit row, and the fragment then replaces the response: a gate 503 (RBAC, management-group or tag store unavailable, or the ITServiceOwner ceiling read degraded) becomes an HTTP 200 data-degraded="gate" note and a gate 403 becomes an HTTP 200 data-denied="true" note with no reason text; a 401 passes through unchanged. GET /api/v1/executions and the MCP twins keep the real 403/503, so an HTTP-status monitor of the fragment no longer sees gate refusals (the audit rows and yuzu_server_rbac_read_degrade_total for the ceiling case are the signals). It also bounds the per-execution agent-status read to the caller’s visible agents in SQL (get_agent_statuses_for_executions_checked’s optional visible_agents), so a confined caller does not read every agent row of a fleet-wide execution; the served result is identical because execution_visible/confined_projection ignore out-of-scope rows. MCP summarize_working_set reads through get_execution_checked (a degrade is an error with retry_after_ms, not “was not found”) and a confined-out execution reads exactly like an absent one: same narrative, a denied audit row, no success row, for a CONFINED caller (the absent case now also audits denied for them, matching get_execution_status’s audit row, although that twin returns an error where this tool returns a success-shaped “was not found” result). An UNCONFINED caller’s absent id stays an audited success: no denial occurred, and the caller never sees the audit row, so the split is not an existence oracle. The denied row’s id is passed through audit_token and capped at 128 bytes. Admission change, stated plainly, and different on the two surfaces. On the FRAGMENT, a group-scoped-only operator now gets a confined 200 where it got 403, and a service-scoped token gets a confined 200 where the flip gave it 403 (the same admission shape /fragments/executions/{id}/detail already has), limited to executions that touched an in-scope agent (see the owner-disjunct point below), but the service-token 200 holds only with RBAC enforcement ON (authz_gates.cpp still refuses with 403 “service-scoped tokens require RBAC to be enabled” with enforcement off, shown on the fragment as the permission note) and a reachable TagStore (a missing or degraded one is a 503, retry_after_ms 5000, shown on the fragment as the gate note; server.cpp refuses to boot without it in production). On MCP summarize_working_set the plain perm_fn(Infrastructure, Read) stays the FIRST gate, so a group-scoped-only operator still gets 403 there; only a caller with a GLOBAL Infrastructure:Read plus a group-scoped Execution:Read is newly admitted to the confined execution view. There is no admission change for a caller holding a global grant; the degrade and audit behaviour changes for every caller, and so does one new prerequisite: require_fleet_read answers a retryable 503 whenever the RBAC store is not open (to every caller except a JIT-elevated non-service session, which returns before any store check) or the management-group store is not open (to every caller except an elevated session and an engine principal, which returns before that check), including to a global administrator; the old plain gate never needed the management-group store. Two smaller points: ordinary callers keep the owner disjunct (dispatched_by == username) on the fragment, but it is SUPPRESSED for service-scoped sessions, because a service-scoped token’s session username is its MINTER and the disjunct would otherwise list every execution the minter dispatched, outside the service scope (the empty-username fail-closed guard is kept); the same minter-keyed disjunct is still live for service-scoped tokens on GET /api/v1/executions, the v1 detail and /children, MCP get_execution_status and list_executions, legacy /api/executions*, the detail fragment, the SSE channel /sse/executions/{id} and GET /api/v1/events, and closing it there needs dispatch-time token attribution (#5557); and the row status badge stays the fleet aggregate while the counters are projected (parity with the REST twin, SSE and the MCP detail view). A service-scoped token is also subject to the ITServiceOwner authority ceiling that require_fleet_read applies on this gate (PR #5546; see the next paragraph), which the Sixth migration relies on and does not add. MCP summarize_working_set stays ServiceScopeClass::denied (pinned by a test) because kind=agent and kind=fleet have no real mechanism on the service-scope axis and confined requires one for EVERY kind (clause 3). Still unconfined, tracked: summarize_working_set kind=fleet and kind=result_set return the whole-registry agent count from agents_fn() with no per-caller scope (the tool’s top gate is Infrastructure:Read; tracked in #4753, whose checklist, including kind=fleet and get_fleet_posture_fast, stays open: the Sixth migration covers only kind=execution). The same whole-registry branch is also reached by kind=execution or kind=agent with an EMPTY id, or when the execution tracker is unavailable (not separately tracked). REST GET /api/v1/execution-statistics/agents and its MCP twin get_execution_statistics_by_agent are #3526’s other half, migrated onto the same chokepoint (see “Execution-statistics per-agent rollup (#3526)” above). Coverage: tests/unit/server/test_workflow_executions_list_authz.cpp drives the real require_fleet_read over a real RbacStore/ManagementGroupStore/ApiTokenStore/TagStore.

Operate pages built on the same gate (Executions and Approvals). The /executions page’s fleet statistics strip (today, all time, device success rate, average duration) is rendered only when require_fleet_read returns no scope, i.e. for unconfined operators, because ExecutionTracker::get_fleet_summary_checked is fleet-wide and cannot be projected to a scope; a confined caller gets no strip and a store failure omits it rather than printing zeros. The page’s Cancel button is rendered enabled for a live execution only when a render-only probe of Execution:Execute passes (the detail read itself already required Execution:Read); a caller without the grant sees it disabled, an unprobed render leaves it enabled, and the cancel route re-checks both permissions, so the probe is never an authorization decision. The list route does not audit, so the strip adds no audit row.

Operate pages (view=page). The Executions and Approvals page views sit behind the same admission prelude as the fragments they extend and receive only projected rows and the redaction constant; the Approvals page keeps the bare Approval:Read namespace gate, since approvals are workflow rows and not per-agent fleet data. The Workflows page’s definitions fragment is gated by Workflow:Read through perm_fn alone (never stacked with fleet_read_fn) and reads the instruction store only behind a render-only InstructionDefinition:Read probe, so a role holding Workflow:Read without it is shown ids and labels only (the run detail follows the same rule for step titles). The same fragment probes Workflow:Execute, Workflow:Write and Workflow:Delete once per render to decide which controls to offer (Run now, New workflow, Delete workflow); a probe is never an authorization decision and each write route re-checks its own grant, with one auth.permission_required audit row per refused probe; its Schedules panel is the existing two-stage schedule gate, with the owner decision made server-side and only a boolean sent to the page, and probes Schedule:Write, Execution:Execute and Schedule:Delete once per render for the Enable, Disable, Delete and New schedule controls (the owner-scoped write routes remain the gate and answer 200 when they change nothing, so the page re-reads the stored state). The run fragments (/fragments/workflow-runs, /fragments/workflow-runs/{id}) are gated by fleet_read_fn(Workflow, Read) alone, never stacked with perm_fn, hand its scope to the store (list) or to workflow_execution_visible and the confined projections (detail), answer a hidden and an unknown run id with identical bytes, and audit the detail read as workflow_execution.detail.fetch. Seventh migration (the workflow RUN list, GET /api/v1/workflow-executions + MCP list_workflow_executions): a new surface born on the chokepoint, not a migration of an existing one. Workflow:Read through require_fleet_read is its sole gate (never stacked with perm_fn on the same pair), and the composed scope is passed to the WorkflowApi seam as a required argument, where WorkflowEngine::query_executions_page applies it in SQL BEFORE ORDER BY/LIMIT (ADR-0017 INV-3): a run is returned only if at least one string element of its agent_ids_json is in scope, so a hidden run never takes a page slot, and next_cursor is always the position of a row the caller was served. The SQL image of workflow_execution_visible() fails closed the same way (malformed, empty or non-array agent_ids_json is invisible to a confined caller) and is pinned against the C++ predicate over a corpus; it is not a byte-for-byte image, because PostgreSQL’s jsonb and nlohmann accept different JSON, and each divergence is recorded with its direction in the store’s tests. A document jsonb refuses (a \u0000 escape, a UTF-8 byte-order mark) is invisible to a confined caller in the list while the detail route’s nlohmann parse would still show it, the safe direction. A document jsonb accepts but nlohmann rejects (a number past double range, ["a1",1e400]) is shown by the list to a caller whose scope holds a1 while the detail route answers not-found for it: a listed run the caller cannot open, never an out-of-scope run, since the shown run always names an in-scope agent; in latest_per_workflow mode it can also change which run a workflow’s latest is. Only a write that bypasses the engine can store either shape (execute() writes a JSON array of strings, which both parsers read identically). Workflow runs carry no dispatcher column, so there is no owner disjunct and #5557’s defect class (a service token inheriting its minter’s own runs) cannot occur. Rows are metadata only (no agent ids, step results, agents_reached), and pagination.total is the page’s row count, never a fleet-wide count, so neither leaks fleet size or out-of-scope existence. kServiceScopeGlobalSafe stays empty; the MCP row is ServiceScopeClass::confined (a real downstream mechanism exists), and Workflow:Read is deliberately NOT in the authorization-topology floor: a run list is operational metadata, not authorization topology. A successful list writes no audit row (metadata, like GET /api/v1/executions); a refusal is audited by the gate as auth.fleet_read_required; MCP writes only the generic mcp.list_workflow_executions row. The confined predicate parses agent_ids_json per candidate row and workflow_executions has no started_at index, so the read runs under a 1.5 s SET LOCAL statement_timeout and a timeout is an honest 503, never a short or empty page.

require_fleet_read now applies the ITServiceOwner authority ceiling on its service axis (found while working #3526, which stays open for other surfaces). Before this change require_fleet_read’s service-scoped branch checked only that RBAC enforcement is in effect and then met the minter’s authorize_list_read result with the service tag set; unlike AuthRoutes::require_permission it never asked whether the ITServiceOwner role itself holds the pair. It now does, at authz_gates.cpp right after the “service-scoped tokens require RBAC to be enabled” check, through ONE shared helper, authz::service_ceiling_check (declared in authz_gates.hpp, defined in authz_gates.cpp), which require_permission and require_scoped_permission (auth_routes.cpp) now call too. The helper asks RbacStore::role_permission_allowed_checked("ITServiceOwner", ...), the one read, which fetches the single role_permissions row for the pair (SELECT effect keyed by role, securable and operation, the table’s primary key) rather than every row of the role, and returns a tri-state: Admit (an allow row), Deny (an explicit deny row, any other effect text, or the pair absent, which is also how an operator revoke looks), Degraded (the checked read failed). That read is breaker-gated and short-budget: it asks the RBAC authz breaker for admission first, acquires with kAuthzAcquireTimeout (250 ms) and reports its outcome to the breaker, so a degraded store fails fast and, once the breaker is open, requests are answered without touching the pool. Before this change the read acquired with kReadTimeout (2000 ms) and was not breaker-gated, but PgPool clamps a bounded acquire to min(timeout, 500 ms) when the pool is already saturated at entry (saturated_fast_fail, pg_pool.hpp), so on a saturated pool the wait was about 500 ms, not 2 s (a pool that saturated only after the call entered it was bounded by the full 2 s). The change therefore lowers that bound from up to 500 ms to up to 250 ms and adds breaker shedding. An open breaker is reported as “circuit breaker open”, which authz::ceiling_degrade_reason counts under pool_acquire_timeout. The breaker bounds how many requests wait, not how long an already admitted read holds its connection: a read that has acquired one can still wait up to the pool’s lock_timeout (10 s default) or statement_timeout (30 s default) before failing; on a dark network path (no reply at all) the wait is bounded instead by the pool’s tcp_user_timeout (10 s, per pg_pool.hpp: confirmed on Linux, unconfirmed on Windows, a no-op on macOS), and until two failures have returned, up to the pool size (16 by default, --postgres-pool-size) of these reads can each hold a connection for that long. The breaker is shared with operator permission checks: the ceiling read’s failures feed the same per-replica RBAC authz breaker as check_permission, user_rbac_group_names and role_effects_for, so a degraded store seen through service-scoped tokens can open it, and an open breaker denies operators’ cache-miss permission checks too (fail closed; a cache hit is still served). The require_permission and require_scoped_permission sibling gates use the same 250 ms budget behind that breaker; before, they acquired with kReadTimeout (2000 ms), clamped by the pool to 500 ms when it was saturated at entry, and were not breaker-gated. Degraded is a retryable 503 only where a retry can succeed. On require_fleet_read and require_scoped_permission, a Degraded verdict (the checked read failed) is a retryable 503 (retry_after_ms 5000, no .permission, yuzu_server_rbac_read_degrade_total{reason} incremented with pool_acquire_timeout or query_error, and an audit row whose detail says the read degraded: auth.fleet_read_required / “fleet read blocked: RBAC read degraded resolving the ITServiceOwner ceiling for ”, auth.scoped_permission_required / “service-scoped token blocked: RBAC read degraded resolving the ITServiceOwner ceiling”), produced by ONE shared private helper, AuthRoutes::respond_ceiling_degraded (authz_gates.cpp), because an outage is not a missing grant and the caller can retry. Before this change require_scoped_permission answered Degraded with the same 403 as Deny and recorded no degrade metric. require_permission does NOT answer 503: its service-scope allow-list (kServiceScopeGlobalSafe) is empty, so no pair is admitted whatever the ceiling read returns, and a retry hint would advertise a recovery that cannot happen. A Degraded verdict there continues to the allow-list check and is answered by the same default-deny 403 (audit “service-scoped token blocked: default-deny ( not on the service-scope global-safe allow-list)”, yuzu_auth_service_scope_default_denied_total incremented) as a healthy read, with no retry_after_ms and no yuzu_server_rbac_read_degrade_total increment; the one difference is in the audit detail, which ends with ”; ceiling read degraded” after a failed read and does not after a healthy one, so an outage is visible in that row. A guard at the point where the allow-list would admit answers the 503 instead, so a future populated allow-list can never admit on a failed ceiling read; it is unreachable in production (the allow-list is empty) and is pinned by a test that populates the allow-list through its test override. Every case fails CLOSED; a definitive deny stays 403 on all three gates with different bodies and audit rows (require_fleet_read: “does not grant (the ITServiceOwner role does not hold it)” / auth.fleet_read_required; require_permission / require_scoped_permission keep their own bodies and the audit detail “service-scoped token blocked: lacks ITServiceOwner permission”). The 403 body reads service-scoped token does not grant <securable>:<operation> (the ITServiceOwner role does not hold it) and omits .permission (clause 5: granting the pair to the minter would not admit this caller); the audit row is auth.fleet_read_required / denied / fleet read blocked: service-scoped token lacks ITServiceOwner permission <pair>. Branch order is unchanged (elevated, engine, mcp_tier, service, RBAC, legacy; the mcp_tier branch still never admits on its own), and kServiceScopeGlobalSafe is neither applied nor widened here: that allow-list guards routes that return fleet-wide data UNCONFINED, whereas this gate’s service axis always narrows to the tagged set. Behaviour with the seeded defaults: unchanged for every securable the seeded ITServiceOwner role holds (Infrastructure, Execution, Response, Policy, GuaranteedState, Inventory, all five CRUD operations, plus Workflow:Read), which is every pair the routes below pass except one: Enrollment:Read is NOT held by ITServiceOwner, so a service-scoped token is now refused (403) on GET /api/v1/enrollment/pending-agents, where before it received the minter’s pending-agent view narrowed to the service’s tagged agents (a deliberate tightening, the same answer require_permission gives). Behaviour under a non-default revocation (a pair absent from ITServiceOwner: RbacStore::remove_permission records the removal in revoked_seed_defaults so a reboot does not re-seed it, but no REST, MCP or CLI surface calls it today, and a direct database DELETE of the row is re-seeded at the next boot unless the pair is recorded in revoked_seed_defaults): service-scoped tokens are refused on EVERY route that gates on that pair through require_fleet_read, even when the minter keeps the grant and the tag meet would admit; non-service callers, elevated sessions and engine principals are unaffected. The constrained callers are every call site of fleet_read_fn/require_fleet_read in server/core/src (grep for those two names; over 50 sites). The securables they pass, all with Read, are Execution (the executions drawer’s /fragments/executions/{id}/detail fragment, the legacy /api/executions* routes (the GET routes, and the POST /rerun and /cancel routes, which call fleet_read_fn after perm_fn("Execution", "Execute"); a service token never gets past that first gate because kServiceScopeGlobalSafe is empty), GET /api/v1/executions and its /{id}, /children and /api/v1/events twins), Response, Inventory, Infrastructure, Policy, GuaranteedState, Workflow and Enrollment. MCP tools on fleet_read_fn_ whose C8 class is confined reach the same check; the ones that are denied at C8 never get that far for a service token. Coverage: tests/unit/server/test_authz_gates.cpp (ceiling absent is 403 although the minter holds the grant and the tag meet would admit, with a non-service control and a pair-specific control; a degraded permission read is 503 with retry_after_ms, the degrade metric and a truthful audit row on require_fleet_read and require_scoped_permission, while require_permission answers its default-deny 403 with no retry hint and no degrade count, and its audit detail ends with the ”; ceiling read degraded” marker that the healthy default-deny row lacks; a definitive deny stays 403 on all three (explicit deny row included), a service token through an MCP tier is not admitted by the ceiling on its own, and a table-driven pass over the eight fleet-read securables incl. the Enrollment:Read refusal, and a starved pool that trips the authz breaker so the real gate answers 503 under the pool_acquire_timeout label) and tests/unit/server/test_fleet_read_ceiling_routes_authz.cpp (the same revocation through three real (Execution, Read) routes: the detail fragment, legacy /api/executions and GET /api/v1/executions; the Enrollment:Read refusal through the real enrollment route; and one real route each for Response, Inventory, Infrastructure and GuaranteedState, each answering the ceiling for exactly its own pair).

GET /api/v1/upload-grants refuses a service-scoped token (found while working #3526). That route’s only gate was list_read_fn(session->username), which evaluates the MINTER’s username, so a service-scoped token inherited its minter’s UploadGrant:Read view (every grant, for a minter holding a global grant); it is not a require_fleet_read route. file_retrieval_routes.cpp’s register_list now refuses a service-scoped session with 403 before the store or the list-admit resolver is touched, through the shared deny_service_scoped_session closure (audit upload_grant.list.access_denied; no .permission, because no grant would admit the caller; register_file_retrieval_routes throws std::invalid_argument at registration if the closure is unbound, and a bound closure that writes no response still gets the route’s generic 403). It matches the MCP twin list_upload_grants. Coverage: tests/unit/server/test_upload_grants_service_scope.cpp. This is an explicit service-scope deny on one authorize_list_read caller, shaped like the ADR-1006 §3e explicit denies (although this route did have a gate: it evaluated the minter’s username, so the service token inherited the minter’s view). It is not the §3d supersede-to-intersect migration, which stays deferred for the authorize_list_read callers, this one included (see docs/security-reviews/service-scope-phase2-migrations-2026-08.md).

GET /api/v1/upload-grants applies the engine default-deny gate, and the username-keyed visibility resolvers are engine-aware. An engine principal’s only authority is an explicit RBAC grant, so the legacy-open fallback (RBAC off) must never apply to it. register_list clears require_permission(UploadGrant, Read) for an engine session before the list resolver runs (403 RBAC off or no grant, including a stale cached-disabled view (this plain require_permission gate is deny-on-degrade, #6437), 503 store unavailable, 403 when perm_fn is unwired), matching the MCP twin. The shared resolvers carry the same rule so a route that reaches one without a gate in front inherits it: authz::compose_exec_visible (ExecVisibleFacts::principal_is_engine) returns deny-all, and RbacStore::authorize_list_read, ManagementGroupStore::get_visible_agents, schedule_arming_permitted, and the resolvers in engine_aware_visibility.hpp (dispatch_permission, response_agent_in_scope, visible_agents_json, infrastructure_visible_set, response_visible_set, bound by server.cpp, plus the exec-visible derivations) refuse or return no agents. The engine test for a username is structural: authz::is_engine_principal_name (the reserved engine: prefix, which no human account can hold); a new username-keyed resolver must take the same fact. The sweep for remaining resolvers was by rbac_enforcement_in_effect, authorize_list_read and get_visible_agents call sites, not an exhaustive proof. Coverage: the resolver bodies for dispatch_has_permission, response_agent_in_scope, get_visible_agents_json, visible_set_fn and response_visible_set_fn live in engine_aware_visibility.hpp (free functions over the stores, bound into the route seams by server.cpp) and are tested behaviourally against real stores in test_engine_aware_visibility.cpp: with RBAC off each case compares an engine with a human under the same facts, and with RBAC on the cases pin the grant-versus-no-grant behaviour. The pure composer, authorize_list_read, get_visible_agents and schedule_arming_permitted have behavioural tests (test_authz_model.cpp, test_management_group_store.cpp, test_operator_surface_twins.cpp). Those cases also cover an absent RBAC store (no resolver returns the whole fleet: the permission resolvers deny, and a group member sees at most their group’s agents) and pin the two store refusals that keep an engine from ever holding a confined view (scoped role assignment and group membership). Each resolver makes one enforcement read of its own, so its own admission branch uses one snapshot (ManagementGroupStore::get_visible_agents keeps its own RBAC probe, so for a human the two reads can still differ across a toggle, as before; an engine stays empty on both because that method refuses the engine: prefix when RBAC is off); test_store_wiring_order.cpp pins that read count per function in the header (counting rbac_enforcement_in_effect, rbac_enforcement_label and is_rbac_enabled, so a read through another helper would not be seen), because a timing test could not fail on the defect. server.cpp itself, which is not unit-constructible, is pinned by a source-presence test in the same file that matches each seam as a whole (the enclosing member or lambda, then its single delegation with the rbac and management-group stores in order); the same file counts the places each seam is handed on (the two route registrations that take visible_set_fn, the two dispatch lambdas that take dispatch_has_permission, and the response-visible-set seam through its own case); that catches a dropped or re-inlined binding, a wrong or swapped store, and a delegation in dead code, but not a delegation wrapped in a block comment or #if 0, and not a route rebound to a different resolver that keeps the same use-site count. With enforcement on and no management-group store, a caller without a global grant gets deny or empty (a global grant still sees the fleet). Not covered: a fresh run of the header under MSVC outside CI.

Consequences accepted for v1 (recorded, not oversights): fleet-wide aggregates with no per-agent identity (e.g. get_dex_perf_fleet, get_network_fleet) stay denied at C8 — a confined label with no real downstream mechanism would be an unenforced claim; re-admission is a Phase 2 kServiceScopeGlobalSafe entry with security-guardian sign-off, not an inferred-safe classification during a routine change. Service-tag writes (whoever sets an agent’s service tag moves scope) are hardened as of #3289 — a service-scoped session is denied, value-blind, before writing/deleting the service key at every REST/legacy-dashboard/MCP tag-mutation site, and the agent’s own gRPC Register sync path no longer accepts an agent-claimed service value at all; plain Tag:Write/Tag:Delete remains sufficient for non-service-scoped (already fleet-scoped) holders. A related but distinct gap — a live agent’s in-memory self-reported tags shadowing the store during scope-DSL evaluation — was tracked separately as #3295 and is now closed: evaluate_scope’s tag:<key> resolver is store-first (a TagStore row of any source wins over a connected agent’s live claim; the session value answers only when the store has no row at all), and register_agent drops an agent-claimed service key from the session at ingest. No cached derived confinement sets. Dispatch’s supersede→intersect migration (§3d, four authorize_list_read callers) is deferred, not part of this PR. Bootstrap note: an empty-cohort service token cannot bootstrap its own scope via any route — and since #3289, neither can an agent via its own Register sync; onboarding a brand-new service still needs an interactive/unscoped path; see docs/user-manual/authentication.md.

Known-but-unfixed instances predating this PR were tracked as GitHub issues #3123 (device discovery), #3124 (response/execution data), #3125 (inventory data) — this PR proposes closing them as fixed-by-flip (every instance they list reaches require_permission/require_scoped_permission, which now denies by default), pending review; per the issue-standard, automation does not close a security-labelled issue unilaterally. Check each issue’s current body before citing an instance count from it historically — issues were edited down as fixes landed elsewhere.

ADR: docs/adr/1006-service-scope-default-deny.md records the design-of-record — the two-axis composition, the branch-order rule and the two rejected hoists, the two enforcement shapes behind one composer, why RbacStore stays single-axis, and the four decisions above (RBAC-off hard-403, the two-gate split, explicit-deny for §3e, ADR-in-same-PR).

Engine principals & delegation (ADR-1005 — design)

Stream liveness is cached; authorization is not (#2367). The per-tick re-check that keeps a held-open MCP/SSE stream alive reads the engine principal through EnginePrincipalStore::get_for_auth_revalidate — a 15 s, Active-only liveness cache with exactly one caller, the private AuthRoutes::engine_credential_state. Every fresh authorization decision — session synthesis, and the REST/MCP on-behalf-of target checks — still calls the authoritative get_for_auth and reads through to Postgres on every call, so a revoked principal cannot obtain a new session or a new delegation at any point. A cached answer is reported as auth::CredentialCheck::kValidStale rather than kValid, which is what stops a stream from riding the cache and then collecting a full fresh outage-grace window on top of it. See ADR-0031 Consequences for the invalidation and bounding rules, and docs/mcp-server.md “Revocation.” for the resulting latency bounds.

Catastrophic invariants (routed-concern detail)

These are the clauses .claude/routed-concerns-access-control.md routes here. A service-scoped API token (session->token_scope_service non-empty) must never reach fleet-wide data or actions unfiltered.

  1. AuthRoutes::require_permission’s service-scoped branch is the PRIMARY closure mechanism. ITServiceOwner is the authority ceiling only; a (securable_type, operation) pair must ALSO clear the seeded-EMPTY kServiceScopeGlobalSafe allow-list (service_scope_policy.hpp) or it 403s. Widening that allow-list is a security decision — security-guardian sign-off plus a policy-table entry, never inferred from one route being changed. The ceiling half is ONE shared check, authz::service_ceiling_check (authz_gates.{hpp,cpp}), called by require_permission, require_scoped_permission and require_fleet_read alike: extend it, never fork it. A Degraded verdict is a retryable 503 on the last two; require_permission lets it fall through to its allow-list instead (see the fleet-read ceiling paragraph above). A new service-token gate calls authz::service_ceiling_check, which keeps a failed read distinct from a deny; there is no bool wrapper over the role-permission read that would fold the two.

  2. Branch order in require_permission/require_scoped_permission is fixed — elevated → engine → mcp_tier → service → RBAC-enforced → legacy — never reorder it. The elevated branch is guarded (is_elevated && token_scope_service.empty()); the mcp_tier branch must never return true on its own (deny, or fall through into the service branch only). A tier-admit that bypasses the service check reopens the flip on every route at once.

  3. MCP’s mirror is C8’s ServiceScopeClass — denied by default; confined requires a REAL downstream confinement mechanism (a label with none is the same lie this concern exists to prevent); global_safe boot-validates against the same allow-list.

  4. A route reaching agent/fleet data via NO require_permission/require_scoped_permission/C8 call at all is untouched by the flip and needs its OWN explicit deny — EXTEND an existing per-file helper, never fork a new one. The helpers: AuthRoutes::deny_service_scoped_session (server.cpp), ComplianceRoutes::deny_service_scoped_, WorkflowRoutes’ local deny_service_scoped_schedule_list/deny_service_scoped_scope_estimate, deny_service_scoped_/deny_service_scoped_mutation_ (guardian_routes.*), deny_service_scoped_ (dex_routes.*, deployment_routes.*, preflight_routes.*), the shared deny_fleet_wide_service_scoped lambda (rest_api_v1.cpp, mcp_server.cpp), plus inline checks in device_routes.cpp/network_routes.cpp/inventory_routes.cpp/echo_tree_routes.cpp.

    These per-file helpers are now a SECOND, largely-redundant layer for routes the flip already covers — scheduled for Phase 2 retirement, not removed here. schedule_routes.*’s deny_service_scoped_schedule was the first FULLY retired (#3290 Phase 2 bucket 1a, 2026-08-21): all four of its call sites were provably dead (they fired after require_permission), unlike the still-live-but-redundant helpers above, which fire before their route’s gate and so are a different, more cautious retirement class.

  5. A blanket deny’s A4 error body MUST NOT name a .permission the caller does not currently hold if holding it would not, by itself, admit the caller — omit the field rather than claim a false self-remediation grant. (Found live in PR 3’s own require_permission/require_scoped_permission service branches, and fixed in the same round that wrote the clause.)

    This MUST binds: every branch inside the service-scoped path of require_permission/require_scoped_permission; every NEW gate-less-route deny added after this clause was written; and every site in (4) already migrated to the no-.permission a4_denial shape — as of that round: AuthRoutes::deny_service_scoped_session, ComplianceRoutes::deny_service_scoped_, WorkflowRoutes’ deny_service_scoped_schedule_list/deny_service_scoped_scope_estimate, GuardianRoutes::deny_service_scoped_/deny_service_scoped_mutation_, DexRoutes::deny_service_scoped_, DeploymentRoutes::deny_service_scoped_, and AuthRoutes::require_list_read’s inline service-scoped deny (it refuses the whole credential class, so its body carries no .permission). #3167 closed the remainder — PreflightRoutes::deny_service_scoped_’s 3 call sites plus its separate inline deny on the MUTATING POST /fragments/auto/run, schedule_routes.*’s deny_service_scoped_schedule and its 4 call sites (each already passed a correctly-typed permission per operation — the defect was unactionability, not a wrong permission TYPE, a claim the routed row previously made in error), and the inline checks in device_routes.cpp/network_routes.cpp/inventory_routes.cpp (×2)/ echo_tree_routes.cpp (×2). #5047 added AuthRoutes::require_tier_policy’s actionable_permission parameter — a THIRD shape, not a fourth call-site-by-call-site migration: a single production factory, AuthRoutes::gateless_tier_policy_fn(), is the ONE place all 8 gate-less-route call sites (the 4 result-set REST write routes plus their 4 dashboard-fragment twins) source their TierPolicyFn from, with actionable_permission=false baked in there once — require_permission’s own call (RBAC-gated, the grant WOULD admit the caller) passes true explicitly at its one call site. actionable_permission deliberately carries no default value, so a future 9th gate-less caller cannot inherit true by omission the way the original violation this clause exists to prevent did.

    One known, tracked exception, not retroactively bound: pre-existing deny_fleet_wide_service_scoped call sites that fire AFTER the route’s own perm_fn already confirmed the caller holds that exact grant, where .permission names a grant the caller demonstrably already has — a different, informational claim. GET /api/v1/inventory/software no longer illustrates this (#3290 retired its after-gate deny entirely, migrating the route onto require_fleet_read). NO LIVE EXAMPLE CURRENTLY EXISTS: an exhaustive check of every remaining deny_fleet_wide_service_scoped call site — 27 in rest_api_v1.cpp and 14 in mcp_server.cpp (live counts measured by grep deny_fleet_wide_service_scoped( against each file, excluding the shared lambda’s own definition and comment mentions — these counts drift as routes are added or migrated onto require_fleet_read, and are not a maintained invariant) — found every REST site fires BEFORE its route’s perm_fn, not after (see docs/security-reviews/service-scope-phase2-migrations-2026-08.md’s migration checklist). deny_service_scoped_schedule (previously 4 sites, permission param defaulting empty) and the one MCP site whose surrounding perm_fn fired first (get_dex_group_app_perf) are no longer part of this count at all — both were retired outright in bucket 1a as provably dead, not merely candidates that failed to match the exception shape.

    Do not read “no current instance” as “no longer needed”: the next route to add an after-gate deny_fleet_wide_service_scoped call with a non-empty .permission becomes the example, and this clause still governs it. A gate-less-route deny not yet audited against this clause is NOT assumed compliant.

  6. The tag being read to decide admission must never be mutable by that same admission (#3289). require_scoped_permission’s service branch reads the target agent’s service tag to decide whether to admit a Tag:Write/Tag:Delete — so without a separate guard it would authorize the very write that changes that tag, letting a service-scoped token move an agent out of (or a different agent into) its own confinement.

    EXTEND authz::service_scope_may_mutate_tag_key (service_scope_policy.hpp) at every tag-mutation site — REST v1 PUT/DELETE /api/v1/tags, the legacy dashboard /api/tags/set//api/tags/delete (via AuthRoutes::deny_service_scoped_service_tag_mutation), and MCP set_tag/delete_tag — checked BEFORE the scoped gate, value-blind (no read-compare TOCTOU: even a no-op rewrite of the token’s own current value is denied).

    The agent’s own gRPC Register→TagStore::sync_agent_tags path is the same class of gap on a DIFFERENT axis (no gate at all, not a TOCTOU) — authz::is_service_tag_key is filtered there too, silently dropping an agent-claimed service value rather than refusing the whole sync (a pre-existing agent-authored row self-heals on the next sync).

    A live agent’s in-memory self-reported tags shadowing the store during scope-DSL evaluation (agent_registry.cpp) was a THIRD, distinct mechanism this clause did not cover, tracked separately as #3295 — now CLOSED: the tag:<key> resolver is store-first (a TagStore row of any source wins over a connected agent’s live claim; the session value answers only when the store has no row at all), and register_agent drops an agent-claimed service key from the session at ingest, mirroring this clause’s own store-side purge.

Full design, decisions, and the closed route inventory: docs/adr/1006-service-scope-default-deny.md and docs/security-reviews/service-scope-flip-route-inventory-2026-08.md.

Lifecycle surface (PR 4.3)

Operator-facing CRUD for engine-principal identities and their credentials — full reference: docs/user-manual/rest-api.md “Engine Principals” section, docs/user-manual/mcp.md tools 55–63 (52–54 are the role-assignment tools), docs/user-manual/engine-principals.md walkthrough.

Human API-token rotation (P2 #11, SOC 2 CC6.3)

Self-service overlap-pair rotation for human-owned API tokens — ApiTokenStore::rotate_token/confirm_token_rotation (server/core/src/api_token_store.hpp), the human-arm sibling of rotate_engine_credential/confirm_rotation documented under “Engine principals & delegation” → “Overlap-pair credential rotation” above. This section covers the capability’s design rationale; the wire surface, error matrix, and telemetry are documented once each, cross-referenced below — this section does not restate them.

Agent enrollment (3 tiers)

Per-session peer binding and NAT-aware relaxation

Register and Subscribe are separate gRPC connections correlated by a session_id. To stop a sniffed session_id from being replayed from another host, Subscribe is bound to the Register connection by two layers:

NAT-aware relaxation (#1128). Exact-IP binding false-rejects a legitimate agent whose Register and Subscribe egress different public IPs (multi-egress NAT, proxy pool, CG-NAT, SD-WAN). Strict exact-match is the default; two opt-in accommodations downgrade a mismatch to advisory (audit + metric, no reject) instead:

  1. mTLS-advisory — --nat-trust-mtls-identity (Config::nat_trust_mtls_identity, default off) — when enabled, a verified client identity matching the one bound at Register treats the IP as defence-in-depth only, so the mismatch is tolerated. Opt-in because it is safe ONLY with per-agent client certs: a shared/fleet-wide cert makes every identity “match”, which would let an insider agent replay another agent’s session from its own IP (gov UP-2). Off by default — identity-match never relaxes the IP binding unless the operator affirms per-agent certs via this flag.
  2. --trusted-nat-cidr <cidr>[,…] (Config::trusted_nat_cidrs) — when the Register and Subscribe IPs both fall inside one operator-declared range (analogous to --gateway-mode, but for direct-connect NAT). Declaring a range asserts the hosts in it are mutually trusted not to replay each other’s sessions — keep ranges narrow (never 0.0.0.0/0). Malformed entries are logged and ignored at startup.

A mismatch outside both accommodations is still a hard reject — the replay guard is intact, and an empty/malformed extracted IP is always reject (#826: empty is a mismatch, never a wildcard). A tolerated mismatch emits yuzu_grpc_subscribe_peer_advisory_total{event="security",reason=…} plus a session.peer_mismatch audit row with result="ok" outcome=advisory. The pure decision lives in AgentServiceImpl::evaluate_peer_binding (unit-tested); CIDR containment in cidr_match.{hpp,cpp}.

Gateway origin-IP attribution (#1064). On the gateway ProxyRegister path the server’s transport peer is the gateway’s IP, so audit rows would mis-attribute the source (SOC 2 CC7.2). RegisterRequest.gateway_observed_peer (an optional, gateway-authoritative, transport-agnostic field — carried in the message, not in transport metadata) carries the agent’s origin IP; the server records source_ip=agent origin and gateway_ip=transport peer, falling back to the gateway IP (origin_observed=false) when absent. The direct Register path ignores the field, so a direct agent cannot forge a source IP. It is not a defence against a compromised gateway (which is inside the trust boundary and can set any value) — both source_ip and the gateway’s gateway_ip are recorded so an auditor can cross-check. The gateway sets the field itself and discards whatever the agent sent (yuzu_gw_agent_service:register/2): the value is the call’s origin address as the gateway’s own gRPC layer decides it — the transport peer by default, or the address a trusted proxy vouched for when the listener is configured with an origin mode (proxy_protocol or forwarded_for, judged by who connected, never by whether the claim matches; a claim from a peer that is not a configured proxy is ignored, and attributed to its TCP peer) — as a bare IP text. Empty means no observation: on a listener configured as proxied, a trusted proxy that gave no usable address (a PROXY LOCAL, a missing or unusable x-forwarded-for) is sent as empty rather than attributed to the proxy itself, and the server then records source_ip=the gateway node with origin_observed=false. A listener left in transport mode behind a proxy that was never configured records the proxy’s address and nothing flags it. The server cannot tell a gateway that overwrites the field from an older one that forwards the agent’s value, so all gateways must be upgraded before source_ip is relied on. See docs/user-manual/gateway.md, “Client addresses behind a proxy”.

HTTPS and bind defaults (hard invariants)

Default certificates (PKI PR2, v0.13.0+)

A fresh install no longer refuses to start without operator certs. On first boot the server generates a per-install internal CA (ECDSA P-384, 10-year) and P-256 leaves for the HTTPS, agent-gRPC, and management-gRPC listeners under the cert directory (auth::default_cert_dir(); override with --ca-dir), recorded in ca_store (Postgres, ADR-0053). Implementation: default_certs.{hpp,cpp} on the x509_ca/key_provider/ca_store engine. Behaviour:

Per-agent mTLS (PKI PR3, v0.13.0+)

When the server runs with its built-in CA, agents are issued their own client certificate at enrollment, so the agent↔server data plane is full mutual TLS with a cryptographic identity bound to agent_id. This makes the existing peer-identity binding (#1118) cryptographic with no new binding mechanism — the issued leaf’s CN is the agent_id the server already checks.

Issuance happens on both the direct Register and the gateway-proxied ProxyRegister paths (PKI PR5d — both share one sign_agent_csr chokepoint); see docs/pki-architecture.md “Per-agent enrollment through the gateway” for the gateway specifics (the agent↔gateway hop is one-way TLS in M1, so through the gateway the agent’s address is attributed by the app-layer gateway_observed_peer — attribution, not authentication — until gateway mTLS lands).

Bootstrap (chicken-and-egg) — resolved on one port. The agent has no client cert on first boot, but the data plane requires one. Resolution:

  1. The agent connects server-authenticated TLS (verifies the server leaf against the CA cert it was given via --ca-cert), presenting no client cert.
  2. The agent generates an EC P-256 keypair + a PKCS#10 CSR and sends the CSR in Register (RegisterRequest.csr_pem). The agent’s private key never leaves the host.
  3. When enrollment is approved (token / attestation / admin-approve — unchanged) and the built-in CA is active, the server verifies the CSR’s proof-of-possession, signs a client leaf — CN=<agent_id> + URI SAN yuzu://<ca-fingerprint>/agent/<agent_id> — sized to ≤ the CA’s notAfter, records it in ca_store (purpose=agent), and returns it in RegisterResponse.issued_certificate + issued_ca_chain. The CSR’s own subject/SAN are ignored — identity is set by the server from the authenticated enrollment, never from attacker-controlled CSR fields (this is what stops an enrolling agent requesting another agent’s identity).
  4. The agent persists the leaf + key (0600) + chain under --cert-dir (default <data-dir>/certs), rebuilds its channel, and re-Registers presenting the leaf — a fresh session whose bound identity is the leaf’s CN. (The first, no-cert session bound an empty identity, so the data plane would reject it; re-registering binds CN=<agent_id>.)

App-layer enforcement. Register is the only RPC permitted without a verified client identity (it is how an agent obtains one). Enforcement is gradual, so per-agent mTLS rolls out without breaking a heterogeneous or mid-upgrade fleet:

Rollout / upgrade. Because enforcement is gradual, upgrading a fleet to PR3 is non-breaking: agents that have not yet auto-provisioned (or run --no-auto-provision-cert, or a pre-PR3 binary) keep connecting on session + peer-IP binding, while provisioned agents get strict mTLS. Once a fleet is fully provisioned, a future --require-agent-identity flag (tracked follow-up) can harden this to require a bound identity for every agent and reject the unprovisioned fallback. Folding revocation + identity into a single gRPC interceptor so every identity-requiring RPC enforces them uniformly is the related follow-up (today they are enforced at Register, Subscribe, Heartbeat, DownloadUpdate).

Custody & renewal. The CA issuing key is loaded transiently per signature via FileKeyProvider and zeroed (RAII) so the crown jewel is not resident for the process lifetime. Server issuance is fail-closed: a cert that cannot be recorded in ca_store (so it could never be revoked) is not handed out, and per-agent issuance is rate-limited (one signature per agent_id per 30 s) so a holder of a valid enrollment credential cannot spam the signer. Agent leaves are ~1-year and auto-renew once two-thirds of their lifetime has elapsed (evaluated at agent start; a fresh CSR rides the next Register). Issuance is audited (ca.cert.issued). On the agent, the leaf key is written 0600 via an atomic O_EXCL stage-and-rename on POSIX; on Windows the key falls back to std::ofstream with no DACL tightening — an explicit owner-only ACL (SetNamedSecurityInfoW) is a tracked follow-up shared with the server’s FileKeyProvider, so on Windows run the agent under a dedicated service account with no inherited group-read on the cert directory until then.

Trust scoping. Presented client certs are accepted as agent identities only when they signature-verify to our issuing CA (verify_chain), so in a multi-CA trust bundle a foreign cert carrying a matching CN is not mistaken for a Yuzu agent (nor conflated with a revoked Yuzu serial). If the server signs but the agent never receives the cert (an active MITM stripping the field, or a persistent signer outage), the agent bounds its retries and gives up auto-provisioning for that run rather than looping.

Agent CA pinning is fail-closed (#1303). When the agent has TLS on but no CA to pin — no --ca-cert and no install CA auto-discovered at any standard shared-cert path (/etc/yuzu/certs/default-ca.pem, ProgramData on Windows, or ~/Library/Application Support/Yuzu/certs/default-ca.pem for a non-root agent on macOS) — it refuses to connect rather than silently falling back to the system trust store. An empty root set makes gRPC verify against the OS roots, which do not trust a Yuzu self-signed install CA, so with the gateway one-way-TLS edge live any publicly-trusted impostor cert for the dial host would be accepted — a fail-open MITM on the command fan-out plane. The deliberate escape hatch is --tls-system-roots / YUZU_TLS_SYSTEM_ROOTS, for the legitimate case where the server certificate chains to a public or corporate CA already in the system store; it logs a loud warning and is never the default. (--no-tls remains the dev/demo opt-out.)

Operator surface. Server: --ca-dir (shared with the default-cert bootstrap). Agent: --cert-dir / env YUZU_CERT_DIR (where the provisioned credential lives) and --no-auto-provision-cert (disable the CSR-at-enrollment flow — e.g. when supplying an operator-minted client cert via --client-cert / --client-key, or an OS-store cert). The provisioned credential is written under --cert-dir as agent-client.key (private key, 0600), agent-client.pem (the issued leaf), and agent-ca.pem (the issuing CA chain the agent pins the server against). Deleting these files makes the agent auto-re-provision on its next enrollment: it generates a fresh keypair + CSR and the server signs a NEW leaf with a NEW serial. The previously-issued serial stays in ca_store inventory as a now-orphaned agent row that no live agent holds — harmless, but operators reconciling the issued-cert inventory should expect one orphan row per key-loss event (revoke the orphan if a strict inventory is required). Revocation-bypass guard (#1239 H-2): auto-re-provision is refused when the agent’s prior cert is revoked (not merely orphaned). sign_agent_csr scans ca_store for a revoked, non-expired cert with subject==agent_id and, if found, returns nullopt (audit ca.cert.reissue_blocked, metric yuzu_server_ca_reissue_blocked_total{reason=revoked_identity}) — so a compromised endpoint cannot drop its key and re-enroll its way back onto the data plane. Clearing a revocation is a deliberate operator re-approval, never an automatic consequence of key loss. Implementation: server signer in server.cpp (sign_agent_csr / is_peer_cert_revoked) on the x509_ca/key_provider/ca_store engine; agent provisioning in agents/core/src/agent_csr.{hpp,cpp} (self-contained OpenSSL) wired into the agent.cpp connect/register loop.

Gateway-proxied agents: revocation scope (known limitation). Per-agent mTLS identity and revocation enforcement are authoritative on direct connect only. A gateway-proxied agent terminates its TLS at the gateway; on the gateway→server hop the server’s transport peer is the gateway’s cert, not the agent’s leaf — so the server-side revocation gate and the open-stream sweep above never see the proxied agent’s serial, and a revoked agent behind a gateway stays functional on the data plane. PR5d closes the issuance half of this gap (gateway-proxied agents now obtain a per-agent leaf via ProxyRegister CSR-signing, so the identity exists and is recorded/revocable in ca_store), but enforcing that revocation at the gateway edge is future work: durable cryptographic through-gateway identity (and therefore through-gateway revocation) arrives with the gRPC gateway-hop identity design, #5578 (ADR-0066; the QUIC migration, #376, was withdrawn). Until then, to revoke a gateway-proxied agent promptly, revoke at the gateway/management layer (disconnect the agent) in addition to POST /api/v1/ca/revoke. This is the same direct-connect-authoritative caveat called out in docs/pki-architecture.md (“Gateway path identity”).

Gateway CSR-swap forgery (R-5, accepted M1 residual). Wiring ProxyRegister to sign forwarded CSRs makes the gateway a bounded confused deputy. The server signs the relayed CSR’s public key under the gateway-supplied agent_id (through the gateway the agent_id is a gateway-supplied message field and the agent’s address is the app-layer gateway_observed_peer: attribution, not authentication, and not a transport-cryptographic binding in M1), so a compromised or on-path gateway can relay a victim’s agent_id paired with its own CSR and obtain a real CA-signed leaf for that agent_id. Worse than a transient relay: that leaf is a durable credential the attacker can present for persistent direct mTLS reconnect, bypassing the gateway entirely — it survives gateway eviction. Compensating controls (why this is accepted for M1, not a live break):

The actual cryptographic remediation — gateway agent-identity attestation + per-gateway issuance scoping so a gateway can only obtain leaves for the agent_ids it legitimately fronts — is tracked in #1292 (cryptographic through-gateway binding is the gRPC gateway-hop identity design, #5578, ADR-0066). Full threat model: docs/security-reviews/pki-pr5-gateway-tls.md; also summarised in docs/pki-architecture.md.

HTTP security response headers (SOC2-C1)

All HTTP responses (dashboard, REST API, MCP, metrics, health probes) carry six headers (the plain-HTTP redirect listener sends the first five): Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy (deny-all baseline for camera/mic/geo/usb/etc.), and Strict-Transport-Security: max-age=31536000; includeSubDomains (HTTPS only, per RFC 6797).

The CSP is fully 'self'-only with no external CDN allowance because the HTMX runtime and SSE extension are embedded in the server binary (server/core/src/static_js_bundle.cpp) and served from /static/htmx.js and /static/sse.js — the dashboard works in air-gapped deployments.

The CSP uses 'unsafe-inline' for script-src/style-src because the dashboard has inline <script>, onclick= handlers, and <style> blocks; tightening to nonce-based CSP requires a separate dashboard refactor. upgrade-insecure-requests is appended to the CSP only on HTTPS deployments.

Operators can extend the CSP via --csp-extra-sources "https://cdn.example.com https://beacon.example.com" (space-separated, validated at CLI parse — control bytes / semicolons / 'unsafe-eval' are rejected at startup with a clear error). The flag’s value is appended to script-src/style-src/connect-src/img-src only.

Header construction lives in server/core/src/security_headers.{hpp,cpp} (yuzu::server::security namespace) — the production server and the unit/integration tests in tests/unit/server/test_security_headers.cpp (38 cases) share the same HeaderBundle::make()/apply() code path. The resolved bundle is logged at INFO at startup so operators can confirm activation: Security headers active: CSP=N bytes, HSTS=on/off, Referrer-Policy="...", Permissions-Policy=N bytes.

Self-target principal-destruction guard (hard invariant, #397/#403/ca-B1)

Any handler that destroys, demotes, or otherwise revokes a principal’s privileges MUST reject the case where the URL/form target equals the caller’s session->username (or differs from session->role for upserts that demote). UI suppression alone is insufficient — a hand-crafted HTTP request bypasses the dashboard.

Load-bearing routes today:

Pattern requirements:

  1. Compare against session->username byte-exact. Fail closed when session->username.empty().
  2. Emit audit_fn_(req, "<noun>.<verb>", "denied", "User", target, "<reason>_blocked") on the rejection branch — spdlog::warn alone breaks the SOC 2 CC7.2 evidence chain.
  3. Corresponding fragment renderers must accept the session username and suppress destructive controls on the matching row (see render_users_fragment(const std::string& current_username) — no default arg, every caller must pass explicitly so a future caller forgetting it is a compile-time failure rather than a silent UI regression).

Scaling note: when the third such handler ships, lift the comparison logic into a helper.

AuthDB — persistent authentication store (Postgres, ADR-0006 — v0.12.0

SQLite retired)

AuthDB is born-on-Postgres, schema auth (AuthDB(pg::PgPool&, pg::SecretCodec&), migrated via PgMigrationRunner, fail-closed construction — is_open()/is_ready() false means the server refuses to start, ADR-0012 §1). It replaced the v0.12.0 SQLite auth.db file (itself the replacement for the original in-memory + on-config-flush model that lost users on every restart, #618/#388/#527) in the ADR-0006 Wave 3 substrate migration. Tables: users, enrollment_tokens, pending_agents, mfa_recovery_codes. Dropped, not migrated: the SQLite-era sessions table (at the time of this cutover sessions were in-memory-authoritative in AuthManager::sessions_, and that SQLite sessions mirror had been a permanent v1 dead-write, never read back — since HA WS-1/1a (ADR-2002 §4) sessions_ is a validate cache in front of the durable Postgres SessionStore; see “Durable operator sessions (HA WS-1/1a, ADR-2002 §4)”) and auth_kv (unused scaffolding, superseded outright by pg::SecretCodec, ADR-0010). ScimStore migrated in lockstep to its own scim_store Postgres schema — see “Storage” under SCIM v2 provisioning above.

This was a fresh-start cutover — NOT a backfill. On first boot against a Postgres database whose auth.users table is empty, main.cpp seeds the config-file admin AND grants it a durable fleet-wide Administrator role, in one transaction, via RbacStore::provision_first_admin (docs/adr/1008-rbac-management-groups-target-architecture.md, “Delivery note (fresh-install bootstrap)”) — AuthDB::seed_admin_if_empty’s own INSERT ... SELECT ... WHERE NOT EXISTS is the same TOCTOU-free shape but is no longer called in production at all (a redundant second call was removed; it stays exported for its own tests) — and logs a loud “RBAC BOOTSTRAP” warning plus a durable rbac.bootstrap.first_admin audit row. A legacy SQLite auth.db is never read — any prior local accounts, roles, and MFA enrollments that existed only in a pre-cutover auth.db are gone on upgrade; SCIM self-heals on the IdP’s next sync cycle; humans re-enroll MFA. This is a breaking upgrade by design, matching the ApiTokenStore fresh-start precedent (4.1) rather than attempting a cross-substrate data migration.

mfa_totp_secret is SecretCodec’s first production consumer (ADR-0010) — see “MFA / TOTP” above for the envelope-encryption shape and the AuthDBError::SecretUnavailable fail-closed contract. Everything else (password hashes, recovery codes, enrollment/SCIM tokens) stays a verify-only hash, unaffected by SecretCodec.

Operator recovery: docs/ops-runbooks/auth-db-recovery.md (rewritten for the Postgres substrate — detection signals, the KEK backup-pairing rule, and the break-glass one-shots’ --postgres-dsn/--ca-dir requirements). Security review record: docs/security-reviews/authdb-2026-04-30.md (SQLite-era baseline).

The hard invariants for AuthDB-touching changes (schema/PgPool construction, fail-closed posture, SecretCodec registration, config-as-seed-only / fresh-start, role-field ignored, gate-level audit, MFA fail-closed on secret-read failure, cleanup cadence, snapshot-and-release publishing) live in .claude/agents/authdb.md — the AuthDB review agent loads them on any change to auth_db.{hpp,cpp} / auth_routes.{hpp,cpp} / auth.{hpp,cpp}.

Availability coupling and the transient-blip ride-out (#2396)

Every password login makes several PostgreSQL-backed AuthDB calls — the lockout pre-check, record_failed_login/clear_failed_logins, and mfa_status. Under ADR-0006 the auth substrate is fail-closed with no SQLite fallback, so a store error on the MFA read (or the lockout write) returns a 503 and mints no session — deliberately, because collapsing an unreadable enrolled-MFA state to “not enrolled” would silently strip a privileged account of its second factor (see “MFA / TOTP” above). The cost is that a transient Postgres outage — most commonly the shared pool’s connect-backoff breaker fast-failing every acquire for its 200 ms–5 s window after one connectivity hiccup — could deny all logins until it cleared.

To keep a momentary blip from becoming a console lockout, the login-decision reads mfa_status and load_mfa_row retry the acquire within a small bounded budget (acquire_with_retry in auth_db.cpp: the first acquire is attempted with kReadTimeout (1500 ms), then up to kAcquireRetries short retries; worst-case ≈600 ms extra on top of the first attempt). Since #2146 A2-R1 Gate 8’s fast-fail-on-saturation clamp landed at the shared PgPool chokepoint (pg_pool.hpp’s Options::saturated_fast_fail, ADR-0012’s 2026-09-20 Update), the first acquire no longer necessarily gets its full kReadTimeout budget: if the pool is already saturated at that instant, the wait is clamped to min(1500 ms, 500 ms) = 500 ms instead. This makes the worst case FASTER under saturation (≈1100 ms total instead of ≈2100 ms), not slower; the 150 ms kAcquireRetryTimeout retry leg was already below the 500 ms clamp and stays unaffected. mfa_status is the exact call #2396 names as denying all logins: it 503s a legitimate, correct-password login on a blip. This never weakens fail-closed: only the acquire is retried (a query that ran and errored is not retried — it will not self-heal in milliseconds), and on budget exhaustion the call still returns a store-unavailable error (AuthDBError::StoreBusy, which is_store_unavailable() treats as unavailable) → 503, no session.

The retry is deliberately not applied to the lockout-section acquires (lockout_status, record_failed_login, clear_failed_logins), which run under the /login per-username stripe mutex: sleeping under that mutex during an outage would extend the per-username hold and let a same-username login pile-up pin one HTTP worker per attempt, starving unrelated routes (worker-pool starvation, closed in governance review). Those reads lose nothing by not retrying — lockout_status/clear_failed_logins fail open (a blip degrades the brute-force throttle for that attempt but never denies a valid login), and record_failed_login only gates a wrong-password attempt. So no acquire_with_retry call ever sleeps under the login stripe.

The remaining 503s are made honest and observable: they carry a Retry-After header and a retry_after_ms body field, and each fail-closed refusal in the initial POST /login handler increments yuzu_auth_read_degrade_total{route,reason} alongside the existing yuzu_auth_secret_unavailable_total{route}. The reason label distinguishes a transient acquire outage (pool_acquire_timeout) from a query that ran and errored (query_error) and from an undecryptable/absent secret (secret_unavailable), so SRE can tell a retry-storm apart from a uniform outage. Use yuzu_auth_read_degrade_total{reason="secret_unavailable"}, not the coarser yuzu_auth_secret_unavailable_total, to alert on a KEK/secret outage — the latter also moves on a plain pool blip (it counts every store-unavailable 503 on the secret path, not only secret-decrypt failures). The approve and reject routes (route="approval") increment only the coarser counter, never yuzu_auth_read_degrade_total, so that series does not cover them. The other is_store_unavailable→503 auth sites (/login/mfa, step-up, enrollment, elevate) are not yet reason-instrumented (finer cardinality and wider coverage tracked as #2401).

Not addressed here, by design: break-glass arming (--break-glass-arm) and --mfa-reset are host-CLI one-shots that also require a reachable Postgres and have no PostgreSQL-free local escape hatch — that is inherent to the ADR-0006 fail-closed posture, and the mitigation for a sustained database outage is Postgres high availability (the /ha workstream), not a local bypass that would itself weaken the fail-closed guarantee. --postgres-pool-size is the operator lever for reducing acquire contention on the live server (the login path runs on the shared server pool).

Role recheck at login — row-lock plus post-mint recheck (#4107)

Local-auth credential checks (authenticate()/verify_password()) re-verify role against AuthDB after the password check, rather than trusting the in-process cache: AuthManager::recheck_role_after_credential_check calls AuthDB::recheck_role_locked, which takes a SELECT ... FOR UPDATE row lock on the user’s row and holds it across the in-process cache write — the same technique mfa_verify_login_code already uses to close its own replay race. This closes the same-process AND cross-replica divergence residual an earlier, now-retired in-memory version counter could only narrow (a version counter can tell you something changed since you looked; it cannot make your look happen atomically with the change) — Postgres row locks serialize at the database-engine level, not per-process, so a racing update_role()/ reactivate_user() from a different replica sharing the same Postgres primary is serialized against this read exactly like a same-process writer.

What the row lock alone does NOT close: the check-then-mint gap. A role this call observes under the row lock is provably fresh at the moment of the read — but persist_new_session (the actual session mint) is a separate, later step, outside the row lock (external adversarial review, fjarvis, PR #4076 — “an inherent check-then-mint gap no non-serialized recheck can close”). A demotion committing in the window between this function returning and the mint completing would otherwise mint a session at the pre-demote role, surviving that demotion’s own session sweep (update_role’s std::erase_if(sessions_, ...), which already ran before the new session existed).

Closing that gap by holding AuthDB’s row lock across the mint was considered and rejected — persist_new_session calls SessionStore::create in durable mode, and holding one store’s pool lease while calling another is exactly the cross-store-lock deadlock hazard §3 above names for the structurally identical OIDC/SAML deprovision race (SessionStore’s shared write- generation row is the concrete instance: this row lock could end up waiting on that row while some other path holds the gen row and needs this row lock). Closed instead via the SAME pattern already used for OIDC/SAML: mint normally, then a post-mint re-check (AuthManager::post_mint_role_recheck) immediately re-verifies the just-minted role against a fresh AuthDB read and, on divergence, a store error, or the account having no active AuthDB row at all (never provisioned, or removed - the same UserNotFound branch recheck_role_after_credential_check uses), revokes the session (invalidate_user_sessions) and denies. Wired into both authenticate() and create_local_session() — the latter also closes the same gap for the MFA login-challenge (TOTP/ recovery verify at /login/mfa) and enrollment-confirm routes, whose caller-supplied role can be stale across an entire TOTP round trip, a wider window than authenticate()’s own — NOT /login/mfa/stepup, which re-proves MFA on an existing session and never calls create_local_session.

The honest guarantee. For the SAME-PROCESS/single-primary-Postgres case this is airtight, not merely narrowed: a racing update_role() commits its AuthDB UPDATE before taking mu_ to sweep sessions_, and the mint’s own mu_ write is mutually ordered against that sweep, so either the sweep (running after the mint) removes the just-minted session, or the post-mint recheck (running after the demote’s already-committed write) catches the divergence directly — one of the two always fires. Ordinary READ COMMITTED visibility extends the same guarantee across replicas sharing one primary, with no per-replica coordination needed. Not closed: a demote landing strictly between the post-mint recheck’s own read and the response/ Set-Cookie actually reaching the client is invisible to this mechanism — closing that would mean holding the mint’s transaction open all the way to the HTTP response, a larger change than a role recheck (the same residual OIDC/SAML’s own post-mint recheck discloses, docs/adr/2001-scim-oidc-identity-linkage.md “Known residuals”).

A denied mint is audited via auth.login result=failure, detail=reason=session_mint_failed;cause=undifferentiated[;method=...] (auth_routes.cpp) and counted in yuzu_auth_login_session_mint_denied_total — undifferentiated not just between a genuine role divergence and a post-mint store error, but also against a plain SessionStore persist failure (an ordinary availability event, unrelated to the role recheck at all), since create_local_session’s caller-facing contract (an empty string) collapses all three into one sentinel. Unlike the OIDC/SAML counters’ genuine-vs-store-unavailable split, this counter is not a role-recheck-specific signal and must not be alerted on as one.

RbacStore — the authorization substrate (Postgres, ADR-0041 — SQLite rbac.db retired)

RbacStore (server/core/src/rbac_store.{hpp,cpp}) is the authorization substrate: role definitions, role→permission grants, principal→role assignments, RBAC groups + membership, and the global rbac_enabled flag. require_permission / require_scoped_permission / authorize_list_read (World-A confinement, ADR-0017) / the engine-principal default-deny path (ADR-0031) all resolve through it. It moved from the SQLite rbac.db file to the server’s PostgreSQL substrate in the ADR-0041 (Wave 2.1) migration, schema rbac_store, born-on-Postgres on the shared server PgPool with fail-closed construction (!is_open() → the server refuses to start, ADR-0007/0012 §1). It is the highest-blast-radius store on the migration ladder — a defect here is a fleet-wide authorization failure — and reuses the shared pool, so it adds no new flag or environment variable.

Reads FAIL CLOSED — deny-on-degrade (the load-bearing invariant). Every authz read keeps its bool/deny-on-error contract: a store-not-open, pool-acquire timeout, or query error returns deny (false for the check_permission / check_scoped_permission / holds_permission_via_any_group bool checks; the empty/most-restrictive result for the list/scope reads), NEVER allow. Where a caller needs to distinguish “denied” from “store degraded” for a 403-vs-503 decision (e.g. authorize_list_read), that is exposed via a separate tri-state / std::expected accessor — the plain bool path stays deny-on-error so no existing chokepoint can regress to fail-open. This closes the prior “fails open on a corrupt rbac.db” hole: under SQLite a corrupt/unreadable RBAC store could fall through to an engaged allow at some call sites; the PostgreSQL rbac_store now denies on any degrade.

Cross-replica coherence — durable generation token. Each replica keeps its in-process permission cache (perm_cache_) but validates it against a durable rbac_meta.write_generation counter bumped in the same transaction as every mutation (assign_role, unassign_role, set_permission, role/group changes, set_rbac_enabled). A read refreshes its cached view of the durable generation at most once per kRbacGenerationRefreshMs (1000 ms — one cheap indexed single-row SELECT, not a permission re-query) and clears perm_cache_ on a change; a local mutation bumps the counter and clears its own cache immediately. This bounds cross-replica staleness to the refresh interval under normal conditions: a revoke on replica A is typically visible on replica B within ~1 s. That bound is a target the refresh loop aims for, not a hard guarantee: the interval-gating timestamp is claimed BEFORE the query runs (deliberately, to prevent a refresh stampede), so a concurrent reader that lands while a refresh is genuinely slow (pool saturation, a Postgres blip) can observe staleness beyond the interval. That condition is now measured, not silently assumed: a durable-completion timestamp tracks only actual refresh successes (separately from the stampede-gating timestamp), and a reader who misses the gate while already past the bound counts a stale_beyond_accepted_bound degrade (fjarvis #2703 F3) — the read still proceeds from the existing cache; nothing is denied. Updated 2026-08-11 (#2703 Gate 7 merge-slice, ADR-0041 “Update” section — supersedes the original “assume changed” text below it): a failed generation refresh no longer clears the cache immediately. Trust is extended for a bounded ~5 s window (kRbacStaleServeBoundMs) past the last confirmed-good refresh — a deliberate bounded-staleness-for-continuity tradeoff, layered underneath the ~1 s propagation target above. Only once that 5 s bound is exceeded is the failure treated as “assume changed” (cache cleared) and counted as a generation_refresh_failed degrade. A separate fail-fast circuit breaker (2 consecutive pool-acquire/query failures, including those of the ITServiceOwner ceiling read behind service-scoped tokens) independently bounds how long an uncached check can block on a doomed pool — it denies such checks immediately once open, but it does not itself clear the cache or shorten the 5 s bound; a cache hit is served regardless of breaker state. The breaker counts consecutive failures and any successful authz read, a ceiling read included, resets the count, so a partial fault that lets the role_permissions read succeed while other authz reads fail can delay the breaker opening for operators’ cache-miss checks. The bound is tight only for pool-acquisition failure (no connection available within the 250ms acquire budget — well under a second for 2 consecutive attempts). A query that acquires a connection and then blocks on a PostgreSQL-side lock inherits PgPool’s lock_timeout (10s default) instead — measured ~18.5s for 2 such attempts against a live held ACCESS EXCLUSIVE lock on rbac_meta (#3016); both modes still converge on a fail-closed deny, just not at the same speed. See docs/enterprise-readiness-soc2-first-customer.md’s “Availability posture under PostgreSQL degradation” note for the full mechanism and its CAIQ characterization. The rbac_enabled flag propagates on the same durable path. The ~1 s bounded stale-allow window is an accepted, gate-recorded residual risk (well inside the fleet’s minutes-scale revocation-latency envelope); LISTEN/NOTIFY (window → 0) is the named follow-up. The 5 s stale-serve bound above covers this flag’s cached view too, not just perm_cache_: rbac_enforcement_in_effect() — the fail-closed accessor every confinement-critical caller MUST use instead of the raw is_rbac_enabled() — consults rbac_enabled_view_degraded(), which is gated by the same kRbacStaleServeBoundMs window as the permission cache. A refresh failure inside the bound therefore keeps trusting the last-known-good rbac_enabled state exactly as it keeps trusting cached permission verdicts; only past the bound does the view count as degraded and rbac_enforcement_in_effect() fail closed (treats degraded the same as enabled) regardless of what the raw flag last read.

Terminology note: every “the bound” reference in this paragraph and the ones above it means kRbacStaleServeBoundMs (~5 s, the trust/staleness bound governing when cached state stops being trusted) — a DIFFERENT constant from kRbacGenerationRefreshMs (~1 s, the propagation-target interval discussed earlier in this section, which only governs how often a refresh may even be attempted and carries no trust semantics of its own).

Addendum (2026-08-11, same day, pre-merge, never shipped, G11-CPPEXPERT-B2): “a failed generation refresh” above describes only a refresh attempt that ran to completion and then failed. Gate 8 re-review found the fix missed a second trigger the same 5 s bound needs to cover just as strictly: a refresh attempt stuck in flight and never completing at all — e.g. blocked on the ACCESS EXCLUSIVE-class lock contention discussed above, for up to the full ~10 s lock_timeout. Every concurrent caller during that stall either takes the gated fast-return path (no state touched) or is itself blocked inside its own query, so the completed-failure code path that degrades the cache never ran — trust could be extended for the whole stuck-in-flight duration, not just the intended 5 s. Fixed same-day: the staleness check now measures elapsed time directly rather than depending on a refresh attempt’s own completion, at all three sites that decide whether cached state is still trustworthy (now one shared chokepoint, generation_view_stale_locked()). Never shipped; no SOC 2 assessment period or deployed fleet carried the gap.

Fail-closed BOOT on the rbac_enabled flag. The rbac_enabled flag is durable in rbac_meta. An unreadable OR non-canonical flag at boot refuses to start — the server never serves RBAC-off on a fleet that had enabled it (which would silently make every confined operator fleet-wide-authorized: a catastrophic fail-open). “Non-canonical” means any value other than the exact strings "true"/"false" (fjarvis #2703 F2) — a query error or a missing row was always fail-closed, but a readable value that wasn’t exactly one of those two strings previously coerced silently to false. A schema-level CHECK constraint on rbac_meta.value for this key rejects a non-canonical write outright as defense in depth; the application-level strict parse is what refuses to boot if a bad value ever lands regardless.

migrate_from_sqlite() retired (2026-09-03, #3623, ADR-0041 Update). No production Yuzu fleet has ever run a pre-Postgres build of this store (ADR-0009’s 2026-08-25 fresh-start-by-default amendment), so the mandatory, single-shot, fingerprint-verified backfill this section previously described in full — seed-then-backfill via ON CONFLICT DO NOTHING, the rbac_enabled-flag-first read-back-verified transfer, the revoked_seed_defaults suppression table (still live and unaffected by this retirement — seed_defaults()’s reseed still consults it), the CHAOS-1 concurrency fix (pg_advisory_xact_lock, still live — seed_defaults() and remove_permission() both still take it, only the backfill’s third writer is gone), and the SHA-256 cross-replica marker fingerprinting — is gone. RbacStore::migrate_from_sqlite() and its backfill-only helpers are removed; the two backfill-only rows in rbac_meta (backfill_complete/backfill_source_fingerprint) are POISONED, not dropped, via a version-bumped migration — backfill_source_fingerprint is forced to the retired code’s own kSourcelessFingerprint sentinel ("sourceless"), never deleted; rbac_meta itself stays, still holding rbac_enabled/write_generation. This is the one store in the retirement batch whose marker is ROWS rather than a whole table, and a bare DELETE (the first version of this migration, caught by governance unhappy-path before merge) would have let a pre-#3623 binary rolling back read the resulting absence as “never migrated” and fall straight through its own fingerprint-verification safety net into an unconditional overwrite of the live rbac_enabled flag from a local legacy file — see ADR-0041’s Update for the full trace. server.cpp’s boot path now runs legacy_sqlite_probe::warn_if_legacy_rows over rbac_config (the legacy table that held the enabled flag), securable_types, operations, roles, role_permissions, principal_roles, groups, and group_members instead — WARN-only, never refuse-boot, a posture reopened and explicitly reconfirmed for this store given the enabled-flag stakes rather than defaulted by uniformity with the rest of this retirement batch (see ADR-0041’s Update for the full reasoning). docs/ops-runbooks/rbac-store-backfill-recovery.md, which documented this mechanism’s failure modes, is deleted along with it.

Metrics. yuzu_server_rbac_read_degrade_total{reason} — three DENYING reasons (pool_acquire_timeout / query_error / generation_refresh_failed): each failed read denies the request that needed it (operator permission and list-read checks on a permission-cache miss, service-scoped tokens on every require_fleet_read or require_scoped_permission request whose ITServiceOwner ceiling read fails). A failed role query, the read that precedes a permission check, counts query_error and books a breaker failure on every path that makes it (require_permission’s RBAC branch included; get_effective_permissions, a display read that denies nobody, counts it too), while the ITServiceOwner ceiling read inside require_permission is uncounted. So a non-zero rate on one of these is an authorization-availability event, though not necessarily one that denies every caller, and the YuzuRbacReadDegraded alert pages on exactly this subset. Two OBSERVE-ONLY reasons share the same metric but deny nothing — the read still proceeds — and are deliberately excluded from that alert: rbac_enabled_non_canonical (a periodic refresh saw a non-canonical value; the cached enabled-state is left unchanged rather than coerced) and stale_beyond_accepted_bound (the bounded-stale-serve paragraph above). See docs/user-manual/metrics.md and the YuzuRbacReadDegraded alert in docs/prometheus/yuzu-alerts.yml.

Read split for reviewers. The plain bool authz checks fail closed (deny-on-error) so no chokepoint can regress to fail-open; the tri-state _checked / std::expected accessors are the ONLY place a caller may learn “degraded” (503) as distinct from “denied” (403). A new read must land on the correct side of this split — see ADR-0041 and docs/adr/0017-management-group-confinement-list-reads.md.