diff --git a/.changeset/15212-8a-one-predicate.md b/.changeset/15212-8a-one-predicate.md new file mode 100644 index 00000000000..c6400f436b4 --- /dev/null +++ b/.changeset/15212-8a-one-predicate.md @@ -0,0 +1,25 @@ +--- +'@objectstack/objectql': minor +'@objectstack/core': minor +'@objectstack/spec': minor +'@objectstack/plugin-security': patch +--- + +feat(objectql,core): the driver's tenant read scope is the organization wall's verdict — one predicate for Layer 0 and every driver (ADR-0131 D8) + +Clause-②: yes (widening) + +- **What changed.** The engine no longer derives `DriverOptions.tenantId` / `tenantIds` beside the organization wall. `buildDriverOptions` projects the wall's own verdict onto them: the verdict plugin-security records on the operation (`OperationContext.tenantLayer0Verdict`) where its wall ran, and otherwise the same function over the engine's own facts (the posture in force, the context's organization and membership set). The function, `computeTenantLayer0Verdict` with its input type `TenantLayer0Input`, moves to `@objectstack/core` (new export); `@objectstack/plugin-security`'s `tenant-layer.ts` re-exports it and its Layer 0 is byte-identical. +- **FROM → TO, per posture, for a read, count, aggregate, update or delete on an object with an organization column:** + + | posture / caller | driver scope before | driver scope now | + |:--|:--|:--| + | `single`, any caller naming an organization | that organization (plus organization-less rows) | **none** | + | `isolated`, member | the active organization | unchanged | + | `group`, member | the membership set | unchanged | + | walled, exempt `PLATFORM_ADMIN` on a posture-permitting object, or the verified platform owner's read | the active organization | **none** — what Layer 0 already admitted | + | walled, system context naming an organization | that organization | unchanged | + +- **Not changed.** Inserts and upserts keep the active organization as `tenantId` in every posture: the row is stamped with it, its counter scopes by it, and an upsert that would merge into another organization's row is still refused. Both `orWhereNull` compatibility arms in `SqlDriver.applyTenantScope` stay (their retirement is a later stage of ADR-0131 D13). +- **Who notices.** A `single` deployment that holds more than one organization was kept apart by the driver only by accident; postures now differ only in enforcement (ADR-0131 D11), and `single` enforces no wall. To keep organizations apart, run the `isolated` or `group` posture. Code that relied on a context organization to narrow a read on `single` names the organization in its own `where`. +- **Migration.** ADR-0087 semantic entry `driver-read-scope-follows-tenant-wall`. A deployment's `platformGlobalObjects` carve-out already reaches the driver through the column plan (ADR-0131 D7): a declared object carries no organization column, so neither wall has anything to scope. diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index f7d119734d3..b8a1553d683 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -73,6 +73,14 @@ export { type RefusedEntitlementKey, } from './deployment-org-scoping-entitlement.js'; +// [ADR-0131 D8] The ONE tenant read predicate — the Layer 0 verdict that +// plugin-security projects onto its `where` wall and the engine projects onto +// the driver's native scope (`DriverOptions.tenantId` / `tenantIds`). +export { + computeTenantLayer0Verdict, + type TenantLayer0Input, +} from './tenant-layer0-verdict.js'; + // `PluginConfigValidator` / `createPluginConfigValidator` were RETIRED here on // 2026-08-27 (#11982, ADR-0049 enforce-or-remove; recorded in ADR-0025 §3.7). // The kernel never received a plugin's config to validate — factories close diff --git a/packages/core/src/security/tenant-layer0-verdict.ts b/packages/core/src/security/tenant-layer0-verdict.ts new file mode 100644 index 00000000000..3a70d5329b3 --- /dev/null +++ b/packages/core/src/security/tenant-layer0-verdict.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { + postureEnforcesWall, + postureUsesUnionScope, + type TenancyPosture, + type TenantLayer0Verdict, +} from '@objectstack/spec/security'; + +/** + * ── The ONE tenant read predicate (ADR-0131 D8 「一道谓词,算一次」) ────────── + * + * What the organization wall decides for one operation, computed by ONE + * function for both of its enforcers: + * + * - **Layer 0** (`plugin-security`'s `tenant-layer.ts`, ADR-0095 D1) projects + * the verdict onto the `where` predicate it AND-composes first, and records + * it on the operation (`OperationContext.tenantLayer0Verdict`, #15813); + * - **the engine** (`@objectstack/objectql`, `buildDriverOptions`) projects the + * same verdict onto `DriverOptions.tenantId` / `tenantIds`, the driver's + * native read scope — the recorded verdict where the wall ran, this function + * over the engine's own facts where it did not (a system context, a by-id + * write, a composition with no security layer). + * + * Before this module the engine derived the driver's scope on its own, from + * the active organization alone: a scope under `single`, where the wall is + * inert, and a scope for the exempt platform admin, whom the wall lets + * through — two walls, two answers. Here, so `@objectstack/objectql` and + * `@objectstack/plugin-security` (both already depend on this package) read + * one decision rather than two copies of it. + */ + +/** Inputs the Layer 0 decision needs — all cheap, already-loaded facts. */ +export interface TenantLayer0Input { + /** + * [ADR-0105 D1] The tenancy posture actually IN FORCE (`tenancy.posture`) — + * the single fact that decides what this layer enforces: + * + * - `single` → inert (no wall) + * - `group` → `organization_id IN accessibleOrgIds` (union / MOAC) + * - `isolated` → `organization_id = organizationId` (the hard wall) + * + * A degraded deployment (a wall requested but not enforceable) already + * resolves to `single` at the service, so this layer never sees a posture it + * cannot honour. + */ + tenancyPosture: TenancyPosture; + /** The resolved authz context's active organization id (`ExecutionContext.tenantId`). */ + organizationId?: string; + /** + * [ADR-0105 D2] The caller's org access set + * (`ExecutionContext.accessible_org_ids`) — every organization they hold a + * currently-valid membership in. Read ONLY under the `group` posture, where + * it is the wall. An empty/absent set there denies (fail closed): a principal + * with no membership has no group data reach. + */ + accessibleOrgIds?: readonly string[]; + /** + * Whether the object carries an `organization_id` column. `undefined` = the + * schema/field-set could not be resolved yet (boot); treated as "assume tenant + * object" so a not-yet-registered object still fails safe toward isolation + * (parity with the pre-extraction path, which kept the wildcard tenant policy + * when the field set was unavailable). + */ + objectHasOrgIdField?: boolean; + /** + * Whether the object opted out of tenancy (`tenancy.enabled === false` or + * `systemFields.tenant === false`) — a platform-global object. Such objects + * are NOT tenant objects; Layer 0 contributes nothing. + */ + tenancyDisabled: boolean; + /** + * Whether the object's POSTURE permits a platform admin to cross the tenant + * wall — `private`, platform-global (`tenancy.enabled:false`), or + * better-auth-managed (exactly the ADR-0066 ① gate). Business (public) tenant + * objects do NOT permit it, so a platform admin stays org-scoped on them. + */ + posturePermitsCrossTenant: boolean; + /** + * TRUE-platform-admin evidence (ADR-0095 D3 `PLATFORM_ADMIN` rung) for THIS + * operation side. This is the ONLY gate that lets a caller cross the tenant + * wall, so it MUST distinguish a PLATFORM operator from a TENANT `org_admin`. + * + * It is NOT merely the superuser bit (`viewAllRecords`/`modifyAllRecords`): + * `organization_admin` holds that bit via its `'*'` wildcard grant + * (`default-permission-sets.ts`), so gating the exemption on the superuser bit + * alone let an org admin cross the wall on a `private` (or platform-global / + * better-auth) TENANT object — the D1 "B2 posture stand-in" gap this field's + * former semantics carried. The caller now requires the superuser bit AND a + * platform-EXCLUSIVE capability (`manage_metadata`/`manage_platform_settings`/ + * `studio.access`/`manage_users` — the caps `admin_full_access` carries and + * `organization_admin` deliberately withholds), i.e. the `PLATFORM_ADMIN` + * posture. It is NEVER the better-auth role. A caller holding no permission + * sets (the engine, over its own facts) passes `false`. + */ + isPlatformAdmin: boolean; +} + +/** The two verdicts that carry no organization — shared, frozen, safe to hand out. */ +const LAYER0_NONE: TenantLayer0Verdict = Object.freeze({ kind: 'none' as const }); +const LAYER0_DENY: TenantLayer0Verdict = Object.freeze({ kind: 'deny' as const }); + +/** + * [#15813 / ADR-0131 D8] Compute the Layer 0 VERDICT — what the wall decides + * for this operation, as a value another package can read without re-deriving + * it (`TenantLayer0VerdictSchema`, `@objectstack/spec/security`). + * `computeTenantLayer0Filter` (plugin-security) is this verdict PROJECTED onto + * a `where` predicate; the engine's driver scope is the same verdict projected + * onto `DriverOptions`. Neither can disagree with the other, because both are + * derived from this verdict, never computed beside it. + * + * The decision ladder is the wall's own, in the wall's own order: every exit + * that answers `null` as a filter answers `none` here, every fail-closed exit + * answers `deny`, and the two walled shapes answer the organization(s) they + * name. The `group` set is DEDUPLICATED here (`accessible_org_ids` is a + * membership list; a repeated membership names one organization once), so a + * reader may test `organizationIds.length === 1` and read no further. + * + * - `none` → the wall contributes nothing (`single` posture; non-tenant object; + * or an exempt platform admin). + * - `organization` → the `isolated` wall, `organization_id = `. + * - `organizations` → the `group` union wall, `organization_id IN ` + * (ADR-0105 D2). + * - `deny` → a walled posture on a tenant object, but the context carries no + * organization scope to enforce with (no active org under `isolated`, empty + * access set under `group`) → fail closed (zero rows / write denied). + */ +export function computeTenantLayer0Verdict(input: TenantLayer0Input): TenantLayer0Verdict { + // `single` posture (incl. a degraded deployment, which resolves to `single`) + // → parity with today's policy stripping. + if (!postureEnforcesWall(input.tenancyPosture)) return LAYER0_NONE; + + // Not a tenant object: platform-global (tenancy disabled) or simply carries no + // `organization_id` column (e.g. better-auth identity tables like `sys_user`). + // Layer 0 contributes nothing — the object's own business RLS (Layer 1, e.g. + // `_self` carve-outs) is its only scoping. + if (input.tenancyDisabled) return LAYER0_NONE; + if (input.objectHasOrgIdField === false) return LAYER0_NONE; + + // Exemption is a Layer 0 rule (W2 fix): only a TRUE PLATFORM_ADMIN caller on an + // object whose posture permits it crosses the wall — NOT a tenant `org_admin` + // that merely holds the superuser bit (Finding 2 / #2937). Layer 1's superuser + // bypass no longer implies Layer 0's. + if (input.isPlatformAdmin && input.posturePermitsCrossTenant) return LAYER0_NONE; + + // [ADR-0105 D2] `group`: union access over the caller's memberships (MOAC). + // The ACTIVE organization no longer bounds reads here — membership does — so + // a group-HQ analyst sees every plant they belong to on one screen without + // context switching, and a plant admin still sees only their own plants. + // An empty access set is the fail-closed case: no membership, no reach. + if (postureUsesUnionScope(input.tenancyPosture)) { + const orgIds = input.accessibleOrgIds; + if (!orgIds || orgIds.length === 0) return LAYER0_DENY; + return { kind: 'organizations', organizationIds: [...new Set(orgIds)] }; + } + + // `isolated`: the hard wall. Missing active org → fail closed. + if (!input.organizationId) return LAYER0_DENY; + return { kind: 'organization', organizationId: input.organizationId }; +} diff --git a/packages/drivers/driver-memory/src/memory-tenancy-guard.ts b/packages/drivers/driver-memory/src/memory-tenancy-guard.ts index 7b008c1f12b..200b90ac10e 100644 --- a/packages/drivers/driver-memory/src/memory-tenancy-guard.ts +++ b/packages/drivers/driver-memory/src/memory-tenancy-guard.ts @@ -46,21 +46,25 @@ * * Seams 1 and 2 read the two STATIC signals: the deployment posture and the * object's own metadata. The engine's decision to scope is neither. It is - * `Engine.buildDriverOptions`: - * - * ```ts - * const hasTenant = - * execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && !isFederated; - * ``` - * - * — the engine scopes unless the object opts OUT, while seam 2 refuses only an - * explicit opt-IN. An object that OMITS the `tenancy` block falls between them: - * the engine scopes it, seam 2 never sees it, and seam 1 passes because the - * posture really is `single`. A `single` posture constrains the WALL, not the - * number of organizations, so rows still carry whichever `organization_id` they - * were written with — and this driver used to discard the scope and answer with - * EVERY organization's rows. That is the silent non-isolation this whole guard - * exists to remove, reappearing on the DEFAULT case. + * `Engine.buildDriverOptions`, and it is made per CALL: a read, update or + * delete is scoped by the organization wall's verdict (ADR-0131 D8 — none + * under `single`), and an insert or upsert carries the caller's active + * organization as its write target in EVERY posture, unless the object opts + * OUT of tenancy. + * + * Seam 2 refuses only an explicit opt-IN. An object that OMITS the `tenancy` + * block falls between them: the engine hands it the write target, seam 2 never + * sees it, and seam 1 passes because the posture really is `single`. A `single` + * posture constrains the WALL, not the number of organizations, so rows still + * carry whichever `organization_id` they were written with — and this driver + * used to discard the scope it was handed and answer as if it had applied it. + * That is the silent non-isolation this whole guard exists to remove, + * reappearing on the DEFAULT case. + * + * (Before ADR-0131 D8 the engine also derived a READ scope under `single` from + * the context's organization alone; it no longer does, because the wall it would + * mirror is inert there. The seam still judges whatever scope arrives, so it is + * unchanged by that: what no longer arrives is no longer refused.) * * Why the gap cannot be closed at seam 1 or 2 — measured, not assumed: * `execCtx?.tenantId !== undefined` is a property of the REQUEST, and at @@ -183,8 +187,9 @@ export interface TenancyAwareSchema { * (twelve seeded by the app, one minted by the platform for the admin), with * rows carrying whichever `organization_id` they were written with. And the * omission it describes is not the absence of a tenant signal but its default - * PRESENCE — `Engine.buildDriverOptions` scopes an object unless it opts out, - * so "omits the block" is precisely the SCOPED case, not the exempt one. + * PRESENCE — `Engine.buildDriverOptions` hands an object a write target (and, + * under a wall, a read scope) unless it opts out, so "omits the block" is + * precisely the SCOPED case, not the exempt one. * * The sentence is recorded rather than deleted because it is what justified * this predicate being an opt-IN test, and anyone re-reading that decision needs diff --git a/packages/objectql/src/engine-aggregate-search.test.ts b/packages/objectql/src/engine-aggregate-search.test.ts index 0f2e5418298..9331ef3f94e 100644 --- a/packages/objectql/src/engine-aggregate-search.test.ts +++ b/packages/objectql/src/engine-aggregate-search.test.ts @@ -145,6 +145,10 @@ const TIERS: readonly Tier[] = ['native', 'in-memory']; async function makeEngine(tier: Tier) { const { driver, reads } = makeDriver(tier === 'native'); const engine = new ObjectQL(); + // [ADR-0131 D8] The `context` proof is that the caller's organization + // reaches the driver as its read scope — which it does only under a wall; + // under `single` (the env default) the scope is none. + engine.setTenancyPostureProvider(() => 'isolated'); engine.registerDriver(driver, true); await engine.init(); (engine.registry as any).registerObject(account); diff --git a/packages/objectql/src/engine-driver-read-scope.test.ts b/packages/objectql/src/engine-driver-read-scope.test.ts new file mode 100644 index 00000000000..33c80b6572a --- /dev/null +++ b/packages/objectql/src/engine-driver-read-scope.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D8] One predicate, computed once: the driver's native tenant read + * scope (`DriverOptions.tenantId` / `tenantIds`, which `SqlDriver.applyTenantScope` + * compiles) is the Layer 0 verdict projected onto `DriverOptions` — never a + * second derivation beside the wall. + * + * Before this, `buildDriverOptions` derived the driver's scope on its own from + * the active organization alone, so the two walls answered differently in two + * places: under `single` the driver narrowed a read Layer 0 leaves open, and + * for an exempt `PLATFORM_ADMIN` (or the verified platform owner's read) the + * driver narrowed what Layer 0 had admitted. + * + * The seam under test is `DriverOptions`, as in + * `engine-external-tenant-scope.test.ts`: `@objectstack/objectql` cannot import + * a driver, and "no `tenantId`" is exactly "no organization predicate" in the + * SQL driver. The recorded verdict is planted the way plugin-security records + * it — on the operation context, by a middleware, before the core handler runs. + * + * Both directions are pinned: what widens (the `single` read, a recorded + * `none`) and what must not move (the walled read, the system read under a + * wall, the write target in every posture, a recorded verdict that does not + * parse). + */ + +import { describe, it, expect } from 'vitest'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { ObjectQL } from './engine.js'; + +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; +const MEMBER: ExecutionContext = { userId: 'u_member', tenantId: ORG_A }; +const GROUP_MEMBER = { ...MEMBER, accessible_org_ids: [ORG_A, ORG_B] } as ExecutionContext; + +interface ObservedCall { + method: string; + options: Record | undefined; +} + +function makeDriver(observed: ObservedCall[]) { + const record = (method: string, options: any) => observed.push({ method, options }); + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(_o: string, _ast: any, options: any) { record('find', options); return []; }, + async findOne(_o: string, _ast: any, options: any) { record('findOne', options); return null; }, + async count(_o: string, _ast: any, options: any) { record('count', options); return 0; }, + async create(_o: string, data: any, options: any) { record('create', options); return { id: 'r_1', ...data }; }, + async update(_o: string, id: string, data: any, options: any) { record('update', options); return { id, ...data }; }, + async updateMany(_o: string, _ast: any, _data: any, options: any) { record('updateMany', options); return 0; }, + async delete() { return true; }, + async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, + async syncSchema() {}, + }; + return driver; +} + +/** Tenant-scoped by default: the registry injects `organization_id`. */ +const TASK = { name: 'ds_task', fields: { name: { type: 'text' } } } as any; + +async function makeEngine(opts: { posture: string; recordedVerdict?: unknown }) { + const observed: ObservedCall[] = []; + const engine = new ObjectQL(); + engine.registerDriver(makeDriver(observed), true); + await engine.init(); + engine.registry.registerObject(TASK, 'com.example.driver-read-scope'); + engine.setTenancyPostureProvider(() => opts.posture); + if (opts.recordedVerdict !== undefined) { + // Where plugin-security records what its wall decided (#15813). + engine.registerMiddleware(async (opCtx, next) => { + (opCtx as any).tenantLayer0Verdict = opts.recordedVerdict; + await next(); + }); + } + const last = (method: string) => observed.filter((c) => c.method === method).at(-1)?.options; + return { engine, observed, last }; +} + +describe('[ADR-0131 D8] the driver read scope over the engine\'s own facts (no wall recorded)', () => { + it('`single`: a member\'s read carries no tenant scope — the wall is inert, so the driver is too', async () => { + const { engine, last } = await makeEngine({ posture: 'single' }); + await engine.find('ds_task', {}, { context: MEMBER }); + await engine.count('ds_task', {}, { context: MEMBER }); + for (const method of ['find', 'count']) { + expect(last(method)?.tenantId, method).toBeUndefined(); + expect(last(method)?.tenantIds, method).toBeUndefined(); + } + }); + + it('`single`: an insert still carries the active organization — the write target, not a read scope', async () => { + const { engine, last } = await makeEngine({ posture: 'single' }); + await engine.insert('ds_task', { name: 'A' }, { context: MEMBER }); + expect(last('create')?.tenantId).toBe(ORG_A); + }); + + it('`isolated`: a member\'s read is scoped to the active organization', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated' }); + await engine.find('ds_task', {}, { context: MEMBER }); + expect(last('find')).toMatchObject({ tenantId: ORG_A }); + expect(last('find')?.tenantIds).toBeUndefined(); + }); + + it('`isolated`: a SYSTEM read naming an organization stays scoped to it — the engine grants no exemption', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated' }); + await engine.find('ds_task', {}, { context: { isSystem: true, tenantId: ORG_A } as ExecutionContext }); + expect(last('find')).toMatchObject({ tenantId: ORG_A }); + }); + + it('`isolated`: a SYSTEM read naming no organization is unscoped, as before', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated' }); + await engine.find('ds_task', {}, { context: { isSystem: true } as ExecutionContext }); + expect(last('find')?.tenantId).toBeUndefined(); + }); + + it('`group`: the read reaches the membership union, keyed on the active organization', async () => { + const { engine, last } = await makeEngine({ posture: 'group' }); + await engine.find('ds_task', {}, { context: GROUP_MEMBER }); + expect(last('find')).toMatchObject({ tenantId: ORG_A, tenantIds: [ORG_A, ORG_B] }); + }); + + it('`group` with no membership set: the active organization, never wider', async () => { + const { engine, last } = await makeEngine({ posture: 'group' }); + await engine.find('ds_task', {}, { context: MEMBER }); + expect(last('find')).toMatchObject({ tenantId: ORG_A }); + expect(last('find')?.tenantIds).toBeUndefined(); + }); +}); + +describe('[ADR-0131 D8] the driver read scope is the verdict the wall RECORDED on the operation', () => { + it('a recorded `none` (an exempt platform admin, the verified owner\'s read) reaches what Layer 0 admitted', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated', recordedVerdict: { kind: 'none' } }); + await engine.find('ds_task', {}, { context: MEMBER }); + await engine.update('ds_task', { name: 'x' }, { where: { name: 'y' }, multi: true, context: MEMBER } as any); + expect(last('find')?.tenantId).toBeUndefined(); + expect(last('updateMany')?.tenantId).toBeUndefined(); + }); + + it('a recorded `organizations` set is the driver\'s union, deduplicated by the wall', async () => { + const { engine, last } = await makeEngine({ + posture: 'group', + recordedVerdict: { kind: 'organizations', organizationIds: [ORG_B] }, + }); + await engine.find('ds_task', {}, { context: GROUP_MEMBER }); + // The recorded verdict (e.g. a delegator intersection) wins over the + // caller's own membership list. + expect(last('find')).toMatchObject({ tenantId: ORG_A, tenantIds: [ORG_B] }); + }); + + it('a recorded `organization` is the driver\'s equality', async () => { + const { engine, last } = await makeEngine({ + posture: 'isolated', + recordedVerdict: { kind: 'organization', organizationId: ORG_B }, + }); + await engine.find('ds_task', {}, { context: MEMBER }); + expect(last('find')).toMatchObject({ tenantId: ORG_B }); + }); + + it('a recorded `deny` keeps the active organization — the narrowest scope a driver can express', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated', recordedVerdict: { kind: 'deny' } }); + await engine.find('ds_task', {}, { context: MEMBER }); + expect(last('find')).toMatchObject({ tenantId: ORG_A }); + }); + + it('a recorded value that does not parse reads as absent — the engine decides, with no exemption', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated', recordedVerdict: { kind: 'everyone' } }); + await engine.find('ds_task', {}, { context: MEMBER }); + expect(last('find')).toMatchObject({ tenantId: ORG_A }); + }); + + it('an insert ignores the recorded verdict: the active organization is where the row lands', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated', recordedVerdict: { kind: 'none' } }); + await engine.insert('ds_task', { name: 'A' }, { context: MEMBER }); + expect(last('create')?.tenantId).toBe(ORG_A); + }); +}); diff --git a/packages/objectql/src/engine-external-tenant-scope.test.ts b/packages/objectql/src/engine-external-tenant-scope.test.ts index a79819a0eb1..55314bc568f 100644 --- a/packages/objectql/src/engine-external-tenant-scope.test.ts +++ b/packages/objectql/src/engine-external-tenant-scope.test.ts @@ -209,9 +209,9 @@ describe('#7738 — a federated read carries NO org-scope predicate', () => { // how a leak ships green, so these cases are the reason this file exists. describe('#7738 non-regression — an ORDINARY object is still org-scoped', () => { it.each(READ_DOORS)( - '$name: DriverOptions for a managed object still carry tenantId for a non-system caller', + '$name: DriverOptions for a managed object still carry tenantId for a non-system caller (isolated)', async ({ driverMethod, run }) => { - const { engine, observed } = await makeEngine(); + const { engine, observed } = await makeEngine({ posture: 'isolated' }); await run(engine, 'showcase_account', MEMBER); const call = observed.find((c) => c.object === 'showcase_account' && c.method === driverMethod); @@ -220,6 +220,22 @@ describe('#7738 non-regression — an ORDINARY object is still org-scoped', () = }, ); + // [ADR-0131 D8] The driver's read scope is the wall's verdict, and under + // `single` the wall is inert — so the managed object's read carries no + // tenant scope either. The write target is a different knob (below). + it.each(READ_DOORS)( + '$name: under `single` the managed object read carries no tenant scope (one predicate)', + async ({ driverMethod, run }) => { + const { engine, observed } = await makeEngine({ posture: 'single' }); + await run(engine, 'showcase_account', MEMBER); + + const call = observed.find((c) => c.object === 'showcase_account' && c.method === driverMethod); + expect(call, `driver.${driverMethod} was never reached`).toBeDefined(); + expect(call!.options?.tenantId).toBeUndefined(); + expect(call!.options?.tenantIds).toBeUndefined(); + }, + ); + it('still threads the `group`-posture tenantIds union for a managed object', async () => { const { engine, observed } = await makeEngine({ posture: 'group' }); await engine.find( diff --git a/packages/objectql/src/engine-findone-contract.test.ts b/packages/objectql/src/engine-findone-contract.test.ts index 18acaaede3c..012e6998002 100644 --- a/packages/objectql/src/engine-findone-contract.test.ts +++ b/packages/objectql/src/engine-findone-contract.test.ts @@ -149,6 +149,11 @@ describe('findOne executes what it declares and refuses an empty predicate (#441 two = await engine.insert('crm_account', { name: 'Two', industry: 'Metals', annual_revenue: 200 }); three = await engine.insert('crm_account', { name: 'Three', industry: 'Mining', annual_revenue: 200 }); reads.length = 0; // drop insert-path reads; the pins below own this log + // [ADR-0131 D8] The `context` proof is that the caller's organization + // reaches the driver as its read scope — which it does only under a + // wall; under `single` (the env default) the scope is none. Set after + // the seed: a walled posture refuses an organization-less insert (D9). + engine.setTenancyPostureProvider(() => 'isolated'); }); const lastRead = () => reads.at(-1)!; diff --git a/packages/objectql/src/engine-reference-tenant-scope.test.ts b/packages/objectql/src/engine-reference-tenant-scope.test.ts index d79544a2dae..5edc9d13cbf 100644 --- a/packages/objectql/src/engine-reference-tenant-scope.test.ts +++ b/packages/objectql/src/engine-reference-tenant-scope.test.ts @@ -221,6 +221,11 @@ describe('[#19808] the lookup existence probe is scoped to the caller\'s organiz beforeEach(async () => { observed = []; engine = new ObjectQL(); + // [ADR-0131 D8] An organization wall is what this suite is about: the + // driver scope is the wall's verdict, and under `single` (the env + // default) there is no wall to scope by. Cases that test `group` + // re-point the provider. + engine.setTenancyPostureProvider(() => 'isolated'); const stub = makeTenantScopedDriver(observed); storeFor = stub.storeFor; engine.registerDriver(stub.driver, true); @@ -506,6 +511,11 @@ describe('[#19837] the master-detail parent binding is scoped to the caller\'s o beforeEach(async () => { observed = []; engine = new ObjectQL(); + // [ADR-0131 D8] An organization wall is what this suite is about: the + // driver scope is the wall's verdict, and under `single` (the env + // default) there is no wall to scope by. Cases that test `group` + // re-point the provider. + engine.setTenancyPostureProvider(() => 'isolated'); const stub = makeTenantScopedDriver(observed); storeFor = stub.storeFor; engine.registerDriver(stub.driver, true); diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index 8654d4c0a65..c4cc39c07ef 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -727,8 +727,10 @@ describe('ObjectQL Engine', () => { // the context (e.g. an intended isSystem bypass), a real source of // "empty result once scoping was added" bugs. Reads now ALSO accept the // trailing options.context, aligning with insert/update. `tenantId` is a - // convenient observable: buildDriverOptions forwards it to the driver. + // convenient observable: buildDriverOptions forwards it to the driver + // — as the read scope, so only under a wall (ADR-0131 D8). beforeEach(async () => { + (engine as any).setTenancyPostureProvider(() => 'isolated'); engine.registerDriver(mockDriver, true); await engine.init(); vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} }); @@ -809,10 +811,24 @@ describe('ObjectQL Engine', () => { 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', {}, { context: groupCtx }); - expect(lastFindOpts()?.tenantIds).toBeUndefined(); - expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a' }); + it('no provider (no enforcement layer) → the env posture decides: `single` scopes nothing, a wall is equality', async () => { + // [ADR-0131 D8] With no `tenancy` service the engine reads the + // posture the operator ASKED for (`resolveEnginePosture`), and + // derives the same verdict Layer 0 would: nothing under `single`. + const prior = process.env.OS_TENANCY_POSTURE; + try { + delete process.env.OS_TENANCY_POSTURE; + await engine.find('task', {}, { context: groupCtx }); + expect(lastFindOpts()?.tenantId).toBeUndefined(); + expect(lastFindOpts()?.tenantIds).toBeUndefined(); + process.env.OS_TENANCY_POSTURE = 'isolated'; + await engine.find('task', {}, { context: groupCtx }); + expect(lastFindOpts()?.tenantIds).toBeUndefined(); + expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a' }); + } finally { + if (prior === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = prior; + } }); it('isolated posture → equality only (the union is a group-only widening)', async () => { @@ -852,12 +868,14 @@ describe('ObjectQL Engine', () => { }); it('still stamps tenantId for objects without a tenancy declaration', async () => { + (engine as any).setTenancyPostureProvider(() => 'isolated'); vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} } as any); await engine.find('task', {}, { context: { tenantId: 'org_a' } as any }); expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a' }); }); it('still stamps tenantId for an explicit tenancy.enabled:true object', async () => { + (engine as any).setTenancyPostureProvider(() => 'isolated'); vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', tenancy: { enabled: true }, fields: {}, } as any); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 2cd58569b3c..8a8d2d505fc 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -143,6 +143,9 @@ import { // keys, before file-field hydration forwards it to the download door's // field-owned verdict — see `readParentDerivedFiles`. withoutOperationPrivateKeys, + // [ADR-0131 D8] The ONE tenant read predicate, shared with plugin-security's + // Layer 0 — see {@link buildDriverOptions}. + computeTenantLayer0Verdict, } from '@objectstack/core'; import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js'; import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js'; @@ -4098,6 +4101,16 @@ function eventOrganizationValue(value: unknown): string | undefined { return undefined; } +/** + * [ADR-0105 D2] The context's organization access set + * (`ExecutionContext.accessible_org_ids`), stringified — `undefined` when + * absent or empty, which the verdict reads as "no membership" under `group`. + */ +function accessibleOrganizationsOf(execCtx: ExecutionContext | undefined): string[] | undefined { + const set = (execCtx as { accessible_org_ids?: unknown } | undefined)?.accessible_org_ids; + return Array.isArray(set) && set.length > 0 ? set.map(String) : undefined; +} + /** * `BulkDataEvent.organizationId` — the ONE organization the tenant wall named * for a predicate write, or `undefined` when the producer cannot assert one @@ -6762,14 +6775,48 @@ export class ObjectQL implements IObjectQLEngine { /** * Build the DriverOptions blob passed to every IDataDriver call. * - * 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 the two object - * postures below. 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, under both exemptions. + * # The tenant read scope is the wall's verdict (ADR-0131 D8) + * + * The driver's native tenant scope — `tenantId` (equality) and `tenantIds` + * (the `group` union), which `applyTenantScope` compiles — is the Layer 0 + * verdict projected onto `DriverOptions`, never a second derivation beside + * it. One function decides ({@link computeTenantLayer0Verdict}, + * `@objectstack/core`), and the engine reads it from one of two places: + * + * - **the verdict the wall RECORDED on this operation** + * ({@link OperationContext.tenantLayer0Verdict}, passed in as + * `scope.recordedVerdict`) — present where the security layer composed its + * wall onto the predicate: a read, or a predicate write. It already carries + * what only that layer can know (the exempt `PLATFORM_ADMIN` on a + * posture-permitting object, the verified platform owner's read, the + * delegator's intersection), so the driver reaches exactly the rows Layer 0 + * admits; + * - **otherwise, the same function over the engine's own facts** — the + * posture in force ({@link resolveEnginePosture}), the context's active + * organization and access set, the object's tenancy — with no admin + * exemption, since the engine holds no permission sets. That covers a + * system context, a by-id write, and a composition with no security layer + * mounted. + * + * Projected: `none` (the `single` posture; a non-tenant, tenancy-disabled or + * federated object; an exempt platform admin) → no scope; `organization` → + * `tenantId`; `organizations` → `tenantIds`, with the active organization as + * `tenantId` (the driver keys its scope on `tenantId` being present); `deny` + * → the active organization if there is one — the narrowest scope a driver + * can express, and where the wall ran its deny sentinel is already in the + * predicate. Under `single` the driver therefore no longer narrows a read to + * the active organization: the posture is one tenant, and isolation inside it + * was never the driver's to add (ADR-0131 D11). + * + * # The write target is not a read scope + * + * An insert (`scope.writeTarget`) keeps the active organization as + * `tenantId` in EVERY posture — `injectTenantOnInsert` stamps the row with + * it, the counter scopes by it, and an upsert's identity probe refuses a + * merge into another organization's row by it — and the `group` membership + * set beside it, unchanged. + * + * # Withheld for two object postures * * 1. **`tenancy.enabled: false`** (ADR-0066 platform-global posture, e.g. * `sys_license`): stamping the caller's active-org tenantId there would @@ -6800,11 +6847,18 @@ export class ObjectQL implements IObjectQLEngine { * the remote and to the layers above (RBAC/RLS, the datasource binding), * not to a predicate the platform guesses onto someone else's table. * - * System / isSystem callers may still cross tenants by clearing - * `tenantId` themselves on the resulting object; this helper does not - * mask the system path. + * 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, under every verdict. 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(object: string, execCtx?: ExecutionContext, base?: any): any { + private buildDriverOptions( + object: string, + execCtx?: ExecutionContext, + base?: any, + scope: { writeTarget?: boolean; recordedVerdict?: unknown } = {}, + ): 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. [#22855] A handle @@ -6830,10 +6884,13 @@ export class ObjectQL implements IObjectQLEngine { // federated object by — one spelling of "this schema is the remote's", // not a second reading of it. const isFederated = isFederatedObject(objectSchema); - const hasTenant = - execCtx?.tenantId !== undefined && - !isTenancyDisabled(objectSchema) && - !isFederated; + const tenantObject = !isTenancyDisabled(objectSchema) && !isFederated; + const tenantScope = tenantObject + ? scope.writeTarget + ? this.driverWriteTarget(execCtx) + : this.driverReadScope(execCtx, scope.recordedVerdict) + : undefined; + const hasTenant = tenantScope !== undefined; const hasTz = execCtx?.timezone !== undefined; const isSystem = execCtx?.isSystem === true; // [#13491] The object's tenancy classification — the ONE reading both the @@ -6851,21 +6908,10 @@ export class ObjectQL implements IObjectQLEngine { opts.transaction = tx; } if (hasTenant && opts.tenantId === undefined) { - opts.tenantId = execCtx!.tenantId; + opts.tenantId = tenantScope.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 (hasTenant && opts.tenantIds === undefined && tenantScope.tenantIds !== undefined) { + opts.tenantIds = tenantScope.tenantIds; } if (hasTz && opts.timezone === undefined) { // Thread the business timezone so date-dependent driver generation @@ -6904,6 +6950,69 @@ export class ObjectQL implements IObjectQLEngine { return opts; } + /** + * [ADR-0131 D8] The driver's native read scope for one operation — the Layer + * 0 verdict projected onto `DriverOptions` (see {@link buildDriverOptions}). + * `undefined` is "no scope"; the caller has already excluded a + * tenancy-disabled or federated object. + * + * A recorded verdict that does not parse as `TenantLayer0VerdictSchema` + * reads as absent — never as an organization — and the engine then decides + * over its own facts, which carry no admin exemption: fail toward the wall. + */ + private driverReadScope( + execCtx: ExecutionContext | undefined, + recordedVerdict: unknown, + ): { tenantId: string; tenantIds?: string[] } | undefined { + const activeOrganization = + execCtx?.tenantId !== undefined && execCtx.tenantId !== null && String(execCtx.tenantId) !== '' + ? String(execCtx.tenantId) + : undefined; + const recorded = recordedVerdict === undefined ? undefined : TenantLayer0VerdictSchema.safeParse(recordedVerdict); + const verdict = recorded?.success + ? recorded.data + : computeTenantLayer0Verdict({ + tenancyPosture: this.resolveEnginePosture(), + organizationId: activeOrganization, + accessibleOrgIds: accessibleOrganizationsOf(execCtx), + // Left to the driver, which resolves the tenant column from the + // table it actually built (`resolveTenantField`): an object with no + // such column is unscoped there whatever this says. `undefined` is + // the verdict's "assume a tenant object", as Layer 0 reads an + // unresolved field set. + objectHasOrgIdField: undefined, + tenancyDisabled: false, + // The engine holds no permission sets: no exemption is ITS to grant. + posturePermitsCrossTenant: false, + isPlatformAdmin: false, + }); + switch (verdict.kind) { + case 'none': + return undefined; + case 'organization': + return { tenantId: verdict.organizationId }; + case 'organizations': + return { tenantId: activeOrganization ?? verdict.organizationIds[0], tenantIds: [...verdict.organizationIds] }; + case 'deny': + return activeOrganization !== undefined ? { tenantId: activeOrganization } : undefined; + } + } + + /** + * The write target of an insert or upsert: the active organization, in every + * posture, with the `group` membership set beside it — the scope the + * driver's insert readback and upsert identity probe narrow by (ADR-0105 D5: + * the active organization is where a write lands). + */ + private driverWriteTarget( + execCtx: ExecutionContext | undefined, + ): { tenantId: string; tenantIds?: string[] } | undefined { + if (execCtx?.tenantId === undefined) return undefined; + const tenantIds = + this.resolveEnginePosture() === 'group' ? accessibleOrganizationsOf(execCtx) : undefined; + return { tenantId: execCtx.tenantId as string, ...(tenantIds ? { tenantIds: [...tenantIds] } : {}) }; + } + /** * [#8844] The tenancy posture IN FORCE for this engine, by the same two-step * `app-plugin.organizationWallActive` uses and for the same reason: the @@ -14050,7 +14159,7 @@ export class ObjectQL implements IObjectQLEngine { ql: this }; await this.triggerHooks('beforeFind', hookContext); - hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any); + hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); // [ADR-0139 D2] The executor boundary: the AST is final here — after the // doors, the middleware chain and `beforeFind` — so every `ReadableIds` // leaf is composed (or refused) on it, immediately before the driver. @@ -14352,7 +14461,7 @@ export class ObjectQL implements IObjectQLEngine { ql: this }; await this.triggerHooks('beforeFind', hookContext); - hookContext.input.options = this.buildDriverOptions(objectName, opCtx.context, hookContext.input.options as any); + hookContext.input.options = this.buildDriverOptions(objectName, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); // [ADR-0139 D2] The executor boundary, as in `find`. const driverAst = await this.composeReadableIdsAtBoundary(objectName, 'findOne', hookContext.input.ast as QueryAST, opCtx.context); @@ -15830,6 +15939,9 @@ export class ObjectQL implements IObjectQLEngine { derivedOrganizationId !== undefined ? { ...(optionsBase && typeof optionsBase === 'object' ? optionsBase : {}), tenantId: derivedOrganizationId } : optionsBase, + // The active organization is where the row LANDS, in every posture — + // not a read scope (ADR-0131 D8 leaves the write target alone). + { writeTarget: true }, ); for (const rowCtx of rowHookContexts) { rowCtx.input.options = driverOptions; @@ -17018,7 +17130,7 @@ export class ObjectQL implements IObjectQLEngine { await dispatchHooksExplainingWithheldReadonly(readonlyHiddenFromHooks, 'beforeUpdate', () => this.dispatchUnscopedMultiWriteHooks('beforeUpdate', object, hookContext)); } - const preOpts = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any); + const preOpts = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); readPriorRows = async () => { if (!priorRowsRead) { priorRowsRead = true; @@ -17193,7 +17305,7 @@ export class ObjectQL implements IObjectQLEngine { // refuses before any driver call. reportDroppedFields(preComputedPayload, computedStripUpdate.rows[0] as Record | null | undefined, 'computed'); - hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any); + hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); try { let result; @@ -19541,7 +19653,7 @@ export class ObjectQL implements IObjectQLEngine { const declaredDeleteColumns = declaredColumnSet(deleteSchema); const readPreImage = async (targetId: unknown): Promise | null> => { const preAst: QueryAST = { object, where: { id: targetId }, limit: 1 }; - const preOpts = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any); + const preOpts = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); return withDeclaredColumnsOnly( (await driver.findOne(object, preAst, preOpts)) as Record | null, declaredDeleteColumns, @@ -19684,7 +19796,7 @@ export class ObjectQL implements IObjectQLEngine { const perRowBeforeHooks = this.hasHooksFor('beforeDelete', object); const perRowAfterHooks = this.hasHooksFor('afterDelete', object); if (perRowBeforeHooks || perRowAfterHooks) { - const preOpts = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any); + const preOpts = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); // [#21613] Each doomed row is its per-row `previous`: declared set. const doomed = rowsWithDeclaredColumnsOnly( (await driver.find(object, ast, preOpts) as Record[]) ?? [], @@ -19703,7 +19815,7 @@ export class ObjectQL implements IObjectQLEngine { } } - hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any); + hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); try { let result; @@ -19921,7 +20033,7 @@ export class ObjectQL implements IObjectQLEngine { const callerWhere = (opCtx.ast as QueryAST).where; await this.executeWithMiddleware(opCtx, async () => { - const countOpts = this.buildDriverOptions(object, opCtx.context); + const countOpts = this.buildDriverOptions(object, opCtx.context, undefined, { recordedVerdict: opCtx.tenantLayer0Verdict }); if (driver.count) { // [ADR-0139 D2] The executor boundary, as in `find` (`count` has no // before-hook): the AST is final once the chain has run. The @@ -20442,7 +20554,7 @@ export class ObjectQL implements IObjectQLEngine { // natively today; one that grows native support must advertise a // capability flag, at which point this post-filter becomes the // fallback tier — the dateGranularity two-tier pattern. - const aggregated = await drv.aggregate(object, ast, this.buildDriverOptions(object, opCtx.context)); + const aggregated = await drv.aggregate(object, ast, this.buildDriverOptions(object, opCtx.context, undefined, { recordedVerdict: opCtx.tenantLayer0Verdict })); return applyHaving(aggregated, ast.having, havingColumnClasses); } // In-memory fallback path: ask the driver for raw rows, then bucket + @@ -20486,7 +20598,7 @@ export class ObjectQL implements IObjectQLEngine { delete rowsAst.groupBy; delete rowsAst.aggregations; delete rowsAst.having; - const raw = await driver.find(object, rowsAst, this.buildDriverOptions(object, opCtx.context)); + const raw = await driver.find(object, rowsAst, this.buildDriverOptions(object, opCtx.context, undefined, { recordedVerdict: opCtx.tenantLayer0Verdict })); return applyHaving(applyInMemoryAggregation(raw, ast, tz, declaredFields), ast.having, havingColumnClasses); }); diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index 378d595c808..4c03c0c9ff0 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -58,16 +58,11 @@ * * ## Measured divergences * - * Two disagreements this table measured are findings of this card, reported - * and not fixed here. Their rows carry the finding's name and assert the + * A disagreement this table measured is a finding of this card, reported and + * not fixed here. Its rows carry the finding's name and assert the * disagreement itself, so they turn red the day either face moves, and the row * then joins the invariant: * - * - `NATIVE_SCOPING_UNDER_SINGLE` (explain's side): under `single` the engine - * still scopes a tenant object's read to the context's organization, which - * explain's tenant layer does not report. Its `readFilter` row measures it; - * its record row joined the invariant once the record story was gated on the - * asker's own read (#22792), which that scoping answers. * - `CLAIM_KEPT_UNDER_A_WALL` (enforcement's side): with `org-scoping` and no * `tenancy` service, admission reads no posture and never drops a removed * member's organization claim, while this plugin walls Layer 0 at @@ -873,18 +868,6 @@ function principalRows( return rows; } -/** - * Under `single` there is no tenant wall, yet the engine still scopes a tenant - * object's read to the context's organization (driver-native tenant scoping, - * any posture). Explain's tenant layer contributes nothing under `single`, so - * its `readFilter` reports the other organization's rows as served. Explain's - * side; a finding of this card, not fixed here. [#22792] The record verdict no - * longer carries it: whether an explanation may say a record exists is the - * asker's own by-id read, which that same native scoping answers, so the other - * organization's row is answered as an id no row carries. - */ -const NATIVE_SCOPING_UNDER_SINGLE = - 'under `single`, the engine scopes the read to the context organization; explain reports the other organization\'s row'; /** * With `org-scoping` and no `tenancy` service, admission hands the resolver no * posture, so a removed member's organization claim is never dropped, while @@ -963,15 +946,13 @@ const TABLE: Row[] = [ removed: { LEDGER: REFUSED_DENIED, PROBE: REFUSED_DENIED, NOTES: REFUSED_DENIED }, member: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, }), + // [ADR-0131 D8] The `single` rows joined the invariant: the driver's read + // scope is the wall's verdict, and under `single` that verdict is `none`, so + // the engine no longer narrows the read to the context organization that + // explain's tenant layer (correctly) never reported. ...principalRows('#20580 control · #20604 P3', { tenancy: 'single' }, '`single`', { removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, member: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, - }, { - // [#22792] `record:l_beta` joined the invariant: the asker's own read is - // scoped to the context organization the same way, so the record verdict - // is the missing record's, as the explained user's read answers. - removed: { 'readFilter:LEDGER': NATIVE_SCOPING_UNDER_SINGLE }, - member: { 'readFilter:LEDGER': NATIVE_SCOPING_UNDER_SINGLE }, }), // The posture-source asymmetry: the member's rows hold; the removed member's are enforcement's finding. ...principalRows('#20604 A4', { orgScopingOnly: true }, '`org-scoping` with no `tenancy` service', { diff --git a/packages/plugins/plugin-security/src/tenant-layer.ts b/packages/plugins/plugin-security/src/tenant-layer.ts index 8794bbbc995..2963caa49c1 100644 --- a/packages/plugins/plugin-security/src/tenant-layer.ts +++ b/packages/plugins/plugin-security/src/tenant-layer.ts @@ -1,11 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { - postureEnforcesWall, - postureUsesUnionScope, - type TenancyPosture, - type TenantLayer0Verdict, -} from '@objectstack/spec/security'; +import { computeTenantLayer0Verdict, type TenantLayer0Input } from '@objectstack/core'; +import type { TenantLayer0Verdict } from '@objectstack/spec/security'; import { RLS_DENY_FILTER } from './rls-compiler.js'; @@ -29,132 +25,17 @@ import { RLS_DENY_FILTER } from './rls-compiler.js'; * Layer-1 business policies and is out of scope for D1). */ -/** Inputs the Layer 0 decision needs — all cheap, already-loaded facts. */ -export interface TenantLayer0Input { - /** - * [ADR-0105 D1] The tenancy posture actually IN FORCE (`tenancy.posture`) — - * the single fact that decides what this layer enforces: - * - * - `single` → inert (no wall) - * - `group` → `organization_id IN accessibleOrgIds` (union / MOAC) - * - `isolated` → `organization_id = organizationId` (the hard wall) - * - * A degraded deployment (a wall requested but not enforceable) already - * resolves to `single` at the service, so this layer never sees a posture it - * cannot honour. - */ - tenancyPosture: TenancyPosture; - /** The resolved authz context's active organization id (`ExecutionContext.tenantId`). */ - organizationId?: string; - /** - * [ADR-0105 D2] The caller's org access set - * (`ExecutionContext.accessible_org_ids`) — every organization they hold a - * currently-valid membership in. Read ONLY under the `group` posture, where - * it is the wall. An empty/absent set there denies (fail closed): a principal - * with no membership has no group data reach. - */ - accessibleOrgIds?: readonly string[]; - /** - * Whether the object carries an `organization_id` column. `undefined` = the - * schema/field-set could not be resolved yet (boot); treated as "assume tenant - * object" so a not-yet-registered object still fails safe toward isolation - * (parity with the pre-extraction path, which kept the wildcard tenant policy - * when the field set was unavailable). - */ - objectHasOrgIdField?: boolean; - /** - * Whether the object opted out of tenancy (`tenancy.enabled === false` or - * `systemFields.tenant === false`) — a platform-global object. Such objects - * are NOT tenant objects; Layer 0 contributes nothing. - */ - tenancyDisabled: boolean; - /** - * Whether the object's POSTURE permits a platform admin to cross the tenant - * wall — `private`, platform-global (`tenancy.enabled:false`), or - * better-auth-managed (exactly the ADR-0066 ① gate). Business (public) tenant - * objects do NOT permit it, so a platform admin stays org-scoped on them. - */ - posturePermitsCrossTenant: boolean; - /** - * TRUE-platform-admin evidence (ADR-0095 D3 `PLATFORM_ADMIN` rung) for THIS - * operation side. This is the ONLY gate that lets a caller cross the tenant - * wall, so it MUST distinguish a PLATFORM operator from a TENANT `org_admin`. - * - * It is NOT merely the superuser bit (`viewAllRecords`/`modifyAllRecords`): - * `organization_admin` holds that bit via its `'*'` wildcard grant - * (`default-permission-sets.ts`), so gating the exemption on the superuser bit - * alone let an org admin cross the wall on a `private` (or platform-global / - * better-auth) TENANT object — the D1 "B2 posture stand-in" gap this field's - * former semantics carried. The caller now requires the superuser bit AND a - * platform-EXCLUSIVE capability (`manage_metadata`/`manage_platform_settings`/ - * `studio.access`/`manage_users` — the caps `admin_full_access` carries and - * `organization_admin` deliberately withholds), i.e. the `PLATFORM_ADMIN` - * posture. It is NEVER the better-auth role. - */ - isPlatformAdmin: boolean; -} - -/** The two verdicts that carry no organization — shared, frozen, safe to hand out. */ -const LAYER0_NONE: TenantLayer0Verdict = Object.freeze({ kind: 'none' as const }); -const LAYER0_DENY: TenantLayer0Verdict = Object.freeze({ kind: 'deny' as const }); - /** - * [#15813 / ADR-0131 D8] Compute the Layer 0 VERDICT — what the wall decides - * for this operation, as a value another package can read without re-deriving - * it (`TenantLayer0VerdictSchema`, `@objectstack/spec/security`). - * {@link computeTenantLayer0Filter} is this verdict PROJECTED onto a `where` - * predicate ({@link tenantLayer0FilterOf}); the two cannot disagree because - * the filter is derived from the verdict, never computed beside it. - * - * The decision ladder is the wall's own, in the wall's own order: every exit - * that answers `null` as a filter answers `none` here, every fail-closed exit - * answers `deny`, and the two walled shapes answer the organization(s) they - * name. The `group` set is DEDUPLICATED here (`accessible_org_ids` is a - * membership list; a repeated membership names one organization once), so a - * reader may test `organizationIds.length === 1` and read no further. - * - * - `none` → Layer 0 contributes nothing (`single` posture; non-tenant object; - * or an exempt platform admin). The caller applies only Layer 1. - * - `organization` → the `isolated` wall, `organization_id = `. - * - `organizations` → the `group` union wall, `organization_id IN ` - * (ADR-0105 D2). - * - `deny` → a walled posture on a tenant object, but the context carries no - * organization scope to enforce with (no active org under `isolated`, empty - * access set under `group`) → fail closed (zero rows / write denied). + * [ADR-0131 D8] The verdict and its inputs live in `@objectstack/core`, so the + * engine projects the SAME decision onto the driver's native scope + * (`DriverOptions.tenantId` / `tenantIds`) that this layer projects onto its + * `where` wall — one predicate, computed by one function. Re-exported here + * because this module is where the wall's readers already import it from. */ -export function computeTenantLayer0Verdict(input: TenantLayer0Input): TenantLayer0Verdict { - // `single` posture (incl. a degraded deployment, which resolves to `single`) - // → parity with today's policy stripping. - if (!postureEnforcesWall(input.tenancyPosture)) return LAYER0_NONE; +export { computeTenantLayer0Verdict, type TenantLayer0Input }; - // Not a tenant object: platform-global (tenancy disabled) or simply carries no - // `organization_id` column (e.g. better-auth identity tables like `sys_user`). - // Layer 0 contributes nothing — the object's own business RLS (Layer 1, e.g. - // `_self` carve-outs) is its only scoping. - if (input.tenancyDisabled) return LAYER0_NONE; - if (input.objectHasOrgIdField === false) return LAYER0_NONE; - - // Exemption is a Layer 0 rule (W2 fix): only a TRUE PLATFORM_ADMIN caller on an - // object whose posture permits it crosses the wall — NOT a tenant `org_admin` - // that merely holds the superuser bit (Finding 2 / #2937). Layer 1's superuser - // bypass no longer implies Layer 0's. - if (input.isPlatformAdmin && input.posturePermitsCrossTenant) return LAYER0_NONE; - - // [ADR-0105 D2] `group`: union access over the caller's memberships (MOAC). - // The ACTIVE organization no longer bounds reads here — membership does — so - // a group-HQ analyst sees every plant they belong to on one screen without - // context switching, and a plant admin still sees only their own plants. - // An empty access set is the fail-closed case: no membership, no reach. - if (postureUsesUnionScope(input.tenancyPosture)) { - const orgIds = input.accessibleOrgIds; - if (!orgIds || orgIds.length === 0) return LAYER0_DENY; - return { kind: 'organizations', organizationIds: [...new Set(orgIds)] }; - } - - // `isolated`: the hard wall. Missing active org → fail closed. - if (!input.organizationId) return LAYER0_DENY; - return { kind: 'organization', organizationId: input.organizationId }; -} +/** The fail-closed verdict — shared, frozen, safe to hand out. */ +const LAYER0_DENY: TenantLayer0Verdict = Object.freeze({ kind: 'deny' as const }); /** * Project a Layer 0 verdict onto the `where` predicate the wall AND-composes — diff --git a/packages/plugins/plugin-sharing/src/record-share-organization-stamp.test.ts b/packages/plugins/plugin-sharing/src/record-share-organization-stamp.test.ts index 2b86b51e8eb..aa8fe9d144a 100644 --- a/packages/plugins/plugin-sharing/src/record-share-organization-stamp.test.ts +++ b/packages/plugins/plugin-sharing/src/record-share-organization-stamp.test.ts @@ -193,7 +193,9 @@ const byRecord = (rows: ShareRow[]) => Object.fromEntries(rows.map((r) => [r.rec describe("[#14484] a rule-materialised grant carries the RULE's organization", () => { it("an ORG-STAMPED rule's grants all carry ORG_A — the organization-less record included", async () => { - const { rules, shares } = await boot(); + // [ADR-0131 D8] `isolated`: the sweep's organization scope is the wall's, + // and under `single` there is none to keep ORG_B's won deals out. + const { rules, shares } = await boot('isolated'); const rule = await rules.defineRule( { name: 'os14484_org_a_won', label: 'ORG_A won', object: OBJECT, criteria: WON, recipientType: 'user', recipientId: 'u_a', accessLevel: 'read' } as never, ORG_A_ADMIN, @@ -434,6 +436,10 @@ describe("[#14484] a record read that FAILS never stamps the acting session's or }); describe('[#14484] the scoped UPDATE half cannot reach a row stamped with a DIFFERENT organization — measured: a loud RECORD_NOT_FOUND, not a silent no-op (2026-09-02 review, §5 note 3)', () => { + // [ADR-0131 D8] Under a wall: the update half's scope is the wall's verdict, + // so these cases boot `isolated`; under `single` there is no scope (the + // re-home case below). + // // `SqlDriver.applyTenantScope` scopes the update half to // `organization_id = ? OR organization_id IS NULL`: a legacy NULL row is // reachable (pinned above), a row carrying ANOTHER organization is not — @@ -457,7 +463,7 @@ describe('[#14484] the scoped UPDATE half cannot reach a row stamped with a DIFF ); it("grant: throws RECORD_NOT_FOUND (404); the stored row keeps the other organization's stamp, level and source", async () => { - const { driver, sharing } = await boot(); + const { driver, sharing } = await boot('isolated'); await driver.create('sys_record_share', { id: 'shr_org_a', object_name: OBJECT, record_id: 'deal_p1', recipient_type: 'user', recipient_id: 'u_x', access_level: 'read', source: 'rule', source_id: 'rule_org_a', organization_id: ORG_A, created_at: '2026-01-01T00:00:00Z', @@ -476,7 +482,7 @@ describe('[#14484] the scoped UPDATE half cannot reach a row stamped with a DIFF }); it("reconcile: two organizations' rules on one organization-less record — the second's pass aborts at that record; grants before it stay, its stale revocation never runs", async () => { - const { driver, rules, shares } = await boot(); + const { driver, rules, shares } = await boot('isolated'); const ruleA = await rules.defineRule( { name: 'os14484_note3_a', label: 'ORG_A won', object: OBJECT, criteria: WON, recipientType: 'user', recipientId: 'u_x', accessLevel: 'read' } as never, ORG_A_ADMIN, @@ -514,7 +520,14 @@ describe('[#14484] the scoped UPDATE half cannot reach a row stamped with a DIFF }); }); - it("reconcile: a record re-homed after a platform-global rule granted it — the rule's next pass aborts at it, and nothing in that pass lands", async () => { + it("reconcile under `single`: a record re-homed after a platform-global rule granted it — no wall, so the next pass reaches its row and completes", async () => { + // [ADR-0131 D8] This case used to abort here with RECORD_NOT_FOUND: the + // engine scoped the update half to the re-homed record's ORG_B even on a + // `single` install, where no organization wall exists. The driver's scope + // is now the wall's verdict — `none` under `single` — so the pass reaches + // the ORG_A-stamped row like every other. (The platform-global rule cannot + // be defined under a walled posture at all: its organization-less system + // insert is refused, ADR-0131 D9 — so the walled abort has no twin here.) const { driver, rules } = await boot(); const define = (accessLevel: 'read' | 'edit') => rules.defineRule( { name: 'os14484_note3_rehome', label: 'Platform won', object: OBJECT, criteria: WON, recipientType: 'user', recipientId: 'u_x', accessLevel } as never, @@ -522,8 +535,7 @@ describe('[#14484] the scoped UPDATE half cannot reach a row stamped with a DIFF ); const rule = await define('read'); await rules.evaluateRule(rule.id, SYSTEM); - const before = brief(await allShares(driver)); - expect(before).toEqual({ + expect(brief(await allShares(driver))).toEqual({ deal_a1: { organization_id: ORG_A, access_level: 'read', source_id: rule.id }, deal_b1: { organization_id: ORG_B, access_level: 'read', source_id: rule.id }, deal_b2: { organization_id: ORG_B, access_level: 'read', source_id: rule.id }, @@ -533,12 +545,15 @@ describe('[#14484] the scoped UPDATE half cannot reach a row stamped with a DIFF // The record moves to ORG_B; its grant row still says ORG_A (the // organization the record was in when the platform-global rule stamped it). await driver.update(OBJECT, 'deal_a1', { organization_id: ORG_B }); - // The rule's level changes, so the next pass takes the update half for - // every matched record. deal_a1 comes first: `grant` resolves ORG_B from - // the re-homed record, the ORG_B-scoped update cannot see the ORG_A row, - // and the pass dies before any other record is touched. await define('edit'); - await expect(rules.evaluateRule(rule.id, SYSTEM)).rejects.toMatchObject({ code: 'RECORD_NOT_FOUND', status: 404 }); - expect(brief(await allShares(driver))).toEqual(before); + await expect(rules.evaluateRule(rule.id, SYSTEM)).resolves.toMatchObject({ grantsUpdated: 4, grantsCreated: 0 }); + expect(brief(await allShares(driver))).toEqual({ + // The direct-grant rule (the RECORD's organization) carries the row + // along with its re-homed record. + deal_a1: { organization_id: ORG_B, access_level: 'edit', source_id: rule.id }, + deal_b1: { organization_id: ORG_B, access_level: 'edit', source_id: rule.id }, + deal_b2: { organization_id: ORG_B, access_level: 'edit', source_id: rule.id }, + deal_p1: { organization_id: null, access_level: 'edit', source_id: rule.id }, + }); }); }); diff --git a/packages/plugins/plugin-sharing/src/rule-criteria-org-scope.test.ts b/packages/plugins/plugin-sharing/src/rule-criteria-org-scope.test.ts index 66189547c6a..3bc2ebc03e8 100644 --- a/packages/plugins/plugin-sharing/src/rule-criteria-org-scope.test.ts +++ b/packages/plugins/plugin-sharing/src/rule-criteria-org-scope.test.ts @@ -30,8 +30,10 @@ * * The scope is a DRIVER decision: `SqlDriver.applyTenantScope` turns * `DriverOptions.tenantId` into `(organization_id = ? OR organization_id IS - * NULL)`, and the engine only threads it when `execCtx.tenantId` is set on a - * non-federated, tenancy-enabled object (`ObjectQLEngine.buildDriverOptions`). + * NULL)`, and the engine threads it as the projection of the tenant wall's + * verdict (ADR-0131 D8, `ObjectQLEngine.buildDriverOptions`) — so only under + * a walled posture: the org-stamped cases boot `isolated`, and under `single` + * there is no organization scope for a sweep to keep. * A hand-written engine double proves none of that chain — it would report * green on a `tenantId` the real stack never applies. So these cases run a real * `SqlDriver` on better-sqlite3 `:memory:` behind a real `ObjectQL`, the way @@ -169,7 +171,7 @@ const open: SqlDriver[] = []; * equality probe, no longer as a claim that * NULL means platform-wide — see the header. */ -async function boot(): Promise { +async function boot(posture?: 'single' | 'isolated' | 'group'): Promise { const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, @@ -180,6 +182,7 @@ async function boot(): Promise { const ql = new ObjectQL(); ql.registerDriver(driver as never, true); await ql.init(); + if (posture) ql.setTenancyPostureProvider(() => posture); ql.registerObject({ name: OBJECT, label: 'Deal', @@ -237,7 +240,10 @@ const WON = { stage: 'won' }; describe('[#10119] sharing-rule criteria sweep is scoped to the rule\'s organization', () => { describe('findMatchingRecords — the whole-rule evaluation pass', () => { it('an ORG-STAMPED rule materialises NO grant on another organization\'s records', async () => { - const { rules, sharedRecordIds } = await boot(); + // [ADR-0131 D8] An organization wall is what scopes the sweep: the + // driver's read scope is the wall's verdict, and under `single` (the + // env default) there is none — one tenant, one sweep. + const { rules, sharedRecordIds } = await boot('isolated'); const rule = await rules.defineRule( { @@ -303,7 +309,8 @@ describe('[#10119] sharing-rule criteria sweep is scoped to the rule\'s organiza describe('recordMatches — the per-record write-hook pass', () => { it('an ORG-STAMPED rule does not match another organization\'s record', async () => { - const { rules, sharedRecordIds } = await boot(); + // [ADR-0131 D8] Under a wall — see the whole-rule pass above. + const { rules, sharedRecordIds } = await boot('isolated'); await rules.defineRule( { diff --git a/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts b/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts index a6fb3c659e4..aafad76daf0 100644 --- a/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts @@ -140,6 +140,23 @@ function rowsOf(body: any): any[] { return Array.isArray(body) ? body : (body?.records ?? []); } +/** + * The organization `@objectstack/verify`'s `signUp` files its audience-gate + * invitations under — one per fixture identity, so the `invite_only` gate + * admits it (harness.ts, `inviteForAudienceGate`). + * + * [ADR-0131 D8] Those rows used to be kept out of this ledger by the driver + * alone: under `single` it still narrowed a read to the session's active + * organization. The driver's read scope is now the organization wall's verdict, + * and `single` has no wall, so on this one-tenant install the harness's rows + * are part of what an owner reads. They are the harness's admission plumbing, + * not this organization's ledger, so `rows` leaves them out. `raw` is the + * unfiltered body: a harness row is addressed to the persona that signed up, + * so it reaches that persona alone through the addressee carve-out, and never + * carries an invitee address this file writes. + */ +const HARNESS_AUDIENCE_GATE_ORGANIZATION = 'org_verify_audience_gate'; + /** Read `/data/sys_invitation` as a persona and return `{ status, rows, raw }`. */ async function readLedger(stack: VerifyStack, token: string) { const res = await stack.apiAs(token, 'GET', '/data/sys_invitation'); @@ -150,7 +167,8 @@ async function readLedger(stack: VerifyStack, token: string) { } catch { /* non-JSON body — `raw` is still asserted on */ } - return { status: res.status, rows: rowsOf(body), raw }; + const rows = rowsOf(body).filter((r) => r?.organization_id !== HARNESS_AUDIENCE_GATE_ORGANIZATION); + return { status: res.status, rows, raw }; } describe('#8095/#8240: the sys_invitation ledger, read by four personas', () => { @@ -382,8 +400,11 @@ describe('#8095/#8240: the sys_invitation ledger, read by four personas', () => // The delegate is the addressee of NOTHING, so this row cannot have arrived // through `sys_invitation_self`. Without this the test would still pass if // the issuer policy were deleted and the fixture quietly addressed a row to - // the delegate instead. - expect(raw).not.toContain(DELEGATE_EMAIL); + // the delegate instead. Asserted on `rows`: the harness's own audience-gate + // invitation IS addressed to the delegate, in its own organization, and + // reaches it through that carve-out on a `single` install (see + // `HARNESS_AUDIENCE_GATE_ORGANIZATION`). + expect(rows.map((r) => r.email)).not.toContain(DELEGATE_EMAIL); }, 60_000); it('[#8240] the ADR-0105 D8 checklist step reads correctly — and stops being vacuous', async () => { diff --git a/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts index b452bf88d81..950484a92ef 100644 --- a/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts +++ b/packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts @@ -582,7 +582,9 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { * claiming it pinned a 503 refusal: opposite polarity, no delivery check, * so it certified nothing in either direction. What is pinned instead is * the REASON the control is unavailable, measured at the seam that makes - * it so — a tenant-scoped read on this driver produces NO ANSWER. The + * it so — a tenant-scoped call on this driver produces NO ANSWER (since + * ADR-0131 D8 an org-bound write: under `single` a read carries no + * tenant scope to refuse). The * day the driver gains isolation that goes red and whoever fixes it * enables the real control here. */ @@ -590,22 +592,35 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { if (databaseDriver === 'memory') { // Not the control — the control cannot run here. This pins the reason, // so the exemption expires by itself. + // + // [ADR-0131 D8] The probe is an org-bound WRITE. Under this suite's + // `single` posture an org-bound READ no longer reaches the driver with + // a tenant scope at all (the driver's read scope is the wall's verdict, + // `none` here), but a write still carries its organization as the + // write target in every posture — and that is the scope a run triggered + // by an org-bound session hands this driver when it delivers. let thrown: unknown = null; let answered: unknown = null; try { - answered = await ql.find(INBOX_OBJECT, { where: {}, context: { userId: recipientId, tenantId: orgA } }); + answered = await ql.insert( + INBOX_OBJECT, + { user_id: recipientId, title: 'tenancy probe', topic: 'sched.probe', severity: 'info' }, + // Elevated, as the run that delivers is: the probe asks the driver + // seam, not the caller's object permissions. + { context: { isSystem: true, tenantId: orgA } }, + ); } catch (err) { thrown = err; } expect( thrown, - `driver-memory ANSWERED a tenant-scoped read (${JSON.stringify(answered)}) — it has gained row-level isolation, so an org-bound session is now possible here: boot this driver with orgContext and enable the real control B (#16589 / #6915)`, + `driver-memory ANSWERED a tenant-scoped write (${JSON.stringify(answered)}) — it has gained row-level isolation, so an org-bound session is now possible here: boot this driver with orgContext and enable the real control B (#16589 / #6915)`, ).toBeTruthy(); - // ⛔ Taken as a nullable value, not with `.toHaveLength`: a refused call - // must produce NO row count at all, and `.not.toHaveLength` passes over - // a null target for the wrong reason. - expect(Array.isArray(answered) ? answered.length : null).toBeNull(); + // ⛔ Taken as a nullable value: a refused call must produce NO row at + // all, and a matcher that tolerates a null target passes for the wrong + // reason. + expect(answered ?? null).toBeNull(); expect( String((thrown as { code?: string }).code ?? (thrown as Error).message), 'the refusal must be the driver\'s own tenancy refusal, not some unrelated failure that happens to throw', diff --git a/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts b/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts index 8b1af785045..d4525ed5ab6 100644 --- a/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts @@ -217,6 +217,13 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { priorPosture = process.env.OS_TENANCY_POSTURE; process.env[SCHEDULED_WORK_ENV] = 'true'; process.env.OS_TENANCY_POSTURE = 'isolated'; + // [ADR-0131 D8] …and the ENGINE is told the same thing. The driver's + // read scope is the organization wall's verdict, computed from the + // posture the engine holds — the stack's `tenancy` service, which still + // reads `single` here. Under `single` the verdict is `none`: the sweep's + // declared organization would narrow nothing, and both drivers would + // answer a different subject (all three rows; no refusal on memory). + ql.setTenancyPostureProvider(() => 'isolated'); automation.registerFlow(SWEEP_FLOW, declaringTimeRelativeFlow(orgA, recipientId)); diff --git a/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts b/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts index f6bb123e1c9..9ea667946d6 100644 --- a/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts +++ b/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts @@ -424,6 +424,9 @@ describe('[#22332] updateSessionProgressIfUnchanged on a wired engine is the eng it('a row stamped for another organization matches nothing — the write misses, it does not land there', async () => { const { store } = backend; + // [ADR-0131 D8] Under a wall: the update's organization scope is the + // wall's verdict, and under `single` (the env default) there is none. + backend.engine.setTenancyPostureProvider(() => 'isolated'); await store.createSession(sessionRec('s1'), { organizationId: 'org_B' }); const seen = (await store.getSession('s1'))!; diff --git a/packages/spec/src/migrations/entries/semantic/18.driver-read-scope-follows-tenant-wall.ts b/packages/spec/src/migrations/entries/semantic/18.driver-read-scope-follows-tenant-wall.ts new file mode 100644 index 00000000000..f9be260b89a --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.driver-read-scope-follows-tenant-wall.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D8 (stage 8a of the C8 card) — a runtime widening with no authorable +// key: the engine stops deriving the driver's tenant read scope beside the +// organization wall and projects the wall's own verdict instead. Registered +// because a read, update or delete that used to be narrowed to the context's +// organization by the driver alone now reaches what the wall admits, and a +// caller that leaned on the old narrowing must be told where the scope lives. +// A driver option is code, never stack metadata, so there is no authored source +// for the chain to rewrite and no schema tombstone: this entry is the migration +// channel beside the changeset's FROM → TO table. +export const entry: SemanticMigration = { + id: 'driver-read-scope-follows-tenant-wall', + // No backticks in `surface`: build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'ObjectQL find, findOne, count, aggregate and the update and delete predicates on an object ' + + 'with an organization column, called with an execution context that names an organization: ' + + 'the DriverOptions tenantId and tenantIds the engine hands every driver', + replacement: + 'the driver read scope is the organization wall\'s verdict (computeTenantLayer0Verdict, ' + + '@objectstack/core): none on a single deployment, the active organization on isolated, the ' + + 'membership set on group, and none where the wall admits an exempt platform administrator or ' + + 'the verified platform owner\'s read. To keep one organization\'s rows apart from another\'s, ' + + 'run the deployment on the isolated or group posture, or name the organization in the query\'s ' + + 'own where clause. Inserts and upserts still stamp the active organization in every posture', + reason: + 'ADR-0131 D8 makes the tenant predicate one value computed once and threaded to Layer 0 and to ' + + 'every driver. Before this change the engine derived the driver scope on its own from the ' + + 'context organization alone: on a single deployment, where Layer 0 composes nothing, the driver ' + + 'still narrowed every read, update and delete to that organization, and under a wall it ' + + 'narrowed the exempt platform administrator\'s and the verified owner\'s reads that Layer 0 had ' + + 'admitted. Two walls gave two answers for one operation. A single deployment holding more than ' + + 'one organization was isolated by that accident, not by any declared rule; ADR-0131 D11 makes ' + + 'postures differ only in enforcement, and single enforces no wall.', + acceptanceCriteria: + 'No code on a single deployment relies on a context organization to keep another organization\'s ' + + 'rows out of a read, count, aggregate, update or delete; no code under a wall relies on the ' + + 'driver to narrow a platform administrator\'s read that the security layer admits. Proven when, ' + + 'on a single deployment, a read with a context organization returns the same rows as the same ' + + 'read with none, while on isolated it returns that organization\'s rows and on group the ' + + 'membership set\'s (organization-less rows still included by the driver\'s compatibility ' + + 'arm until ADR-0131 D13 retires it), and an insert on any posture lands stamped with the active organization.', +};