Skip to content

Commit 4578c76

Browse files
committed
fix(tenancy): platform-global (tenancy.enabled:false) objects are never driver-org-scoped (#3249)
An org-context read of a platform-global object (e.g. sys_license, ADR-0066) could return 0 rows for an authenticated caller while an anonymous read saw the data: the engine stamped execCtx.tenantId into driver options unconditionally, and the SQL driver's tenant-field cache could be re-corrupted to organization_id by a partial re-registration (lifecycle archive syncSchema, schema-drift re-sync) whose schema omitted the tenancy block — the implicit organization_id heuristic then org-scoped the table and its NULL-org rows vanished. - spec: new isTenancyDisabled(schema) export from @objectstack/spec/data — single source of truth for the ADR-0066 platform-global posture, shared by registry (tenant-column injection), engine (tenantId propagation) and drivers (native scoping). - objectql: buildDriverOptions is now object-aware and withholds execCtx.tenantId for tenancy-disabled objects (protects every driver at the source). An explicitly-passed base tenantId still wins. - driver-sql (+ inherited by driver-sqlite-wasm): sticky opt-out record (tenantOptOutByTable) — a registration carrying a tenancy declaration is authoritative; one without preserves a previously declared opt-out instead of falling back to the organization_id heuristic. Rotation shards inherit the opt-out. - tests: driver-sql sticky re-registration regressions; port the missing tenancy.enabled:false block to the sqlite-wasm suite; engine tests for tenantId withholding on reads and writes. The orWhereNull global-row inclusion (#2734) is unchanged as defense-in-depth. plugin-security's inline posture checks (security-plugin.ts:2794/2882) are a mechanical follow-up, not touched here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YcGcBgUxeeKXXxv189kqR
1 parent 4b659d0 commit 4578c76

9 files changed

Lines changed: 305 additions & 25 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': patch
4+
'@objectstack/driver-sql': patch
5+
---
6+
7+
fix(tenancy): platform-global (`tenancy.enabled:false`) objects are never driver-org-scoped (#3249)
8+
9+
An org-context read of a platform-global object (e.g. `sys_license`, ADR-0066)
10+
could return 0 rows for an authenticated caller while an anonymous read saw the
11+
data: the engine stamped `execCtx.tenantId` into driver options unconditionally,
12+
and the SQL driver's tenant-field cache could be re-corrupted to
13+
`organization_id` by a partial re-registration (lifecycle archive `syncSchema`,
14+
schema-drift re-sync) whose schema omitted the `tenancy` block.
15+
16+
- New `isTenancyDisabled(schema)` export from `@objectstack/spec/data` — the
17+
single source of truth for the ADR-0066 platform-global posture, now shared by
18+
the registry (tenant-column injection), the ObjectQL engine, and the SQL
19+
driver.
20+
- `ObjectQL.buildDriverOptions` no longer stamps `tenantId` for objects whose
21+
registered schema declares `tenancy.enabled: false` (an explicitly-passed
22+
options `tenantId` still wins — deliberate caller intent).
23+
- `SqlDriver` (and `SqliteWasmDriver`) now keep a sticky record of an explicit
24+
`tenancy.enabled:false` declaration: a later registration without a `tenancy`
25+
block preserves the opt-out instead of re-scoping via the implicit
26+
`organization_id` heuristic; a registration that carries a `tenancy`
27+
declaration stays authoritative.

packages/objectql/src/engine.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,76 @@ describe('ObjectQL Engine', () => {
530530
});
531531
});
532532

