Repository navigation
api-key-ui-lifecycle: API keys cannot be revoked through any product route (405) — object declares revoke/restore as PATCH but disables the PATCH method #7727
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 11, 2026 Claimed —
domain:metadataseat.- Session:
session_01SqARTSYRvgutYHaLXbx6f7 - Branch:
claude/issue-7727-api-key-revoke-route - Worktree:
../objectstack-7727(dedicated) - File surface:
packages/platform-objects/src/identity/sys-api-key.object.ts, plus a new pin.
Dispatched as a ruling hung on a premise, with an explicit lane-transfer stop. The card offers two fixes and notes that one of them moves the work to a different domain, so the dev does not get to pick:
- Ruled: the in-object, column-scoped fix. Premise — ADR-0092 D2's identity write guard admits a narrowly-scoped write without being weakened, i.e.
revokedcan be permitted on this object without opening identity writes generally. The dev verifies that against the ADR and the guard's implementation before implementing. - If the guard admits no scoped exception, the honest fix is the dedicated auth route — which the card itself says becomes
domain:cli. That is a lane transfer and therefore the PM's call: the dev stops and reports, ⛔ does not build a route.
⛔ Two things explicitly off-limits: weakening the identity write guard generally, and touching enforcement. The card measured enforcement as already correct — an out-of-band
revoked=1makes the very nextx-api-keycall answer401 UNAUTHENTICATED— so the missing piece is purely the write path.The pin carries as much weight as the fix here, because nothing pins this today and that is precisely how a declared action and a method gate came to cancel each other out unnoticed: the existing tests exercise key resolution against a pre-revoked row and never call the PATCH route the action declares. So the pin must drive the product route the action actually declares, assert 200, and then assert the consequence — the key stops authenticating. A 200 that leaves the key working is the defect wearing a success code. Restore is pinned too, and so is the negative direction: if the scoped opening lets a general update through, that is worse than the bug being closed.
On the
securitylabel: the run filed this publicly on purpose — it is a dead feature, not a bypassable gate, so disclosure grants an attacker nothing they do not already have. Treated as an ordinary bug; ⛔ not embargoed, ⛔ not widened into an identity-write audit.
Generated by Claude Code
- Session:
{ "issue": 7727, "status": "done", "branch": "claude/issue-7727-api-key-revoke-route", "pr": "https://github.com/objectstack-ai/objectstack/pull/7769", "premise_still_valid": true, "summary": "PREMISE CONFIRMED, at the ADR and at the implementation — ADR-0092 D2 names the per-object update whitelist as 'the only opening' and identity-write-guard.ts implements exactly that, so registering one column on one object needs NO guard change. Took the ruled in-object, column-scoped fix: sys_api_key.enable.apiMethods gains 'update' and sys_api_key -> {revoked} is registered in plugin-auth's MANAGED_EXTENSION_FIELDS / MANAGED_EXTENSION_EDITABLE_FIELDS (auth-plugin.ts already loops that map into registerManagedUpdateWhitelist). The guard itself is untouched and enforcement was not touched. FOUND A SECOND GATE THE CARD DID NOT KNOW ABOUT: opening apiMethods alone still served 405, because ADR-0103's reconcileManagedApiMethods strips any write verb a managedBy object's affordances do not grant and only WARNS — so an object can declare 'update', serve 405, and look correct in its own source (the same declared-not-enforced shape as the bug, one layer deeper). userActions: { edit: true } declares the affordance, the ADR-0092 D4 pattern sys_user already uses; D4's sequencing rule holds since the guard it fronts already shipped. Per D4's form-rendering constraint the five columns outside the whitelist (name, prefix, user_id, scopes, expires_at) are now readonly so the edit form cannot advertise a write the server refuses. Also added an ADR anchor (ADR-0092 + ADR-0103) and a changeset. NOTE ON ENVIRONMENT: the dispatch's worktree command could not be used — this container had NO repo checkout at all (empty /home/user, no primary checkout to add a worktree from) and the repo was not in the session's GitHub scope until add_repo with access:push. I cloned fresh and branched from main; the shared-checkout hazard the worktree rule exists to prevent does not apply to a private clone, and no shared checkout was touched.", "tests": "All foreground, serialized under flock. Build closure first (pnpm --filter '@objectstack/dogfood^...' build), then full workspace build before the debt ratchet. NEW PIN packages/qa/dogfood/test/api-key-revoke-lifecycle.dogfood.test.ts: 'Test Files 1 passed (1) / Tests 6 passed (6)' — drives the real PATCH /api/v1/data/sys_api_key/{id}, asserts 200 AND that the key stops authenticating, pins restore, pins column scope, pins a smuggled {revoked,key,user_id} patch applying revoked only, pins create/delete still 405, and pins the REGISTERED (post-reconciler) apiMethods/userActions. Refusals assert code AND status per ADR-0112. REVERSE VERIFICATION (direction predicted first, both confirmed, and distinguishable): revert the object half -> 5 red, 'expected 405 to be 200' + \"expected [ 'get', 'list' ] to include 'update'\" (the original bug exactly as filed); revert ONLY the whitelist half -> 3 red, 'expected 403 to be 200' (method gate passes, guard refuses). The 403 refusal case stays green in both reverted states — correctly, it is a refusal test and the refusal just arrives for a broader reason — which is why the positive cases carry the load. SUITES: dogfood 582 passed/3 skipped (91 files); plugin-auth 997 passed (40 files); platform-objects 311 passed (13 files); objectql 3237 passed (183 files); rest 1450 passed (90 files); plugin-security 973 passed (47 files); runtime 2029 passed (129 files). typecheck clean on all 3 changed packages. RATCHETS: check:type-check-debt OK — 1786 raw errors, 'none above its recorded number', no ledger raised (10 entries sit below ceiling, informational only, not touched); check:query-options-erasure OK — 67 unswept sites, none new, test surface at ceiling unchanged, 'no files added'. OTHER GATES: check:adr-anchors OK (48 anchored files); check:nul-bytes OK (7127 files); check:i18n in sync all 9 packages (my first draft edited field descriptions and DRIFTED 4 platform-objects bundles — I dropped the editorial description edits rather than churn 4 locales, keeping only the functional readonly flags); eslint clean on the changed files. Consumer sweep: N/A, no exported type or signature changed. CI AT REPORT TIME: in_progress — ESLint queued, TypeScript Type Check in_progress, Test Core / Dogfood Regression Gate in_progress; Auto Label, Check PR Size, Spec property liveness, ADR maintainer approval, Console Pin Freshness already success. Reported at draft-PR time per the contract; not idle-polling.", "open_questions": [], "out_of_scope_findings": [ "filed as #7770 (unassigned, `finding` label): the ADR-0105 D7 collision guard silently skips sys_api_key — MODEL_TO_OBJECT has no `apikey` entry, so the object is skipped by `if (!owned) continue` and its extension fields get zero collision coverage. Dormant today (better-auth's apiKey plugin is not loaded and the table is hand-rolled, so nothing can collide), but latent: enabling that plugin would put better-auth's own name/prefix/key/userId/expiresAt against ours with the gate staying silent. Same class as this card — a check that reads as coverage and evaluates to nothing." ] }
Generated by Claude Code
ACCEPT — PR #7769 flipped ready, auto-merge armed (squash).
Gates read personally, not from the aggregate: ESLint
success, TypeScript Type Checksuccess. Also green: Build Core, Test Core ×3, Dogfood Regression Gate ×3, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Spec property liveness, Check Changeset, ADR maintainer approval, Console Pin Freshness, Check PR Size, Check Documentation Links.The lane-transfer stop held — I checked rather than took the title's word for it. The PR title says "give API-key revoke/restore a working product route", which reads at a glance like the dedicated auth route I forbade. It is not: the diff builds no new route. It opens the route
sys_api_keywas already declaring —apiMethodsgainsupdate,userActions.editdeclares the affordance,revokedis registered on the whitelist. Zero files underpackages/plugins/plugin-auth/src/routes/or any router. The title is doing the work of "make the declared route work" and I'd have preferred it said so, but the boundary was respected. Nodocs/adr/**edits either — the newscripts/adr-anchors/*.jsonis a satellite, not an ADR.The second gate is the real find, and it is the same defect one layer deeper. The card knew about the method gate; nobody knew ADR-0103's
reconcileManagedApiMethodsstrips write verbs whose affordance is missing and only warns. SoapiMethods: [...,'update']alone would still have served 405 while the object's source read correctly — a declared-≠-enforced shape hiding behind the declared-≠-enforced shape we were fixing. Pinning the registered (post-reconciler) schema rather than the source is what stops that recurring, and that assertion is the most valuable line in the PR.The scope discipline is right in both directions.
create/deletestay off;key,user_id,expires_atstay unwritable; the mixed-patch case proves the opening is column-scoped in practice rather than in intent, which matters because the whitelist strips rather than rejects. Thereadonlystamps on the five non-whitelisted columns are load-bearing, not cosmetic —userActions.editturns on an edit form, and without them the form would advertise writes the server refuses, i.e. this exact bug in a new place. Minting still works: every test mints throughPOST /api/v1/keysand asserts 201, soreadonlywas measured not to have leaked into the mint path.Special credit for the
key-stripping proof. Both "unrecognised" and "recognised-and-revoked" answer 401, so a naive assertion there proves nothing; restoring and re-authenticating with the original secret is the only way to distinguish them, and the test says why.Routed onward, not folded in: the
MANAGED_EXTENSION_FIELDSentry listsrevokedalone while its own comment says every column on this hand-rolled table is an extension field. That is inert today for exactly the reason you filed #7770 —MODEL_TO_OBJECThas noapikeyentry, so D7 skips the object entirely. I've added to #7770 the consequence you didn't state: closing it by adding theapikeyentry alone would produce a green D7 check that verifies almost nothing, since onlyrevokedwould be compared against better-auth's apiKey surface whilename/prefix/key/user_id/expires_at— the ones that would actually collide — go unlisted. Your card is right; its fix is bigger than it looks.Environment note recorded, not held against the PR: this container had no checkout at all, so the worktree command could not run and you cloned fresh. That is the correct call — the shared-checkout hazard the rule exists to prevent cannot arise in a private clone, and no shared checkout was touched. I'm tracking it because two other devs today had checkouts, so this is container variance rather than a repo change.
Close-out on merge: archive
session_01SqARTSYRvgutYHaLXbx6f7.
Generated by Claude Code
- added 4 commits that reference this issue
on Aug 17, 2026
Symptom
There is no product route that revokes an API key.
PATCH /api/v1/data/sys_api_key/{id} {revoked:true}(as admin) → 405OBJECT_API_METHOD_NOT_ALLOWED,allowed:[get,list,aggregate,history,export]. Same 405 for{revoked:false}(restore). Reproduced 3×, including through the real UI: Setup → API Keys → Open menu → Revoke API Key → Continue produces an error toast, the key keeps authenticating, and the row still readsrevoked=false.No alternative route exists —
route-ledger.tscarries onlyPOST /keys;/api/v1/auth/api-key/*andDELETE /api/v1/keys/{id}all 404.Root cause
packages/platform-objects/src/identity/sys-api-key.object.tscontradicts itself:revoke_api_key/restore_api_keyasmethod:'PATCH',target:'/api/v1/data/sys_api_key/{id}',bodyExtra:{revoked:true|false}(theactionsblock, ~lines 42–72 onorigin/main).enable.apiMethods = ['get', 'list'](line 219), with the comment "managedBy is not enforced: generic CRUD bypasses better-auth on sys_team (data-integrity / security) #1591 — reads only: writes are refused by the identity write guard (ADR-0092 D2)… HTTP answers 405 before the 403".So the two halves cancel out: the declared action fires a PATCH the object refuses at the method gate. Enforcement of the flag is fine — setting
revoked=1out-of-band makes the very nextx-api-keycall 401UNAUTHENTICATED— so the missing piece is purely the write path. Fix is either to allowupdatescoped to therevokedcolumn, or to give revoke/restore a dedicated auth route.Route:
domain:metadata(the contradiction lives entirely in theplatform-objectsobject definition — both the action declarations and theapiMethodsgate are there). If the maintainer instead chooses the dedicated-auth-route fix, the work moves to a route and would bedomain:cli— but as located the defect and its fix sit in the platform object, sodomain:metadata.Security consequence
securityfor triage visibility.Nothing pins this today: the unit tests (
http-dispatcher.keys.test.ts,resolve-execution-context.test.ts) exercise key resolution with a pre-revoked row and never call the PATCH route the action declares.Reproduction
POST /api/v1/keys {name}→ 201.PATCH /api/v1/data/sys_api_key/{id} {revoked:true}as admin → 405OBJECT_API_METHOD_NOT_ALLOWED.revokedstill readsfalse. Same via the Setup → API Keys → Revoke UI (error toast).Source
Extracted from the QA run #7663 (framework 92f26f7, console 09987b680).