diff --git a/docs/adr/0069-enterprise-authentication-hardening.md b/docs/adr/0069-enterprise-authentication-hardening.md index b4bd841a4bd..b8251a292fd 100644 --- a/docs/adr/0069-enterprise-authentication-hardening.md +++ b/docs/adr/0069-enterprise-authentication-hardening.md @@ -169,6 +169,7 @@ Plus admin-facing surface: a `sys_user` list-view filter for locked accounts + a 3. **Hono auth-route middleware** (in `plugin-auth` route registration, *before* delegating to better-auth) — IP allowlist, rate-limit pre-check. 4. **Session-validation path** (resolve-execution-context / `customSession`) — MFA step-up gate, password-expiry gate, idle-timeout, locked-account rejection. 5. **`bindAuthSettings` -> `applyConfigPatch`** — the existing channel that turns every new setting into live config without restart. +6. **API-key admission** (`resolveApiKeyAdmission` in `packages/core/src/security/api-key.ts`, fed by `userAuthGates`) — the key half of D3's amended note: an owner's `account`-scope posture holds the key (`held`). Each row in D1-D6 names exactly one of these seams. No setting is introduced without one. @@ -189,6 +190,13 @@ Each row in D1-D6 names exactly one of these seams. No setting is introduced wit | **P2/P3** (D6) | 🟔 partial | Generic OIDC RP wired (`genericOAuth`/`sso`); admin OIDC **trust-list settings UI** still env/`sys_sso_provider`-only. | | **P3** (SAML, broader social) | 🟔 partial | `@better-auth/sso` present (SAML now better-auth-native — see Addendum); broader settings-driven social providers pending. | +**Status (2026-10-14): ruling C′ is enforced.** D3's amended note holds on `main` through PR [#23440](https://github.com/objectstack-ai/objectstack/pull/23440) (`0f059078ec`) and PR [#23508](https://github.com/objectstack-ai/objectstack/pull/23508) (`daafae6d6d`): + +- **The posture vocabulary is closed.** `AuthGateCodeSchema` (`@objectstack/spec/kernel`) is the closed set of codes an `authGate` may carry: `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` and `MFA_REQUIRED`. `AUTH_GATE_SCOPES`, typed total over it, declares each code's scope once: `MFA_REQUIRED` holds the **account** (every credential of the user); `PASSWORD_EXPIRED` and `PASSWORD_RESET_REQUIRED` hold the **credential** (password sign-in alone). +- **An API key answers as the session does** (seam 6). `resolveApiKeyAdmission` asks `userAuthGates` for the key's owner and returns the `held` outcome on an `account`-scope posture; the door refuses the key with the posture's code. The hold is a refusal, not a revocation: it keeps no state of its own, and the same key is admitted again once the posture clears. The key's use is still stamped while it is held (`last_used_at`, at most hourly, and, where the lifecycle policy anchors one, a no-expiry key's grace end as `expires_at`), so its inactivity window does not run out during the hold. A `credential`-scope posture leaves the key admitted. +- **Every seam that admits a key asks.** PR #23508 made `userAuthGates` a required input of `resolveAuthzContext`, `resolveApiKeyAdmission` and `resolveApiKeyPrincipal`, which closed the two seams that still admitted a held owner's key: the `@objectstack/cloud-connection` install-local resolver and the `@objectstack/mcp` stdio face. The runtime dispatcher, the REST data API, the settings, storage-download, datasource-admin and share-link routes pass the source as well. `resolveApiKeyPrincipal` answers a held owner's key with no principal. +- **The forced reset revokes, on by default.** An administrator's forced password reset (D7) carries `revokeSessionsAndKeys`: it ends every session of the user (the D4 mechanism) and revokes every API key they own. It is on unless the administrator passes a literal `false`. Unlike the hold, this is a revocation: a revoked key stays revoked after the password change. + ## Out of scope / deferred - **SCIM / directory provisioning & bulk import** — separate ADR (provisioning, not authentication); tracked in #2246.