Skip to content

fix(platform-objects,plugin-auth): give API-key revoke/restore a working product route - #7769

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-7727-api-key-revoke-route
Aug 11, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-7727-api-key-revoke-route

Conversation

@claude

@claude claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #7727

What was wrong

sys_api_key contradicted itself. It declared two row actions — revoke_api_key / restore_api_key — as method: 'PATCH' against /api/v1/data/sys_api_key/{id} with bodyExtra: { revoked: true|false }, while the same object set enable.apiMethods = ['get', 'list']. The declared PATCH was refused at the ADR-0049 method gate with 405 OBJECT_API_METHOD_NOT_ALLOWED before any authorization ran, so no product route revoked an API key: the Setup → API Keys → Revoke button produced an error toast, the row still read revoked = false, and the key kept authenticating.

Enforcement of the flag was never the problem. The verifier filters revoked: false and re-checks the row, so a flipped bit takes effect on the very next x-api-key call. The missing piece was purely the write path.

The premise, verified before implementing

The card ruled the in-object, column-scoped fix, hung on this premise: ADR-0092 D2's identity write guard admits a narrowly-scoped write without being weakened. Confirmed, at the ADR and at the implementation.

ADR-0092 D2 states it directly — "Per-object update whitelist is the only opening: plugin-auth exposes a small registry — registerManagedUpdateWhitelist(object, fields) — consulted by the beforeUpdate guard." identity-write-guard.ts implements exactly that: non-whitelisted keys are stripped, and a payload that strips to nothing throws. Registering one column on one object needs no guard change at all, and auth-plugin.ts already loops MANAGED_EXTENSION_EDITABLE_FIELDS into that registry. So the scoped exception is the mechanism's declared purpose, not a hole widened to fit.

revoked qualifies as an ObjectStack extension field under ADR-0105 D7 without ambiguity: this table is hand-rolled (packages/core/src/security/api-key.ts mints and verifies it) and better-auth's apiKey plugin is not loaded, so better-auth owns none of these columns.

The gate the card did not know about

The write path had two gates. Opening enable.apiMethods alone still served 405, because ADR-0103's reconcileManagedApiMethods strips any write verb a managedBy object's resolved affordances do not grant — and it only warns. An object can therefore declare update, serve 405, and look perfectly correct in its own source: the same declared-≠-enforced shape as the original defect, one layer deeper.

userActions: { edit: true } declares the affordance, exactly as sys_user does under ADR-0092 D4. D4's sequencing rule is satisfied — the guard it fronts already shipped.

Scope of the opening

Opening the METHOD does not open the COLUMNS. The object stays managedBy: 'better-auth', the guard still fail-closed rejects user-context writes, and its per-object whitelist stays the only opening — carrying revoked and nothing else:

  • key stays unwritable — a rotated hash would mint a credential nobody holds.
  • user_id stays unwritable — re-owning a key is privilege transfer.
  • expires_at stays on the mint path.
  • create / delete stay off — minting is POST /api/v1/keys, and keys are retired by revoking, not deleting.

The guard itself is unchanged. No general weakening; every other identity table keeps its default-deny. Enforcement was not touched either — the card measured it as already correct.

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 this affordance turns on cannot advertise a write the server refuses.

The pin

Nothing pinned this before — the existing tests exercise key resolution against a pre-revoked row and never call the route the actions declare, which is exactly how a declared action and a method gate cancelled out unnoticed. api-key-revoke-lifecycle.dogfood.test.ts drives the real product route and asserts the consequence, not just the status:

  1. Revoke — PATCH {revoked:true} → 200, then the key stops authenticating (a 200 that leaves the key working is the defect wearing a success code). Baseline-asserted first, so the post-revoke 401 is falsifiable.
  2. Restore — PATCH {revoked:false} → 200, and the key authenticates again.
  3. Column scope — a {name} PATCH is refused 403 + PERMISSION_DENIED, the stored value is unchanged, and the key is untouched. If the opening let a general update through, that would be worse than the bug being closed.
  4. Smuggling — {revoked, key, user_id} applies revoked only; user_id is unchanged and the ORIGINAL secret still authenticates after restore, which is what proves key was stripped rather than merely unread.
  5. Create / delete still 405 + OBJECT_API_METHOD_NOT_ALLOWED.
  6. The declarations — the row actions' verb/target/bodyExtra and the REGISTERED (post-reconciler) apiMethods and userActions.edit, read from the live schema. This is what stops the reconciler from silently stripping update again.

