Repository navigation
feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account (#23287) - #23440
Conversation
… holds an account-scope owner Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
…nswer; forced reset gets its own code and a revocation switch Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
…in-auth posture list and the forced-reset switch Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
…ion-map, docs, skill refs); declared-endpoint key pin Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
…he key is looked up Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
… pins, typed settings slot lookup Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
The tenant-audit census took main's side and was regenerated on the merged tree (tenant-audit-census --write); its hand-written figures follow the census. Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
…e two posture enums Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 10 package(s): 43 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 157 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6879ae4c29bfc06ca41bbdaae7648a1ff65a28d7 && git checkout 6879ae4c29bfc06ca41bbdaae7648a1ff65a28d7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b60eb78d35044949839e383823b228bcaf67dec4 5b59be6e66d7b2419c754ee756ee82dd30d1f1d2 && git checkout -B drift-repro b60eb78d35044949839e383823b228bcaf67dec4 && git merge --no-ff 5b59be6e66d7b2419c754ee756ee82dd30d1f1d2
node scripts/docs-audit/affected-docs.mjs --json b60eb78d35044949839e383823b228bcaf67dec4
|
… it does not implement Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude <noreply@anthropic.com>
维护者速读
席位核对
Verdict: ACCEPT. Tier H: it lands only on an authorized approval, with the landing clauses in item 7.
|
…can restore a key a routine reset revoked
The sentence on the forced reset's revocation read as if the revocation were
final ("the owner mints replacements"). sys_api_key declares the
restore_api_key row action (bodyExtra revoked false) and key admission reads
revoked false, so an administrator can restore a key a routine reset revoked.
The sentence now says so; the rest of the changeset is byte-identical.
Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #23287 (body and all 15 comments, ruling C′ ① Derived judgments
② Semver level
③ Boundary flagsDev report
Patch-round report Security, key-admitting surfaces the diff leaves unasked and nothing has filed:
Governed landing: three Check-runs on Implemented-by: VERDICT: PASS |
Fixes #23287
Clause-②: yes (narrowing) — the published contract widens (a closed posture vocabulary with a declared scope, the new
PASSWORD_RESET_REQUIREDcode, a per-user posture member on the auth service contract, new@objectstack/coreexports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served).What changed
This is the code half of ruling C′ (director record
6159450584, the maintainer's 「同意」). The ADR-0069 D3 sentence (ruling item 5) is a separate Tier H PR. The key-lifecycle policy (ruling item 6) is #23367. Nothing here touches the cloud repository.1. Posture codes are a closed vocabulary, and each code declares its scope (
@objectstack/spec/kernel, ruling items 1 and 2).AuthGateCodeSchema=PASSWORD_EXPIRED|PASSWORD_RESET_REQUIRED|MFA_REQUIRED.ExecutionContext.authGate.codeis now typed against it.AUTH_GATE_SCOPESdeclares each code's scope once, beside the codes:MFA_REQUIRED→account;PASSWORD_EXPIREDandPASSWORD_RESET_REQUIRED→credential. The table is typed total over the enum, so a new code without a scope does not compile.PASSWORD_RESET_REQUIREDis registered under@objectstack/plugin-authin the error-code ledger.2. One per-user posture computation (
@objectstack/plugin-auth).AuthManager.resolveUserAuthGates(userId, { organizationId })answers every posture the user holds, most urgent first: forced reset, then expiry, then MFA. It is declared on the contract asIAuthService.resolveUserAuthGates. The session gate (computeAuthGate) takes its first entry, so a session sees what it saw before. The key seam reads the whole list, so a credential-scope entry at the head cannot hide an MFA requirement behind it. It is keyed by user id, which is the shape seat 1's waiter note asks for: #23335's impersonation leg can call the same oracle.3. One judgment at the shared key-admission seam (
@objectstack/core, ruling item 3).resolveApiKeyAdmissiontakes an optionaluserAuthGatessource. It asks it for the key's owner, in the key's own organization, AFTER the tenancy refusal. An account-scope posture yields a new outcome,held, carrying the principal and the posture.resolveAuthzContextresolves a held key as a held principal: the owner,credential: 'api_key', and the posture onauthGate. A held key does not fall through to the session path. The session arm now carriescredential: 'session'and the session's own posture, so a door reads one field for either credential.userAuthGateSourceOf(authService)binds the source from an auth service. A posture computation that rejects is the branded store outage (503), never an admission.4. REST and the dispatcher drop the cookie re-read after a key has admitted a user.
RestServer.computeExecCtxandHttpDispatcher.enforceAuthGatenow re-read the session for the gate only when a session admitted the caller. For a key, they use the key's answer. The dispatcher learns the admitting credential through a newonAdmissionoption onresolveExecutionContext; the envelope'sauthGatestays withheld on that face, as before. The declared-endpoint fallback (dispatcher-plugin.ts, gate step ②) does the same.5. The five raw-host families, measured on
maine4ec6ec1a.requireDatasourceAdmin)resolveAuthzContextevaluateAuthGateabout the resolver's posture, and wiresuserAuthGatesSharingServicePluginresolver)userAuthGates; the posture rides the spread envelope to the doorSettingsServicePluginresolver)userAuthGatesand putsauthz.authGateon the contextregisterStorageFamilydoor)resolveApiKeyAdmissionfor a key, and reads the session only when no key was recognisedserveRedeliver)401on both facesSo the claim's assumption that the families needed only test changes does not hold: four of the five read the posture off the session user, which a key-admitted request never has. Each now reads the shared resolver's answer.
6. The forced reset (ruling item 2).
sys_user.must_change_passwordnow answersPASSWORD_RESET_REQUIREDinstead of reusingPASSWORD_EXPIRED.runAdminSetUserPassword(theset_user_passwordaction) carriesrevokeSessionsAndKeys, on by default. When on, after the new password is set, it revokes every live session (ADR-0069 D4's shape:expires_atpast,revoked_at,revoke_reason: admin_password_reset) and every unrevoked API key (sys_api_key.revoked) of the user. The response and the audit row report what landed:revoked: { sessions, apiKeys, failed, complete }. A refused write is counted and logged, and the reset still succeeds. The act records no reason, so the switch defaults on, which is the suspected-compromise default (see Open questions). The action gains the switch as a param, translated in the four platform-objects bundles.Seven faces, each with its pin
Every face pins three facts: a key whose owner holds
MFA_REQUIREDgets403 MFA_REQUIREDwith the session's message; a key whose owner holds only a credential-scope posture is served; and the same key is served again once the posture clears.packages/core/src/security/api-key-account-posture.test.tspackages/rest/src/rest-api-key-account-posture.pin.test.ts(also: a held cookie beside the key is never re-read)packages/runtime/src/http-dispatcher.api-key-account-posture.pin.test.ts, plus three key cases inendpoint-policy.auth-gate.pin.test.tsfor the declared-endpoint fallbackservice-datasource/src/__tests__/admin-routes-api-key-account-posture.pin.test.ts(every mounted route)plugin-sharing/src/share-link-routes.api-key-account-posture.pin.test.ts(booted plugin)service-settings/src/settings-routes.api-key-account-posture.pin.test.ts(booted plugin, every route)service-storage/src/storage-routes.api-key-account-posture.pin.test.ts(composed mount, every route)plugin-webhooks/src/webhook-redeliver-api-key.pin.test.ts(non-admission, both faces)The forced-reset switch is pinned in
admin-user-endpoints.test.ts: the default on position, the off position, a malformed value keeping the default, an incomplete revocation, and an engine with no read surface. The posture list is pinned inauth-manager.test.ts.Negative control: the seam's judgment removed
The
heldreturn inresolveApiKeyAdmissionwas replaced throughscripts/ablation-replace.mjsin WRAP mode, on the committed tree2d2dd3fa4. Anchor 1 → 0, blob8f6d0a1daa71→aaabc628a1e4. Core was rebuilt, andablation-dist-preflight.mjs --absentread the marker absent from all 14 built files. The face pins import core through itsexports, so this dist leg was required.4 failed / 13 passed2 failed / 3 passed3 failed / 21 passed12 failed / 12 passed2 failed / 1 passed2 failed / 1 passed11 failed / 20 passed2 passed: green by construction, because the door never asks the seamOnly the held cases went red. The credential-scope, cookie-beside-key and session controls stayed green. Restore: the blob after restore equals HEAD (
8f6d0a1daa71) andgit diff HEADis empty. Core was then rebuilt, and the preflight read the marker present indist/index.jsanddist/index.cjswith a clean tree.Verification (head
56004af60)Tests and type checks, per package, all passing:
origin/main(b5b1bf727) was merged in, rebuilt and re-run: runtime 374 / 5463; spec 672 of 673 files. The one red case was the ADR-0122 isomorphic-pin count, raised 785 → 787 for the two new enums (93db5c5f0) and re-run green.pnpm --filter … run typecheckexits 0 for all eleven packages, including theircheck:test-typecheckledgers.56004af60:eslint --no-inline-config --format jsonover the 45 changed.tsfiles reports 45 files linted, 0 errors, 0 warnings, none ignored.eslint.config.mjsnever enables type-aware linting, so this diff cannot move a verdict on an untouched file.Gates, on head
56004af60.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, derived 125 commands from that tree.--ranreconcile:125 derived, 125 run, 0 NOT-MEASURED, 0 UNRUN.limit-bound, refusing an unimplemented combinator;authslot lookup in the settings plugin;check:dual-build-cjs-loads,check:i18nandcheck:skill-examplesmeasured rather than refused. The i18n bundles, which carry the new action param, are in sync with the generator.pnpm --filter @objectstack/spec check:generated: every artifact up to date. The regeneratedskills/**index lines are forced bycheck:skill-refs, which measured stale on this diff before regeneration.origin/main, under the 3,000-line threshold.Acceptance notes
PASSWORD_RESET_REQUIREDyet. objectui'sdetectAuthGate(packages/auth/src/auth-gate-events.tsatorigin/mainb434f5f4, and at the pinned.objectui-sha) raises the remediation overlay forMFA_REQUIREDandPASSWORD_EXPIREDonly, andRemediationOverlayroutesPASSWORD_EXPIREDto the change-password form. Once this lands, a force-reset user gets403 PASSWORD_RESET_REQUIREDand no overlay. See Open questions; this PR should not land ahead of the objectui change.@objectstack/cloud-connection, which read the gate off the session user only, and the MCP stdio face (resolveStdioExecutionContext), which carries no gate at all. Both are reported for the seat to route. The cloud-connection binding doors, the platform-admin judge and the webhook redeliver door authenticate by session alone.authRequiredfloor keys on the session principal (resolveSessionPrincipalId), so a key alone meets401on a default endpoint whatever its owner's standing. The posture bites onauthRequired: falseendpoints, which run as the key's principal. Pre-existing, not changed here.403 MFA_REQUIREDthere, while REST answers the membership refusal's401. Both refuse.credential-scope posture still holds every session, an SSO-opened one included, as before. The ruling's scope decides what holds a key; it does not change the session gate.share-link-routes.ts,settings-routes.ts,storage-routes.ts, the two REST sub-route families,platform-admin-gate.ts) now name the closed vocabulary.skills/**: the only paths aregen:skill-refsoutputs (skills/objectstack-{api,data,ui}/references/_index.md, one line each), generator-owned under the [finding]skills/*/references/_index.mdis generator-owned (gen:skill-refs) yet sits inside theskills/**governed-merge fence — every spec PR touching an indexed headline is forced to carry a governed-surface file #11705 carve-out. Line readings: each file 46 → 46, 72 → 72 and 66 → 66; allSKILL.mdtogether 4408 → 4408.Open questions
MFA_REQUIREDandPASSWORD_EXPIREDas remediation codes, so once this lands a force-reset user gets403 PASSWORD_RESET_REQUIREDand no change-password overlay.detectAuthGateacceptPASSWORD_RESET_REQUIREDand routes it to the change-password form. It is harmless before the backend emits the code. This PR waits on it and on the.objectui-shabump.runAdminSetUserPassword, theset_user_passwordaction): its body isuserId,newPassword/generatePasswordandmustChangePassword.reasonfield (suspected_compromise|routine) whose value sets the switch's default. That is a new API and action field nobody pulls today.Clause-②: yes (narrowing)reads as "widens one surface and narrows another" (scripts/pm/clause2-line.mjs: valueyes, armnarrowing). Nothing is picked silently; noted here only because the dispatch asked.维护者速读(草稿)
Generated by Claude Code