Skip to content

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

@huangyiirene

Symptom

There is no product route that revokes an API key.

  • Observed: PATCH /api/v1/data/sys_api_key/{id} {revoked:true} (as admin) → 405 OBJECT_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 reads revoked=false.
  • Expected: 200, and the key stops authenticating.

No alternative route exists — route-ledger.ts carries only POST /keys; /api/v1/auth/api-key/* and DELETE /api/v1/keys/{id} all 404.

Root cause

packages/platform-objects/src/identity/sys-api-key.object.ts contradicts itself:

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=1 out-of-band makes the very next x-api-key call 401 UNAUTHENTICATED — so the missing piece is purely the write path. Fix is either to allow update scoped to the revoked column, or to give revoke/restore a dedicated auth route.

Route: domain:metadata (the contradiction lives entirely in the platform-objects object definition — both the action declarations and the apiMethods gate are there). If the maintainer instead chooses the dedicated-auth-route fix, the work moves to a route and would be domain:cli — but as located the defect and its fix sit in the platform object, so domain:metadata.

Security consequence

⚠️ A leaked API key cannot be revoked without direct database access. The QA run filed this publicly deliberately: it is a dead feature rather than a bypassable gate — knowing about it grants an attacker nothing they don't already have. Adding security for 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

  1. POST /api/v1/keys {name} → 201.
  2. PATCH /api/v1/data/sys_api_key/{id} {revoked:true} as admin → 405 OBJECT_API_METHOD_NOT_ALLOWED.
  3. The key still authenticates; revoked still reads false. Same via the Setup → API Keys → Revoke UI (error toast).

Source

Extracted from the QA run #7663 (framework 92f26f7, console 09987b680).

Activity

  1. self-assigned this
    on Aug 11, 2026
  2. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Claimed — domain:metadata seat.

    • 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. revoked can 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=1 makes the very next x-api-key call answer 401 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 security label: 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

  3. claude commented on Aug 11, 2026

    @claude
    Contributor
    {
      "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

  4. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #7769 flipped ready, auto-merge armed (squash).

    Gates read personally, not from the aggregate: ESLint success, TypeScript Type Check success. 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_key was already declaring — apiMethods gains update, userActions.edit declares the affordance, revoked is registered on the whitelist. Zero files under packages/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. No docs/adr/** edits either — the new scripts/adr-anchors/*.json is 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 reconcileManagedApiMethods strips write verbs whose affordance is missing and only warns. So apiMethods: [...,'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/delete stay off; key, user_id, expires_at stay 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. The readonly stamps on the five non-whitelisted columns are load-bearing, not cosmetic — userActions.edit turns 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 through POST /api/v1/keys and asserts 201, so readonly was 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_FIELDS entry lists revoked alone 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_OBJECT has no apikey entry, so D7 skips the object entirely. I've added to #7770 the consequence you didn't state: closing it by adding the apikey entry alone would produce a green D7 check that verifies almost nothing, since only revoked would be compared against better-auth's apiKey surface while name/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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions