Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/group-union-driver-scope.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/references/data/driver.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const result = DriverCapabilities.parse(data);
| **skipCache** | `boolean` | optional | Bypass cache |
| **traceContext** | `Record<string, string>` | 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) |

Expand Down
44 changes: 44 additions & 0 deletions packages/objectql/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down
71 changes: 71 additions & 0 deletions packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
17 changes: 17 additions & 0 deletions packages/plugins/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>)` sets. Absent is the norm — probe once.
try {
Expand Down
21 changes: 21 additions & 0 deletions packages/spec/src/data/driver.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading