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
27 changes: 27 additions & 0 deletions .changeset/tenant-scope-platform-global-3249.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/spec': minor
'@objectstack/objectql': patch
'@objectstack/driver-sql': patch
---

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.

- New `isTenancyDisabled(schema)` export from `@objectstack/spec/data` — the
single source of truth for the ADR-0066 platform-global posture, now shared by
the registry (tenant-column injection), the ObjectQL engine, and the SQL
driver.
- `ObjectQL.buildDriverOptions` no longer stamps `tenantId` for objects whose
registered schema declares `tenancy.enabled: false` (an explicitly-passed
options `tenantId` still wins — deliberate caller intent).
- `SqlDriver` (and `SqliteWasmDriver`) now keep a sticky record of an explicit
`tenancy.enabled:false` declaration: a later registration without a `tenancy`
block preserves the opt-out instead of re-scoping via the implicit
`organization_id` heuristic; a registration that carries a `tenancy`
declaration stays authoritative.
70 changes: 70 additions & 0 deletions packages/objectql/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,76 @@ describe('ObjectQL Engine', () => {
});
});

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)
// got org-scoped at the driver and its NULL-org rows vanished for an
// authenticated org-context read while anonymous reads saw them.
beforeEach(async () => {
engine.registerDriver(mockDriver, true);
await engine.init();
});

const lastFindOpts = () => (mockDriver.find as any).mock.calls.at(-1)?.[2];

it('does not stamp execCtx.tenantId into driver options for a tenancy-disabled object', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
name: 'sys_license', tenancy: { enabled: false }, fields: {},
} as any);
await engine.find('sys_license', { filters: [] }, { context: { tenantId: 'org_admin' } as any });
expect(lastFindOpts()?.tenantId).toBeUndefined();
});

it('still stamps tenantId for objects without a tenancy declaration', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} } as any);
await engine.find('task', { filters: [] }, { context: { tenantId: 'org_a' } as any });
expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a' });
});

it('still stamps tenantId for an explicit tenancy.enabled:true object', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
name: 'task', tenancy: { enabled: true }, fields: {},
} as any);
await engine.find('task', { filters: [] }, { context: { tenantId: 'org_a' } as any });
expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a' });
});

it('still threads timezone (and the rest of the context) while withholding tenantId', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
name: 'sys_license', tenancy: { enabled: false }, fields: {},
} as any);
await engine.find('sys_license', { filters: [] }, {
context: { tenantId: 'org_admin', timezone: 'Asia/Shanghai' } as any,
});
expect(lastFindOpts()).toMatchObject({ timezone: 'Asia/Shanghai' });
expect(lastFindOpts()?.tenantId).toBeUndefined();
});

it('an explicitly-passed base tenantId still reaches the driver (caller intent wins)', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
name: 'sys_license', tenancy: { enabled: false }, fields: {},
} as any);
// buildDriverOptions merges over the caller-supplied base options
// (for find: the query object) and never overwrites an explicit
// tenantId — deliberate cross-checks stay possible.
await engine.find(
'sys_license',
{ filters: [], tenantId: 'org_explicit' } as any,
{ context: { timezone: 'UTC' } as any },
);
expect(lastFindOpts()).toMatchObject({ tenantId: 'org_explicit' });
});

it('write path: insert on a tenancy-disabled object carries no tenantId to the driver', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
name: 'sys_license', tenancy: { enabled: false }, fields: {},
} as any);
await engine.insert('sys_license', { customer: 'ACME' }, { context: { tenantId: 'org_admin' } as any });
const createOpts = (mockDriver.create as any).mock.calls.at(-1)?.[2];
expect(createOpts?.tenantId).toBeUndefined();
});
});

