Repository navigation
Commit 7766b62
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
- .changeset
- packages/plugins/plugin-security/src
- objects
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
Lines changed: 64 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
192 | 192 | | |
193 | 193 | | |
194 | 194 | | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
195 | 247 | | |
196 | 248 | | |
197 | 249 | | |
| |||
480 | 532 | | |
481 | 533 | | |
482 | 534 | | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
483 | 539 | | |
484 | 540 | | |
485 | 541 | | |
| |||
704 | 760 | | |
705 | 761 | | |
706 | 762 | | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
707 | 767 | | |
708 | 768 | | |
709 | 769 | | |
| |||
1024 | 1084 | | |
1025 | 1085 | | |
1026 | 1086 | | |
| 1087 | + | |
| 1088 | + | |
1027 | 1089 | | |
1028 | 1090 | | |
1029 | 1091 | | |
| |||
1148 | 1210 | | |
1149 | 1211 | | |
1150 | 1212 | | |
| 1213 | + | |
| 1214 | + | |
1151 | 1215 | | |
1152 | 1216 | | |
1153 | 1217 | | |
| |||
Lines changed: 8 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
129 | 129 | | |
130 | 130 | | |
131 | 131 | | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
132 | 139 | | |
133 | 140 | | |
134 | 141 | | |
| |||
154 | 161 | | |
155 | 162 | | |
156 | 163 | | |
| 164 | + | |
157 | 165 | | |
158 | 166 | | |
159 | 167 | | |
| |||
0 commit comments