Skip to content

Commit 7766b62

Browse files
fix(plugin-security)!: row-scope the private credential tables (sys_verification, sys_jwks) in every shipped set (#20033)
Fixes #20027 Clause-②: no (narrowing) ## What was wrong `sys_verification` (one-time verification and password-reset tokens) and `sys_jwks` (the environment's JWT signing keys) both declare `access: { default: 'private' }`, and `default-permission-sets.ts` has always documented them as "DENY for non-admins by design". The shipped permission sets did not hold that. Both objects are on `BETTER_AUTH_MANAGED_OBJECTS`, so `denyWritesOnManagedObjects()` gives every shipped set that spreads the blanket an **explicit** per-object read entry (`allowRead: true`). The `private` posture only keeps a plain `'*'` wildcard off an object (`resolveObjectPermission`); it does not touch an explicit entry. Neither object carried a row policy, and the wildcard `tenant_isolation` policy that once denied their rows retired with ADR-0095 D1 (Layer 0 is inert on an object with no tenant column). So the object-level read resolved `true` for every non-admin set and nothing narrowed it. ## Reachability, measured before the fix Measured on the real `SecurityPlugin` + `ObjectQL` over a real driver, with the shipped permission sets, across the five non-admin shipped personas (`member_default`, `viewer_readonly`, `organization_admin`, `organization_admin_no_bypass`, and `mcp_agent_data_write` acting for a member). The full surface-by-surface table is on the card. In summary, verdicts only: - Engine read: **reachable** for every non-admin persona, for both objects. - `sys_verification`: reachable through the generic data route and through object-generic readers (analytics; reports where composed). - `sys_jwks`: reachable through the object-generic readers only (its generic-data-route and MCP surfaces are closed by the object's own declarations). - MCP data tools, global search, related-record expansion, share links and batch/realtime: not reachable. Every reachable surface funnels through the one engine read-filter path, so all of them close with a single instrument. ## Fix The instrument the file header names: a per-object row scope, and no edit of the blanket. A new `privateCredentialRowScope()` returns a `select` policy that admits no row (`id == null`, the `_none` shape PR #20023 uses) for each of `sys_verification` and `sys_jwks`, spread into every shipped set that spreads the blanket and carries row-level security — `organization_admin` (and so its derived `organization_admin_no_bypass`), `member_default` and `viewer_readonly`. - There is no `_self` form: neither table names a user, and no principal below platform admin needs to read even their own live token. - `admin_full_access` is unchanged: its wildcard carries the superuser read bypass, which skips the row layer, and it names neither object explicitly. The platform admin still reads every row. - better-auth's own flows are unchanged: it reads and writes these tables through its adapter under system context, which no row policy reaches. - The MCP write ceiling holds the blanket too and carries no row policy of its own by design (ADR-0090 D10); its bound is the delegating user's sets, which now carry the scope. Measured, not assumed. - Why every set carries the scope and not the baseline alone: with `fallbackPermissionSet: null` no platform baseline is composed, and a set with no policy for an object leaves that object unfiltered. Why a row policy rather than a precedence change in the evaluator: three other `private` objects on the blanket — `sys_device_code`, `sys_oauth_access_token`, `sys_oauth_refresh_token` — are deliberately readable by their owner through the same explicit entry plus a `_self` policy. Making the evaluator refuse a managed-list explicit entry on a `private` object would retire those reads too. The row scope touches only the two objects the contract names. ## Tests Driven on the real `SecurityPlugin` + `ObjectQL` over a real `SqlDriver`, with the shipped sets, in three deployment shapes (stock `isolated`, `isolated` with no baseline, `single`) — the shapes PR #20023's test covers. - New: `packages/plugins/plugin-security/src/private-credential-row-scope.test.ts` (37 tests). Every non-admin persona reads 0 rows of both objects (list, count and by-id, including its own verification row). Controls: the platform admin reads every row list and by-id, and better-auth's adapter (system context) reads every row by identifier and by id. - The probe was committed first, on the pre-fix tree, where its persona cases were red (`Tests 28 failed | 9 passed`). - Re-judged, not deleted: `objects/rbac-objects.test.ts`'s `member_default` policy-name list gains `sys_jwks_none` and `sys_verification_none`, with a note. - `@objectstack/plugin-security`, full suite: `Test Files 131 passed (131) · Tests 2598 passed (2598)`. - `@objectstack/plugin-security` typecheck, all three tsc programs plus `check:test-typecheck`: exit 0, "0 file(s) / 0 error(s)". The new test file is in the test tsc program (`--listFiles`). - `@objectstack/plugin-auth`, full suite (its adapter reads these tables; its vitest config aliases plugin-security to source): `Test Files 114 passed (114) · Tests 2439 passed (2439)`. - Other consumers naming either object in a test (`objectql`, `platform-objects`, `plugin-pinyin-search`, `rest`, `spec`): none pins the shipped sets' policy names or reads a row of these tables under a user context; declared to CI. ### Ablation of every negative pin The fix was committed before any ablation. Each ablation mutated the committed `default-permission-sets.ts` through `scripts/ablation-replace.mjs` (on-disk anchor-hit and blob-change proof), ran the probe, then restored; every restore proven as blob `37920bbfa764` == HEAD with `git diff HEAD` empty. | ablation | result | |---|---| | A1 remove the spread from `member_default` | 10 failed: `member_default` and the MCP agent (both composed shapes) | | A2 remove the spread from `organization_admin` | 4 failed: `organization_admin` and `organization_admin_no_bypass`, no-baseline shape only | | A3 remove the spread from `viewer_readonly` | 2 failed: `viewer_readonly`, no-baseline shape only | | A4 delete the `sys_verification_none` policy | 28 failed: every persona in every shape | | A5 delete the `sys_jwks_none` policy | 28 failed: every persona in every shape | ## Changeset `.changeset/20027-private-credential-objects.md`, `@objectstack/plugin-security: minor`, a `!` headline, the `Clause-②: no (narrowing)` line, a **BREAKING** paragraph naming what a non-admin can no longer read and the remedy, and the honest ADR-0087 marker (`not-required`, no migration prescription — the sets are code evaluated from the in-memory declaration). ## Gates - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 62 families from the real diff; all 62 run, `--ran` reconciliation: `62 derived famil(ies) accounted for — 62 run`. - 61 exited 0. `pnpm check:dual-build-cjs-loads` exited 3 (`PREREQUISITE NOT MET` — needs a whole-repo `dist/`), declared to CI. - `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs`: exit 0, 5 citations resolve. - ESLint, narrowed to the three changed TypeScript files (`--no-inline-config --format json`): 3 files, 0 errors, 0 warnings. The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **The class recurs by construction** (unchanged from PR #20023's note): at `kernel:ready`, `applyManagedWriteDenies` gives every registered `managedBy: 'better-auth'` object an `allowRead: true` entry on the target sets, so the next `private` managed table arrives readable by every member unless its author also adds a row scope here. No guard is added; new gates default to no. Carrier: whoever adds the next private managed object. - `sys_secret` (`managedBy: 'engine-owned'`) is `private` too but is NOT on this blanket, so it is not part of this class and was not measured here. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e9a425 commit 7766b62

4 files changed

Lines changed: 394 additions & 0 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
fix(plugin-security)!: no shipped permission set below platform admin reads a row of `sys_verification` or `sys_jwks` — the credential rows those objects declare private (#20027)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING** — shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11+
this banner and the ADR-0087 disposition below, never by the level).
12+
13+
`sys_verification` (one-time verification and password-reset tokens) and
14+
`sys_jwks` (JWT signing keys) declare `access: { default: 'private' }`, and the
15+
shipped permission sets documented them as denied to every principal below
16+
platform admin. The sets did not hold that: the read they grant on every
17+
better-auth-managed object is an explicit per-object entry, which the `private`
18+
posture does not govern, and neither object had a row policy behind it.
19+
20+
**What a principal can no longer read.** Every shipped set that grants that read
21+
and carries row-level security — `member_default`, `viewer_readonly`,
22+
`organization_admin` and its wall-less variant `organization_admin_no_bypass` —
23+
now declares a row policy that admits no row on each of the two objects
24+
(`sys_verification_none`, `sys_jwks_none`). That covers the rows the objects
25+
declare private, including a caller's own verification row. An MCP agent acting
26+
for a user is bounded by that user's sets and reads the same. `admin_full_access`
27+
is unchanged and still reads every row.
28+
29+
**Remedy.** None is expected to be needed: no shipped product surface reads these
30+
tables under a user context. A deployment that genuinely needs a principal below
31+
platform admin to inspect them grants that in a permission set of its own, with
32+
a row-level-security policy that names the rows it may see.
33+
34+
Unchanged: better-auth's own verification, password-reset and token-signing flows
35+
(they read and write through its adapter under system context, which no row
36+
policy reaches), the owner-scoped reads of the other `private` identity objects
37+
(`sys_device_code`, `sys_oauth_access_token`, `sys_oauth_refresh_token`), and
38+
every other managed object.
39+
40+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authored moves: `packages/spec` is untouched, no object definition or column changes, and the shipped permission sets are code evaluated from the in-memory declaration, so `objectstack migrate meta` has nothing to rewrite and the ledger has no row to gain. The change is a narrowing of what the platform's own sets admit at runtime. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers it (not `registered` / `already-registered`); and it is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

‎packages/plugins/plugin-security/src/objects/default-permission-sets.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,58 @@ const scimProjectionRowScope = () => [
192192
},
193193
];
194194