Refusal cases assert code and status per ADR-0112 — a status-only or bare-throw assertion cannot separate "refused with the wrong envelope" from "did not refuse at all".

Reverse verification

Direction predicted before running: reverting each half turns the new pins red, and in distinguishable ways. Both confirmed.

Reverted Result Reads
the object half 5 red — expected 405 to be 200, expected ['get','list'] to include 'update' the original bug, exactly as filed
the whitelist half only 3 red — expected 403 to be 200 method gate passes, guard refuses
neither 6 passed —

The 403 refusal case stays green in both reverted states, correctly: it is a refusal test, and the refusal merely arrives for a broader reason. That is why the positive cases carry the load.

Verification

Gate Result
@objectstack/dogfood 582 passed, 3 skipped (91 files)
@objectstack/plugin-auth 997 passed (40 files)
@objectstack/platform-objects 311 passed (13 files)
@objectstack/objectql 3237 passed (183 files)
@objectstack/rest 1450 passed (90 files)
@objectstack/plugin-security 973 passed (973)
@objectstack/runtime 2029 passed (129 files)
typecheck (the 3 changed packages) clean
check:type-check-debt OK — 1786 raw errors, none above its recorded number; no ledger raised
check:query-options-erasure OK — 67 unswept sites, none new; test surface at ceiling, unchanged
check:adr-anchors OK — 48 anchored files (this PR adds one)
check:i18n in sync, all 9 packages
check:nul-bytes OK — 7127 files
eslint (changed files) clean

Consumer-sweep direction: not applicable — no exported type or signature changed. The build closure was rebuilt first (--filter '@objectstack/dogfood^...' build) before any judgement, and the full workspace was built before the debt ratchet.

Also in this PR

  • An ADR anchor for sys-api-key.object.ts (ADR-0092, ADR-0103), so the next reader is told which decisions userActions.edit and the readonly stamps are standing on rather than reading them as arbitrary.
  • A one-line correction to a pre-existing comment that named the route as /api/v1/sys_api_key/{id}, omitting /data/ — the exact path this card is about.
  • A changeset (patch on both packages).

Generated by Claude Code

