From bf72f6209585ab598e093fa3d70a7dfdaafc4f44 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:23:56 +0000 Subject: [PATCH 1/6] test(plugin-security): measurement probe for the organization-less position-name fold Records, over a real ObjectQL + SqlDriver, which sys_permission_set rows the permission-set loader returns for a principal with no active organization, and whether their capabilities reach the resolved sets and the effective object map. Readings only; converted into the pin once the fix lands. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../src/orgless-position-name-fold.test.ts | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts new file mode 100644 index 0000000000..a0fcb45b92 --- /dev/null +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20555] MEASUREMENT PROBE — what an organization-less principal's + * position-name fold resolves, over a real engine. + * + * Probe stage: records readings only (to the file named by + * `OS_TEST_PROBE_20555_OUT` when set). Converted into the pin once measured. + */ + +import { writeFileSync } from 'node:fs'; +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; + +import { SysPosition } from './objects/sys-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; +import { + assertEngineUpdateDispatch, + assertEngineFindOnePredicate, + assertEngineDeleteDispatch, +} from '@objectstack/metadata-core'; + +import { SecurityPlugin } from './security-plugin.js'; +import { buildContextForUser } from './explain-engine.js'; + +const SYS = { context: { isSystem: true } } as any; +const ORG_A = 'org_a_author'; +const ORG_B = 'org_b_home'; +const USER_B = 'usr_b_member'; +/** Org-less principal holding GLOBAL grants only (A4: what must keep resolving). */ +const USER_G = 'usr_g_global'; + +const PROBE_OBJECT: any = { + name: 'probe_ledger', + label: 'Probe Ledger', + fields: { + id: { type: 'text', label: 'Id', primary: true }, + name: { type: 'text', label: 'Name' }, + owner_id: { type: 'text', label: 'Owner' }, + }, +}; + +const FULL = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function insertSet(e: any, id: string, name: string, organizationId: string | null, caps: string[]) { + await e.insert('sys_permission_set', { + id, + name, + label: `${organizationId ?? 'GLOBAL'} ${name}`, + organization_id: organizationId, + managed_by: 'admin', + active: true, + object_permissions: JSON.stringify({ probe_ledger: FULL }), + system_permissions: JSON.stringify(caps), + }, SYS); +} + +async function boot(): Promise { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.orgless-position-fold-20555', + name: 'Org-less position fold', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysPermissionSet, SysPositionPermissionSet, + SysUserPosition, SysUserPermissionSet, + SysOrganization, SysUser, SysMember, PROBE_OBJECT, + ], + } as any); + await engine.syncSchemas(); + engines.push(engine); + const e = engine as any; + await e.insert('sys_organization', { id: ORG_A, name: ORG_A, slug: 'a' }, SYS); + await e.insert('sys_organization', { id: ORG_B, name: ORG_B, slug: 'b' }, SYS); + await e.insert('sys_user', { id: USER_B, name: 'b', email: 'b@example.test' }, SYS); + await e.insert('sys_user', { id: USER_G, name: 'g', email: 'g@example.test' }, SYS); + // Both principals: members of ORG_B only — never of ORG_A. + await e.insert('sys_member', { id: 'mem_b', user_id: USER_B, organization_id: ORG_B, role: 'member' }, SYS); + await e.insert('sys_member', { id: 'mem_g', user_id: USER_G, organization_id: ORG_B, role: 'member' }, SYS); + // ORG_A authors permission sets whose NAMES spell names the principals hold as positions. + await insertSet(e, 'ps_a_org_member', 'org_member', ORG_A, ['manage_metadata', 'probe.cap.a_org_member']); + await insertSet(e, 'ps_a_everyone', 'everyone', ORG_A, ['manage_metadata', 'probe.cap.a_everyone']); + + // A4 — the GLOBAL grants USER_G really holds: + // (1) a global position assignment folded onto a global same-named set; + await e.insert('sys_user_position', { id: 'up_g_ops', user_id: USER_G, position: 'ops_lead', organization_id: null }, SYS); + await insertSet(e, 'ps_global_ops_lead', 'ops_lead', null, ['probe.cap.global_ops_lead']); + // (2) a global user grant to a global DB-authored set — but ORG_A holds a + // same-named copy, inserted FIRST, so an unordered read meets it first. + await insertSet(e, 'ps_a_platform_ops', 'platform_ops', ORG_A, ['probe.cap.a_platform_ops']); + await insertSet(e, 'ps_global_platform_ops', 'platform_ops', null, ['probe.cap.global_platform_ops']); + await e.insert('sys_user_permission_set', { + id: 'ups_g_ops', user_id: USER_G, permission_set_id: 'ps_global_platform_ops', organization_id: null, + }, SYS); + return engine; +} + +function observed(engine: any, reads: any[]): any { + return { + registry: engine.registry, + registerMiddleware: (...a: any[]) => engine.registerMiddleware?.(...a), + getSchema: (n: string) => engine.getSchema?.(n), + find: async (o: string, q?: any, opt?: any) => { + const r = await engine.find(o, q, opt); + if (o === 'sys_permission_set' && q?.where?.name?.$in) { + reads.push({ + where: q.where, + context: opt?.context ?? q?.context, + rows: (r as any[]).map((x) => ({ id: x.id, name: x.name, organization_id: x.organization_id ?? null })), + }); + } + return r; + }, + findOne: (o: string, q?: any, opt?: any) => { + assertEngineFindOnePredicate(o, q); + return engine.findOne(o, q, opt); + }, + insert: (o: string, d: any, opt?: any) => engine.insert(o, d, opt), + update: (o: string, d: any, opt?: any) => { + assertEngineUpdateDispatch(d, opt); + return engine.update(o, d, opt); + }, + delete: (o: string, id: any, opt?: any) => { + assertEngineDeleteDispatch(opt); + return engine.delete(o, id, opt); + }, + }; +} + +async function securityServiceOver(engine: any, reads: any[]) { + const plugin = new SecurityPlugin(); + const svc = observed(engine, reads); + const services: Record = {}; + const ctx: any = { + logger: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} }, + registerService: (n: string, s: any) => { services[n] = s; }, + registerMiddleware: () => {}, + getService: (n: string) => { + if (n === 'objectql') return svc; + if (n === 'metadata') return { list: async () => [] }; + if (n === 'manifest') return { register: () => {} }; + return services[n]; + }, + }; + await plugin.init(ctx); + await plugin.start(ctx); + expect(services.security, 'security service never registered').toBeTruthy(); + return services.security; +} + +async function reading(security: any, reads: any[], base: any, organizationId: string | undefined) { + const ctx = organizationId ? { ...base, tenantId: organizationId, organizationId } : { ...base, tenantId: undefined }; + reads.length = 0; + const sets = await security.resolvePermissionSetsForContext(ctx); + const loaderReads = [...reads]; + const eff = await security.getEffectiveObjectPermissions({ ...ctx }); + return { + positions: base.positions, + permissions: base.permissions, + coreSystemPermissions: base.systemPermissions, + posture: base.posture, + loaderReads, + sets: sets.map((s: any) => ({ name: s.name, label: s.label, systemPermissions: s.systemPermissions })), + effectiveProbeLedger: eff?.probe_ledger ?? null, + }; +} + +describe('[#20555] PROBE', () => { + it('records the org-less resolution', async () => { + const engine = await boot(); + const reads: any[] = []; + const security = await securityServiceOver(engine, reads); + + const now = Date.now(); + const out = { + userB_orgless: await reading(security, reads, await buildContextForUser(engine, USER_B, now, undefined), undefined), + userB_home: await reading(security, reads, await buildContextForUser(engine, USER_B, now, ORG_B), ORG_B), + userG_orgless: await reading(security, reads, await buildContextForUser(engine, USER_G, now, undefined), undefined), + userG_home: await reading(security, reads, await buildContextForUser(engine, USER_G, now, ORG_B), ORG_B), + }; + const file = process.env.OS_TEST_PROBE_20555_OUT; + if (file) writeFileSync(file, JSON.stringify(out, null, 2)); + }, 120_000); +}); From 2b4e67bb0710b714852c3a3078f86a39d4dfc67f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:26:25 +0000 Subject: [PATCH 2/6] fix(plugin-security): an organization-less permission-set read resolves organization-less rows only With no active organization the permission-set loader read sys_permission_set by name with no tenant, which the driver treats as an unscoped path: every organization's row of each requested name came back and the first one won. The requested names include the caller's positions, which with no active organization still carry every membership's role and the everyone anchor. The read now asks for organization-less rows only when no organization is active, the rule the grants resolver already applies to grant rows. Scoped reads are unchanged. The measurement probe becomes the pin. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../src/orgless-position-name-fold.test.ts | 250 +++++++++++++----- .../plugin-security/src/security-plugin.ts | 34 ++- 2 files changed, 212 insertions(+), 72 deletions(-) diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts index a0fcb45b92..d08c471e11 100644 --- a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -1,14 +1,51 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20555] MEASUREMENT PROBE — what an organization-less principal's - * position-name fold resolves, over a real engine. + * [#20555] A principal with NO active organization resolves permission sets + * from the organization-less rows only — never from a set some organization + * authored under a name the principal happens to hold as a position. * - * Probe stage: records readings only (to the file named by - * `OS_TEST_PROBE_20555_OUT` when set). Converted into the pin once measured. + * ## The seam + * + * `resolvePermissionSetsForContextUnmemoized` requests + * `[...positions, ...permissions]` as permission-set NAMES, and anything not + * declared in metadata or bootstrap is read from `sys_permission_set` by name + * through `dbLoaderFor(callerOrganizationId(context))`. With no active + * organization, `resolveUserAuthzGrants` still lists every current + * membership's role (`org_member`, …) and the `everyone` anchor in + * `positions`, and the by-name read carried no tenant — so it returned every + * organization's row of each name, and the loader kept whichever came first. + * + * Measured on `main` at 1c761c0d, over this file's rig: a member of + * `ORG_HOME` only, with no organization active, resolved the sets `ORG_OTHER` + * had authored as `org_member` and `everyone` — their `systemPermissions` and + * their object map (view/modify-all included) reached the resolved sets and + * `getEffectiveObjectPermissions`. The same principal's GLOBAL grant to + * `platform_ops` resolved `ORG_OTHER`'s same-named copy instead of the global + * row it names. With `ORG_HOME` active the read was scoped and none of this + * happened. + * + * ## The rule pinned here + * + * A row scoped to an organization applies only while that organization is + * active; an organization-less row applies everywhere — the rule + * `resolveUserAuthzGrants` already applies to grant rows. So the negative + * pins below are paired with preservation pins: the global grants an + * organization-less principal really holds (a global position folded onto a + * global same-named set, a global user grant) keep resolving, and an + * organization's own set still reaches its own members while it is active. + * + * ## The rig + * + * A real `ObjectQL` over a real `SqlDriver` (better-sqlite3 `:memory:`), the + * real platform object definitions, the real `SecurityPlugin` resolved through + * the `security` service it registers, and principals built by + * `buildContextForUser` — the same `resolveUserAuthzGrants` the request path + * runs. The plugin's `kernel:ready` bootstraps are collected and never fired: + * the loader under test does not depend on them, and a bootstrap left running + * would race the engine teardown. */ -import { writeFileSync } from 'node:fs'; import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; @@ -29,11 +66,16 @@ import { SecurityPlugin } from './security-plugin.js'; import { buildContextForUser } from './explain-engine.js'; const SYS = { context: { isSystem: true } } as any; -const ORG_A = 'org_a_author'; -const ORG_B = 'org_b_home'; -const USER_B = 'usr_b_member'; -/** Org-less principal holding GLOBAL grants only (A4: what must keep resolving). */ -const USER_G = 'usr_g_global'; +/** The organization that authors the colliding sets. Nobody below is its member except `USER_OTHER`. */ +const ORG_OTHER = 'org_other'; +/** The principals' own organization. */ +const ORG_HOME = 'org_home'; +/** A member of `ORG_HOME` only. */ +const USER_HOME = 'usr_home_member'; +/** A member of `ORG_HOME` only, holding two GLOBAL grants. */ +const USER_GLOBAL = 'usr_home_global'; +/** A member of `ORG_OTHER` — the control that the authored set resolves at all. */ +const USER_OTHER = 'usr_other_member'; const PROBE_OBJECT: any = { name: 'probe_ledger', @@ -45,7 +87,10 @@ const PROBE_OBJECT: any = { }, }; -const FULL = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }; +const FULL = { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, +}; const engines: ObjectQL[] = []; afterEach(async () => { @@ -58,7 +103,7 @@ async function insertSet(e: any, id: string, name: string, organizationId: strin await e.insert('sys_permission_set', { id, name, - label: `${organizationId ?? 'GLOBAL'} ${name}`, + label: `${organizationId ?? 'global'} ${name}`, organization_id: organizationId, managed_by: 'admin', active: true, @@ -75,8 +120,8 @@ async function boot(): Promise { ); await engine.init(); engine.registerApp({ - id: 'com.objectstack.orgless-position-fold-20555', - name: 'Org-less position fold', + id: 'com.objectstack.qa.orgless-position-name-fold', + name: 'Organization-less position-name fold', version: '1.0.0', type: 'plugin', scope: 'system', @@ -89,32 +134,43 @@ async function boot(): Promise { await engine.syncSchemas(); engines.push(engine); const e = engine as any; - await e.insert('sys_organization', { id: ORG_A, name: ORG_A, slug: 'a' }, SYS); - await e.insert('sys_organization', { id: ORG_B, name: ORG_B, slug: 'b' }, SYS); - await e.insert('sys_user', { id: USER_B, name: 'b', email: 'b@example.test' }, SYS); - await e.insert('sys_user', { id: USER_G, name: 'g', email: 'g@example.test' }, SYS); - // Both principals: members of ORG_B only — never of ORG_A. - await e.insert('sys_member', { id: 'mem_b', user_id: USER_B, organization_id: ORG_B, role: 'member' }, SYS); - await e.insert('sys_member', { id: 'mem_g', user_id: USER_G, organization_id: ORG_B, role: 'member' }, SYS); - // ORG_A authors permission sets whose NAMES spell names the principals hold as positions. - await insertSet(e, 'ps_a_org_member', 'org_member', ORG_A, ['manage_metadata', 'probe.cap.a_org_member']); - await insertSet(e, 'ps_a_everyone', 'everyone', ORG_A, ['manage_metadata', 'probe.cap.a_everyone']); - - // A4 — the GLOBAL grants USER_G really holds: - // (1) a global position assignment folded onto a global same-named set; - await e.insert('sys_user_position', { id: 'up_g_ops', user_id: USER_G, position: 'ops_lead', organization_id: null }, SYS); - await insertSet(e, 'ps_global_ops_lead', 'ops_lead', null, ['probe.cap.global_ops_lead']); - // (2) a global user grant to a global DB-authored set — but ORG_A holds a - // same-named copy, inserted FIRST, so an unordered read meets it first. - await insertSet(e, 'ps_a_platform_ops', 'platform_ops', ORG_A, ['probe.cap.a_platform_ops']); - await insertSet(e, 'ps_global_platform_ops', 'platform_ops', null, ['probe.cap.global_platform_ops']); + await e.insert('sys_organization', { id: ORG_OTHER, name: ORG_OTHER, slug: 'other' }, SYS); + await e.insert('sys_organization', { id: ORG_HOME, name: ORG_HOME, slug: 'home' }, SYS); + for (const id of [USER_HOME, USER_GLOBAL, USER_OTHER]) { + await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS); + } + await e.insert('sys_member', { id: 'mem_home', user_id: USER_HOME, organization_id: ORG_HOME, role: 'member' }, SYS); + await e.insert('sys_member', { id: 'mem_global', user_id: USER_GLOBAL, organization_id: ORG_HOME, role: 'member' }, SYS); + await e.insert('sys_member', { id: 'mem_other', user_id: USER_OTHER, organization_id: ORG_OTHER, role: 'member' }, SYS); + + // ORG_OTHER authors sets named after what every member holds as a position. + await insertSet(e, 'ps_other_org_member', 'org_member', ORG_OTHER, ['manage_metadata', 'probe.other_org_member']); + await insertSet(e, 'ps_other_everyone', 'everyone', ORG_OTHER, ['manage_metadata', 'probe.other_everyone']); + + // USER_GLOBAL's GLOBAL grants — what an organization-less principal really holds: + // (1) a global position assignment, folded onto a global same-named set; + await e.insert('sys_user_position', { + id: 'up_global_ops_lead', user_id: USER_GLOBAL, position: 'ops_lead', organization_id: null, + }, SYS); + await insertSet(e, 'ps_global_ops_lead', 'ops_lead', null, ['probe.global_ops_lead']); + // (2) a global user grant to a global set, beside ORG_OTHER's same-named + // copy inserted FIRST — an unordered read meets the copy first. + await insertSet(e, 'ps_other_platform_ops', 'platform_ops', ORG_OTHER, ['probe.other_platform_ops']); + await insertSet(e, 'ps_global_platform_ops', 'platform_ops', null, ['probe.global_platform_ops']); await e.insert('sys_user_permission_set', { - id: 'ups_g_ops', user_id: USER_G, permission_set_id: 'ps_global_platform_ops', organization_id: null, + id: 'ups_global_platform_ops', user_id: USER_GLOBAL, permission_set_id: 'ps_global_platform_ops', organization_id: null, }, SYS); return engine; } -function observed(engine: any, reads: any[]): any { +/** One `sys_permission_set` by-name read the loader issued, and the rows it got. */ +interface LoaderRead { rows: Array<{ id: string; name: string; organization_id: string | null }> } + +/** + * The real engine, `find` OBSERVED and forwarded verbatim. The dispatch-shaped + * verbs open with the producer's own predicates (`check:engine-double-contract`). + */ +function observed(engine: any, reads: LoaderRead[]): any { return { registry: engine.registry, registerMiddleware: (...a: any[]) => engine.registerMiddleware?.(...a), @@ -123,8 +179,6 @@ function observed(engine: any, reads: any[]): any { const r = await engine.find(o, q, opt); if (o === 'sys_permission_set' && q?.where?.name?.$in) { reads.push({ - where: q.where, - context: opt?.context ?? q?.context, rows: (r as any[]).map((x) => ({ id: x.id, name: x.name, organization_id: x.organization_id ?? null })), }); } @@ -146,16 +200,25 @@ function observed(engine: any, reads: any[]): any { }; } -async function securityServiceOver(engine: any, reads: any[]) { +interface Rig { + engine: ObjectQL; + reads: LoaderRead[]; + security: any; +} + +async function rig(): Promise { + const engine = await boot(); + const reads: LoaderRead[] = []; const plugin = new SecurityPlugin(); - const svc = observed(engine, reads); + const ql = observed(engine, reads); const services: Record = {}; const ctx: any = { logger: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} }, registerService: (n: string, s: any) => { services[n] = s; }, registerMiddleware: () => {}, + hook: () => { /* collected, never fired — see the file header */ }, getService: (n: string) => { - if (n === 'objectql') return svc; + if (n === 'objectql') return ql; if (n === 'metadata') return { list: async () => [] }; if (n === 'manifest') return { register: () => {} }; return services[n]; @@ -163,41 +226,88 @@ async function securityServiceOver(engine: any, reads: any[]) { }; await plugin.init(ctx); await plugin.start(ctx); - expect(services.security, 'security service never registered').toBeTruthy(); - return services.security; + expect(services.security, 'the security service was never registered').toBeTruthy(); + return { engine, reads, security: services.security }; } -async function reading(security: any, reads: any[], base: any, organizationId: string | undefined) { - const ctx = organizationId ? { ...base, tenantId: organizationId, organizationId } : { ...base, tenantId: undefined }; - reads.length = 0; - const sets = await security.resolvePermissionSetsForContext(ctx); - const loaderReads = [...reads]; - const eff = await security.getEffectiveObjectPermissions({ ...ctx }); +/** What a principal resolves, read back through the registered `security` service. */ +async function resolve(r: Rig, userId: string, organizationId?: string) { + const base = await buildContextForUser(r.engine, userId, Date.now(), organizationId); + const context = organizationId + ? { ...base, tenantId: organizationId, organizationId } + : { ...base, tenantId: undefined }; + r.reads.length = 0; + const sets = await r.security.resolvePermissionSetsForContext(context); + const loaderRows = r.reads.flatMap((read) => read.rows); + const effective = await r.security.getEffectiveObjectPermissions(context); + const byName = new Map(sets.map((s: any) => [s.name, s])); return { - positions: base.positions, - permissions: base.permissions, - coreSystemPermissions: base.systemPermissions, - posture: base.posture, - loaderReads, - sets: sets.map((s: any) => ({ name: s.name, label: s.label, systemPermissions: s.systemPermissions })), - effectiveProbeLedger: eff?.probe_ledger ?? null, + positions: base.positions as string[], + permissions: base.permissions as string[], + loaderRows, + setNames: sets.map((s: any) => s.name) as string[], + byName, + capabilities: new Set(sets.flatMap((s: any) => s.systemPermissions ?? [])), + ledger: effective?.probe_ledger ?? null, }; } -describe('[#20555] PROBE', () => { - it('records the org-less resolution', async () => { - const engine = await boot(); - const reads: any[] = []; - const security = await securityServiceOver(engine, reads); - - const now = Date.now(); - const out = { - userB_orgless: await reading(security, reads, await buildContextForUser(engine, USER_B, now, undefined), undefined), - userB_home: await reading(security, reads, await buildContextForUser(engine, USER_B, now, ORG_B), ORG_B), - userG_orgless: await reading(security, reads, await buildContextForUser(engine, USER_G, now, undefined), undefined), - userG_home: await reading(security, reads, await buildContextForUser(engine, USER_G, now, ORG_B), ORG_B), - }; - const file = process.env.OS_TEST_PROBE_20555_OUT; - if (file) writeFileSync(file, JSON.stringify(out, null, 2)); +describe('[#20555] with no active organization, another organization\'s set named like a position does not reach the principal', () => { + it('precondition: the organization-less principal really holds the colliding names as positions', async () => { + const r = await rig(); + const orgless = await resolve(r, USER_HOME); + // Without these two names in `positions` the pins below would pass for want + // of a collision, not because the read is scoped. + expect(orgless.positions).toEqual(expect.arrayContaining(['org_member', 'everyone'])); + }, 120_000); + + it('the by-name read returns no other organization\'s row', async () => { + const r = await rig(); + const orgless = await resolve(r, USER_HOME); + expect(orgless.loaderRows.filter((row) => row.organization_id !== null)).toEqual([]); + }, 120_000); + + it('the authored sets\' capabilities and object map reach neither the resolved sets nor the effective map', async () => { + const r = await rig(); + const orgless = await resolve(r, USER_HOME); + expect(orgless.setNames).not.toContain('org_member'); + expect(orgless.setNames).not.toContain('everyone'); + expect([...orgless.capabilities].filter((c) => c === 'manage_metadata' || c.startsWith('probe.'))).toEqual([]); + expect(orgless.ledger).toBeNull(); + }, 120_000); + + it('a GLOBAL grant resolves the global row it names, not another organization\'s same-named copy', async () => { + const r = await rig(); + const orgless = await resolve(r, USER_GLOBAL); + expect(orgless.permissions).toContain('platform_ops'); + expect(orgless.byName.get('platform_ops')?.systemPermissions).toEqual(['probe.global_platform_ops']); + expect(orgless.capabilities.has('probe.other_platform_ops')).toBe(false); + }, 120_000); +}); + +describe('[#20555] what the rule keeps', () => { + it('a global position folded onto a global same-named set still resolves with no active organization', async () => { + const r = await rig(); + const orgless = await resolve(r, USER_GLOBAL); + expect(orgless.positions).toContain('ops_lead'); + expect(orgless.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']); + // …while the colliding names stay unresolved for this principal too. + expect(orgless.setNames).not.toContain('org_member'); + expect(orgless.setNames).not.toContain('everyone'); + }, 120_000); + + it('the global grants resolve identically with the principal\'s own organization active', async () => { + const r = await rig(); + const home = await resolve(r, USER_GLOBAL, ORG_HOME); + expect(home.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']); + expect(home.byName.get('platform_ops')?.systemPermissions).toEqual(['probe.global_platform_ops']); + expect(home.setNames).not.toContain('org_member'); + }, 120_000); + + it('CONTROL · the authoring organization\'s own member, with it active, does resolve its set — the set is resolvable at all', async () => { + const r = await rig(); + const other = await resolve(r, USER_OTHER, ORG_OTHER); + expect(other.byName.get('org_member')?.systemPermissions).toEqual(['manage_metadata', 'probe.other_org_member']); + expect(other.ledger).toMatchObject({ viewAllRecords: true, modifyAllRecords: true }); }, 120_000); }); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index edf38d7a99..026ca66aea 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1521,9 +1521,37 @@ export class SecurityPlugin implements Plugin { // UNREACHABLE. Letting the read fault propagate is what re-arms a // diagnostic this repo had already built, and it is the direction the // 2026-08-11 store-fault ruling settles: a fault propagates. + // [#20555] With NO active organization the read asks for the + // organization-less rows ONLY. `seedCtx(undefined)` carries no + // tenant, and `applyTenantScope` reads "no tenant" as an unscoped + // path, so a bare by-name read returned every organization's row of + // each name — and `resolveOwnOrganizationRow` answers an undefined + // organization with whichever row came first. The names asked for + // include the caller's POSITIONS (the fold in + // `resolvePermissionSetsForContextUnmemoized`), and with no active + // organization those still carry every membership's role plus the + // `everyone` anchor. Measured over a real `SqlDriver`: a member of + // one organization, with none active, resolved a set ANOTHER + // organization had authored under the name `org_member` — its + // `systemPermissions` and its object map, view/modify-all included — + // and a GLOBAL grant resolved another organization's same-named copy + // in place of the global row it named. + // + // The rule is the one `resolveUserAuthzGrants` applies to grant rows, + // and ADR-0123 D2's "tenant-scoped reads resolve to nothing": a row + // scoped to an organization applies only while that organization is + // active; an organization-less row applies everywhere. With a tenant + // the driver's scope already returns exactly those two classes. With + // none, the predicate below is that same scope, pushed into the read + // rather than filtered after it — after the `limit`, other + // organizations' copies could crowd out the global row this caller + // does hold. + const where = organizationId + ? { name: { $in: names } } + : { name: { $in: names }, organization_id: null }; const rows = await ql.find( 'sys_permission_set', - { where: { name: { $in: names } }, limit: Math.max(names.length * 4, 20) }, + { where, limit: Math.max(names.length * 4, 20) }, { context: seedCtx(organizationId) }, ); const fetched = permissionSetPageOrRefuse(rows, names); @@ -1546,7 +1574,9 @@ export class SecurityPlugin implements Plugin { // // Preference order is unchanged and still closes the cross-tenant // bleed #11121 fixed: this organization's own row WINS wherever it - // exists, and a leftover is consulted only in its absence. + // exists, and a leftover is consulted only in its absence. With no + // active organization every row here is organization-less (the read + // above asked for nothing else), so `own` is one of those. const byName = new Map(); for (const name of new Set(names)) { const { own, organizationLessResidue } = resolveOwnOrganizationRow( From c60be715a6a6a99dc1f4ccb68a7b7704a864d85e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:27:36 +0000 Subject: [PATCH 3/6] test(plugin-security): order the same-named copy first so the global-grant pin can fail The SQL driver returns the by-name read ordered by id, and the global row's id sorted first, so the global-grant pin stayed green against the unscoped read. The other organization's copy now sorts first. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../src/orgless-position-name-fold.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts index d08c471e11..6b5b9dd627 100644 --- a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -154,11 +154,15 @@ async function boot(): Promise { }, SYS); await insertSet(e, 'ps_global_ops_lead', 'ops_lead', null, ['probe.global_ops_lead']); // (2) a global user grant to a global set, beside ORG_OTHER's same-named - // copy inserted FIRST — an unordered read meets the copy first. - await insertSet(e, 'ps_other_platform_ops', 'platform_ops', ORG_OTHER, ['probe.other_platform_ops']); - await insertSet(e, 'ps_global_platform_ops', 'platform_ops', null, ['probe.global_platform_ops']); + // copy. The copy's id SORTS FIRST (`ps_0_…` before `ps_1_…`) and the + // SQL driver's read comes back ordered by id, so an unscoped read meets + // the copy first. With the ids the other way round this pin stays green + // against the unscoped read (measured by its ablation), so the order is + // load-bearing. + await insertSet(e, 'ps_0_other_platform_ops', 'platform_ops', ORG_OTHER, ['probe.other_platform_ops']); + await insertSet(e, 'ps_1_global_platform_ops', 'platform_ops', null, ['probe.global_platform_ops']); await e.insert('sys_user_permission_set', { - id: 'ups_global_platform_ops', user_id: USER_GLOBAL, permission_set_id: 'ps_global_platform_ops', organization_id: null, + id: 'ups_global_platform_ops', user_id: USER_GLOBAL, permission_set_id: 'ps_1_global_platform_ops', organization_id: null, }, SYS); return engine; } From 5b39fb50856de7b7e5bf5b1c0dcd1f565697703f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:28:42 +0000 Subject: [PATCH 4/6] test(plugin-security): one fact per negative pin so each is ablated on its own Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../src/orgless-position-name-fold.test.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts index 6b5b9dd627..ec0c59c55a 100644 --- a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -271,12 +271,23 @@ describe('[#20555] with no active organization, another organization\'s set name expect(orgless.loaderRows.filter((row) => row.organization_id !== null)).toEqual([]); }, 120_000); - it('the authored sets\' capabilities and object map reach neither the resolved sets nor the effective map', async () => { + // Three pins, one fact each, so the ablation shows each one fail on its own + // rather than all three behind the first failed assertion. + it('the authored sets are not among the resolved sets', async () => { + const r = await rig(); + const orgless = await resolve(r, USER_HOME); + expect(orgless.setNames.filter((n) => n === 'org_member' || n === 'everyone')).toEqual([]); + }, 120_000); + + it('their systemPermissions do not reach the principal', async () => { const r = await rig(); const orgless = await resolve(r, USER_HOME); - expect(orgless.setNames).not.toContain('org_member'); - expect(orgless.setNames).not.toContain('everyone'); expect([...orgless.capabilities].filter((c) => c === 'manage_metadata' || c.startsWith('probe.'))).toEqual([]); + }, 120_000); + + it('their object map does not reach the effective map', async () => { + const r = await rig(); + const orgless = await resolve(r, USER_HOME); expect(orgless.ledger).toBeNull(); }, 120_000); @@ -285,7 +296,12 @@ describe('[#20555] with no active organization, another organization\'s set name const orgless = await resolve(r, USER_GLOBAL); expect(orgless.permissions).toContain('platform_ops'); expect(orgless.byName.get('platform_ops')?.systemPermissions).toEqual(['probe.global_platform_ops']); - expect(orgless.capabilities.has('probe.other_platform_ops')).toBe(false); + }, 120_000); + + it('…and the same-named copy\'s systemPermissions do not reach that principal', async () => { + const r = await rig(); + const orgless = await resolve(r, USER_GLOBAL); + expect([...orgless.capabilities].filter((c) => c.startsWith('probe.other_'))).toEqual([]); }, 120_000); }); @@ -295,9 +311,6 @@ describe('[#20555] what the rule keeps', () => { const orgless = await resolve(r, USER_GLOBAL); expect(orgless.positions).toContain('ops_lead'); expect(orgless.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']); - // …while the colliding names stay unresolved for this principal too. - expect(orgless.setNames).not.toContain('org_member'); - expect(orgless.setNames).not.toContain('everyone'); }, 120_000); it('the global grants resolve identically with the principal\'s own organization active', async () => { From b17af30e3710272cf2d91f9ef2318ea5bcda8838 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:29:59 +0000 Subject: [PATCH 5/6] chore(changeset): plugin-security organization-less permission-set read Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .changeset/20555-orgless-permission-set-read.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/20555-orgless-permission-set-read.md diff --git a/.changeset/20555-orgless-permission-set-read.md b/.changeset/20555-orgless-permission-set-read.md new file mode 100644 index 0000000000..7d2c1b94d8 --- /dev/null +++ b/.changeset/20555-orgless-permission-set-read.md @@ -0,0 +1,12 @@ +--- +"@objectstack/plugin-security": patch +--- + +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. + +Clause-②: no + +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. + +- **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. +- **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. From c3237a7bfe42a1a9bfcb91eee5f2df1da297a070 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:40:16 +0000 Subject: [PATCH 6/6] test(plugin-security): the pin's observed engine exposes find only The loader under test calls find alone, and the plugin's kernel:ready bootstraps are never fired, so no by-id or write verb is needed. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../src/orgless-position-name-fold.test.ts | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts index ec0c59c55a..075cdb1e8a 100644 --- a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -56,11 +56,6 @@ import { SysPositionPermissionSet } from './objects/sys-position-permission-set. import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; -import { - assertEngineUpdateDispatch, - assertEngineFindOnePredicate, - assertEngineDeleteDispatch, -} from '@objectstack/metadata-core'; import { SecurityPlugin } from './security-plugin.js'; import { buildContextForUser } from './explain-engine.js'; @@ -171,8 +166,10 @@ async function boot(): Promise { interface LoaderRead { rows: Array<{ id: string; name: string; organization_id: string | null }> } /** - * The real engine, `find` OBSERVED and forwarded verbatim. The dispatch-shaped - * verbs open with the producer's own predicates (`check:engine-double-contract`). + * The real engine's `find`, OBSERVED and forwarded verbatim — the one verb the + * loader under test calls. No write or by-id verb is exposed: the plugin's + * `kernel:ready` bootstraps are never fired here, so nothing on this path + * needs one, and a verb that is not there cannot answer on the engine's behalf. */ function observed(engine: any, reads: LoaderRead[]): any { return { @@ -188,19 +185,6 @@ function observed(engine: any, reads: LoaderRead[]): any { } return r; }, - findOne: (o: string, q?: any, opt?: any) => { - assertEngineFindOnePredicate(o, q); - return engine.findOne(o, q, opt); - }, - insert: (o: string, d: any, opt?: any) => engine.insert(o, d, opt), - update: (o: string, d: any, opt?: any) => { - assertEngineUpdateDispatch(d, opt); - return engine.update(o, d, opt); - }, - delete: (o: string, id: any, opt?: any) => { - assertEngineDeleteDispatch(opt); - return engine.delete(o, id, opt); - }, }; }