Skip to content

Commit 570f01d

Browse files
docs(adr-0069): record ruling C′'s enforcement and the API-key admission seam (#23875)
Fixes #23483 Clause-②: no ## What this changes One governed record edit, `docs/adr/0069-enterprise-authentication-hardening.md` alone (+8 / -0). No code, no test, no other ADR. 1. **A sixth "Enforcement seam summary" entry: API-key admission.** It names `resolveApiKeyAdmission` in `packages/core/src/security/api-key.ts`, fed by `userAuthGates`, as the key half of D3's amended note. With it, "Each row in D1-D6 names exactly one of these seams" stays true for D3. 2. **A dated status note under "Implementation status (2026-07-04)": "Status (2026-10-14): ruling C′ is enforced."** It follows the ADR's dated-row and "Correction (#4772)" convention and leaves the 2026-07-04 table unchanged. It names PR #23440 (`0f059078ec`) and PR #23508 (`daafae6d6d`) and covers four facts: - the closed `AuthGateCodeSchema` and the scopes `AUTH_GATE_SCOPES` declares; - the `held` outcome of `resolveApiKeyAdmission`; - every key-admitting seam asking the owner's posture; - the forced-reset revocation, on by default. ## Every sentence measured on `main` (`34b740f2d1`) - `packages/spec/src/kernel/execution-context.zod.ts:40`: `AuthGateCodeSchema = z.enum(['PASSWORD_EXPIRED', 'PASSWORD_RESET_REQUIRED', 'MFA_REQUIRED'])`. `:65`: `AUTH_GATE_SCOPES` is `Record<AuthGateCode, AuthGateScope>` (typed total), with `MFA_REQUIRED: 'account'` and both password codes `'credential'`. - `packages/core/src/security/api-key.ts`: `ApiKeyAdmission` has a `held` arm. Near `:822`, `userAuthGates` is asked for the owner, and `accountScopeAuthGate` picks the account-scope posture. The function returns `{ outcome: 'held', principal, gate }`. Before returning, `stampAdmittedKey` (`:832`-`:839`) stamps the held key's use — `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 (pinned by `api-key-lifecycle.test.ts:220`-`:226`). Nothing is revoked, and the hold keeps no state of its own. `resolveApiKeyPrincipal` collapses `held` to `undefined`. - **Seam sweep.** Every non-test caller of `resolveAuthzContext` / `resolveApiKeyAdmission` passes `userAuthGateSourceOf(authService)`: - runtime `resolve-execution-context.ts:254`; - `rest-server.ts:3338`; - `settings-service-plugin.ts:333`; - `storage-service-plugin.ts:1251` and `:1428`; - `service-datasource/admin-routes.ts:509`; - `sharing-plugin.ts:973`; - `marketplace-install-local-plugin.ts:3371`; - MCP stdio (`plugin.ts:520`, `:691`, through `resolveStdioUserAuthGates`). The parameter is typed required (`UserAuthGateSource | undefined`). The only `sys_api_key` lookup by hash on `main` is `api-key.ts:678`, inside `resolveApiKeyAdmission`. No seam is open. - `packages/plugins/plugin-auth/src/admin-user-endpoints.ts:697`: `revokeSessionsAndKeys = body.revokeSessionsAndKeys !== false` (on unless a literal `false` is passed). The `sys_user` action field has `defaultValue: true` (`sys-user.object.ts:306`). `forced-reset-revocation.ts` ends sessions through the D4 mechanism and sets `revoked: true` on every unrevoked key. - Both PRs are ancestors of `origin/main`: `merge-base --is-ancestor` exits 0 for each, and the control leg `origin/main~5` also exits 0. ## Gates The gates were derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `683acf2c`: 19 families. All 19 exit 0. `check:doc-formula-expressions` first exited 3 (a prerequisite was not built); it was green after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` under the verify lock. `--ran` reconciliation: "19 derived famil(ies) accounted for — 19 run, 0 NOT-MEASURED (a DERIVED zero)". `pnpm check:pm-governed-prose` was also run outside the derivation and exits 0. ## Acceptance notes - D3's amended note names the key behaviour ("an API key answers as the session does") but not the symbol `resolveApiKeyAdmission`. The card's file surface is the summary entry and the status note, so the D3 note is left as ruled. The sixth entry points back to it ("the key half of D3's amended note"). - Changeset: none. `docs/adr/**` publishes nothing (the os-dev fast-path no-publish rule), so this PR takes `skip-changeset`. - Tier H (`docs/adr/**`): the maintainer lands this PR after the seat's `CONTRACT_REVIEW_TIER` review of the governed text. ## 维护者速读(草稿) **改了什么**:只改 ADR-0069 一个文件(+8 行)。「Enforcement seam summary」新增第 6 个强制点「API-key admission」(`resolveApiKeyAdmission`)。「Implementation status」表下新增一条 2026-10-14 的状态注记,写明裁决 C′ 已由 PR #23440 与 #23508 落地:posture 代码集是封闭的,并区分 account/credential 两种作用域;MFA 未达标用户的 API key 被「挂起」(`held`)而不是吊销——挂起不另存状态,姿态解除即恢复;挂起期间照常记录使用时间(`last_used_at`),以免闲置期限在挂起中耗尽;所有接收 API key 的入口都会查询属主的 posture;管理员强制重置密码时默认吊销该用户的会话与 API key。 **为什么改**:ADR 原文说「D1-D6 每一行恰好对应一个强制点」,但强制点清单里缺少 API key 入口,而 D3 修订注记已经依赖这个入口,记录与代码不一致。现在两个阻塞项(#23455、#23456)都已合并,代码已经兑现,记录可以如实补上,不会写出代码没有兑现的强制。 **风险与代价(含回滚)**:纯文档,不发布任何包,不影响运行时。风险只在于文字是否与代码一致;每一句都已对照 `main` 实测并附了行号。回滚只需 revert 这一个提交。 **席位意见**:建议批准合并。新增的第 6 个强制点和 2026-10-14 状态注记,席位与契约复核都逐句对照 `main` 的代码核过。第一轮契约复核(`6214537670`)指出「挂起期间什么都不写」不实:挂起的 key 仍会记录使用时间。补丁轮只改了这一句,第二轮复核在新 head `18b3f3215b` 上 PASS(`6214838689`)。只改 ADR 一个文件,不发布任何包,回滚只需 revert 这一个提交。 **你要做的**:看过席位的合约复核记录后,批准并合并本 PR(Tier H,需要维护者本人操作)。 --- _Generated by [Claude Code](https://claude.ai/code/session_01XgS6gBdsbPNicv9ELY6ne5)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 77efc66 commit 570f01d

