| Local password auth (PBKDF2-SHA256) | Shipped (v0.10) | auth.cpp:69 pbkdf2_sha256() (OpenSSL PKCS5_PBKDF2_HMAC + BCrypt path) |
Persistent auth store — Postgres, schema auth (SQLite auth.db retired) | Shipped (v0.12 SQLite → migrated to PG, ADR-0006) | auth_db.cpp born-on-Postgres AuthDB(pg::PgPool&, pg::SecretCodec&), pg::PgMigrationRunner::run(lease, "auth", …); mfa_totp_secret is a SecretCodec envelope column (ADR-0010 first consumer); agent-doc .claude/agents/authdb.md |
| Session-cookie auth (HTMX dashboard) | Shipped | auth_routes.cpp:43,386 (extract_session_cookie, Set-Cookie: yuzu_session=…) |
Durable operator sessions — Postgres SessionStore (HA WS-1/1a, ADR-2002 §4) | Shipped | session_store.{hpp,cpp} (schema-migrated PG store, authoritative); AuthManager write-through with the in-memory sessions_ map as a generation-gated validate cache; DB-clock authority (#3715) — durable timestamps authored from Postgres now(), adjudicated on a local monotonic steady_clock deadline via derive_session_deadlines. A session survives replica restart/failover and validates identically on any replica. |
API tokens — Bearer + X-Yuzu-Token | Shipped | api_token_store.cpp (store); both header forms parsed at auth_routes.cpp:108-119 |
| Owner-scoped token revocation (#222) | Shipped | rest_api_v1.cpp:1058-1082 (owner-vs-admin check at L1060) |
Granular RBAC — 7 roles (adds Reviewer, access-review attestation) × 38 securable types × 8 ops (adds Attest, gated via the dedicated AccessReview securable — NOT AuditLog; the rationale lives in #2324, the access-reviews PR, not #2225, which is the governance-gate-check PR that ran alongside it — and Rotate, P2 #11 SOC 2 CC6.3, ApiToken-specific self-service human-token rotation, seeded only to Administrator/ApiTokenManager, deliberately distinct from Write) | Shipped (Phase 3 + P2 #11) | rbac_store.cpp's seed_defaults types array (std::array<std::string_view, 38>) — the A&A-program 23: Infrastructure, UserManagement, InstructionDefinition, InstructionSet, Execution, Schedule, Approval, Tag, AuditLog, Response, ManagementGroup, ApiToken, Security, Policy, DeviceToken, SoftwareDeployment, License, FileRetrieval, GuaranteedState, Inventory, AccessReview, SoftwareLicensing, EnginePrincipal (#2376 — cut away from the over-broad Security:Read); plus 15 landed via unrelated feature work, tracked here only so the count stays correct, not because they're A&A surface: PluginConfig/PluginSecret/UploadGrant (plugin config/secret/upload-grant plane, PR1.5/1.6), PowerManagement (Wave 6 W1B), Workflow/ProductPack/Directory (#4028-#4032 api-parity FK-seeding fixes — these three were already gating live routes via string comparison before being seeded here, so RBAC-enabled deployments silently couldn't grant them until the fix), TlsConfig/PluginSigning/ServerConfig/AnalyticsConfig (#4028 Settings-read-twins, split by sensitivity), Enrollment/OidcConfig (#4031 admin-only config reads), Forensics/Decommission (Wave 7 forensics class + ADR-0024 Decision 9 erasure gate); ops: Read/Write/Execute/Delete/Approve/Push/Attest/Rotate. Re-verify this count by grepping the array before quoting it — see the routed doc's own "line-number anchors decay faster than status" caution just below; the same caution applies to counts. |
| RBAC admin plane — turning enforcement on/off and assigning roles to human users. Partial: enable toggle + fleet-wide human role assignment shipped; custom-role CRUD, a config/CLI enable path and an SSO/break-glass path are NOT. RBAC still ships OFF and every fresh install is legacy-open until an operator flips it. | Enable toggle + assignment SHIPPED on dev 2026-09-25..27; custom-role CRUD MISSING — on dev only, not in release/v0.14.0-rc1, which predates all three PRs | A2 #4985 (09-26): POST/DELETE /api/v1/rbac/roles/{name}/assignments + MCP assign_rbac_role/unassign_rbac_role; gated on the shared durable-admin predicate is_rbac_administrator() (takes a required RbacAdminSurface{kRest,kMcp}; an MCP-tier bearer token is denied on REST), NOT require_permission; assignable roles are a closed list of 6 (rbac_assignable_roles.hpp: Administrator, PlatformEngineer, Operator, ApiTokenManager, Viewer, Reviewer — ITServiceOwner rejected, it needs a management-group scope this surface lacks; unassign is deliberately not so restricted); last-Administrator guard runs in the same txn as the DELETE, in RbacAdminAuthorityOwner (rbac_admin_authority_owner.{hpp,cpp}, the ADR-0012 §3 query owner). A1 #5030 (09-27): PUT /api/v1/rbac/enforcement + MCP set_rbac_enforcement (Security:Write, supervised tier, step-up on the REST route); caller-inclusive guard — refused unless the caller holds authority under BOTH the durably-true source regime (403) and the destination regime (409); new gauge/counter + YuzuRbacEnforcementChanged/YuzuRbacEnforcementDisabled alerts. A3 #4986 (09-25): the access-review export/campaign carries rbac_enforcement: enabled|disabled|degraded (degraded = closed/unwired store, so an outage is never read as "ungoverned"); the CSV gained a breaking leading # rbac_enforcement=<value> line. Still absent, verified 2026-09-28: RbacStore::create_role/set_permission/remove_permission have ZERO production callers (custom roles need direct psql against rbac_store; docs/user-manual/rbac.md "Custom Roles (Planned)"); no [rbac] config key or CLI flag (#388); only a LOCAL admin account can pass the enable guard, so an SSO-only fleet cannot enable RBAC; no break-glass if enabling strands the operator (#4203). RbacStore::set_rbac_enabled() still has no production caller — A1 goes through RbacAdminAuthorityOwner, so a grep for that name gives a false "no enable path". History: never a regression — RbacStore arrived in PR #203 (2026-03-18) with no wiring. Ref: docs/user-manual/rbac.md "Enabling RBAC", docs/adr/1008-rbac-management-groups-target-architecture.md |
| Self-target principal-destruction guard (#397/#403) | Shipped | settings_routes.cpp:434,1830,2488-2504 (3 call sites); design in docs/auth-architecture.md §self-target |
| OIDC SSO — full PKCE flow, Entra discovery, JWT validation | Shipped | oidc_provider.cpp:189 generate_code_verifier(), L194 compute_code_challenge(), L385 code_verifier post, L766 /.well-known/openid-configuration discovery, L542/L623 JWKS fetch + JWT signature verify |
| Directory Sync — AD/Entra users + groups + role mapping via Microsoft Graph v1.0 | Shipped | directory_sync.cpp:336,509,556,608 calls https://graph.microsoft.com/v1.0/users, /groups, /groups/{id}/members; persisted directory_group_role_mappings + directory_sync_status tables (directory_sync.cpp:147). NOTE: oidc_provider.cpp:248 only parses the JWT groups claim — Graph integration is the separate Directory Sync subsystem. |
| mTLS for agent ↔ server | Shipped | main.cpp:111 --ca-cert flag; peer-cert identity match in agent_service_impl.cpp:47,354 |
| Windows certificate-store mTLS (CryptoAPI/CNG) — agent-side only | Shipped | agents/core/src/cert_store.cpp:78,84,199-201 (CertOpenStore, NCrypt CNG export) |
| HTTPS-by-default, secure bind default (127.0.0.1) | Shipped (hard invariant) | main.cpp:100 127.0.0.1 default, L216 --no-https opt-out; design in docs/auth-architecture.md |
| HTTP security headers — six (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) | Shipped (SOC2-C1) | security_headers.cpp:187-195 (HSTS conditional on HTTPS responses) |
| Cert hot-reload (HTTPS) with audit + metrics | Shipped | cert_reloader.cpp:31 audit cert.reload, L80 watcher loop, L114-191 atomic SSL_CTX swap |
| Agent enrollment — pre-shared / platform-trust (auto-approve via attestation_provider) / admin-approval queue (3 tiers) | Shipped | auth.cpp:717-948 + agent_service_impl.cpp:67-189 (pre-shared L70, attestation auto-approve L101-136, pending-admin queue L138-189) |
| MCP token issuance + tier-before-RBAC ordering | Shipped | mcp_server.cpp:556-557,591,599 (tier check at L591 precedes RBAC at L599); design in docs/mcp-server.md |
auth.admin_required denied audit on every 403 | Shipped (gate) | auth_routes.cpp:150 inside require_admin |
| Private-key permission validation | Shipped | cert_reloader.cpp:120 validate_key_file_permissions() (helper in file_utils.hpp); called at startup from server.cpp and on hot-reload |
| Metrics endpoint localhost-only-no-auth | Shipped | server.cpp:1621 (loopback always unauthenticated; remote behavior toggled by cfg.metrics_require_auth) |
| Account lockout after N failed local-password logins | Shipped (SOC 2 CC6.3) | auth.db v3 columns + AuthDB::lockout_status/record_failed_login/clear_failed_logins (auth_db.cpp); POST /login pre-check + record/clear (auth_routes.cpp); admin unlock POST /api/v1/users/<name>/unlock (rest_api_v1.cpp); --auth-lockout-threshold/--auth-lockout-window-secs (main.cpp). Generic-401 (no enum/oracle), auto-expiring window, audit auth.lockout.applied/.cleared. Ref: docs/auth-architecture.md "Account lockout". |
MFA / TOTP — full ladder (enrollment + login challenge + recovery codes; step-up on 11 high-risk surfaces; enforcement modes + OIDC amr short-circuit + login-time enrollment bootstrap) | Shipped (v0.12–v0.13, SOC 2 CC6.6) | server/core/src/totp.{hpp,cpp} (RFC 6238 + base32); AuthDB::mfa_* accessors; POST /login 202-branches + POST /login/mfa, /login/mfa/stepup, /login/mfa/enroll at auth_routes.cpp; require_mfa_step_up + amr_asserts_mfa at mfa_step_up.{hpp,cpp}; --mfa-enforcement at main.cpp; Settings panel + self-target disable guard at settings_routes.cpp. At-rest TOTP-secret encryption has SHIPPED (PR #2394): auth.users.mfa_totp_secret is a pg::SecretCodec envelope column (ADR-0010, fail-closed decrypt); the auth_kv scaffolding was not used. Full reference: docs/auth-mfa-design.md. |
SCIM v2 provisioning — auto-create/deactivate/reactivate operators from an IdP, plus Groups→role mapping (/scim/v2/*, Users and Groups) | Shipped (SOC 2 CC6.2/CC6.7/CC6.8) | server/core/include/yuzu/server/scim_store.hpp (born-on-Postgres, schema scim_store — scim_resources/scim_tokens, pg::PgMigrationRunner; migrated off the retired auth.db, PR #2394) + scim_json.hpp (JSON codec/discovery) + scim_routes.{hpp,cpp} (routes); provenance guard via AuthDB::set_provisioning_source/get_provisioning_source (auth.users.provisioning_source), now also re-checking role == "user". --scim-admin-group grants role=admin to a SCIM-provisioned user currently in that group (Model A: IdP membership is authoritative, a manual role change is reverted on the next membership recompute). See docs/auth-architecture.md "SCIM v2 provisioning". |
| SCIM ↔ OIDC/SAML identity linkage + credential revoke on deprovision (ADR-2001) | OIDC: Shipped PR1+PR2+PR3 (SOC 2 CC6.8). SAML: Shipped PR4a+PR4b (SOC 2 CC6.8) | Login-time link (oidc::link_oidc_login_to_scim, ScimStore::identity_links/--oidc-scim-link-claim, default sub, allow-list {sub,oid}) + deprovision-time revoke across slug + every linked oidc: principal (deprovision_revoke.{hpp,cpp}, oidc_principal.hpp), credentials-first, fail-closed on non-persist. D1 (yuzu_scim_deprovision_role_refused_with_active_link_total + scim.user.deprovision_role_refused_with_link audit, result=failure — AuditStore has no severity column, so the ADR's "kCritical audit" language is realized as an optional Severity::kCritical AnalyticsEvent gated on analytics collection being enabled, never the audit row itself) covers an externally-elevated SCIM admin (#2021 guard still applies; a human must revoke that identity's tokens manually). D2 (yuzu_scim_deprovision_unlinked_total) covers a federated user who logged in but no link formed (misconfigured --oidc-scim-link-claim, or an IdP whose externalId shares no claim with any OIDC token Yuzu trusts — genuinely unrevocable via SCIM in that case); login records BOTH the sub and oid observation candidates (oidc_login_observations, keyed (iss,sub,claim_name)), so D2 reliably fires on a misconfigured link-claim (an externalId matching the other, unconfigured claim is still detected). Revocation is durable within ~60s (ApiTokenStore validate-cache TTL), not instant. Two residuals: (a) the migration-v3 partial-unique index on scim_resources.external_id is fail-closed — a server carrying a pre-existing duplicate non-empty external_id refuses to boot (dedup pre-upgrade, see docs/user-manual/server-admin.md Upgrade Notes); (b) a login racing an in-flight deprovision (TOCTOU) — now closed by the shipped PR3 deny-at-login (ScimStore::linked_resource_active + oidc_login_denied_deprovisioned, auth.oidc.deprovisioned_denied audit): re-login after a completed deprovision is fully closed, the in-flight microsecond race narrowed (not eliminated) by a post-mint re-check. SAML (PR4a+PR4b): the same shape, keyed on the stable saml:<entity_id>#<NameID> principal (saml::saml_principal_id, saml_principal.hpp), a dedicated saml_identity_links table (ScimStore migration v4, saml_scim_link.{hpp,cpp}), and a link forming ONLY for a stable NameID Format (persistent/SAML-1.1 emailAddress — never transient/unspecified, saml::is_linkable_name_id_format); since SAML mints no API/MCP tokens, deprovision-revoke for a SAML principal is session-invalidation only. PR4b (#3066, the SAML analogue of PR3) SHIPPED — ScimStore::saml_linked_resource_active (the SAML analogue of linked_resource_active, same LEFT-join/fail-closed/orphan-reprovision tri-state) + saml::saml_login_denied_deprovisioned, wired as a primary pre-mint check and a post-mint re-check in /saml/acs, exactly mirroring OIDC's two call sites; denies redirect to the byte-identical /login?error=saml, audit auth.saml.deprovisioned_denied, metric yuzu_auth_saml_deprovisioned_denied_total. Same honest scope as OIDC's PR3: re-login after a completed SAML deprovision is fully closed; the in-flight microsecond race is narrowed (not eliminated) by the post-mint re-check, and — since SAML mints no tokens — a slipped session is bounded by its own TTL rather than the ~60s token-cache window. See docs/auth-architecture.md "SCIM ↔ OIDC identity linkage for deprovision" + "SAML ↔ SCIM identity linkage" and docs/adr/2001-scim-oidc-identity-linkage.md (incl. its SAML addendum, item 8). |