Skip to content

Commit 26550c6

Browse files
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/20001-scim-row-scope.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
fix(plugin-security)!: the SCIM projection tables are row-scoped in every shipped permission set — no principal below platform admin reads a SCIM row another organization provisioned (#20001)
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+
The seven `@better-auth/scim` tables carry no tenant column, so the organization
14+
wall is inert on them, and the read that the shipped permission sets grant on
15+
every better-auth-managed object had no row policy behind it for any of the
16+
seven. Any authenticated member could therefore read the users, groups and
17+
memberships every organization's identity provider had provisioned.
18+
19+
**What a principal can no longer read.** Every shipped set that grants that read
20+
and carries row-level security — `member_default`, `viewer_readonly`,
21+
`organization_admin` and its wall-less variant `organization_admin_no_bypass` —
22+
now declares a row policy on each table:
23+
24+
- `sys_scim_user`, `sys_scim_subject`, `sys_scim_projection_grant` and
25+
`sys_scim_identity_tombstone`: only the rows whose `user_id` is the caller
26+
(the `<table>_self` policies);
27+
- `sys_scim_group`, `sys_scim_group_member` and `sys_scim_connection_binding`:
28+
no row at all (the `<table>_none` policies).
29+
30+
This binds organization admins as well: an org admin no longer reads their own
31+
organization's SCIM users or groups, only the rows about themselves. An MCP
32+
agent acting for a user is bounded by that user's sets and reads the same. No
33+
organization-scoped read replaces the old one, because none of the seven tables
34+
has a column naming the organization and a row policy cannot follow
35+
`connection_id` to the connection's organization. `admin_full_access` is
36+
unchanged and still reads every row.
37+
38+
**Remedy.** A deployment that needs a principal below platform admin to read
39+
these tables grants it in a permission set of its own, with a row-level-security
40+
policy on each table that names the rows it may see. Do not reach for a policy
41+
that admits every row: no organization wall bounds these tables, so such a
42+
policy admits every organization's rows.
43+
44+
Unchanged: SCIM provisioning itself (its reads and writes run through
45+
better-auth's adapter under system context, which no row policy reaches), and
46+
every other managed object.
47+
48+
<!-- 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: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,96 @@ const denyWritesOnManagedObjects = (): Record<string, {
102102
]),
103103
);
104104

105+
/**
106+
* [#20001] The row scope of the seven `@better-auth/scim` projection tables —
107+
* one more instance of the blanket class the header above describes, closed
108+
* with the instrument the header names (the per-object row scope, never an edit
109+
* of the blanket).
110+
*
111+
* Why the blanket was wider here than anywhere else on the list: NONE of these
112+
* tables carries a tenant column (`scripts/platform-object-tenancy-census.json`
113+
* records all seven `reach: out`, `tenantField: null`), so the organization wall
114+
* (Layer 0) is inert on them and the engine scopes nothing by organization. The
115+
* header's "org-wide readable" therefore meant EVERY organization's rows: an
116+
* ordinary member of one organization read the users, groups and memberships
117+
* another organization's identity provider had provisioned.
118+
*
119+
* The scope, per table:
120+
* - `_self` (`user_id == current_user.id`) on the four tables that name the
121+
* platform user a row is about — `sys_scim_user`, `sys_scim_subject`,
122+
* `sys_scim_projection_grant`, `sys_scim_identity_tombstone`. A principal
123+
* reads what the provisioning says about THEM, the same shape as every other
124+
* `_self` carve-out in this file;
125+
* - `_none` (`id == null`) on the three that name no user —
126+
* `sys_scim_group`, `sys_scim_group_member`, `sys_scim_connection_binding`.
127+
* `id` is required on every row, so this admits no row: said plainly, it is
128+
* the mirror of the `id != null` spelling `sys_invitation_org_admin` uses for
129+
* "every row". A policy rather than dropping `allowRead`, because the header
130+
* rules out editing the blanket and a per-object `allowRead: false` would
131+
* be undone by any other set that names the object.
132+
* The platform admin (`admin_full_access`) keeps every row: its wildcard carries
133+
* the superuser read bypass, which skips Layer 1 on a better-auth-managed object.
134+
* That is the audience SCIM is administered by — the connection store
135+
* (`sys_scim_connection_credential`) is gated on `manage_platform_settings`.
136+
*
137+
* ⚠️ There is deliberately NO organization-scoped read here, for members or for
138+
* org admins, because none is expressible without a schema change:
139+
* - an `_org` predicate needs a column to compare, and the only table that
140+
* knows a connection's organization is `sys_scim_connection_credential` — a
141+
* predicate cannot traverse `connection_id` to it (ADR-0055);
142+
* - an admin admission spelled like `sys_invitation_org_admin` (`id != null`,
143+
* bounded by Layer 0) is bounded by NOTHING here — Layer 0 is inert — so it
144+
* would hand an org admin every organization's rows again;
145+
* - `user_id in current_user.org_user_ids` is not an organization scope for
146+
* this data: a projection belongs to the CONNECTION that wrote it, so a
147+
* user who belongs to two organizations would expose one organization's
148+
* identity-provider record of them to the other.
149+
* An organization-scoped SCIM directory is therefore a capability to decide and
150+
* build (a tenant column on the projection, or a resolved connection set), not a
151+
* predicate to add here.
152+
*
153+
* `select` (not `all`): every write on these tables is denied at the object
154+
* layer by `denyWritesOnManagedObjects()` and answered 405 by
155+
* `apiMethods: ['get', 'list']`; the writer is the SCIM protocol endpoint,
156+
* through better-auth's adapter under system context, which no row policy
157+
* reaches.
158+
*
159+
* Spread into EVERY shipped set that spreads the blanket and carries row-level
160+
* security — `organization_admin` (and so its derived no-bypass variant),
161+
* `member_default` and `viewer_readonly` — rather than into the baseline alone:
162+
* a deployment that composes no platform baseline (`fallbackPermissionSet: null`)
163+
* resolves an org admin's sets without `member_default`, and a set with no
164+
* policy for an object leaves that object unfiltered. The MCP write ceiling
165+
* holds the blanket too and carries no row-level security by design (ADR-0090
166+
* D10): its bound is the delegating user's own sets, which carry this scope.
167+
* A fresh array per set, like `denyWritesOnManagedObjects()`, so no two sets
168+
* share one policy object.
169+
*/
170+
const scimProjectionRowScope = () => [
171+
{ name: 'sys_scim_user_self', object: 'sys_scim_user', operation: 'select', using: 'user_id == current_user.id' },
172+
{ name: 'sys_scim_subject_self', object: 'sys_scim_subject', operation: 'select', using: 'user_id == current_user.id' },
173+
{
174+
name: 'sys_scim_projection_grant_self',
175+
object: 'sys_scim_projection_grant',
176+
operation: 'select',
177+
using: 'user_id == current_user.id',
178+
},
179+
{
180+
name: 'sys_scim_identity_tombstone_self',
181+
object: 'sys_scim_identity_tombstone',
182+
operation: 'select',
183+
using: 'user_id == current_user.id',
184+
},
185+
{ name: 'sys_scim_group_none', object: 'sys_scim_group', operation: 'select', using: 'id == null' },
186+
{ name: 'sys_scim_group_member_none', object: 'sys_scim_group_member', operation: 'select', using: 'id == null' },
187+
{
188+
name: 'sys_scim_connection_binding_none',
189+
object: 'sys_scim_connection_binding',
190+
operation: 'select',
191+
using: 'id == null',
192+
},
193+
];
194+
105195
/**
106196
* Default permission sets seeded by the platform.
107197
*
@@ -385,6 +475,11 @@ const baseDefaultPermissionSets: PermissionSet[] = [
385475
operation: 'select',
386476
using: 'organization_id == current_user.organization_id',
387477
},
478+
// [#20001] The SCIM projection tables: self-only / no row, and deliberately
479+
// no org-admin admission — Layer 0 is inert on them, so an `id != null`
480+
// admission shaped like `sys_invitation_org_admin` would read every
481+
// organization's rows. See `scimProjectionRowScope` above.
482+
...scimProjectionRowScope(),
388483
],
389484
}),
390485
PermissionSetSchema.parse({
@@ -926,6 +1021,9 @@ const baseDefaultPermissionSets: PermissionSet[] = [
9261021
using: 'id != null',
9271022
positions: ['org_member'],
9281023
},
1024+
// [#20001] The SCIM projection tables: a member reads the rows about
1025+
// themselves and no other — see `scimProjectionRowScope` above.
1026+
...scimProjectionRowScope(),
9291027
],
9301028
}),
9311029
PermissionSetSchema.parse({
@@ -1045,6 +1143,11 @@ const baseDefaultPermissionSets: PermissionSet[] = [
10451143
operation: 'select',
10461144
using: 'email == current_user.email',
10471145
},
1146+
// [#20001] Repeated here rather than inherited from `member_default`: this
1147+
// set names the SCIM projection tables through the blanket, and wherever
1148+
// the platform baseline is not composed a set with no policy for them
1149+
// leaves them unfiltered. See `scimProjectionRowScope` above.
1150+
...scimProjectionRowScope(),
10481151
],
10491152
}),
10501153

@@ -1148,7 +1251,7 @@ const baseDefaultPermissionSets: PermissionSet[] = [
11481251
* ({@link ORGANIZATION_ADMIN_NO_BYPASS}) from `organization_admin` by dropping
11491252
* the wildcard `viewAllRecords`/`modifyAllRecords` bits — everything else
11501253
* (object grants, managed-write denies, anti-escalation RBAC read-only rules,
1151-
* system permissions, the 15 identity RLS carve-outs) is carried over verbatim.
1254+
* system permissions, the identity RLS carve-outs) is carried over verbatim.
11521255
*
11531256
* DERIVED, never a second literal: two hand-maintained copies of a
11541257
* high-privilege set are a drift waiting to happen, and the drift would be a

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,19 @@ describe('default permission sets', () => {
135135
'sys_oauth_consent_self',
136136
'sys_oauth_refresh_token_self',
137137
'sys_organization_self',
138+
// [#20001] The SCIM projection tables' row scope — re-judged here, not a
139+
// drift: none of the seven carries a tenant column, so Layer 0 is inert
140+
// on them and the managed-object blanket read every organization's rows.
141+
// `_self` on the four that name a user; `_none` (`id == null`, no row) on
142+
// the three that name none. Their behaviour is pinned end to end in
143+
// `scim-projection-row-scope.test.ts`; this list only records presence.
144+
'sys_scim_connection_binding_none',
145+
'sys_scim_group_member_none',
146+
'sys_scim_group_none',
147+
'sys_scim_identity_tombstone_self',
148+
'sys_scim_projection_grant_self',
149+
'sys_scim_subject_self',
150+
'sys_scim_user_self',
138151
'sys_session_self',
139152
'sys_team_member_self',
140153
'sys_two_factor_self',

0 commit comments

Comments
 (0)