From 4578c7651df97621617a88f95603b33cc170d7ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 12:08:38 +0000 Subject: [PATCH 1/2] fix(tenancy): platform-global (tenancy.enabled:false) objects are never driver-org-scoped (#3249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017YcGcBgUxeeKXXxv189kqR --- .../tenant-scope-platform-global-3249.md | 27 +++++++ packages/objectql/src/engine.test.ts | 70 ++++++++++++++++++ packages/objectql/src/engine.ts | 42 ++++++----- packages/objectql/src/registry.ts | 4 +- .../src/sql-driver-tenant-scope.test.ts | 28 ++++++++ packages/plugins/driver-sql/src/sql-driver.ts | 55 ++++++++++++-- .../sqlite-wasm-driver-tenant-scope.test.ts | 71 +++++++++++++++++++ packages/spec/src/data/object.test.ts | 20 +++++- packages/spec/src/data/object.zod.ts | 13 ++++ 9 files changed, 305 insertions(+), 25 deletions(-) create mode 100644 .changeset/tenant-scope-platform-global-3249.md diff --git a/.changeset/tenant-scope-platform-global-3249.md b/.changeset/tenant-scope-platform-global-3249.md new file mode 100644 index 0000000000..2ad5329b59 --- /dev/null +++ b/.changeset/tenant-scope-platform-global-3249.md @@ -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. diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index 3588da367d..354e5fb5ee 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -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); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index d1ca50bf0d..84b2ba710f 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -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'; @@ -781,17 +781,25 @@ 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. @@ -799,7 +807,9 @@ export class ObjectQL implements IDataEngine { ? 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; @@ -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); @@ -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); @@ -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; } @@ -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; @@ -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; @@ -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); } @@ -3149,7 +3159,7 @@ 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 @@ -3157,7 +3167,7 @@ export class ObjectQL implements IDataEngine { // 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); }); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index e2c83b1344..30ebf52604 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -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'; @@ -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 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 8fdb0be751..d42783e666 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 @@ -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', () => { diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 03a8ff0f89..3fcfe0736d 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -8,7 +8,7 @@ */ import type { QueryAST, DriverOptions, SchemaMode } from '@objectstack/spec/data'; -import { parseAutonumberFormat, renderAutonumber, missingFieldValues, type AutonumberToken } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, type AutonumberToken } from '@objectstack/spec/data'; import type { IDataDriver } from '@objectstack/spec/contracts'; import { StorageNameMapping } from '@objectstack/spec/system'; import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared'; @@ -396,9 +396,25 @@ export class SqlDriver implements IDataDriver { * applied — preserves backward compatibility for tools that legitimately * need cross-tenant access. Tenant enforcement is therefore opt-in by * the caller, not by the driver. + * + * Entries are overwritten on every (re-)registration; the sticky + * {@link tenantOptOutByTable} record keeps an explicit `tenancy.enabled:false` + * declaration from being lost to a later partial re-registration. */ protected tenantFieldByTable: Record = {}; + /** + * Tables whose schema EXPLICITLY declared `tenancy.enabled === false`. + * Sticky across re-registrations: a later registration that omits the + * `tenancy` block (lifecycle archive `syncSchema`, schema-drift re-sync, + * partial-schema callers) must NOT resurrect org-scoping via the implicit + * `organization_id` heuristic — that is how a platform-global object like + * `sys_license` ends up org-scoped and its NULL-org rows vanish for + * org-context reads (#3249). A registration that DOES carry a `tenancy` + * declaration is authoritative and updates/clears the entry. + */ + protected tenantOptOutByTable: Set = new Set(); + /** Throttle table for missing-tenantId warnings ({object}:{op}). */ protected tenantAuditWarned: Set = new Set(); @@ -1939,6 +1955,7 @@ export class SqlDriver implements IDataDriver { if (this.datetimeFields[base]) this.datetimeFields[shard] = this.datetimeFields[base]; if (this.timeFields[base]) this.timeFields[shard] = this.timeFields[base]; this.tenantFieldByTable[shard] = this.tenantFieldByTable[base] ?? null; + if (this.tenantOptOutByTable.has(base)) this.tenantOptOutByTable.add(shard); this.tablesWithTimestamps.add(shard); } @@ -1965,7 +1982,7 @@ export class SqlDriver implements IDataDriver { protected computeTenantField(schema: { fields?: Record; tenancy?: any }): string | null { const tenancyDecl = (schema as any)?.tenancy; // Explicit opt-out wins over any column-presence heuristic. - if (tenancyDecl?.enabled === false) return null; + if (isTenancyDisabled(schema)) return null; const fields = schema?.fields; if (tenancyDecl?.tenantField) { const declared = String(tenancyDecl.tenantField); @@ -1975,6 +1992,32 @@ export class SqlDriver implements IDataDriver { return null; } + /** + * {@link computeTenantField} + maintenance of the sticky explicit-opt-out + * record (#3249). Key by the same key the caller uses for + * `tenantFieldByTable` (table name in `initObjects`, object name in + * `registerExternalObject` — {@link resolveTenantField} checks both). + * + * A schema that carries a `tenancy` declaration is authoritative: it sets or + * clears the opt-out and is computed normally. A schema WITHOUT one (partial + * re-registration — e.g. the lifecycle archive path passes only + * `{ name, fields }` to `syncSchema`) preserves a previously declared + * opt-out instead of letting the implicit `organization_id` heuristic + * re-scope a platform-global table. + */ + protected computeAndRecordTenantField( + key: string, + schema: { fields?: Record; tenancy?: any }, + ): string | null { + if (schema?.tenancy != null) { + if (isTenancyDisabled(schema)) this.tenantOptOutByTable.add(key); + else this.tenantOptOutByTable.delete(key); + return this.computeTenantField(schema); + } + if (this.tenantOptOutByTable.has(key)) return null; + return this.computeTenantField(schema); + } + /** * Batch-initialise tables from an array of object definitions. */ @@ -2038,7 +2081,7 @@ export class SqlDriver implements IDataDriver { const timeCols: string[] = []; const autoNumberCols: Array<{ name: string; format: string; tokens: AutonumberToken[]; tenantField: string | null }> = []; - const tenantField = this.computeTenantField(schema); + const tenantField = this.computeAndRecordTenantField(key, schema); if (schema.fields) { for (const [name, field] of Object.entries(schema.fields)) { const type = field.type || 'string'; @@ -2087,9 +2130,9 @@ export class SqlDriver implements IDataDriver { const numericCols: string[] = []; const autoNumberCols: Array<{ name: string; format: string; tokens: AutonumberToken[]; tenantField: string | null }> = []; // Tenant-isolation column: explicit tenancy opt-out → declared field → - // implicit `organization_id`. See {@link computeTenantField} (shared with - // registerExternalObject so the two paths can't drift). - const tenantField = this.computeTenantField(obj); + // implicit `organization_id`. See {@link computeAndRecordTenantField} + // (shared with registerExternalObject so the two paths can't drift). + const tenantField = this.computeAndRecordTenantField(tableName, obj); if (obj.fields) { for (const [name, field] of Object.entries(obj.fields)) { const type = field.type || 'string'; diff --git a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts index f5942ab86e..9374164d68 100644 --- a/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts +++ b/packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts @@ -207,6 +207,77 @@ describe('SqliteWasmDriver tenant scope (organization_id)', () => { }); }); + describe('tenancy.enabled:false opts out of driver org-scoping', () => { + // Regression: a platform-global object (e.g. sys_license, ADR-0066) keeps an + // optional, often-NULL `organization_id` FK but declares `tenancy.enabled: + // false`. The driver previously detected the `organization_id` column via the + // implicit fallback and org-scoped it anyway, so an authenticated caller's + // active-org `tenantId` injected `WHERE organization_id = ` and the + // NULL-org rows vanished — the platform admin read zero rows while an + // unscoped read still saw them. tenancy.enabled:false must win. (#3249) + const platformGlobal = [ + { + name: 'sys_license', + tenancy: { enabled: false }, + fields: { + customer: { type: 'string' }, + organization_id: { type: 'string' }, // optional owner FK, may be NULL + status: { type: 'string' }, + }, + }, + ]; + + beforeEach(async () => { + await driver.disconnect(); + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects(platformGlobal); + // A NULL-org platform row + an org-mapped one. + await driver.create('sys_license', { id: 'lic_global', customer: 'ACME', organization_id: null, status: 'active' }); + await driver.create('sys_license', { id: 'lic_org_b', customer: 'Beta', organization_id: 'org_b', status: 'active' }); + }); + + it('does NOT register a tenant field for a tenancy-disabled object', () => { + expect((driver as any).tenantFieldByTable['sys_license']).toBeNull(); + }); + + it('read is unscoped even when the caller passes tenantId (admin with active org sees all)', async () => { + 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('matches the unscoped (anonymous) read — no auth-dependent divergence', async () => { + const scoped = await driver.find('sys_license', { object: 'sys_license' }, { tenantId: 'org_admin_active' }); + const unscoped = await driver.find('sys_license', { object: 'sys_license' }); + expect(scoped.map(r => r.id).sort()).toEqual(unscoped.map(r => r.id).sort()); + }); + + it('does NOT auto-inject organization_id on insert when tenancy is disabled', async () => { + 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 across partial re-registrations + // (lifecycle archive `syncSchema`, schema-drift re-sync) that omit the + // `tenancy` block — the implicit organization_id heuristic must not + // re-scope a platform-global table. + 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 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', () => { it('logs once per object:op when writing without tenantId', async () => { await driver.disconnect(); diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index bdeb0655da..0e5226d26a 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, resolveCrudAffordances, type ServiceObject } from './object.zod'; +import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, isTenancyDisabled, resolveCrudAffordances, type ServiceObject } from './object.zod'; describe('ObjectCapabilities', () => { it('should apply default values correctly', () => { @@ -1330,6 +1330,24 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', () }); }); +describe('isTenancyDisabled — platform-global posture predicate (#3249, ADR-0066)', () => { + it('is true only for an explicit tenancy.enabled === false', () => { + expect(isTenancyDisabled({ name: 'sys_license', tenancy: { enabled: false } })).toBe(true); + expect(isTenancyDisabled({ name: 'task', tenancy: { enabled: true } })).toBe(false); + }); + + it('is false when tenancy is absent (tenant-scoped by default)', () => { + expect(isTenancyDisabled({ name: 'task', fields: { organization_id: { type: 'text' } } })).toBe(false); + expect(isTenancyDisabled({ name: 'task', tenancy: {} })).toBe(false); + }); + + it('tolerates null/undefined/non-object schemas', () => { + expect(isTenancyDisabled(undefined)).toBe(false); + expect(isTenancyDisabled(null)).toBe(false); + expect(isTenancyDisabled('sys_license')).toBe(false); + }); +}); + describe('userActions row predicates + resolveCrudAffordances (objectui#2614)', () => { it('accepts the plain boolean form unchanged (back-compat)', () => { const obj = ObjectSchema.parse({ diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 24826ccc7b..6e9d443265 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -202,6 +202,19 @@ export const TenancyConfigSchema = lazySchema(() => z.object({ tenantField: z.string().default('tenant_id').describe('Field name for tenant identifier'), }, { error: strictTenancyError }).strict()); +/** + * [ADR-0066] Platform-global posture: `tenancy.enabled === false` explicitly + * opts the object out of row-level org scoping, even when it carries an + * `organization_id` column (e.g. `sys_license` keeps an optional owner FK). + * Single source of truth for the registry (tenant-column injection), the + * ObjectQL engine (tenantId propagation into driver options), and drivers + * (native scoping) — previously each re-derived `tenancy?.enabled === false` + * independently and could drift (#3249). + */ +export function isTenancyDisabled(schema: unknown): boolean { + return (schema as { tenancy?: { enabled?: boolean } } | null | undefined)?.tenancy?.enabled === false; +} + /** * [ADR-0066 D2] Secure-by-default object posture. * From dd9241d308448ad1bbad420f5af9a8c04bd5a7f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 12:18:25 +0000 Subject: [PATCH 2/2] chore(spec): record isTenancyDisabled in the API-surface snapshot The check:api-surface CI gate flags any public-API delta; the new export from #3249 is intentional (see the accompanying changeset). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YcGcBgUxeeKXXxv189kqR --- packages/spec/api-surface.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 84addd22dc..281212d1b7 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -508,6 +508,7 @@ "isDateMacroToken (function)", "isFilterAST (function)", "isIncoherentAggregate (function)", + "isTenancyDisabled (function)", "isTitleEligible (function)", "missingFieldValues (function)", "objectForm (const)",