533+
describe('tenancy.enabled:false objects are platform-global (#3249, ADR-0066)', () => {
534+
// Regression: buildDriverOptions stamped execCtx.tenantId into driver
535+
// options unconditionally, so a platform-global object (sys_license)
536+
// got org-scoped at the driver and its NULL-org rows vanished for an
537+
// authenticated org-context read while anonymous reads saw them.
538+
beforeEach(async () => {
539+
engine.registerDriver(mockDriver, true);
540+
await engine.init();
541+
});
542+
543+
const lastFindOpts = () => (mockDriver.find as any).mock.calls.at(-1)?.[2];
544+
545+
it('does not stamp execCtx.tenantId into driver options for a tenancy-disabled object', async () => {
546+
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
547+
name: 'sys_license', tenancy: { enabled: false }, fields: {},
548+
} as any);
549+
await engine.find('sys_license', { filters: [] }, { context: { tenantId: 'org_admin' } as any });
550+
expect(lastFindOpts()?.tenantId).toBeUndefined();
551+
});
552+
553+
it('still stamps tenantId for objects without a tenancy declaration', async () => {
554+
vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} } as any);
555+
await engine.find('task', { filters: [] }, { context: { tenantId: 'org_a' } as any });
556+
expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a' });
557+
});
558+
559+
it('still stamps tenantId for an explicit tenancy.enabled:true object', async () => {
560+
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
561+
name: 'task', tenancy: { enabled: true }, fields: {},
562+
} as any);
563+
await engine.find('task', { filters: [] }, { context: { tenantId: 'org_a' } as any });
564+
expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a' });
565+
});
566+
567+
it('still threads timezone (and the rest of the context) while withholding tenantId', async () => {
568+
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
569+
name: 'sys_license', tenancy: { enabled: false }, fields: {},
570+
} as any);
571+
await engine.find('sys_license', { filters: [] }, {
572+
context: { tenantId: 'org_admin', timezone: 'Asia/Shanghai' } as any,
573+
});
574+
expect(lastFindOpts()).toMatchObject({ timezone: 'Asia/Shanghai' });
575+
expect(lastFindOpts()?.tenantId).toBeUndefined();
576+
});
577+
578+
it('an explicitly-passed base tenantId still reaches the driver (caller intent wins)', async () => {
579+
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
580+
name: 'sys_license', tenancy: { enabled: false }, fields: {},
581+
} as any);
582+
// buildDriverOptions merges over the caller-supplied base options
583+
// (for find: the query object) and never overwrites an explicit
584+
// tenantId — deliberate cross-checks stay possible.
585+
await engine.find(
586+
'sys_license',
587+
{ filters: [], tenantId: 'org_explicit' } as any,
588+
{ context: { timezone: 'UTC' } as any },
589+
);
590+
expect(lastFindOpts()).toMatchObject({ tenantId: 'org_explicit' });
591+
});
592+
593+
it('write path: insert on a tenancy-disabled object carries no tenantId to the driver', async () => {
594+
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
595+
name: 'sys_license', tenancy: { enabled: false }, fields: {},
596+
} as any);
597+
await engine.insert('sys_license', { customer: 'ACME' }, { context: { tenantId: 'org_admin' } as any });
598+
const createOpts = (mockDriver.create as any).mock.calls.at(-1)?.[2];
599+
expect(createOpts?.tenantId).toBeUndefined();
600+
});
601+
});
602+
533603
describe('Read hooks on findOne + registration guard (#3195)', () => {
534604
beforeEach(async () => {
535605
engine.registerDriver(mockDriver, true);

packages/objectql/src/engine.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
EngineAggregateOptions,
1111
EngineCountOptions
1212
} from '@objectstack/spec/data';
13-
import { parseAutonumberFormat, renderAutonumber, missingFieldValues } from '@objectstack/spec/data';
13+
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled } from '@objectstack/spec/data';
1414
import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel';
1515
import { IDataDriver, IDataEngine, Logger, createLogger, withTransientRetry, type RetryOptions } from '@objectstack/core';
1616
import { SummaryRecomputeError, type SummaryRecomputeFailure } from './summary-errors.js';
@@ -781,25 +781,35 @@ export class ObjectQL implements IDataEngine {
781781
/**
782782
* Build the DriverOptions blob passed to every IDataDriver call.
783783
*
784-
* Always carries `tenantId` from the active ExecutionContext so the
785-
* driver can enforce per-tenant isolation (SQL driver auto-scopes reads
786-
* and auto-injects the tenant column on writes). Existing user-supplied
787-
* shapes (transactions, AST extras) are preserved by spreading them
788-
* first.
784+
* Carries `tenantId` from the active ExecutionContext so the driver can
785+
* enforce per-tenant isolation (SQL driver auto-scopes reads and
786+
* auto-injects the tenant column on writes) — EXCEPT for objects that
787+
* declare `tenancy.enabled: false` (ADR-0066 platform-global posture,
788+
* e.g. `sys_license`): stamping the caller's active-org tenantId there
789+
* would org-scope a global catalog at the driver, and its NULL-org rows
790+
* would vanish for authenticated org-context reads while anonymous
791+
* reads still see them (#3249). The SQL driver has its own opt-out
792+
* (sticky tenant-field cache), but withholding tenantId here protects
793+
* every driver at the source. Existing user-supplied shapes
794+
* (transactions, AST extras) are preserved by spreading them first — an
795+
* explicitly-passed `base.tenantId` is deliberate caller intent and
796+
* still wins.
789797
*
790798
* System / isSystem callers may still cross tenants by clearing
791799
* `tenantId` themselves on the resulting object; this helper does not
792800
* mask the system path.
793801
*/
794-
private buildDriverOptions(execCtx?: ExecutionContext, base?: any): any {
802+
private buildDriverOptions(object: string, execCtx?: ExecutionContext, base?: any): any {
795803
// The open transaction may arrive explicitly via the context, or ambiently
796804
// via txStore when an internal query runs during a transactional write
797805
// (ADR-0034). Explicit wins; ambient is the safety net.
798806
const tx = execCtx?.transaction !== undefined
799807
? execCtx.transaction
800808
: this.txStore.getStore()?.transaction;
801809
const hasTx = tx !== undefined;
802-
const hasTenant = execCtx?.tenantId !== undefined;
810+
const hasTenant =
811+
execCtx?.tenantId !== undefined &&
812+
!isTenancyDisabled(this._registry.getObject(object));
803813
const hasTz = execCtx?.timezone !== undefined;
804814
const isSystem = execCtx?.isSystem === true;
805815
if (!hasTx && !hasTenant && !isSystem && !hasTz) return base;
@@ -2183,7 +2193,7 @@ export class ObjectQL implements IDataEngine {
21832193
ql: this
21842194
};
21852195
await this.triggerHooks('beforeFind', hookContext);
2186-
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options as any);
2196+
hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any);
21872197

21882198
try {
21892199
let result = await driver.find(object, hookContext.input.ast as QueryAST, hookContext.input.options as any);
@@ -2265,7 +2275,7 @@ export class ObjectQL implements IDataEngine {
22652275
ql: this
22662276
};
22672277
await this.triggerHooks('beforeFind', hookContext);
2268-
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options as any);
2278+
hookContext.input.options = this.buildDriverOptions(objectName, opCtx.context, hookContext.input.options as any);
22692279

22702280
let result = await driver.findOne(objectName, hookContext.input.ast as QueryAST, hookContext.input.options as any);
22712281

@@ -2362,7 +2372,7 @@ export class ObjectQL implements IDataEngine {
23622372
// Base the merge on the first row context's options: hooks share the
23632373
// same underlying options object (in-place mutations are visible), and
23642374
// for single inserts this is exactly the pre-#2922 behaviour.
2365-
const driverOptions = this.buildDriverOptions(opCtx.context, rowHookContexts[0]?.input.options as any);
2375+
const driverOptions = this.buildDriverOptions(object, opCtx.context, rowHookContexts[0]?.input.options as any);
23662376
for (const rowCtx of rowHookContexts) {
23672377
rowCtx.input.options = driverOptions;
23682378
}
@@ -2620,7 +2630,7 @@ export class ObjectQL implements IDataEngine {
26202630
ql: this
26212631
};
26222632
await this.triggerHooks('beforeUpdate', hookContext);
2623-
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options as any);
2633+
hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any);
26242634

26252635
try {
26262636
let result;
@@ -2945,7 +2955,7 @@ export class ObjectQL implements IDataEngine {
29452955
ql: this
29462956
};
29472957
await this.triggerHooks('beforeDelete', hookContext);
2948-
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options as any);
2958+
hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any);
29492959

29502960
try {
29512961
let result;
@@ -3041,7 +3051,7 @@ export class ObjectQL implements IDataEngine {
30413051
};
30423052

30433053
await this.executeWithMiddleware(opCtx, async () => {
3044-
const countOpts = this.buildDriverOptions(opCtx.context);
3054+
const countOpts = this.buildDriverOptions(object, opCtx.context);
30453055
if (driver.count) {
30463056
return driver.count(object, opCtx.ast as QueryAST, countOpts);
30473057
}
@@ -3149,15 +3159,15 @@ export class ObjectQL implements IDataEngine {
31493159
const hasDateBucket = structuredItems.some((g: any) => !!g?.dateGranularity);
31503160
const tzRequiresInMemory = !!tz && tz !== 'UTC' && hasDateBucket;
31513161
if (typeof drv.aggregate === 'function' && allStructuredSupported && !tzRequiresInMemory) {
3152-
return drv.aggregate(object, ast, this.buildDriverOptions(opCtx.context));
3162+
return drv.aggregate(object, ast, this.buildDriverOptions(object, opCtx.context));
31533163
}
31543164
// In-memory fallback path: ask the driver for raw rows, then bucket +
31553165
// aggregate here. This guarantees `groupBy` (incl. structured items
31563166
// carrying `dateGranularity`) and `aggregations` always work even on
31573167
// drivers that have no native aggregation support (driver-rest,
31583168
// driver-memory, partial SQL drivers), and is the path that honours a
31593169
// non-UTC reference timezone.
3160-
const raw = await driver.find(object, ast, this.buildDriverOptions(opCtx.context));
3170+
const raw = await driver.find(object, ast, this.buildDriverOptions(object, opCtx.context));
31613171
return applyInMemoryAggregation(raw, ast, tz);
31623172
});
31633173

packages/objectql/src/registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances } from '@objectstack/spec/data';
3+
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, isTenancyDisabled } from '@objectstack/spec/data';
44
import { resolveMultiOrgEnabled, resolveSearchPinyinEnabled } from '@objectstack/types';
55
import { provisionSearchCompanion } from './search-companion.js';
66
import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel';
@@ -247,7 +247,7 @@ export function applySystemFields(
247247
// registry would still inject `organization_id`, and the
248248
// SecurityPlugin's RLS layer would filter every cross-org read down
249249
// to 0 rows even though the schema explicitly disabled multi-tenancy.
250-
const tenancyDisabled = (schema as any).tenancy?.enabled === false;
250+
const tenancyDisabled = isTenancyDisabled(schema);
251251
// The `organization_id` COLUMN is provisioned unconditionally (subject only
252252
// to the explicit opt-outs above) — its existence no longer depends on the
253253
// global multi-tenant flag. Decoupling "does the column exist" from "is

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,34 @@ describe('SqlDriver tenant scope (organization_id)', () => {
298298
const created = await driver.create('sys_license', { id: 'lic_new', customer: 'Gamma', status: 'active' }, { tenantId: 'org_admin_active' });
299299
expect(created.organization_id ?? null).toBeNull();
300300
});
301+
302+
// #3249: the opt-out must be STICKY. Re-registration paths that pass a
303+
// partial schema without the `tenancy` block (lifecycle archive
304+
// `cold.syncSchema(object, obj)`, schema-drift re-sync) previously fell
305+
// through to the implicit organization_id heuristic and re-scoped the
306+
// platform-global table — the admin's org-context read then dropped to
307+
// 0 rows while the anonymous read still saw them.
308+
it('a later tenancy-less re-registration (syncSchema / drift re-sync) preserves the opt-out (#3249)', async () => {
309+
await driver.syncSchema('sys_license', {
310+
name: 'sys_license',
311+
fields: platformGlobal[0].fields,
312+
});
313+
expect((driver as any).tenantFieldByTable['sys_license']).toBeNull();
314+
const adminRead = await driver.find('sys_license', { object: 'sys_license' }, { tenantId: 'org_admin_active' });
315+
expect(adminRead.map(r => r.id).sort()).toEqual(['lic_global', 'lic_org_b']);
316+
});
317+
318+
it('a tenancy-less registerExternalObject after an explicit opt-out preserves it (#3249)', () => {
319+
driver.registerExternalObject({ name: 'sys_license', fields: platformGlobal[0].fields });
320+
expect((driver as any).tenantFieldByTable['sys_license']).toBeNull();
321+
});
322+
323+
it('a re-registration WITH an explicit tenancy declaration is authoritative and re-enables scoping', async () => {
324+
await driver.initObjects([
325+
{ ...platformGlobal[0], tenancy: { enabled: true, tenantField: 'organization_id' } },
326+
]);
327+
expect((driver as any).tenantFieldByTable['sys_license']).toBe('organization_id');
328+
});
301329
});
302330

303331
describe('audit warn on missing tenantId', () => {

0 commit comments

Comments
 (0)