Skip to content

Commit 0e3a226

Browse files
os-zhuangclaude
andauthored
fix(authz): widen the driver's native tenant scope to the group membership union — ADR-0105 D2 reaches the wire (#3623) (#3631)
The Layer 0 wall correctly compiled organization_id IN accessible_org_ids under the group posture, but the 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. - spec: DriverOptions.tenantIds — the union tenant access set. Native scoping widens reads/updates/deletes/aggregates to IN (...), keeping the NULL-tenant global-row carve-out (#2734); inserts still stamp from tenantId (active org = write target, D5). Absent/empty => equality fallback: fail toward isolation, never toward exposure. - objectql: setTenancyPostureProvider seam + buildDriverOptions threads ExecutionContext.accessible_org_ids as tenantIds when the posture is group. - driver-sql: applyTenantScope honors tenantIds via whereIn at the single read-side chokepoint. - plugin-security: wires the provider at start() — from the enforcement layer on purpose, so the driver wall only widens while the Layer 0 union wall enforces above it; embeddings without plugin-security keep equality. Verified: objectql 1091, driver-sql 293, plugin-security 593, spec 6672 tests green; spec docs regenerated (check:docs in sync); repo grep guards clean. Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL Co-authored-by: Claude <noreply@anthropic.com>
1 parent f68d417 commit 0e3a226

9 files changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/objectql": patch
4+
"@objectstack/driver-sql": patch
5+
"@objectstack/plugin-security": patch
6+
---
7+
8+
fix(authz): widen the driver's native tenant scope to the membership union
9+
under the `group` posture — ADR-0105 D2 finally reaches the wire (#3623)
10+
11+
The Layer 0 wall correctly compiled `organization_id IN accessible_org_ids`
12+
under `group`, but the ObjectQL engine also propagated the active-org
13+
`tenantId` into `DriverOptions` unconditionally, and the SQL driver's native
14+
scoping ANDed `organization_id = tenantId` under the union — collapsing every
15+
group read back to active-org (isolated) reach. Found by the cloud-side
16+
`ee-group-showcase` dogfood (cloud#880), the first end-to-end boot of `group`
17+
against a real driver.
18+
19+
- `DriverOptions.tenantIds` (spec): the union tenant access set. Drivers with
20+
native scoping widen reads/updates/deletes/aggregates to `IN (...)`,
21+
keeping the NULL-tenant global-row carve-out; inserts still stamp from
22+
`tenantId` (the active organization is the write target, D5). Absent or
23+
empty ⇒ equality fallback — fail toward isolation, never toward exposure.
24+
- ObjectQL engine threads `ExecutionContext.accessible_org_ids` as
25+
`tenantIds` when the tenancy posture is `group`, reported by a new
26+
`setTenancyPostureProvider` seam.
27+
- SecurityPlugin wires that provider at start — deliberately from the
28+
enforcement layer, so the driver wall only widens while the Layer 0 union
29+
wall enforces above it. Embeddings without plugin-security keep active-org
30+
equality.

content/docs/references/data/driver.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ const result = DriverCapabilities.parse(data);
9595
| **skipCache** | `boolean` | optional | Bypass cache |
9696
| **traceContext** | `Record<string, string>` | optional | OpenTelemetry context or request ID |
9797
| **tenantId** | `string` | optional | Tenant Isolation identifier |
98+
| **tenantIds** | `string[]` | optional | Union tenant access set (group posture): native read scoping widens to organization_id IN (...); inserts still stamp from tenantId |
9899
| **timezone** | `string` | optional | Business reference timezone (IANA) for date-dependent generation, e.g. autonumber date tokens |
99100
| **preserveAudit** | `boolean` | optional | Historical import: keep a supplied updated_at instead of force-stamping now (from ExecutionContext.preserveAudit) |
100101

packages/objectql/src/engine.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,50 @@ describe('ObjectQL Engine', () => {
591591
});
592592
});
593593

594+
describe('group posture widens driver scope to the membership union (#3623, ADR-0105 D2)', () => {
595+
// Regression: the engine stamped only the active-org `tenantId` into
596+
// driver options, so the SQL driver's native equality scope ANDed
597+
// under the Layer 0 union and collapsed `group` reads back to
598+
// active-org reach. Under the group posture (reported by the
599+
// SecurityPlugin-wired provider) the engine now ALSO threads the
600+
// caller's `accessible_org_ids` as `tenantIds`.
601+
beforeEach(async () => {
602+
engine.registerDriver(mockDriver, true);
603+
await engine.init();
604+
vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} });
605+
});
606+
607+
const lastFindOpts = () => (mockDriver.find as any).mock.calls.at(-1)?.[2];
608+
const groupCtx = { tenantId: 'org_a', accessible_org_ids: ['org_a', 'org_b'] } as any;
609+
610+
it('threads accessible_org_ids as tenantIds under the group posture', async () => {
611+
(engine as any).setTenancyPostureProvider(() => 'group');
612+
await engine.find('task', { filters: [] }, { context: groupCtx });
613+
expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] });
614+
});
615+
616+
it('no provider (no enforcement layer) → equality only, never widened', async () => {
617+
await engine.find('task', { filters: [] }, { context: groupCtx });
618+
expect(lastFindOpts()?.tenantIds).toBeUndefined();
619+
expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a' });
620+
});
621+
622+
it('isolated posture → equality only (the union is a group-only widening)', async () => {
623+
(engine as any).setTenancyPostureProvider(() => 'isolated');
624+
await engine.find('task', { filters: [] }, { context: groupCtx });
625+
expect(lastFindOpts()?.tenantIds).toBeUndefined();
626+
});
627+
628+
it('group with an absent/empty accessible set → equality only (fail toward isolation)', async () => {
629+
(engine as any).setTenancyPostureProvider(() => 'group');
630+
await engine.find('task', { filters: [] }, { context: { tenantId: 'org_a', accessible_org_ids: [] } as any });
631+
expect(lastFindOpts()?.tenantIds).toBeUndefined();
632+
await engine.find('task', { filters: [] }, { context: { tenantId: 'org_a' } as any });
633+
expect(lastFindOpts()?.tenantIds).toBeUndefined();
634+
});
635+
636+
});
637+
594638
describe('tenancy.enabled:false objects are platform-global (#3249, ADR-0066)', () => {
595639
// Regression: buildDriverOptions stamped execCtx.tenantId into driver
596640
// options unconditionally, so a platform-global object (sys_license)

packages/objectql/src/engine.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,11 @@ export class ObjectQL implements IDataEngine {
373373
// persists cleartext). Injected by the host via setCryptoProvider().
374374
private cryptoProvider?: ICryptoProvider;
375375

376+
// [ADR-0105 D2 / #3623] Posture accessor for driver-scope widening under the
377+
// `group` posture. Injected by SecurityPlugin via setTenancyPostureProvider();
378+
// absent = equality scoping (fail toward isolation).
379+
private tenancyPostureProvider?: () => string | undefined;
380+
376381
// Per-engine SchemaRegistry instance.
377382
//
378383
// Historically SchemaRegistry was a process-wide singleton of static state,
@@ -871,6 +876,20 @@ export class ObjectQL implements IDataEngine {
871876
if (hasTenant && opts.tenantId === undefined) {
872877
opts.tenantId = execCtx!.tenantId;
873878
}
879+
// [ADR-0105 D2 / #3623] Under the `group` posture the caller's read reach
880+
// is their whole membership set, not the active org — thread it so the
881+
// driver's native scope widens to the SAME union Layer 0 enforces, instead
882+
// of ANDing an active-org equality under it (which collapsed group reads
883+
// to isolated semantics). Inserts still stamp from `tenantId` (the active
884+
// org is the write target, D5). No provider / other postures / absent set
885+
// → no `tenantIds`, and drivers fall back to equality: fail toward
886+
// isolation, never toward exposure.
887+
if (hasTenant && opts.tenantIds === undefined && this.tenancyPostureProvider?.() === 'group') {
888+
const set = (execCtx as any)?.accessible_org_ids;
889+
if (Array.isArray(set) && set.length > 0) {
890+
opts.tenantIds = set.map(String);
891+
}
892+
}
874893
if (hasTz && opts.timezone === undefined) {
875894
// Thread the business timezone so date-dependent driver generation
876895
// (autonumber `{YYYYMMDD}` tokens) resolves the calendar day correctly.
@@ -1473,6 +1492,23 @@ export class ObjectQL implements IDataEngine {
14731492
this.logger.info('CryptoProvider configured for secret fields');
14741493
}
14751494

1495+
/**
1496+
* [ADR-0105 D2 / #3623] Inject the tenancy-posture accessor that decides
1497+
* whether driver-level native tenant scoping widens to the caller's whole
1498+
* membership set (`DriverOptions.tenantIds`, the `group` posture's union)
1499+
* instead of the active-org equality (`DriverOptions.tenantId`).
1500+
*
1501+
* Wired by the enforcement layer (SecurityPlugin) — deliberately NOT
1502+
* self-derived from env here: the posture in force is an entitlement
1503+
* question (`tenancy` service), and widening the driver wall is only safe
1504+
* when the Layer 0 union wall is actually enforcing above it. No provider
1505+
* (an embedding without plugin-security) keeps today's equality scoping —
1506+
* fail toward isolation, never toward exposure.
1507+
*/
1508+
setTenancyPostureProvider(provider: () => string | undefined): void {
1509+
this.tenancyPostureProvider = provider;
1510+
}
1511+
14761512
/**
14771513
* Normalize credential fields on `row` in place before it reaches the driver.
14781514
*

packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,4 +368,75 @@ describe('SqlDriver tenant scope (organization_id)', () => {
368368
expect(warnSpy).toHaveLength(0);
369369
});
370370
});
371+
372+
// [ADR-0105 D2 / #3623] Group posture: the engine threads the caller's whole
373+
// membership set as `tenantIds`; the native scope widens to `IN (...)` so it
374+
// matches the Layer 0 union instead of collapsing it to active-org equality.
375+
describe('union tenant scope — tenantIds (group posture, #3623)', () => {
376+
it('find with tenantIds spans exactly the listed tenants', async () => {
377+
const rows = await driver.find(
378+
'account',
379+
{ object: 'account' },
380+
{ tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] } as any,
381+
);
382+
expect(rows.map(r => r.id).sort()).toEqual(['a1', 'a2', 'b1', 'b2']);
383+
});
384+
385+
it('tenants OUTSIDE the set stay invisible', async () => {
386+
await driver.create('account', { id: 'c1', organization_id: 'org_c', name: 'C1' });
387+
const rows = await driver.find(
388+
'account',
389+
{ object: 'account' },
390+
{ tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] } as any,
391+
);
392+
expect(rows.map(r => r.id)).not.toContain('c1');
393+
});
394+
395+
it('keeps the NULL-tenant global-row carve-out (#2734) on the union path', async () => {
396+
await driver.create('account', { id: 'g1', name: 'GLOBAL' });
397+
const rows = await driver.find(
398+
'account',
399+
{ object: 'account' },
400+
{ tenantId: 'org_a', tenantIds: ['org_a'] } as any,
401+
);
402+
expect(rows.map(r => r.id).sort()).toEqual(['a1', 'a2', 'g1']);
403+
});
404+
405+
it('an empty or malformed tenantIds falls back to tenantId equality (fail toward isolation)', async () => {
406+
const empty = await driver.find(
407+
'account',
408+
{ object: 'account' },
409+
{ tenantId: 'org_a', tenantIds: [] } as any,
410+
);
411+
expect(empty.map(r => r.id).sort()).toEqual(['a1', 'a2']);
412+
const malformed = await driver.find(
413+
'account',
414+
{ object: 'account' },
415+
{ tenantId: 'org_a', tenantIds: [null, ''] } as any,
416+
);
417+
expect(malformed.map(r => r.id).sort()).toEqual(['a1', 'a2']);
418+
});
419+
420+
it('update/delete reach widens with the set — but only within it', async () => {
421+
const unionOpts = { tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] } as any;
422+
await driver.update('account', 'b1', { tier: 'platinum' }, unionOpts);
423+
const b1 = await driver.findOne('account', { object: 'account', where: { id: 'b1' } });
424+
expect(b1.tier).toBe('platinum');
425+
// A tenant OUTSIDE the set stays untouchable — the widened wall still walls.
426+
await driver.create('account', { id: 'c1', organization_id: 'org_c', name: 'C1', tier: 'gold' });
427+
await driver.update('account', 'c1', { tier: 'compromised' }, unionOpts);
428+
const c1 = await driver.findOne('account', { object: 'account', where: { id: 'c1' } });
429+
expect(c1.tier).toBe('gold');
430+
});
431+
432+
it('insert injection STILL stamps from tenantId (active org = write target, D5)', async () => {
433+
await driver.create(
434+
'account',
435+
{ id: 'n1', name: 'New' },
436+
{ tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] } as any,
437+
);
438+
const row = await driver.findOne('account', { object: 'account', where: { id: 'n1' } });
439+
expect(row?.organization_id).toBe('org_a');
440+
});
441+
});
371442
});

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2830,6 +2830,23 @@ export class SqlDriver implements IDataDriver {
28302830
if (tenantId === undefined || tenantId === null || tenantId === '') return builder;
28312831
const field = this.resolveTenantField(object);
28322832
if (!field) return builder;
2833+
// [ADR-0105 D2 / #3623] Union tenant scope: under the `group` posture the
2834+
// engine threads the caller's whole membership set as `tenantIds`, and the
2835+
// native wall widens to `IN (...)` — the SAME union the Layer 0
2836+
// authorization wall enforces above. Equality here would AND under that
2837+
// union and collapse group reads to active-org reach. A malformed or
2838+
// empty set falls through to the equality path: fail toward isolation,
2839+
// never toward exposure. Insert-side injection (injectTenantOnInsert)
2840+
// deliberately keeps `tenantId` — the active org is the write target (D5).
2841+
const rawIds = (options as any)?.tenantIds;
2842+
const tenantIds = Array.isArray(rawIds)
2843+
? rawIds.filter((v: unknown) => typeof v === 'string' && v !== '')
2844+
: [];
2845+
if (tenantIds.length > 0) {
2846+
return builder.where((b) => {
2847+
void b.whereIn(field, tenantIds.map(String)).orWhereNull(field);
2848+
});
2849+
}
28332850
// `(field = :tenantId OR field IS NULL)` — a NULL tenant column marks a
28342851
// GLOBAL/platform row (bootstrap-seeded positions and permission sets,
28352852
// business units, pre-org first-boot seeds). Such a row belongs to no

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,48 @@ describe('SecurityPlugin', () => {
9292
await expect(plugin.destroy()).resolves.toBeUndefined();
9393
});
9494

95+
// [ADR-0105 D2 / #3623] start() hands the engine a posture accessor so the
96+
// driver-level native tenant scope can widen to the membership union under
97+
// `group`. Wired from the enforcement layer on purpose: no SecurityPlugin,
98+
// no widening.
99+
it('wires the tenancy-posture provider onto the engine, reporting the LIVE cached posture', async () => {
100+
const plugin = new SecurityPlugin();
101+
const registerMiddleware = vi.fn();
102+
const setTenancyPostureProvider = vi.fn();
103+
const manifestService = { register: vi.fn() };
104+
const ctx: any = {
105+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
106+
registerService: vi.fn(),
107+
getService: vi.fn().mockImplementation((name: string) => {
108+
if (name === 'manifest') return manifestService;
109+
if (name === 'tenancy') return { posture: 'group' };
110+
return { registerMiddleware, setTenancyPostureProvider };
111+
}),
112+
};
113+
await plugin.init(ctx);
114+
await plugin.start(ctx);
115+
expect(setTenancyPostureProvider).toHaveBeenCalledWith(expect.any(Function));
116+
const provider = setTenancyPostureProvider.mock.calls[0][0] as () => string;
117+
expect(provider()).toBe('group');
118+
});
119+
120+
it('an engine without the provider seam is tolerated (older engine)', async () => {
121+
const plugin = new SecurityPlugin();
122+
const registerMiddleware = vi.fn();
123+
const manifestService = { register: vi.fn() };
124+
const ctx: any = {
125+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
126+
registerService: vi.fn(),
127+
getService: vi.fn().mockImplementation((name: string) => {
128+
if (name === 'manifest') return manifestService;
129+
if (name === 'tenancy') return { posture: 'group' };
130+
return { registerMiddleware };
131+
}),
132+
};
133+
await plugin.init(ctx);
134+
await expect(plugin.start(ctx)).resolves.toBeUndefined();
135+
});
136+
95137
// -------------------------------------------------------------------------
96138
// org-scoping probe — when @objectstack/plugin-org-scoping is installed
97139
// (i.e. the `org-scoping` service is registered), SecurityPlugin keeps

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,19 @@ export class SecurityPlugin implements Plugin {
510510
this.tenancyPosture = 'single';
511511
}
512512
}
513+
// [ADR-0105 D2 / #3623] Hand the engine a posture accessor so its
514+
// driver-level NATIVE tenant scoping can widen to the caller's membership
515+
// union (`DriverOptions.tenantIds`) under the `group` posture. Wired from
516+
// HERE — the enforcement layer — on purpose: widening the driver wall is
517+
// only safe while the Layer 0 union wall enforces above it, so an
518+
// embedding without SecurityPlugin never widens (drivers keep active-org
519+
// equality — fail toward isolation).
520+
try {
521+
const engineSvc = ctx.getService<{ setTenancyPostureProvider?: (p: () => string | undefined) => void }>('objectql');
522+
engineSvc?.setTenancyPostureProvider?.(() => this.tenancyPosture);
523+
} catch {
524+
/* engine absent — nothing to widen */
525+
}
513526
// [ADR-0105 D11] Optional app/plugin membership resolver for the §7.3.1
514527
// `IN (current_user.<key>)` sets. Absent is the norm — probe once.
515528
try {

packages/spec/src/data/driver.zod.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,27 @@ export const DriverOptionsSchema = lazySchema(() => z.object({
3838
*/
3939
tenantId: z.string().optional().describe('Tenant Isolation identifier'),
4040

41+
/**
42+
* [ADR-0105 D2 / #3623] Tenant access SET for union-scoped postures.
43+
*
44+
* Under the `group` tenancy posture a caller's read reach is every
45+
* organization they hold a membership in (`organization_id IN (...)`), not
46+
* just the active one. The engine threads `ExecutionContext.accessible_org_ids`
47+
* here so the driver's NATIVE tenant scoping widens to the same union the
48+
* Layer 0 authorization wall enforces — otherwise the driver's
49+
* `organization_id = tenantId` equality ANDs under the union and collapses
50+
* group reads back to active-org reach.
51+
*
52+
* Semantics for drivers that implement native scoping:
53+
* - non-empty array → scope reads/updates/deletes/aggregates with `IN`,
54+
* keeping any NULL-tenant global-row carve-out the equality path has;
55+
* - absent or empty → fall back to `tenantId` equality (fail toward
56+
* isolation, never toward exposure);
57+
* - insert-side tenant injection ALWAYS uses `tenantId` (the active
58+
* organization is the write target — ADR-0105 D5).
59+
*/
60+
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'),
61+
4162
/**
4263
* Business reference timezone (IANA name, e.g. `Asia/Shanghai`) for the
4364
* request, threaded from `ExecutionContext.timezone` (ADR-0053). Drivers that

0 commit comments

Comments
 (0)