…ing product route (#7727)

`sys_api_key` declared two row actions — `revoke_api_key` / `restore_api_key`
— as `PATCH /api/v1/data/sys_api_key/{id}` with `bodyExtra: {revoked}`, while
the same object set `enable.apiMethods = ['get','list']`. The declared PATCH
died at the ADR-0049 method gate with 405 before any authorization ran, so no
product route revoked an API key: the Setup UI's Revoke button errored, the row
kept `revoked = false`, and the key kept authenticating.

The write path had two gates, not one:

- the method gate — `enable.apiMethods` now carries `update` (`create` /
  `delete` stay off: minting is `POST /api/v1/keys`, and keys are retired by
  revoking rather than deleting);
- ADR-0103's `reconcileManagedApiMethods`, which strips any write verb a
  `managedBy` object's affordances do not grant and only warns. `apiMethods`
  alone would still have served 405 while the source read correctly, so
  `userActions: { edit: true }` declares the affordance — the ADR-0092 D4
  pattern `sys_user` already uses.

Opening the method does not open the columns. The object stays
`managedBy: 'better-auth'`, so ADR-0092 D2's identity write guard still
fail-closed rejects user-context writes and its per-object update whitelist
stays the only opening; `revoked` is registered there and nothing else is. The
guard itself is untouched — no general weakening, and every other identity
table keeps its default-deny. Per D4's form-rendering constraint the columns
outside the whitelist are now `readonly`, so the edit form this affordance
turns on cannot offer a write the server refuses.

Nothing pinned any of this: the existing tests exercise key resolution against
a pre-revoked row and never call the route the actions declare. The new
dogfood suite drives the real PATCH, asserts 200, then asserts the consequence
— the key stops authenticating — and pins the refusals with code AND status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqARTSYRvgutYHaLXbx6f7
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 2:13pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/plugin-auth.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx (via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx (via @objectstack/platform-objects, @objectstack/plugin-auth)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects)

⛔ 2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-auth)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 11, 2026 14:30
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 52200b4 Aug 11, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7727-api-key-revoke-route branch August 11, 2026 14:43
os-help pushed a commit that referenced this pull request Aug 11, 2026
…h decision (#7802)

The `apiMethods` conformance scan in `@objectstack/spec` went red on `main`
after #7769 gave `sys_api_key` `update` without `bulk`. Resolved as the rule's
second documented outcome — a `SINGLE_RECORD_WRITE_ONLY` exemption carrying the
evidence — rather than by widening the object's API surface.

Evidence: the console renders no checkbox column for this object (multi-select
is auto-enabled only when a bulk action exists; the only implicit one is
bulk-delete, and the object grants no delete affordance), and a promoted
multi-select revoke would fan out per row through the action runner rather than
reaching `/batch`. #7769's `update` grant is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…h decision (objectstack-ai#7802) (objectstack-ai#7818)

The `apiMethods` conformance scan in `@objectstack/spec` went red on `main`
after objectstack-ai#7769 gave `sys_api_key` `update` without `bulk`. Resolved as the rule's
second documented outcome — a `SINGLE_RECORD_WRITE_ONLY` exemption carrying the
evidence — rather than by widening the object's API surface.

Evidence: the console renders no checkbox column for this object (multi-select
is auto-enabled only when a bulk action exists; the only implicit one is
bulk-delete, and the object grants no delete affordance), and a promoted
multi-select revoke would fan out per row through the action runner rather than
reaching `/batch`. objectstack-ai#7769's `update` grant is untouched.


Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…objectstack-ai#7802) (objectstack-ai#7928)

A test that resolves the repo root and walks it has an input set far wider
than the package it lives in, and both of CI's scoping layers judged such a
test by its home package:

  - `turbo ls --affected` (ci.yml `test`, the PR path) reaches packages
    through the dependency GRAPH. `packages/spec` declares no dependency on
    `packages/platform-objects` — and should not; the api-methods batch
    conformance scan reads source text precisely to avoid inverting the
    spec -> * direction. Measured on turbo 2.10.7: a platform-objects-only
    diff makes 51 packages affected, `@objectstack/spec` not among them.

  - turbo's task cache hashes `$TURBO_DEFAULT$`, which is package-local, so
    `@objectstack/spec#test` hashed identically before and after the change.
    Even the merge-queue and push builds — which deliberately partition the
    FULL package list — replayed a cached green. Measured: `>>> FULL TURBO`
    in 42ms replaying the prior log, while `--force` on the same tree failed
    the scan. This layer, not the paths-filter, is why objectstack-ai#7769 reached `main`.

Packages whose tests read outside themselves now declare that radius once,
and both layers are driven from it: ci.yml unions a declaring package back
into the shard's package set when the diff touches its globs, and turbo.json
carries matching `$TURBO_ROOT$` inputs so the cache hash moves with them.

The declaration list cannot rot, which is the point: the gate finds escaping
tests itself, statically, and fails on a package that has one and no
declaration, on a stale declaration, on a glob turbo.json does not hash, and
on a radius too narrow to cover the paths its own tests name. A new
cross-package scan that nobody registers produces a red gate naming it and
the globs to write — not a silent skip.

Census: 20 such tests in 8 packages. Radii are kept narrow so the
affected-subset optimisation the 3-way shard exists for survives; the
literal-coverage check is what makes narrowing safe.

Co-authored-by: os-dev <support@objectstack.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants