Skip to content

Commit 6e3aa75

Browse files
fix(plugin-security): an organization-less permission-set read resolves organization-less rows only (#20584)
Fixes #20555 Clause-②: no ## The measurement: reached The card's question was measured before any fix, on `main` at `1c761c0d`, which already contains PR #20540 (merged as `f6ceddc3`). The probe is committed on this branch as `bf72f620` and later became the pin. It runs a real `ObjectQL` over a real `SqlDriver` (better-sqlite3 `:memory:`), the real platform object definitions and the real `SecurityPlugin`, resolved through the `security` service it registers. Principals are built by `buildContextForUser`, which runs the same `resolveUserAuthzGrants` the request path uses. What it read, stated abstractly: - **Reached.** The principal is a member of one organization only and has no organization active. It resolved permission sets that a different organization had authored under the names of built-in roles it holds as positions. The organization-less by-name read returned the other organization's rows. Their `systemPermissions` reached the resolved sets, and their object map (view-all and modify-all included) reached `getEffectiveObjectPermissions`. The capabilities did not reach the core envelope's `systemPermissions`, because §6b of `resolveUserAuthzGrants` resolves by id. They arrived only through plugin-security's by-name fold. - **Control.** With the principal's own organization active, the read was scoped and returned none of those rows. - **A second face of the same read.** An organization-less principal holding a global grant resolved another organization's same-named copy of the granted set, not the global row the grant names. Mechanism assumptions from the dispatch, each measured: - **A1 holds.** `resolvePermissionSetsForContextUnmemoized` folds positions and permissions into one request. The loader's by-name read carries no tenant when none is active, and the driver's `applyTenantScope` reads "no tenant" as an unscoped path. `resolveOwnOrganizationRow(rows, undefined)` then returns whichever row came first. - **A2 holds, at runtime.** After `f6ceddc3`, a resolution with no organization still lists every current membership's role in `positions`, plus the audience anchor. - **A3 holds.** `reserved-identity-names.ts` guards `sys_position.name` and `sys_user_position.position` only. The package-owned collision refusal (ADR-0086 D4) is about package ownership. Neither one touches an organization-authored `sys_permission_set.name`. - **A4: nothing legitimate is dropped.** The pin also holds the global grants an organization-less principal really holds. A global position assignment folded onto a global same-named set still resolves, and so does a global user grant to a global set. Both resolve identically with the principal's own organization active. ## The fix: one mechanism The change lands in `packages/plugins/plugin-security/src/security-plugin.ts`, in the permission-set loader built in `start()`. With no active organization, the by-name read now asks for organization-less rows only (`organization_id: null`). A row scoped to an organization applies only while that organization is active, and an organization-less row applies everywhere. This is the rule `resolveUserAuthzGrants` already applies to grant rows, and it matches ADR-0123 D2 ("tenant-scoped reads resolve to nothing"). A read with an active organization is unchanged. - **Why a predicate in the read, not a filter after it.** The read keeps its `limit`. Filtered afterwards, other organizations' copies of a name could fill the page and push out the global row the caller does hold. `organization_id: null` is the spec's has-no-value predicate. The organizations runtime already reads by it. - **No reserved-identity guard.** The scoped fold alone closes the cross-organization reach, so no second mechanism is added. Inside one organization, a set named after a position still folds for that organization's own members while it is active. That is the governed-fold question, which is already warned about at runtime. It is not this card. - **Blast radius.** Every consumer of `resolvePermissionSetsForContext` reads through this loader: the data-plane middleware, `getEffectiveObjectPermissions`, the delegated-admin gate, explain and `/me/apps`. So the fix reaches all of them from this one place. `packages/core/src/security/**` is not touched. - **Public surface.** Nothing reachable from the package's `exports` entry changes: no new export and no new option. ## Pins and their ablation `packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts` holds 10 cases: - a precondition: the principal really holds the colliding names; - six negative pins, one fact each; - three keep-pins: the global grants, the same grants with the principal's own organization active, and a control that the authored set does resolve for its own organization's member. Ablation, from the committed state at `c3237a7b`. The loader's read was put back unscoped through `scripts/ablation-replace.mjs` (anchor hit 1 time, blob `026ca66a` to `69cc688b`). A `trap` restored the file, and the restore was proven against the HEAD blob. ```text × the by-name read returns no other organization's row × the authored sets are not among the resolved sets × their systemPermissions do not reach the principal × their object map does not reach the effective map × a GLOBAL grant resolves the global row it names, not another organization's same-named copy × …and the same-named copy's systemPermissions do not reach that principal Tests 6 failed | 4 passed (10) ABLATION RESTORED: packages/plugins/plugin-security/src/security-plugin.ts blob 026ca66 == HEAD ``` The four that stay green are the precondition and the three keep-pins, which is the expected direction. On the first ablation run, the global-grant pin stayed green: the SQL driver returns the page ordered by id, and the global row's id sorted first. The fixture ids were reordered so that the other organization's copy sorts first (`c60be715`). The pin was re-ablated red after that. ## Local verification (all at `c3237a7b`) - `pnpm --filter @objectstack/plugin-security exec vitest run`: 144 files, 3062 passed and 16 skipped. - `pnpm --filter @objectstack/plugin-security run typecheck`: green. The new test file is in the `tsconfig.test.json` program, confirmed with `--listFiles`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands. All 63 were run, and `--ran` reports 63 run, 0 NOT-MEASURED, 0 UNRUN. - `check:type-check-debt` first answered exit 3 at this head: the ablation restore left the source newer than `dist/*.d.ts`. It answered 0 after `pnpm --filter @objectstack/plugin-security build`. - Earlier in the round, `check:engine-double-contract` flagged by-id and write verbs on the pin's observed engine. The loader under test calls `find` only, so the double now exposes `find` alone, and the ledger is unchanged. - A targeted dogfood subset ran against the rebuilt `dist/` (the fix marker was confirmed in `dist/index.mjs` and `dist/index.js`): `sharing-rule-org-less-caller`, `me-apps-and-everyone-baseline`, `two-doors-permission`, `showcase-permission-zoo` and `showcase-crud-persona-matrix`. 5 files, 87 tests passed. - Lint, narrowed and stated as such. `eslint --no-inline-config --format json` over the two touched TypeScript files reports 2 files, 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting: every `parserOptions` carries only `ecmaVersion` and `sourceType`, and there is no `parserOptions.project`. So this diff cannot move a verdict on any untouched file. The full `pnpm lint` run is CI's. ## Acceptance notes - **Boundary, not a regression.** A principal with no organization active no longer resolves a permission-set row that is stamped with an organization. Could a real principal have relied on that? It would need a global grant pointing at an organization-stamped set, or a global position named like one. No producer in this repository writes either. `bootstrapPlatformAdmin` points its global grant at an organization-less row, and Setup stamps both the grant and the set with the active organization. The remedy, stated in the changeset, is to make the organization active or to grant the set globally. - **Seeders are untouched.** `resolveOwnOrganizationRow(rows, undefined)` still returns the first row for the seeders' own single-posture pass. The fix changes the enforcement read only. - **A docblock to recheck.** The docblock on `callerOrganizationId` says a single-posture caller carries no organization. A single-posture session that has an active organization does carry one into `ctx.tenantId`, so that sentence is worth rechecking when the file is next touched. This PR does not change that behaviour. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c87d26 commit 6e3aa75

3 files changed

Lines changed: 358 additions & 2 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
A permission-set resolution with no active organization now reads the organization-less permission sets only. A permission set scoped to an organization applies only while that organization is active. This is the rule `resolveUserAuthzGrants` already applies to grant rows.
6+
7+
Clause-②: no
8+
9+
Before, the by-name `sys_permission_set` read carried no organization when the caller had none active. Every organization's row of each requested name came back, and the first one won. The names requested include the principal's positions. So a permission set another organization had authored under the name of a built-in role could reach an organization-less principal's resolved sets and effective object map. The same read could also resolve another organization's same-named copy of a set the principal holds through a global grant.
10+
11+
- **Unchanged:** a principal with an active organization resolves exactly as before. That read was already scoped to the organization and the organization-less rows, and it still prefers the organization's own row. Global grants still apply everywhere. A global position folded onto a global permission set of the same name still resolves with no organization active, and so does a global user grant. Permission sets declared in metadata or bootstrap resolve as before, because they never reach this read.
12+
- **If a principal relied on it:** make the organization active, or grant the permission set globally (no organization) when it is meant to apply everywhere.
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20555] A principal with NO active organization resolves permission sets
5+
* from the organization-less rows only — never from a set some organization
6+
* authored under a name the principal happens to hold as a position.
7+
*
8+
* ## The seam
9+
*
10+
* `resolvePermissionSetsForContextUnmemoized` requests
11+
* `[...positions, ...permissions]` as permission-set NAMES, and anything not
12+
* declared in metadata or bootstrap is read from `sys_permission_set` by name
13+
* through `dbLoaderFor(callerOrganizationId(context))`. With no active
14+
* organization, `resolveUserAuthzGrants` still lists every current
15+
* membership's role (`org_member`, …) and the `everyone` anchor in
16+
* `positions`, and the by-name read carried no tenant — so it returned every
17+
* organization's row of each name, and the loader kept whichever came first.
18+
*
19+
* Measured on `main` at 1c761c0d, over this file's rig: a member of
20+
* `ORG_HOME` only, with no organization active, resolved the sets `ORG_OTHER`
21+
* had authored as `org_member` and `everyone` — their `systemPermissions` and
22+
* their object map (view/modify-all included) reached the resolved sets and
23+
* `getEffectiveObjectPermissions`. The same principal's GLOBAL grant to
24+
* `platform_ops` resolved `ORG_OTHER`'s same-named copy instead of the global
25+
* row it names. With `ORG_HOME` active the read was scoped and none of this
26+
* happened.
27+
*
28+
* ## The rule pinned here
29+
*
30+
* A row scoped to an organization applies only while that organization is
31+
* active; an organization-less row applies everywhere — the rule
32+
* `resolveUserAuthzGrants` already applies to grant rows. So the negative
33+
* pins below are paired with preservation pins: the global grants an
34+
* organization-less principal really holds (a global position folded onto a
35+
* global same-named set, a global user grant) keep resolving, and an
36+
* organization's own set still reaches its own members while it is active.
37+
*
38+
* ## The rig
39+
*
40+
* A real `ObjectQL` over a real `SqlDriver` (better-sqlite3 `:memory:`), the
41+
* real platform object definitions, the real `SecurityPlugin` resolved through
42+
* the `security` service it registers, and principals built by
43+
* `buildContextForUser` — the same `resolveUserAuthzGrants` the request path
44+
* runs. The plugin's `kernel:ready` bootstraps are collected and never fired:
45+
* the loader under test does not depend on them, and a bootstrap left running
46+
* would race the engine teardown.
47+
*/
48+
49+
import { describe, it, expect, afterEach } from 'vitest';
50+
import { ObjectQL } from '@objectstack/objectql';
51+
import { SqlDriver } from '@objectstack/driver-sql';
52+
53+
import { SysPosition } from './objects/sys-position.object.js';
54+
import { SysPermissionSet } from './objects/sys-permission-set.object.js';
55+
import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js';
56+
import { SysUserPosition } from './objects/sys-user-position.object.js';
57+
import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js';
58+
import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity';
59+
60+
import { SecurityPlugin } from './security-plugin.js';
61+
import { buildContextForUser } from './explain-engine.js';
62+
63+
const SYS = { context: { isSystem: true } } as any;
64+
/** The organization that authors the colliding sets. Nobody below is its member except `USER_OTHER`. */
65+
const ORG_OTHER = 'org_other';
66+
/** The principals' own organization. */
67+
const ORG_HOME = 'org_home';
68+
/** A member of `ORG_HOME` only. */
69+
const USER_HOME = 'usr_home_member';
70+
/** A member of `ORG_HOME` only, holding two GLOBAL grants. */
71+
const USER_GLOBAL = 'usr_home_global';
72+
/** A member of `ORG_OTHER` — the control that the authored set resolves at all. */
73+
const USER_OTHER = 'usr_other_member';
74+
75+
const PROBE_OBJECT: any = {
76+
name: 'probe_ledger',
77+
label: 'Probe Ledger',
78+
fields: {
79+
id: { type: 'text', label: 'Id', primary: true },
80+
name: { type: 'text', label: 'Name' },
81+
owner_id: { type: 'text', label: 'Owner' },
82+
},
83+
};
84+
85+
const FULL = {
86+
allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true,
87+
viewAllRecords: true, modifyAllRecords: true,
88+
};
89+
90+
const engines: ObjectQL[] = [];
91+
afterEach(async () => {
92+
while (engines.length) {
93+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
94+
}
95+
});
96+
97+
async function insertSet(e: any, id: string, name: string, organizationId: string | null, caps: string[]) {
98+
await e.insert('sys_permission_set', {
99+
id,
100+
name,
101+
label: `${organizationId ?? 'global'} ${name}`,
102+
organization_id: organizationId,
103+
managed_by: 'admin',
104+
active: true,
105+
object_permissions: JSON.stringify({ probe_ledger: FULL }),
106+
system_permissions: JSON.stringify(caps),
107+
}, SYS);
108+
}
109+
110+
async function boot(): Promise<ObjectQL> {
111+
const engine = new ObjectQL();
112+
engine.registerDriver(
113+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
114+
true,
115+
);
116+
await engine.init();
117+
engine.registerApp({
118+
id: 'com.objectstack.qa.orgless-position-name-fold',
119+
name: 'Organization-less position-name fold',
120+
version: '1.0.0',
121+
type: 'plugin',
122+
scope: 'system',
123+
objects: [
124+
SysPosition, SysPermissionSet, SysPositionPermissionSet,
125+
SysUserPosition, SysUserPermissionSet,
126+
SysOrganization, SysUser, SysMember, PROBE_OBJECT,
127+
],
128+
} as any);
129+
await engine.syncSchemas();
130+
engines.push(engine);
131+
const e = engine as any;
132+
await e.insert('sys_organization', { id: ORG_OTHER, name: ORG_OTHER, slug: 'other' }, SYS);
133+
await e.insert('sys_organization', { id: ORG_HOME, name: ORG_HOME, slug: 'home' }, SYS);
134+
for (const id of [USER_HOME, USER_GLOBAL, USER_OTHER]) {
135+
await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS);
136+
}
137+
await e.insert('sys_member', { id: 'mem_home', user_id: USER_HOME, organization_id: ORG_HOME, role: 'member' }, SYS);
138+
await e.insert('sys_member', { id: 'mem_global', user_id: USER_GLOBAL, organization_id: ORG_HOME, role: 'member' }, SYS);
139+
await e.insert('sys_member', { id: 'mem_other', user_id: USER_OTHER, organization_id: ORG_OTHER, role: 'member' }, SYS);
140+
141+
// ORG_OTHER authors sets named after what every member holds as a position.
142+
await insertSet(e, 'ps_other_org_member', 'org_member', ORG_OTHER, ['manage_metadata', 'probe.other_org_member']);
143+
await insertSet(e, 'ps_other_everyone', 'everyone', ORG_OTHER, ['manage_metadata', 'probe.other_everyone']);
144+
145+
// USER_GLOBAL's GLOBAL grants — what an organization-less principal really holds:
146+
// (1) a global position assignment, folded onto a global same-named set;
147+
await e.insert('sys_user_position', {
148+
id: 'up_global_ops_lead', user_id: USER_GLOBAL, position: 'ops_lead', organization_id: null,
149+
}, SYS);
150+
await insertSet(e, 'ps_global_ops_lead', 'ops_lead', null, ['probe.global_ops_lead']);
151+
// (2) a global user grant to a global set, beside ORG_OTHER's same-named
152+
// copy. The copy's id SORTS FIRST (`ps_0_…` before `ps_1_…`) and the
153+
// SQL driver's read comes back ordered by id, so an unscoped read meets
154+
// the copy first. With the ids the other way round this pin stays green
155+
// against the unscoped read (measured by its ablation), so the order is
156+
// load-bearing.
157+
await insertSet(e, 'ps_0_other_platform_ops', 'platform_ops', ORG_OTHER, ['probe.other_platform_ops']);
158+
await insertSet(e, 'ps_1_global_platform_ops', 'platform_ops', null, ['probe.global_platform_ops']);
159+
await e.insert('sys_user_permission_set', {
160+
id: 'ups_global_platform_ops', user_id: USER_GLOBAL, permission_set_id: 'ps_1_global_platform_ops', organization_id: null,
161+
}, SYS);
162+
return engine;
163+
}
164+
165+
/** One `sys_permission_set` by-name read the loader issued, and the rows it got. */
166+
interface LoaderRead { rows: Array<{ id: string; name: string; organization_id: string | null }> }
167+
168+
/**
169+
* The real engine's `find`, OBSERVED and forwarded verbatim — the one verb the
170+
* loader under test calls. No write or by-id verb is exposed: the plugin's
171+
* `kernel:ready` bootstraps are never fired here, so nothing on this path
172+
* needs one, and a verb that is not there cannot answer on the engine's behalf.
173+
*/
174+
function observed(engine: any, reads: LoaderRead[]): any {
175+
return {
176+
registry: engine.registry,
177+
registerMiddleware: (...a: any[]) => engine.registerMiddleware?.(...a),
178+
getSchema: (n: string) => engine.getSchema?.(n),
179+
find: async (o: string, q?: any, opt?: any) => {
180+
const r = await engine.find(o, q, opt);
181+
if (o === 'sys_permission_set' && q?.where?.name?.$in) {
182+
reads.push({
183+
rows: (r as any[]).map((x) => ({ id: x.id, name: x.name, organization_id: x.organization_id ?? null })),
184+
});
185+
}
186+
return r;
187+
},
188+
};
189+
}
190+
191+
interface Rig {
192+
engine: ObjectQL;
193+
reads: LoaderRead[];
194+
security: any;
195+
}
196+
197+
async function rig(): Promise<Rig> {
198+
const engine = await boot();
199+
const reads: LoaderRead[] = [];
200+
const plugin = new SecurityPlugin();
201+
const ql = observed(engine, reads);
202+
const services: Record<string, any> = {};
203+
const ctx: any = {
204+
logger: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} },
205+
registerService: (n: string, s: any) => { services[n] = s; },
206+
registerMiddleware: () => {},
207+
hook: () => { /* collected, never fired — see the file header */ },
208+
getService: (n: string) => {
209+
if (n === 'objectql') return ql;
210+
if (n === 'metadata') return { list: async () => [] };
211+
if (n === 'manifest') return { register: () => {} };
212+
return services[n];
213+
},
214+
};
215+
await plugin.init(ctx);
216+
await plugin.start(ctx);
217+
expect(services.security, 'the security service was never registered').toBeTruthy();
218+
return { engine, reads, security: services.security };
219+
}
220+
221+
/** What a principal resolves, read back through the registered `security` service. */
222+
async function resolve(r: Rig, userId: string, organizationId?: string) {
223+
const base = await buildContextForUser(r.engine, userId, Date.now(), organizationId);
224+
const context = organizationId
225+
? { ...base, tenantId: organizationId, organizationId }
226+
: { ...base, tenantId: undefined };
227+
r.reads.length = 0;
228+
const sets = await r.security.resolvePermissionSetsForContext(context);
229+
const loaderRows = r.reads.flatMap((read) => read.rows);
230+
const effective = await r.security.getEffectiveObjectPermissions(context);
231+
const byName = new Map<string, any>(sets.map((s: any) => [s.name, s]));
232+
return {
233+
positions: base.positions as string[],
234+
permissions: base.permissions as string[],
235+
loaderRows,
236+
setNames: sets.map((s: any) => s.name) as string[],
237+
byName,
238+
capabilities: new Set<string>(sets.flatMap((s: any) => s.systemPermissions ?? [])),
239+
ledger: effective?.probe_ledger ?? null,
240+
};
241+
}
242+
243+
describe('[#20555] with no active organization, another organization\'s set named like a position does not reach the principal', () => {
244+
it('precondition: the organization-less principal really holds the colliding names as positions', async () => {
245+
const r = await rig();
246+
const orgless = await resolve(r, USER_HOME);
247+
// Without these two names in `positions` the pins below would pass for want
248+
// of a collision, not because the read is scoped.
249+
expect(orgless.positions).toEqual(expect.arrayContaining(['org_member', 'everyone']));
250+
}, 120_000);
251+
252+
it('the by-name read returns no other organization\'s row', async () => {
253+
const r = await rig();
254+
const orgless = await resolve(r, USER_HOME);
255+
expect(orgless.loaderRows.filter((row) => row.organization_id !== null)).toEqual([]);
256+
}, 120_000);
257+
258+
// Three pins, one fact each, so the ablation shows each one fail on its own
259+
// rather than all three behind the first failed assertion.
260+
it('the authored sets are not among the resolved sets', async () => {
261+
const r = await rig();
262+
const orgless = await resolve(r, USER_HOME);
263+
expect(orgless.setNames.filter((n) => n === 'org_member' || n === 'everyone')).toEqual([]);
264+
}, 120_000);
265+
266+
it('their systemPermissions do not reach the principal', async () => {
267+
const r = await rig();
268+
const orgless = await resolve(r, USER_HOME);
269+
expect([...orgless.capabilities].filter((c) => c === 'manage_metadata' || c.startsWith('probe.'))).toEqual([]);
270+
}, 120_000);
271+
272+
it('their object map does not reach the effective map', async () => {
273+
const r = await rig();
274+
const orgless = await resolve(r, USER_HOME);
275+
expect(orgless.ledger).toBeNull();
276+
}, 120_000);
277+
278+
it('a GLOBAL grant resolves the global row it names, not another organization\'s same-named copy', async () => {
279+
const r = await rig();
280+
const orgless = await resolve(r, USER_GLOBAL);
281+
expect(orgless.permissions).toContain('platform_ops');
282+
expect(orgless.byName.get('platform_ops')?.systemPermissions).toEqual(['probe.global_platform_ops']);
283+
}, 120_000);
284+
285+
it('…and the same-named copy\'s systemPermissions do not reach that principal', async () => {
286+
const r = await rig();
287+
const orgless = await resolve(r, USER_GLOBAL);
288+
expect([...orgless.capabilities].filter((c) => c.startsWith('probe.other_'))).toEqual([]);
289+
}, 120_000);
290+
});
291+
292+
describe('[#20555] what the rule keeps', () => {
293+
it('a global position folded onto a global same-named set still resolves with no active organization', async () => {
294+
const r = await rig();
295+
const orgless = await resolve(r, USER_GLOBAL);
296+
expect(orgless.positions).toContain('ops_lead');
297+
expect(orgless.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']);
298+
}, 120_000);
299+
300+
it('the global grants resolve identically with the principal\'s own organization active', async () => {
301+
const r = await rig();
302+
const home = await resolve(r, USER_GLOBAL, ORG_HOME);
303+
expect(home.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']);
304+
expect(home.byName.get('platform_ops')?.systemPermissions).toEqual(['probe.global_platform_ops']);
305+
expect(home.setNames).not.toContain('org_member');
306+
}, 120_000);
307+
308+
it('CONTROL · the authoring organization\'s own member, with it active, does resolve its set — the set is resolvable at all', async () => {
309+
const r = await rig();
310+
const other = await resolve(r, USER_OTHER, ORG_OTHER);
311+
expect(other.byName.get('org_member')?.systemPermissions).toEqual(['manage_metadata', 'probe.other_org_member']);
312+
expect(other.ledger).toMatchObject({ viewAllRecords: true, modifyAllRecords: true });
313+
}, 120_000);
314+
});

‎packages/plugins/plugin-security/src/security-plugin.ts‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1521,9 +1521,37 @@ export class SecurityPlugin implements Plugin {
15211521
// UNREACHABLE. Letting the read fault propagate is what re-arms a
15221522
// diagnostic this repo had already built, and it is the direction the
15231523
// 2026-08-11 store-fault ruling settles: a fault propagates.
1524+
// [#20555] With NO active organization the read asks for the
1525+
// organization-less rows ONLY. `seedCtx(undefined)` carries no
1526+
// tenant, and `applyTenantScope` reads "no tenant" as an unscoped
1527+
// path, so a bare by-name read returned every organization's row of
1528+
// each name — and `resolveOwnOrganizationRow` answers an undefined
1529+
// organization with whichever row came first. The names asked for
1530+
// include the caller's POSITIONS (the fold in
1531+
// `resolvePermissionSetsForContextUnmemoized`), and with no active
1532+
// organization those still carry every membership's role plus the
1533+
// `everyone` anchor. Measured over a real `SqlDriver`: a member of
1534+
// one organization, with none active, resolved a set ANOTHER
1535+
// organization had authored under the name `org_member` — its
1536+
// `systemPermissions` and its object map, view/modify-all included —
1537+
// and a GLOBAL grant resolved another organization's same-named copy
1538+
// in place of the global row it named.
1539+
//
1540+
// The rule is the one `resolveUserAuthzGrants` applies to grant rows,
1541+
// and ADR-0123 D2's "tenant-scoped reads resolve to nothing": a row
1542+
// scoped to an organization applies only while that organization is
1543+
// active; an organization-less row applies everywhere. With a tenant
1544+
// the driver's scope already returns exactly those two classes. With
1545+
// none, the predicate below is that same scope, pushed into the read
1546+
// rather than filtered after it — after the `limit`, other
1547+
// organizations' copies could crowd out the global row this caller
1548+
// does hold.
1549+
const where = organizationId
1550+
? { name: { $in: names } }
1551+
: { name: { $in: names }, organization_id: null };
15241552
const rows = await ql.find(
15251553
'sys_permission_set',
1526-
{ where: { name: { $in: names } }, limit: Math.max(names.length * 4, 20) },
1554+
{ where, limit: Math.max(names.length * 4, 20) },
15271555
{ context: seedCtx(organizationId) },
15281556
);
15291557
const fetched = permissionSetPageOrRefuse(rows, names);
@@ -1546,7 +1574,9 @@ export class SecurityPlugin implements Plugin {
15461574
//
15471575
// Preference order is unchanged and still closes the cross-tenant
15481576
// bleed #11121 fixed: this organization's own row WINS wherever it
1549-
// exists, and a leftover is consulted only in its absence.
1577+
// exists, and a leftover is consulted only in its absence. With no
1578+
// active organization every row here is organization-less (the read
1579+
// above asked for nothing else), so `own` is one of those.
15501580
const byName = new Map<string, any>();
15511581
for (const name of new Set(names)) {
15521582
const { own, organizationLessResidue } = resolveOwnOrganizationRow(

0 commit comments

Comments
 (0)