@@ -354,6 +354,52 @@ const baseDefaultPermissionSets: PermissionSet[] = [
354354 // but the grant itself: it is what keeps `/auth/me`, the org switcher and
355355 // the Account app working for a member with no application profile.
356356 ...denyWritesOnManagedObjects ( ) ,
357+ // [#8053] The ONE override of the block just above: a member may revoke
358+ // their OWN API key. #7727 opened the method gate and registered the
359+ // ADR-0092 D2 column whitelist, but left this object-CRUD layer
360+ // untouched, so `update` on `sys_api_key` resolved for
361+ // `admin_full_access` alone — the `revoke_api_key` row action rendered in
362+ // the member's own My Keys grid and answered 403 for them. A personal key
363+ // "acts as you — treat it like a password", and the owner is the person
364+ // who discovers it leaked; their only remedy was to find an admin.
365+ //
366+ // This is a restoration of declared-≠-enforced intent, not a new grant:
367+ // the row action, the checklist persona and the `sys_api_key_self` policy
368+ // below (which already makes the row owner-VISIBLE) all say the owner
369+ // path was intended — there was simply no `allowEdit` to go with it.
370+ //
371+ // The opening is bounded by TWO pre-existing mechanisms, and it is
372+ // deliberately not bounded by this line alone:
373+ // - WHICH ROWS: the `sys_api_key_self` RLS carve-out below
374+ // (`user_id == current_user.id`, `operation: 'all'`), enforced on
375+ // by-id writes through the security middleware's pre-image check. A
376+ // member PATCHing another user's key is still refused.
377+ // - WHICH FIELDS: ADR-0092 D2's identity write guard, whose per-object
378+ // update whitelist for this table lists `revoked` alone
379+ // (plugin-auth `MANAGED_EXTENSION_EDITABLE_FIELDS`). `key` stays
380+ // unwritable (a rotated hash mints a credential nobody holds) and
381+ // `user_id` stays unwritable (re-owning a key is privilege transfer).
382+ //
383+ // `allowCreate` / `allowDelete` stay false and are NOT an oversight:
384+ // minting is `POST /api/v1/keys` (the only path that returns the raw
385+ // secret once) and rows are retired by revoking, not deleting, so history
386+ // survives. `allowDelete` also stays false because this set is bound to
387+ // the `everyone` anchor and must remain anchor-safe (ADR-0090 D5).
388+ //
389+ // ⚠️ Not a pattern to copy across the managed list. Every OTHER
390+ // better-auth table here stays write-denied because its mutations must
391+ // flow through an auth endpoint; this one is a hand-rolled ObjectStack
392+ // table (`packages/core/src/security/api-key.ts` mints and verifies it,
393+ // better-auth's `apiKey` plugin is not loaded) whose one platform-owned
394+ // column already has a registered whitelist. Widening `update` on
395+ // `sys_api_key` beyond owner-scoped-plus-one-column would close #8053 and
396+ // open a worse defect on a table whose rows act as the user.
397+ //
398+ // Being an EXPLICIT entry is what makes it survive `kernel:ready`:
399+ // `applyManagedWriteDenies` injects its deny only for managed objects a
400+ // target set does not already name (`name in objects` → skip), so this
401+ // line is preserved rather than overwritten.
402+ sys_api_key : { allowRead : true , allowCreate : false , allowEdit : true , allowDelete : false } ,
357403 // Self-service preferences. NOT a better-auth table, so it is not covered
358404 // by the block above, and its `sys_user_preference_self` RLS policy below
359405 // (`operation: 'all'`) declares exactly this intent: a member reads and
0 commit comments