1 file changed

Lines changed: 8 additions & 0 deletions

File tree

‎docs/adr/0069-enterprise-authentication-hardening.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ Plus admin-facing surface: a `sys_user` list-view filter for locked accounts + a
169169
3. **Hono auth-route middleware** (in `plugin-auth` route registration, *before* delegating to better-auth) — IP allowlist, rate-limit pre-check.
170170
4. **Session-validation path** (resolve-execution-context / `customSession`) — MFA step-up gate, password-expiry gate, idle-timeout, locked-account rejection.
171171
5. **`bindAuthSettings` -> `applyConfigPatch`** — the existing channel that turns every new setting into live config without restart.
172+
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`).
172173

173174
Each row in D1-D6 names exactly one of these seams. No setting is introduced without one.
174175

@@ -189,6 +190,13 @@ Each row in D1-D6 names exactly one of these seams. No setting is introduced wit
189190
| **P2/P3** (D6) | 🟡 partial | Generic OIDC RP wired (`genericOAuth`/`sso`); admin OIDC **trust-list settings UI** still env/`sys_sso_provider`-only. |
190191
| **P3** (SAML, broader social) | 🟡 partial | `@better-auth/sso` present (SAML now better-auth-native — see Addendum); broader settings-driven social providers pending. |
191192

193+
**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`):
194+
195+
- **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).
196+
- **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.
197+
- **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.
198+
- **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.
199+
192200
## Out of scope / deferred
193201

194202
- **SCIM / directory provisioning & bulk import** — separate ADR (provisioning, not authentication); tracked in #2246.

0 commit comments

Comments
 (0)