195+
/**
196+
* [#20027] The row scope of the two better-auth tables whose rows ARE
197+
* credentials: `sys_verification` (one-time verification and password-reset
198+
* tokens) and `sys_jwks` (the environment's JWT signing keys). Both declare
199+
* `access: { default: 'private' }`, and the contract this file has always
200+
* stated for them is "DENY for non-admins by design" — see the note above the
201+
* `_self` carve-outs in `member_default`.
202+
*
203+
* Why the declaration alone did not hold: `private` keeps a plain `'*'`
204+
* wildcard off an object (`resolveObjectPermission`), but the blanket above
205+
* gives these objects an EXPLICIT per-object read entry, and an explicit entry
206+
* is not the wildcard. The object-level read therefore resolved `true` for
207+
* every set holding the blanket, and neither object had a row policy. The
208+
* retired wildcard `tenant_isolation` policy was what used to deny their rows;
209+
* ADR-0095 D1 moved the tenant wall to Layer 0, which is inert on an object
210+
* with no tenant column. One more instance of the class this file's header
211+
* describes, closed with the instrument the header names: the per-object row
212+
* scope, ⛔ never an edit of the blanket.
213+
*
214+
* `_none` (`id == null`, i.e. no row), the shape `scimProjectionRowScope`
215+
* uses. There is no `_self` form: neither table names a user
216+
* (`sys_verification` keys on an address, `sys_jwks` on nothing), and no
217+
* principal needs to read even their OWN live token — better-auth reads it.
218+
*
219+
* Who keeps every row, and why this does not reach them:
220+
* - the platform admin (`admin_full_access`): its wildcard carries the
221+
* superuser read bypass, which skips Layer 1, and it names neither object
222+
* explicitly;
223+
* - better-auth itself: it reads through its adapter under system context
224+
* (`withSystemContext`, plugin-auth), which no row policy reaches.
225+
*
226+
* Why a policy, and not `private` taking precedence over explicit entries in
227+
* the evaluator: three OTHER `private` objects on the blanket —
228+
* `sys_device_code`, `sys_oauth_access_token`, `sys_oauth_refresh_token` — are
229+
* deliberately readable by their owner through that same explicit entry plus
230+
* a `_self` policy. An evaluator rule would retire those reads too; this scope
231+
* touches only the two objects the contract names.
232+
*
233+
* `select` (not `all`): every write on these tables is already denied at the
234+
* object layer by the blanket. Spread into EVERY shipped set that spreads the
235+
* blanket and carries row-level security (`organization_admin`, and so its
236+
* derived no-bypass variant, `member_default`, `viewer_readonly`), for the
237+
* reason `scimProjectionRowScope` gives: where no platform baseline is
238+
* composed, a set with no policy for an object leaves it unfiltered. The MCP
239+
* write ceiling holds the blanket too; its bound is the delegating user's own
240+
* sets (ADR-0090 D10), which carry this scope. A fresh array per set.
241+
*/
242+
const privateCredentialRowScope = () => [
243+
{ name: 'sys_verification_none', object: 'sys_verification', operation: 'select', using: 'id == null' },
244+
{ name: 'sys_jwks_none', object: 'sys_jwks', operation: 'select', using: 'id == null' },
245+
];
246+
195247
/**
196248
* Default permission sets seeded by the platform.
197249
*
@@ -480,6 +532,10 @@ const baseDefaultPermissionSets: PermissionSet[] = [
480532
// admission shaped like `sys_invitation_org_admin` would read every
481533
// organization's rows. See `scimProjectionRowScope` above.
482534
...scimProjectionRowScope(),
535+
// [#20027] The credential tables: no row. The org admin's `viewAllRecords`
536+
// does not reach them — the blanket's explicit entry, not the wildcard,
537+
// is what resolves for them. See `privateCredentialRowScope` above.
538+
...privateCredentialRowScope(),
483539
],
484540
}),
485541
PermissionSetSchema.parse({
@@ -704,6 +760,10 @@ const baseDefaultPermissionSets: PermissionSet[] = [
704760
// `user_id` (`sys_verification`, `sys_jwks`, empty `sys_passkey`)
705761
// stay DENY for non-admins by design — only platform admins (via
706762
// `admin_full_access`, which has no RLS) should inspect them.
763+
// [#20027] ⚠️ Since the wildcard policy retired, nothing above denies
764+
// them: the blanket's explicit read entry opens the object, so the
765+
// deny is held by `privateCredentialRowScope` (spread at the end of this
766+
// list and of the two sibling sets). Keep the two together.
707767
{
708768
name: 'sys_organization_self',
709769
object: 'sys_organization',
@@ -1024,6 +1084,8 @@ const baseDefaultPermissionSets: PermissionSet[] = [
10241084
// [#20001] The SCIM projection tables: a member reads the rows about
10251085
// themselves and no other — see `scimProjectionRowScope` above.
10261086
...scimProjectionRowScope(),
1087+
// [#20027] The credential tables: no row — see `privateCredentialRowScope`.
1088+
...privateCredentialRowScope(),
10271089
],
10281090
}),
10291091
PermissionSetSchema.parse({
@@ -1148,6 +1210,8 @@ const baseDefaultPermissionSets: PermissionSet[] = [
11481210
// the platform baseline is not composed a set with no policy for them
11491211
// leaves them unfiltered. See `scimProjectionRowScope` above.
11501212
...scimProjectionRowScope(),
1213+
// [#20027] Repeated here for the same reason: the credential tables, no row.
1214+
...privateCredentialRowScope(),
11511215
],
11521216
}),
11531217

‎packages/plugins/plugin-security/src/objects/rbac-objects.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,13 @@ describe('default permission sets', () => {
129129
// deliberate, not a duplicate to be collapsed.
130130
'sys_invitation_issuer',
131131
'sys_invitation_self',
132+
// [#20027] The credential tables' row scope (`id == null`, no row) —
133+
// re-judged here, not a drift. `sys_jwks` and `sys_verification` declare
134+
// `access: private` and "DENY for non-admins", but the managed-object
135+
// blanket's EXPLICIT read entry opened them to every set holding it.
136+
// Behaviour is pinned end to end in `private-credential-row-scope.test.ts`;
137+
// this list only records presence (its twin is last in the list).
138+
'sys_jwks_none',
132139
'sys_notification_receipt_self',
133140
'sys_oauth_access_token_self',
134141
'sys_oauth_application_self',
@@ -154,6 +161,7 @@ describe('default permission sets', () => {
154161
'sys_user_org_members',
155162
'sys_user_preference_self',
156163
'sys_user_self',
164+
'sys_verification_none',
157165
]);
158166
expect(policyNames).not.toContain('tenant_isolation');
159167
const orgSelf = (member.rowLevelSecurity ?? []).find((p) => p.name === 'sys_organization_self')!;

0 commit comments

Comments
 (0)