Skip to content

fix(plugin-security): let a member revoke their OWN API key — owner-scoped update on sys_api_key - #8127

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8053-member-revoke-own-api-key
Aug 12, 2026
Merged

fix(plugin-security): let a member revoke their OWN API key — owner-scoped update on sys_api_key#8127
os-zhuang merged 1 commit into
mainfrom
claude/issue-8053-member-revoke-own-api-key

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #8053

An ordinary member who minted a personal API key could not revoke it. PATCH /api/v1/data/sys_api_key/{their own id} {"revoked": true} answered 403 PERMISSION_DENIED, the row stayed revoked: false, and the key kept authenticating — while the revoke_api_key / restore_api_key row actions rendered in that member's own My Keys grid the whole time.

This is the residual of #7727, one layer down. #7727 is genuinely fixed for admins and stays fixed: the method gate (enable.apiMethods gained update) and the ADR-0092 D2 column whitelist both landed correctly. What was untouched is object-CRUD: the platform member_default set granted only allowRead across the better-auth-managed identity tables, so update on sys_api_key resolved for admin_full_access and nobody else.

The fix

One explicit sys_api_key entry on member_default, overriding the blanket managed-object deny:

sys_api_key: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false },

It is deliberately not bounded by that boolean alone. Two pre-existing mechanisms do the narrowing, which is why this is a restoration of declared-versus-enforced intent rather than a new grant:

  • which rows — the sys_api_key_self RLS carve-out (user_id == current_user.id), which already made the row owner-visible; there was simply no allowEdit to go with it.
  • which fields — ADR-0092 D2's identity write guard, whose per-object update whitelist for this table lists revoked alone.

Being an explicit entry is also what makes it survive kernel:ready: applyManagedWriteDenies injects its deny only for managed objects a target set does not already name.

The persona is the gate

The card named the root cause of why this survived #7727's own test suite: those pins all drive the seeded admin, and an admin-persona revoke assertion passes against the unfixed build. So the new pin — packages/qa/dogfood/test/api-key-owner-revoke.dogfood.test.ts — signs up a plain member and drives every case as them.

Predict-then-mutate, written down before the first run and confirmed in both directions:

Case Without the grant With it
owner revokes own key, key stops authenticating 403 200
owner restores own key 403 200
/security/explain object_crud for update denies grants, contributor member_default
credential columns smuggled beside a legal revoked 403 200, only revoked applied
cross-owner revoke, non-revoked column, create/delete 403 / 403 / 405 unchanged

⚠️ Recorded honestly: the three refusal cases pass vacuously without the grant, because nothing granted the member update at all, so every PATCH was 403 regardless of row or column. They become load-bearing only once the grant exists — their stillness during the reverse verification is not evidence, and the file says so in its header.

The two existing unit pins that encoded the old all-false posture were updated rather than loosened: the exception is keyed as an exact member_default::sys_api_key pair, the create/delete/read axes are still asserted, and a new assertion pins the exception list itself so it cannot grow silently. All four of those assertions go red with the grant removed.

Not affected, and pinned as such

Cross-owner revocation stays 403 with the row unchanged and the admin's key still live; a non-revoked column stays refused for the owner too; create / delete stay 405 at the method gate; key and user_id are stripped even when smuggled alongside a legal revoked; show-once semantics are intact. Every other better-auth-managed identity table stays write-denied — sys_api_key is the one exception, and it is one because that table is hand-rolled ObjectStack rather than better-auth-owned, with a registered whitelist already governing its single platform-owned column.

Verification

  • @objectstack/plugin-security — 51 files, 1029 tests pass; typecheck clean.
  • @objectstack/dogfood full suite — 663 passed / 4 failed before the fix, 667 passed / 0 failed after, same total: exactly the four predicted cases flipped and nothing else moved. typecheck clean.
  • Gates: check:authz-resolver, check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, check:changeset-gate-self-tests, check-changeset-no-major all green.
  • The two gates that do not appear in the dispatch-gates list: check:query-options-erasure holds (baseline verified against 28df4a6, no files added) and check:type-check-debt is OK with nothing raised. The latter first failed on an unbuilt closure (@objectstack/service-knowledge had no built type entry point), not on this diff — re-run green after building the closure the gate's own message prescribes.

Generated by Claude Code

The residual of #7727, one layer down. That fix opened the method gate and
registered the ADR-0092 D2 column whitelist, but the object-CRUD layer was
untouched: `member_default` granted only `allowRead` across the better-auth
managed identity tables, so `update` on `sys_api_key` resolved for
`admin_full_access` alone. A member could mint a personal key and then not
revoke it — 403 PERMISSION_DENIED, row unchanged, key still authenticating —
while the `revoke_api_key` row action rendered in their own My Keys grid.

`member_default` now carries an explicit `sys_api_key` entry with `allowEdit`.
The grant is bounded by two pre-existing mechanisms rather than by the
permission-set boolean: the `sys_api_key_self` RLS carve-out decides which rows
(cross-owner revocation still 403), and ADR-0092 D2's column whitelist decides
which fields (`revoked` alone; `key` and `user_id` stripped even when smuggled
alongside a legal `revoked`). `create`/`delete` stay closed at 405.

The regression pin runs as the key's OWNER, not as an admin — the persona gap
that let this survive #7727's own test suite. Verified by removing the grant and
re-running: the four owner-path cases go red, the refusal cases stay green.

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

vercel Bot commented Aug 12, 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 12, 2026 5:18pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

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

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security)

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

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

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 12, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 12, 2026 17:36
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 8e0bb68 Aug 12, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8053-member-revoke-own-api-key branch August 12, 2026 17:46
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

Development

Successfully merging this pull request may close these issues.

api-key-ui-lifecycle: after #7727 an ordinary member still cannot revoke their OWN API key — 403 PERMISSION_DENIED, and the key keeps authenticating

2 participants