fix(plugin-security): let a member revoke their OWN API key — owner-scoped update on sys_api_key - #8127
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
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 403PERMISSION_DENIED, the row stayedrevoked: false, and the key kept authenticating — while therevoke_api_key/restore_api_keyrow 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.apiMethodsgainedupdate) and the ADR-0092 D2 column whitelist both landed correctly. What was untouched is object-CRUD: the platformmember_defaultset granted onlyallowReadacross the better-auth-managed identity tables, soupdateonsys_api_keyresolved foradmin_full_accessand nobody else.The fix
One explicit
sys_api_keyentry onmember_default, overriding the blanket managed-object deny: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:
sys_api_key_selfRLS carve-out (user_id == current_user.id), which already made the row owner-visible; there was simply noallowEditto go with it.revokedalone.Being an explicit entry is also what makes it survive
kernel:ready:applyManagedWriteDeniesinjects 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:
/security/explainobject_crudfor updatedeniesgrants, contributormember_defaultrevokedrevokedappliedrevokedcolumn, create/deleteupdateat 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_keypair, 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-
revokedcolumn stays refused for the owner too;create/deletestay 405 at the method gate;keyanduser_idare stripped even when smuggled alongside a legalrevoked; show-once semantics are intact. Every other better-auth-managed identity table stays write-denied —sys_api_keyis 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;typecheckclean.@objectstack/dogfoodfull 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.typecheckclean.check:authz-resolver,check:nul-bytes,check:cross-package-test-inputs,check:test-source-alias,check:changeset-gate-self-tests,check-changeset-no-majorall green.check:query-options-erasureholds (baseline verified against28df4a6, no files added) andcheck:type-check-debtis OK with nothing raised. The latter first failed on an unbuilt closure (@objectstack/service-knowledgehad 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