describe('Read hooks on findOne + registration guard (#3195)', () => {
beforeEach(async () => {
engine.registerDriver(mockDriver, true);
Expand Down
42 changes: 26 additions & 16 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
EngineAggregateOptions,
EngineCountOptions
} from '@objectstack/spec/data';
import { parseAutonumberFormat, renderAutonumber, missingFieldValues } from '@objectstack/spec/data';
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled } from '@objectstack/spec/data';
import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel';
import { IDataDriver, IDataEngine, Logger, createLogger, withTransientRetry, type RetryOptions } from '@objectstack/core';
import { SummaryRecomputeError, type SummaryRecomputeFailure } from './summary-errors.js';
Expand Down Expand Up @@ -781,25 +781,35 @@ export class ObjectQL implements IDataEngine {
/**
* Build the DriverOptions blob passed to every IDataDriver call.
*
* Always carries `tenantId` from the active ExecutionContext so the
* driver can enforce per-tenant isolation (SQL driver auto-scopes reads
* and auto-injects the tenant column on writes). Existing user-supplied
* shapes (transactions, AST extras) are preserved by spreading them
* first.
* Carries `tenantId` from the active ExecutionContext so the driver can
* enforce per-tenant isolation (SQL driver auto-scopes reads and
* auto-injects the tenant column on writes) — EXCEPT for objects that
* declare `tenancy.enabled: false` (ADR-0066 platform-global posture,
* e.g. `sys_license`): stamping the caller's active-org tenantId there
* would org-scope a global catalog at the driver, and its NULL-org rows
* would vanish for authenticated org-context reads while anonymous
* reads still see them (#3249). The SQL driver has its own opt-out
* (sticky tenant-field cache), but withholding tenantId here protects
* every driver at the source. Existing user-supplied shapes
* (transactions, AST extras) are preserved by spreading them first — an
* explicitly-passed `base.tenantId` is deliberate caller intent and
* still wins.
*
* System / isSystem callers may still cross tenants by clearing
* `tenantId` themselves on the resulting object; this helper does not
* mask the system path.
*/
private buildDriverOptions(execCtx?: ExecutionContext, base?: any): any {
private buildDriverOptions(object: string, execCtx?: ExecutionContext, base?: any): any {
// The open transaction may arrive explicitly via the context, or ambiently
// via txStore when an internal query runs during a transactional write
// (ADR-0034). Explicit wins; ambient is the safety net.
const tx = execCtx?.transaction !== undefined
? execCtx.transaction
: this.txStore.getStore()?.transaction;
const hasTx = tx !== undefined;
const hasTenant = execCtx?.tenantId !== undefined;
const hasTenant =
execCtx?.tenantId !== undefined &&
!isTenancyDisabled(this._registry.getObject(object));
const hasTz = execCtx?.timezone !== undefined;
const isSystem = execCtx?.isSystem === true;
if (!hasTx && !hasTenant && !isSystem && !hasTz) return base;
Expand Down Expand Up @@ -2183,7 +2193,7 @@ export class ObjectQL implements IDataEngine {
ql: this
};
await this.triggerHooks('beforeFind', hookContext);
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options as any);
hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any);

try {
let result = await driver.find(object, hookContext.input.ast as QueryAST, hookContext.input.options as any);
Expand Down Expand Up @@ -2265,7 +2275,7 @@ export class ObjectQL implements IDataEngine {
ql: this
};
await this.triggerHooks('beforeFind', hookContext);
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options as any);
hookContext.input.options = this.buildDriverOptions(objectName, opCtx.context, hookContext.input.options as any);

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

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

try {
let result;
Expand Down Expand Up @@ -2945,7 +2955,7 @@ export class ObjectQL implements IDataEngine {
ql: this
};
await this.triggerHooks('beforeDelete', hookContext);
hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options as any);
hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any);

try {
let result;
Expand Down Expand Up @@ -3041,7 +3051,7 @@ export class ObjectQL implements IDataEngine {
};

await this.executeWithMiddleware(opCtx, async () => {
const countOpts = this.buildDriverOptions(opCtx.context);
const countOpts = this.buildDriverOptions(object, opCtx.context);
if (driver.count) {
return driver.count(object, opCtx.ast as QueryAST, countOpts);
}
Expand Down Expand Up @@ -3149,15 +3159,15 @@ export class ObjectQL implements IDataEngine {
const hasDateBucket = structuredItems.some((g: any) => !!g?.dateGranularity);
const tzRequiresInMemory = !!tz && tz !== 'UTC' && hasDateBucket;
if (typeof drv.aggregate === 'function' && allStructuredSupported && !tzRequiresInMemory) {
return drv.aggregate(object, ast, this.buildDriverOptions(opCtx.context));
return drv.aggregate(object, ast, this.buildDriverOptions(object, opCtx.context));
}
// In-memory fallback path: ask the driver for raw rows, then bucket +
// aggregate here. This guarantees `groupBy` (incl. structured items
// carrying `dateGranularity`) and `aggregations` always work even on
// drivers that have no native aggregation support (driver-rest,
// driver-memory, partial SQL drivers), and is the path that honours a
// non-UTC reference timezone.
const raw = await driver.find(object, ast, this.buildDriverOptions(opCtx.context));
const raw = await driver.find(object, ast, this.buildDriverOptions(object, opCtx.context));
return applyInMemoryAggregation(raw, ast, tz);
});

Expand Down
4 changes: 2 additions & 2 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances } from '@objectstack/spec/data';
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, isTenancyDisabled } from '@objectstack/spec/data';
import { resolveMultiOrgEnabled, resolveSearchPinyinEnabled } from '@objectstack/types';
import { provisionSearchCompanion } from './search-companion.js';
import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel';
Expand Down Expand Up @@ -247,7 +247,7 @@ export function applySystemFields(
// registry would still inject `organization_id`, and the
// SecurityPlugin's RLS layer would filter every cross-org read down
// to 0 rows even though the schema explicitly disabled multi-tenancy.
const tenancyDisabled = (schema as any).tenancy?.enabled === false;
const tenancyDisabled = isTenancyDisabled(schema);
// The `organization_id` COLUMN is provisioned unconditionally (subject only
// to the explicit opt-outs above) — its existence no longer depends on the
// global multi-tenant flag. Decoupling "does the column exist" from "is
Expand Down
28 changes: 28 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 @@ -298,6 +298,34 @@ describe('SqlDriver tenant scope (organization_id)', () => {
const created = await driver.create('sys_license', { id: 'lic_new', customer: 'Gamma', status: 'active' }, { tenantId: 'org_admin_active' });
expect(created.organization_id ?? null).toBeNull();
});

// #3249: the opt-out must be STICKY. Re-registration paths that pass a
// partial schema without the `tenancy` block (lifecycle archive
// `cold.syncSchema(object, obj)`, schema-drift re-sync) previously fell
// through to the implicit organization_id heuristic and re-scoped the
// platform-global table — the admin's org-context read then dropped to
// 0 rows while the anonymous read still saw them.
it('a later tenancy-less re-registration (syncSchema / drift re-sync) preserves the opt-out (#3249)', async () => {
await driver.syncSchema('sys_license', {
name: 'sys_license',
fields: platformGlobal[0].fields,
});
expect((driver as any).tenantFieldByTable['sys_license']).toBeNull();
const adminRead = await driver.find('sys_license', { object: 'sys_license' }, { tenantId: 'org_admin_active' });
expect(adminRead.map(r => r.id).sort()).toEqual(['lic_global', 'lic_org_b']);
});

it('a tenancy-less registerExternalObject after an explicit opt-out preserves it (#3249)', () => {
driver.registerExternalObject({ name: 'sys_license', fields: platformGlobal[0].fields });
expect((driver as any).tenantFieldByTable['sys_license']).toBeNull();
});

it('a re-registration WITH an explicit tenancy declaration is authoritative and re-enables scoping', async () => {
await driver.initObjects([
{ ...platformGlobal[0], tenancy: { enabled: true, tenantField: 'organization_id' } },
]);
expect((driver as any).tenantFieldByTable['sys_license']).toBe('organization_id');
});
});

describe('audit warn on missing tenantId', () => {
Expand Down
Loading