Repository navigation
feat(plugin-auth,platform-objects,core)!: a key a forced password reset revoked is restored by a platform administrator only - #24044
Conversation
…he forced reset's stamp and the restore guard Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…ecific revoked message, translations, changeset Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…ason Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…set-revoked-key-restore
…e guard's isSystem read Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 37 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 37 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 a38fb6779290d02f658a5da2afb02598eb200c2e && git checkout a38fb6779290d02f658a5da2afb02598eb200c2e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b74434c5816520fddfb531e530df15f281992ba7 f782520bf2b07070a46eba75a2d7bdd11a952a08 && git checkout -B drift-repro b74434c5816520fddfb531e530df15f281992ba7 && git merge --no-ff f782520bf2b07070a46eba75a2d7bdd11a952a08
node scripts/docs-audit/affected-docs.mjs --json b74434c5816520fddfb531e530df15f281992ba7
|
Contract reviewServed-tier: ① Derived judgmentsInputs read. Card #23993 (body; triage grade 6221482817; claim 6222506660; Accept-set and public-surface changes, each judged:
Security — can a non-platform-admin user-context write clear
Lockout — can a platform administrator be locked out of restoring? Not a platform administrator by ADR-0068 D2 as amended on 2026-10-06: System context — can the mint, the Non-blocking observations (no action owed on this PR): (i) on a multi-row admin un-revoke, D2 at priority 10 sees the previous row's hook-written ② Semver level
③ Boundary flags
CI on the head at read time. 26 check-runs success, 5 skipped, 8 still Implemented-by: VERDICT: PASS |
席位核对 · ✅ ACCEPT: PR #24044 at
|
Fixes #23993
Clause-②: yes (narrowing)
An API key that an administrator's forced password reset revoked can now be restored only by a platform administrator. Its owner is refused
403 PERMISSION_DENIED, and the refusal names both roads: ask a platform administrator, or create a new key. This follows the changeset of #23287 ruling C′ (.changeset/pre/23287-api-key-account-posture.md): "an administrator can restore a key a routine reset revoked …, or the owner mints a replacement". The field widenssys_api_key; the owner's restore of such a key narrows.What changed
packages/platform-objects/src/identity/sys-api-key.object.tsrevoked_reason: a readonlyselect(ADR-0092 D4) whose one option isadmin_password_reset, described for an administratorpackages/platform-objects/src/apps/translations/*.objects.generated.tsenregenerated (pnpm i18n:extract); thezh-CN/ja-JP/es-ESleaves are hand-written, and the re-run extract kept them and dropped their source-fill hashespackages/plugins/plugin-auth/src/forced-reset-revocation.tsrevoked_reason: admin_password_resetis written besiderevoked: true. This is the only place the reason is set. A key already revoked before the reset is not written, so it keeps no reasonpackages/plugins/plugin-auth/src/api-key-restore-guard.ts(new)beforeUpdatehook onsys_api_key, at priority 15, after the ADR-0092 D2 guard (10). It enforces the rules listed belowpackages/plugins/plugin-auth/src/auth-plugin.tskernel:ready, besideregisterIdentityWriteGuardpackages/plugins/plugin-auth/src/managed-extension-fields.tsrevoked_reasonadded toMANAGED_EXTENSION_FIELDS.sys_api_key, and NOT to the editable mappackages/core/src/security/api-key.tsAPI_KEY_REVOKEDmessage, which now branches onrow.revoked_reason. Code, status, order and carried fields are unchangedcontent/docs/api/error-catalog.mdxAPI_KEY_REVOKEDcontent/docs/permissions/system-context.mdx.changeset/23993-reset-revoked-key-restore.mdminorforplatform-objects,plugin-authandcore;Clause-②: yes (narrowing); ADR-0087not-required (no-migration-prescription)The hook's rules:
revokedvalue that is not true) is refused when the row's storedrevoked_reasonisadmin_password_reset(or no pre-image is in hand), unless the caller passeshasPlatformAdminStanding(engine, session.userId). That is the ADR-0068 D2 PLATFORM_ADMIN rung, and it fails closed.revoked_reason, including an administrator's.revokedalone, never touches the reason. So{ revoked: true }on an already-revoked key keeps it. A caller-sentrevoked_reasonis withheld by the engine's readonly strip and by D2, so it cannot clear the reason either.last_used_atstamp and the forced reset all pass.Measured before building
Both measurements used a throwaway probe on a real
ObjectQLengine over SQLite, abeforeUpdatehook at priority 15, and a user context. The probe was deleted and never committed.readonlycolumn on a user-context update persists.'a'persisted'a'.null,nullpersisted; the forgery was not handed back.banda; the per-rowpreviousthe hook saw wasr1=b,r2=a.nulllanded on both rows.Neither reading falsifies the hook route.
Pins and their red legs
All pins run on the real write path: real
ObjectQLover SQLite, the realSecurityPluginwith the shipped permission sets (the owner resolvesmember_defaultand itssys_api_key_selfrow scope; the administrator holds the unscopedadmin_full_accessgrant thatbootstrapPlatformAdminwrites), the D2 guard with the whitelistsauth-plugin.tsregisters, the real forced-reset revocation and the real key door.Each red leg was made with
node scripts/ablation-replace.mjsin wrap mode against the committed fix (39730a73e): the anchor hit 1 → 0, the blob changed, and the restore was proven blob == HEAD withgit diff HEADempty. An outer trap restored fromHEADby absolute path. The CONTROL stayed green on every leg.403 PERMISSION_DENIED, the message names the platform administrator andPOST /api/v1/keys, and the row is unchanged (api-key-restore-guard.test.ts)throw restoreRefused(): redrevoked_reason: nullis withheld, and the restore after it is still refused (same file)admin_password_reset, and a key revoked before it keeps no reason (same file, and theDEFAULT ONcase ofadmin-user-endpoints.test.ts)coreapi-key-lifecycle.test.ts; end to end through the key door inapi-key-restore-guard.test.ts)false: redlast_used_atstamp leaves itauth-plugin.tsregisters exactly onebeforeUpdatehook onsys_api_keyat a priority after the D2 guardRed-leg counts: L1 3 failed of 11; L2 3 of 11; L3 7 of 68 (the two files together); L4 1 of 22; L5 1 of 11 (that run used
-t kernel:ready, so 10 were skipped).Tests
plugin-auth, full suite, two shards (vitest run --maxWorkers=2 --shard=1/2and2/2): 76 + 75 files, 1753 + 1202 tests passed (10 skipped). Lock verdicts were command-exit 0.platform-objects, full suite: 73 files, 1136 tests passed.core:api-key-lifecycle,api-keyandapi-key-account-posturetests, 61 passed.core,platform-objectsandplugin-auth: exit 0, withcheck:test-typecheckOK on each. Theplugin-authledger is unchanged at 10 files / 88 errors / 23 signatures.17ca3f000(afterorigin/mainup to2b274600b): the plugin-auth pins and neighbouring suites (125 tests),coreapi-key (61) andplatform-objectssys_api_key and translations (144) all passed.test:repowas not run locally; it is CI's.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derived 102 commands at the final headf782520bf. Every command was run with its exit captured before any pipe.--ranreconciliation: 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN, and every command exited 0. That includescheck:auth-mount-ledger,check:durability-log-level,check:route-envelope,check:i18n,check:i18n-stale-fill,check:system-context-census,check:changeset-no-major,check:adr-0087-registrationandcheck:empty-changeset.The artifact-roster block marks four families whose roster sits under one of these paths:
check-changeset-fixed,check:authz-resolver,check:error-code-casingandcheck:filter-alias-parity. The wide-population block declares ten:init-service-contract,live-db-isolation,meta-type-normalized,optional-error-sink,resume-authority-declared,runner-env-posture,settings-bind-window,startup-registry-verdict,verify-stand-inandwildcard-fallthrough. All fourteen were also run locally, and each exited 0. CI's check-runs are the verdict for the rest.Deviation: one row outside the amended surface
check:system-context-censusfailed on the new hook:[file-without-a-row] … api-key-restore-guard.ts holds 1 elevation read site(s) … (isUserContextWrite). The hook must readisSystem(rule 4), and that page holds every such read site. So the page gains row 26b, anchoredpackages/plugins/plugin-auth/src/api-key-restore-guard.ts#isUserContextWrite. Without it the requiredLint & Repo Gatesgoes red. This page is not in the claim's surface and needs the seat's declaration (domain:devx, aserror-catalog.mdxwas).Acceptance notes
@objectstack/plugin-auth@18.0.0-next.2(dist-tagnext) and onmainbuilds since 2026-10-14. There is no backfill: the reset's audit row records counts, not key ids.identity-write-guard.ts, module-private and outside the surface). The two judge the same writes.visiblepredicate on the action would be a follow-up.Generated by Claude Code