diff --git a/.changeset/group-union-driver-scope.md b/.changeset/group-union-driver-scope.md new file mode 100644 index 0000000000..048d716d73 --- /dev/null +++ b/.changeset/group-union-driver-scope.md @@ -0,0 +1,30 @@ +--- +"@objectstack/spec": patch +"@objectstack/objectql": patch +"@objectstack/driver-sql": patch +"@objectstack/plugin-security": patch +--- + +fix(authz): widen the driver's native tenant scope to the membership union +under the `group` posture — ADR-0105 D2 finally reaches the wire (#3623) + +The Layer 0 wall correctly compiled `organization_id IN accessible_org_ids` +under `group`, but the ObjectQL engine also propagated the active-org +`tenantId` into `DriverOptions` unconditionally, and the SQL driver's native +scoping ANDed `organization_id = tenantId` under the union — collapsing every +group read back to active-org (isolated) reach. Found by the cloud-side +`ee-group-showcase` dogfood (cloud#880), the first end-to-end boot of `group` +against a real driver. + +- `DriverOptions.tenantIds` (spec): the union tenant access set. Drivers with + native scoping widen reads/updates/deletes/aggregates to `IN (...)`, + keeping the NULL-tenant global-row carve-out; inserts still stamp from + `tenantId` (the active organization is the write target, D5). Absent or + empty ⇒ equality fallback — fail toward isolation, never toward exposure. +- ObjectQL engine threads `ExecutionContext.accessible_org_ids` as + `tenantIds` when the tenancy posture is `group`, reported by a new + `setTenancyPostureProvider` seam. +- SecurityPlugin wires that provider at start — deliberately from the + enforcement layer, so the driver wall only widens while the Layer 0 union + wall enforces above it. Embeddings without plugin-security keep active-org + equality. diff --git a/content/docs/references/data/driver.mdx b/content/docs/references/data/driver.mdx index e26295497b..74236e59cb 100644 --- a/content/docs/references/data/driver.mdx +++ b/content/docs/references/data/driver.mdx @@ -95,6 +95,7 @@ const result = DriverCapabilities.parse(data); | **skipCache** | `boolean` | optional | Bypass cache | | **traceContext** | `Record` | optional | OpenTelemetry context or request ID | | **tenantId** | `string` | optional | Tenant Isolation identifier | +| **tenantIds** | `string[]` | optional | Union tenant access set (group posture): native read scoping widens to organization_id IN (...); inserts still stamp from tenantId | | **timezone** | `string` | optional | Business reference timezone (IANA) for date-dependent generation, e.g. autonumber date tokens | | **preserveAudit** | `boolean` | optional | Historical import: keep a supplied updated_at instead of force-stamping now (from ExecutionContext.preserveAudit) | diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index af4bfb025f..200cf837f5 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -591,6 +591,50 @@ describe('ObjectQL Engine', () => { }); }); + describe('group posture widens driver scope to the membership union (#3623, ADR-0105 D2)', () => { + // Regression: the engine stamped only the active-org `tenantId` into + // driver options, so the SQL driver's native equality scope ANDed + // under the Layer 0 union and collapsed `group` reads back to + // active-org reach. Under the group posture (reported by the + // SecurityPlugin-wired provider) the engine now ALSO threads the + // caller's `accessible_org_ids` as `tenantIds`. + beforeEach(async () => { + engine.registerDriver(mockDriver, true); + await engine.init(); + vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} }); + }); + + const lastFindOpts = () => (mockDriver.find as any).mock.calls.at(-1)?.[2]; + const groupCtx = { tenantId: 'org_a', accessible_org_ids: ['org_a', 'org_b'] } as any; + + it('threads accessible_org_ids as tenantIds under the group posture', async () => { + (engine as any).setTenancyPostureProvider(() => 'group'); + await engine.find('task', { filters: [] }, { context: groupCtx }); + expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] }); + }); + + it('no provider (no enforcement layer) → equality only, never widened', async () => { + await engine.find('task', { filters: [] }, { context: groupCtx }); + expect(lastFindOpts()?.tenantIds).toBeUndefined(); + expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a' }); + }); + + it('isolated posture → equality only (the union is a group-only widening)', async () => { + (engine as any).setTenancyPostureProvider(() => 'isolated'); + await engine.find('task', { filters: [] }, { context: groupCtx }); + expect(lastFindOpts()?.tenantIds).toBeUndefined(); + }); + + it('group with an absent/empty accessible set → equality only (fail toward isolation)', async () => { + (engine as any).setTenancyPostureProvider(() => 'group'); + await engine.find('task', { filters: [] }, { context: { tenantId: 'org_a', accessible_org_ids: [] } as any }); + expect(lastFindOpts()?.tenantIds).toBeUndefined(); + await engine.find('task', { filters: [] }, { context: { tenantId: 'org_a' } as any }); + expect(lastFindOpts()?.tenantIds).toBeUndefined(); + }); + + }); + describe('tenancy.enabled:false objects are platform-global (#3249, ADR-0066)', () => { // Regression: buildDriverOptions stamped execCtx.tenantId into driver // options unconditionally, so a platform-global object (sys_license) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 5cdb98f0b7..6cafb91e96 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -373,6 +373,11 @@ export class ObjectQL implements IDataEngine { // persists cleartext). Injected by the host via setCryptoProvider(). private cryptoProvider?: ICryptoProvider; + // [ADR-0105 D2 / #3623] Posture accessor for driver-scope widening under the + // `group` posture. Injected by SecurityPlugin via setTenancyPostureProvider(); + // absent = equality scoping (fail toward isolation). + private tenancyPostureProvider?: () => string | undefined; + // Per-engine SchemaRegistry instance. // // Historically SchemaRegistry was a process-wide singleton of static state, @@ -871,6 +876,20 @@ export class ObjectQL implements IDataEngine { if (hasTenant && opts.tenantId === undefined) { opts.tenantId = execCtx!.tenantId; } + // [ADR-0105 D2 / #3623] Under the `group` posture the caller's read reach + // is their whole membership set, not the active org — thread it so the + // driver's native scope widens to the SAME union Layer 0 enforces, instead + // of ANDing an active-org equality under it (which collapsed group reads + // to isolated semantics). Inserts still stamp from `tenantId` (the active + // org is the write target, D5). No provider / other postures / absent set + // → no `tenantIds`, and drivers fall back to equality: fail toward + // isolation, never toward exposure. + if (hasTenant && opts.tenantIds === undefined && this.tenancyPostureProvider?.() === 'group') { + const set = (execCtx as any)?.accessible_org_ids; + if (Array.isArray(set) && set.length > 0) { + opts.tenantIds = set.map(String); + } + } if (hasTz && opts.timezone === undefined) { // Thread the business timezone so date-dependent driver generation // (autonumber `{YYYYMMDD}` tokens) resolves the calendar day correctly. @@ -1473,6 +1492,23 @@ export class ObjectQL implements IDataEngine { this.logger.info('CryptoProvider configured for secret fields'); } + /** + * [ADR-0105 D2 / #3623] Inject the tenancy-posture accessor that decides + * whether driver-level native tenant scoping widens to the caller's whole + * membership set (`DriverOptions.tenantIds`, the `group` posture's union) + * instead of the active-org equality (`DriverOptions.tenantId`). + * + * Wired by the enforcement layer (SecurityPlugin) — deliberately NOT + * self-derived from env here: the posture in force is an entitlement + * question (`tenancy` service), and widening the driver wall is only safe + * when the Layer 0 union wall is actually enforcing above it. No provider + * (an embedding without plugin-security) keeps today's equality scoping — + * fail toward isolation, never toward exposure. + */ + setTenancyPostureProvider(provider: () => string | undefined): void { + this.tenancyPostureProvider = provider; + } + /** * Normalize credential fields on `row` in place before it reaches the driver. * diff --git a/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts b/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts index d42783e666..ff14678de2 100644 --- a/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts @@ -368,4 +368,75 @@ describe('SqlDriver tenant scope (organization_id)', () => { expect(warnSpy).toHaveLength(0); }); }); + + // [ADR-0105 D2 / #3623] Group posture: the engine threads the caller's whole + // membership set as `tenantIds`; the native scope widens to `IN (...)` so it + // matches the Layer 0 union instead of collapsing it to active-org equality. + describe('union tenant scope — tenantIds (group posture, #3623)', () => { + it('find with tenantIds spans exactly the listed tenants', async () => { + const rows = await driver.find( + 'account', + { object: 'account' }, + { tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] } as any, + ); + expect(rows.map(r => r.id).sort()).toEqual(['a1', 'a2', 'b1', 'b2']); + }); + + it('tenants OUTSIDE the set stay invisible', async () => { + await driver.create('account', { id: 'c1', organization_id: 'org_c', name: 'C1' }); + const rows = await driver.find( + 'account', + { object: 'account' }, + { tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] } as any, + ); + expect(rows.map(r => r.id)).not.toContain('c1'); + }); + + it('keeps the NULL-tenant global-row carve-out (#2734) on the union path', async () => { + await driver.create('account', { id: 'g1', name: 'GLOBAL' }); + const rows = await driver.find( + 'account', + { object: 'account' }, + { tenantId: 'org_a', tenantIds: ['org_a'] } as any, + ); + expect(rows.map(r => r.id).sort()).toEqual(['a1', 'a2', 'g1']); + }); + + it('an empty or malformed tenantIds falls back to tenantId equality (fail toward isolation)', async () => { + const empty = await driver.find( + 'account', + { object: 'account' }, + { tenantId: 'org_a', tenantIds: [] } as any, + ); + expect(empty.map(r => r.id).sort()).toEqual(['a1', 'a2']); + const malformed = await driver.find( + 'account', + { object: 'account' }, + { tenantId: 'org_a', tenantIds: [null, ''] } as any, + ); + expect(malformed.map(r => r.id).sort()).toEqual(['a1', 'a2']); + }); + + it('update/delete reach widens with the set — but only within it', async () => { + const unionOpts = { tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] } as any; + await driver.update('account', 'b1', { tier: 'platinum' }, unionOpts); + const b1 = await driver.findOne('account', { object: 'account', where: { id: 'b1' } }); + expect(b1.tier).toBe('platinum'); + // A tenant OUTSIDE the set stays untouchable — the widened wall still walls. + await driver.create('account', { id: 'c1', organization_id: 'org_c', name: 'C1', tier: 'gold' }); + await driver.update('account', 'c1', { tier: 'compromised' }, unionOpts); + const c1 = await driver.findOne('account', { object: 'account', where: { id: 'c1' } }); + expect(c1.tier).toBe('gold'); + }); + + it('insert injection STILL stamps from tenantId (active org = write target, D5)', async () => { + await driver.create( + 'account', + { id: 'n1', name: 'New' }, + { tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] } as any, + ); + const row = await driver.findOne('account', { object: 'account', where: { id: 'n1' } }); + expect(row?.organization_id).toBe('org_a'); + }); + }); }); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index d1f8c4c2a4..763259a790 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -2830,6 +2830,23 @@ export class SqlDriver implements IDataDriver { if (tenantId === undefined || tenantId === null || tenantId === '') return builder; const field = this.resolveTenantField(object); if (!field) return builder; + // [ADR-0105 D2 / #3623] Union tenant scope: under the `group` posture the + // engine threads the caller's whole membership set as `tenantIds`, and the + // native wall widens to `IN (...)` — the SAME union the Layer 0 + // authorization wall enforces above. Equality here would AND under that + // union and collapse group reads to active-org reach. A malformed or + // empty set falls through to the equality path: fail toward isolation, + // never toward exposure. Insert-side injection (injectTenantOnInsert) + // deliberately keeps `tenantId` — the active org is the write target (D5). + const rawIds = (options as any)?.tenantIds; + const tenantIds = Array.isArray(rawIds) + ? rawIds.filter((v: unknown) => typeof v === 'string' && v !== '') + : []; + if (tenantIds.length > 0) { + return builder.where((b) => { + void b.whereIn(field, tenantIds.map(String)).orWhereNull(field); + }); + } // `(field = :tenantId OR field IS NULL)` — a NULL tenant column marks a // GLOBAL/platform row (bootstrap-seeded positions and permission sets, // business units, pre-org first-boot seeds). Such a row belongs to no diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 78c939e594..90b4105b2d 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -92,6 +92,48 @@ describe('SecurityPlugin', () => { await expect(plugin.destroy()).resolves.toBeUndefined(); }); + // [ADR-0105 D2 / #3623] start() hands the engine a posture accessor so the + // driver-level native tenant scope can widen to the membership union under + // `group`. Wired from the enforcement layer on purpose: no SecurityPlugin, + // no widening. + it('wires the tenancy-posture provider onto the engine, reporting the LIVE cached posture', async () => { + const plugin = new SecurityPlugin(); + const registerMiddleware = vi.fn(); + const setTenancyPostureProvider = vi.fn(); + const manifestService = { register: vi.fn() }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn().mockImplementation((name: string) => { + if (name === 'manifest') return manifestService; + if (name === 'tenancy') return { posture: 'group' }; + return { registerMiddleware, setTenancyPostureProvider }; + }), + }; + await plugin.init(ctx); + await plugin.start(ctx); + expect(setTenancyPostureProvider).toHaveBeenCalledWith(expect.any(Function)); + const provider = setTenancyPostureProvider.mock.calls[0][0] as () => string; + expect(provider()).toBe('group'); + }); + + it('an engine without the provider seam is tolerated (older engine)', async () => { + const plugin = new SecurityPlugin(); + const registerMiddleware = vi.fn(); + const manifestService = { register: vi.fn() }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn().mockImplementation((name: string) => { + if (name === 'manifest') return manifestService; + if (name === 'tenancy') return { posture: 'group' }; + return { registerMiddleware }; + }), + }; + await plugin.init(ctx); + await expect(plugin.start(ctx)).resolves.toBeUndefined(); + }); + // ------------------------------------------------------------------------- // org-scoping probe — when @objectstack/plugin-org-scoping is installed // (i.e. the `org-scoping` service is registered), SecurityPlugin keeps diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index ba89e82693..e7e997c0f1 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -510,6 +510,19 @@ export class SecurityPlugin implements Plugin { this.tenancyPosture = 'single'; } } + // [ADR-0105 D2 / #3623] Hand the engine a posture accessor so its + // driver-level NATIVE tenant scoping can widen to the caller's membership + // union (`DriverOptions.tenantIds`) under the `group` posture. Wired from + // HERE — the enforcement layer — on purpose: widening the driver wall is + // only safe while the Layer 0 union wall enforces above it, so an + // embedding without SecurityPlugin never widens (drivers keep active-org + // equality — fail toward isolation). + try { + const engineSvc = ctx.getService<{ setTenancyPostureProvider?: (p: () => string | undefined) => void }>('objectql'); + engineSvc?.setTenancyPostureProvider?.(() => this.tenancyPosture); + } catch { + /* engine absent — nothing to widen */ + } // [ADR-0105 D11] Optional app/plugin membership resolver for the §7.3.1 // `IN (current_user.)` sets. Absent is the norm — probe once. try { diff --git a/packages/spec/src/data/driver.zod.ts b/packages/spec/src/data/driver.zod.ts index 8fdabe9983..cead684cb7 100644 --- a/packages/spec/src/data/driver.zod.ts +++ b/packages/spec/src/data/driver.zod.ts @@ -38,6 +38,27 @@ export const DriverOptionsSchema = lazySchema(() => z.object({ */ tenantId: z.string().optional().describe('Tenant Isolation identifier'), + /** + * [ADR-0105 D2 / #3623] Tenant access SET for union-scoped postures. + * + * Under the `group` tenancy posture a caller's read reach is every + * organization they hold a membership in (`organization_id IN (...)`), not + * just the active one. The engine threads `ExecutionContext.accessible_org_ids` + * here so the driver's NATIVE tenant scoping widens to the same union the + * Layer 0 authorization wall enforces — otherwise the driver's + * `organization_id = tenantId` equality ANDs under the union and collapses + * group reads back to active-org reach. + * + * Semantics for drivers that implement native scoping: + * - non-empty array → scope reads/updates/deletes/aggregates with `IN`, + * keeping any NULL-tenant global-row carve-out the equality path has; + * - absent or empty → fall back to `tenantId` equality (fail toward + * isolation, never toward exposure); + * - insert-side tenant injection ALWAYS uses `tenantId` (the active + * organization is the write target — ADR-0105 D5). + */ + tenantIds: z.array(z.string()).optional().describe('Union tenant access set (group posture): native read scoping widens to organization_id IN (...); inserts still stamp from tenantId'), + /** * Business reference timezone (IANA name, e.g. `Asia/Shanghai`) for the * request, threaded from `ExecutionContext.timezone` (ADR-0053). Drivers that