Repository navigation
Commit 26550c6
fix(plugin-security)!: row-scope the SCIM projection tables in every shipped permission set (#20023)
Fixes #20001
Clause-②: no (narrowing)
## What was wrong
The seven `@better-auth/scim` projection tables are on
`BETTER_AUTH_MANAGED_OBJECTS`, so every shipped set that spreads
`denyWritesOnManagedObjects()` grants an object-level read on them. None
of the seven carries a tenant column
(`scripts/platform-object-tenancy-census.json`: `reach: out`,
`tenantField: null` for all seven), so the organization wall (Layer 0)
is inert on them, and no shipped set declared a row policy for any of
them. The file header's "org-wide readable" blanket therefore meant
every organization's rows, for every principal below platform admin,
organization admins included.
## Measurement, recorded before the fix
Commit `81cbffa259` adds the probe (`scim-projection-row-scope.test.ts`)
on the unfixed tree. It uses the real `SecurityPlugin` + `ObjectQL` over
`SqlDriver`, the `isolated` posture and the shipped sets. It went red:
`Tests 11 failed | 2 passed (13)`. Every persona of organization X read
every row of all seven tables, including the rows organization Y's
connection wrote. The failing personas were `member_default`,
`viewer_readonly`, `organization_admin`, `organization_admin_no_bypass`,
and `mcp_agent_data_write` acting for a member. The two controls passed:
the platform admin reads both organizations' rows, and `sys_member`
stays within organization X.
A census over all 28 better-auth-managed objects was taken on the same
harness, before and after the fix, on engine `find` plus a by-id read of
the other organization's row. For the seven SCIM tables:
| table | every non-admin shipped persona, before | after (isolated and
single) | `admin_full_access` |
|---|---|---|---|
| `sys_scim_user`, `sys_scim_subject`, `sys_scim_projection_grant`,
`sys_scim_identity_tombstone` | 2 rows (incl. org Y's) / by-id: row | 1
row (own) / by-id: null | 2 rows / row (unchanged) |
| `sys_scim_group`, `sys_scim_group_member`,
`sys_scim_connection_binding` | 2 rows (incl. org Y's) / by-id: row | 0
rows / by-id: null | 2 rows / row (unchanged) |
Outside the seven tables, no cell of the census changed between before
and after.
## Fix
This uses the instrument the file header names: the per-object row
scope, and ⛔ no edit of the blanket. A new `scimProjectionRowScope()` in
`default-permission-sets.ts` returns seven `select` policies, spread
into every shipped set that spreads the blanket and carries row-level
security. Those sets are `organization_admin` (so also the derived
`organization_admin_no_bypass`), `member_default` and `viewer_readonly`.
- `TABLE_self` (`user_id == current_user.id`) goes on `sys_scim_user`,
`sys_scim_subject`, `sys_scim_projection_grant` and
`sys_scim_identity_tombstone`: a principal reads the rows about
themselves.
- `TABLE_none` (`id == null`, i.e. no row) goes on `sys_scim_group`,
`sys_scim_group_member` and `sys_scim_connection_binding`, which name no
user.
- `admin_full_access` is unchanged. Its wildcard superuser read bypass
skips Layer 1 on a better-auth-managed object, so the platform admin
still reads every row. SCIM is administered by the platform admin:
`sys_scim_connection_credential` is gated on `manage_platform_settings`.
- The MCP write ceiling (`mcp_agent_data_write`) also holds the blanket.
It carries no row-level security by design (ADR-0090 D10), and its bound
is the delegating user's sets, which now carry the scope. That is
measured, not assumed.
- Why every set carries the scope and not only the baseline: with
`fallbackPermissionSet: null` no platform baseline is composed, and a
set with no policy for an object leaves that object unfiltered. The
probe's no-baseline shape is what catches this, as the ablation below
shows.
**Why there is no organization-scoped read, for admins or members.**
None is expressible without a schema change, and the dispatch forbids
one:
- an `_org` predicate needs a column to compare, and only
`sys_scim_connection_credential` knows a connection's organization; a
predicate cannot traverse `connection_id` (ADR-0055);
- an admin admission shaped like `sys_invitation_org_admin` (`id !=
null`) relies on Layer 0 for the organization bound, and Layer 0 is
inert here, so it would re-open every organization's rows;
- `user_id in current_user.org_user_ids` is not an organization scope
for this data. A projection belongs to the connection that wrote it, so
a user in two organizations would expose one organization's
identity-provider record to the other.
**Breaking:** an organization admin no longer reads their own
organization's SCIM users or groups; they read only the rows about
themselves. We found no product surface that reads these tables under a
user context. There is no Setup nav entry and no docs page for them.
SCIM provisioning reads and writes through better-auth's adapter under
system context, which no row policy reaches. The changeset states this,
with the remedy.
A comment-only touch in the same file: the `deriveWallLessOrgAdmin`
docblock said "the 15 identity RLS carve-outs" (18 before this change,
25 after). The number is dropped rather than re-counted.
## Tests
The code tree is identical between `173a6307c9` and HEAD `a3544ace60`.
The last commit adds only `.changeset/20001-scim-row-scope.md` (`git
diff --stat 173a630 a3544ac`: 1 file, 48 insertions).
- New:
`packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts`,
36 tests over three deployment shapes:
- `isolated` with the platform baseline composed (the stock shape);
- `isolated` with no baseline, where each set stands on its own;
- `single`, where the policies carry no tenant token and so survive the
ADR-0105 D3 strip.
Each shipped persona is asserted by table: its own rows admitted, the
other organization's rows absent (list and by-id). There are three
controls: the platform admin reads both organizations' rows, a co-member
reads its own rows, and `sys_member` is unchanged. Result: `Tests 36
passed (36)`.
- Re-judged, not deleted: `objects/rbac-objects.test.ts`. Its exact
`member_default` policy-name list gains the seven names, with a note.
- `@objectstack/plugin-security`, full suite at `173a6307c9`: `Test
Files 129 passed (129) · Tests 2537 passed (2537)`.
- `@objectstack/plugin-security` typecheck, all three tsc programs plus
`check:test-typecheck`: exit 0, "0 file(s) / 0 error(s)".
- `@objectstack/plugin-auth`, full suite: `Test Files 114 passed (114) ·
Tests 2439 passed (2439)`. Its vitest config aliases
`@objectstack/plugin-security` to source.
- Other packages whose tests name `member_default`: `qa/dogfood` (21
files), `rest` (5), `runtime` (4), `spec` (4), `plugin-hono-server` (3),
`plugin-sharing` (2), `objectql` (2), `cli` (2), and one file each in
`verify`, `service-storage`, `service-automation`, `plugin-audit` and
`core`. These were not run locally and are declared to CI. None of them
pins the shipped sets' policy names or counts, and none reads a
`sys_scim_*` table (grep).
### Ablation of every negative pin
The fix was committed before any ablation. Each ablation mutated the
committed file through `scripts/ablation-replace.mjs`, ran the probe,
then restored. Every restore was proven as blob `2ae6cfa3ab13` == HEAD
with `git diff HEAD` empty. The subject is imported from `src`, so there
is no dist leg.
| ablation | result |
|---|---|
| A1 remove the spread from `member_default` | 13 failed:
`member_default` (find + by-id) in all 3 shapes, the MCP agent in both
composed shapes, and the co-member control in all 3 |
| A2 remove the spread from `organization_admin` | 4 failed:
`organization_admin` and `organization_admin_no_bypass`, in the
no-baseline shape only. The stock shape stays green through
`member_default`, which is why the no-baseline shape exists |
| A3 remove the spread from `viewer_readonly` | 2 failed:
`viewer_readonly`, in the no-baseline shape only |
| A4 delete the `sys_scim_user_self` policy | 31 failed: every persona
in every shape, plus the co-member control. The 5 remaining are the
platform-admin and `sys_member` controls |
| A5 delete the `sys_scim_group_none` policy | 28 failed: every persona
in every shape. The co-member control stays green because it reads only
the user-keyed tables |
The first A1 attempt was a no-op. The replacement was a substring of the
anchor, `ablation-replace` refused it, and the test never ran. The
attempt was redone with a distinct marker, and the result above comes
from that second run.
### Gates at HEAD `a3544ace60`
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 62 gates, all run. `--ran` reconciliation: `62
derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED`.
- 59 exited 0 on the first pass, including `check-adr-0087-registration`
(`not-required (no-migration-prescription)` accepted) and
`check-changeset-no-major`. The first pass of `pnpm check:i18n` exited 3
because its build prerequisite was missing. After that closure was built
it exited 0 ("all bundles in sync").
- NOT MEASURED: `pnpm check:dual-build-cjs-loads` and `pnpm
check:type-check-debt`. Both exit 3 with `PREREQUISITE NOT MET`, because
they need every package's `dist/` (a whole-repo build), and they are
declared to CI.
- `GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs`:
exit 0, 4 citations resolve.
- ESLint, narrowed to the three changed TypeScript files
(`--no-inline-config --format json`): 3 files, 0 errors, 0 warnings.
- Population: the config's `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus
`NEVER_LINTED`, and none of the three files is ignored.
- Invariance: 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.** At `kernel:ready`,
`applyManagedWriteDenies` (`managed-object-write-denies.ts`) gives
`MANAGED_DENY_ENTRY` (`allowRead: true`, writes denied) to every
registered `managedBy: 'better-auth'` object that a target set does not
name. So the next better-auth-managed table arrives readable by every
member with no row scope, unless its author also adds a policy here.
Today the union adds nothing beyond this baseline, because
`default-permission-sets.test.ts` pins the two lists as equal. No guard
is added here: new gates default to no. Carrier: whoever adds the next
managed object (承接者:无).
- `sys_oauth_resource`, `sys_oauth_client_resource` and
`sys_oauth_client_assertion` are on the same blanket with no row policy,
and every shipped persona reads them at engine level. All three declare
`apiEnabled: false`, so no REST surface was measured. Noted, not filed.
- **Open, not decided here:** an organization-scoped SCIM directory for
org admins (or members). It needs a tenant column on the projections or
a resolved connection-to-organization set. It is a capability to decide
and build, and no pull for it was measured.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 7b76fff commit 26550c6
4 files changed
Lines changed: 510 additions & 1 deletion
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 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
102 | 102 | | |
103 | 103 | | |
104 | 104 | | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
105 | 195 | | |
106 | 196 | | |
107 | 197 | | |
| |||
385 | 475 | | |
386 | 476 | | |
387 | 477 | | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
388 | 483 | | |
389 | 484 | | |
390 | 485 | | |
| |||
926 | 1021 | | |
927 | 1022 | | |
928 | 1023 | | |
| 1024 | + | |
| 1025 | + | |
| 1026 | + | |
929 | 1027 | | |
930 | 1028 | | |
931 | 1029 | | |
| |||
1045 | 1143 | | |
1046 | 1144 | | |
1047 | 1145 | | |
| 1146 | + | |
| 1147 | + | |
| 1148 | + | |
| 1149 | + | |
| 1150 | + | |
1048 | 1151 | | |
1049 | 1152 | | |
1050 | 1153 | | |
| |||
1148 | 1251 | | |
1149 | 1252 | | |
1150 | 1253 | | |
1151 | | - | |
| 1254 | + | |
1152 | 1255 | | |
1153 | 1256 | | |
1154 | 1257 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
135 | 135 | | |
136 | 136 | | |
137 | 137 | | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
138 | 151 | | |
139 | 152 | | |
140 | 153 | | |
| |||
0 commit comments