From aafed336fa6001c4c8548d1f801eec6a0822afe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 09:26:17 +0000 Subject: [PATCH 1/3] wip(security): record the Layer 0 verdict on the operation; the bulk-event producer reads it (#15813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work in progress — spec verdict schema, plugin-security recording, engine reader, retargeted pins, regenerated spec artifacts. Not yet verified. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .changeset/layer0-verdict-on-operation.md | 17 + content/docs/references/index.mdx | 10 +- content/docs/references/security/misc.mdx | 55 ++- .../objectql/src/engine-data-events.test.ts | 452 ++++++------------ packages/objectql/src/engine.ts | 244 ++++------ packages/objectql/src/registry.ts | 23 +- .../plugin-security/src/security-plugin.ts | 63 ++- .../plugin-security/src/tenant-layer.test.ts | 127 ++++- .../plugin-security/src/tenant-layer.ts | 142 +++++- ...tenant-layer0-verdict-on-operation.test.ts | 250 ++++++++++ packages/spec/api-surface/security.json | 2 + packages/spec/declaration-map/security.json | 4 +- packages/spec/export-origins/security.json | 2 + .../spec/json-schema.manifest/security.json | 3 +- packages/spec/src/security/index.ts | 1 + .../security/tenant-layer0-verdict.test.ts | 48 ++ .../src/security/tenant-layer0-verdict.ts | 84 ++++ 17 files changed, 1025 insertions(+), 502 deletions(-) create mode 100644 .changeset/layer0-verdict-on-operation.md create mode 100644 packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts create mode 100644 packages/spec/src/security/tenant-layer0-verdict.test.ts create mode 100644 packages/spec/src/security/tenant-layer0-verdict.ts diff --git a/.changeset/layer0-verdict-on-operation.md b/.changeset/layer0-verdict-on-operation.md new file mode 100644 index 0000000000..dec5415870 --- /dev/null +++ b/.changeset/layer0-verdict-on-operation.md @@ -0,0 +1,17 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +"@objectstack/plugin-security": minor +--- + +feat(security): the Layer 0 tenant wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall + +`BulkDataEventSchema.organizationId` is stamped on a `data.records.updated` / `data.records.deleted` event only when the Layer 0 tenant wall named exactly one organization for the whole predicate write. The producer (`publishBulkDataEvent`, `@objectstack/objectql`) used to decide that by re-deriving the wall's inputs — posture, context, and the object's own tenancy clauses. It could never see the third clause plugin-security folds into `tenancyDisabled`: the deployment-declared `platformGlobalObjects` carve-out (#12699). On such an object under an armed wall the producer stamped the caller's organization while Layer 0 had composed no wall at all — a wrong key asserting "every affected record belongs to this organization" over a batch that could span several, the #13566 leak shape reappearing on the bulk path (#15706). + +Ruled on #15706 (seam (i), ADR-0131 D8 「一道谓词,算一次」): the wall records what it decided, and the reader composes nothing. + +- **`@objectstack/spec`** — new export `TenantLayer0VerdictSchema` / `TenantLayer0Verdict` (`@objectstack/spec/security`): the four verdicts a Layer 0 wall can reach for one operation — `none`, `organization`, `organizations`, `deny`. Additive. +- **`@objectstack/objectql`** — `OperationContext` gains an optional member `tenantLayer0Verdict`, written by the enforcement layer at the moment it composes the wall onto the operation's predicate. Additive widening of a published surface, hence `minor`. `publishBulkDataEvent` now reads that member and nothing else: a recorded `organization` (or a one-member `organizations`) verdict stamps the key; `none`, `deny`, a multi-member set, a malformed value, or NO recorded verdict all omit it. The engine no longer consults the enforced posture, the execution context or the object schema to answer the question — the mirror is deleted, not moved. +- **`@objectstack/plugin-security`** — the engine middleware records `opCtx.tenantLayer0Verdict` on every operation whose predicate it composes the wall onto (reads and predicate writes); `computeTenantLayer0Filter` is now a projection of the new `computeTenantLayer0Verdict`, so the recorded verdict and the injected predicate come from one computation. An on-behalf-of write records the intersection of the caller's and the delegator's walls. System contexts and by-id writes record nothing (no wall is composed for them). + +What moves, and in which direction: a deployment-exempted object under an armed wall now publishes `organizationId` ABSENT (it was wrongly present); a `PLATFORM_ADMIN` rung on a PUBLIC tenant object now publishes it PRESENT (the wall stands there; it was conservatively absent); a hand-built context with no rung is answered by the plugin's capability probe rather than conservatively absent. Every population the previous producer answered correctly is unchanged. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 153d797bc5..043e0fd030 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1590 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -28,12 +28,12 @@ counts are sums of the rows they head. Regenerate with | [Integration Protocol](/docs/references/integration) | 1 | 24 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 30 | 162 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | -| [Security Protocol](/docs/references/security) | 5 | 29 | Permission sets, row-level security, sharing rules, tenancy posture. | +| [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 8 | 26 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1589** | 14 protocol modules | +| **Total** | **200** | **1590** | 14 protocol modules | --- @@ -270,14 +270,14 @@ Declarative test suites — scenarios, steps, actions and assertions. ## Security Protocol -**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 29 schemas** +**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 30 schemas** Permission sets, row-level security, sharing rules, tenancy posture. | File | Schemas | | :--- | :--- | | [`explain.zod.ts`](/docs/references/security/explain) | `AccessMatrix`, `AccessMatrixEntry`, `AuthzPosture`, `ExplainDecision`, `ExplainLayer`, `ExplainMatchedRule`, `ExplainOperation`, `ExplainRecordAttribution`, `ExplainRequest` | -| [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `OrgScopingEntitlement`, `PlatformGlobalObjects`, `TenancyPosture` | +| [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `OrgScopingEntitlement`, `PlatformGlobalObjects`, `TenancyPosture`, `TenantLayer0Verdict` | | [`permission.zod.ts`](/docs/references/security/permission) | `AdminScope`, `EffectiveObjectPermission`, `FieldPermission`, `ObjectAccessScope`, `ObjectPermission`, `PermissionSet` | | [`rls.zod.ts`](/docs/references/security/rls) | `RLSEvaluationResult`, `RLSOperation`, `RLSUserContext`, `RowLevelSecurityPolicy` | | [`sharing.zod.ts`](/docs/references/security/sharing) | `CriteriaSharingRule`, `OWDModel`, `ShareRecipientType`, `SharingLevel`, `SharingRule`, `SharingRuleType` | diff --git a/content/docs/references/security/misc.mdx b/content/docs/references/security/misc.mdx index a0c7004888..660df0197d 100644 --- a/content/docs/references/security/misc.mdx +++ b/content/docs/references/security/misc.mdx @@ -8,8 +8,8 @@ description: Misc protocol schemas ## TypeScript Usage ```typescript -import { CapabilityDeclarationSchema, OrgScopingEntitlementSchema, PlatformGlobalObjectsSchema, TenancyPostureSchema } from '@objectstack/spec/security'; -import type { CapabilityDeclaration, OrgScopingEntitlement, PlatformGlobalObjects, TenancyPosture } from '@objectstack/spec/security'; +import { CapabilityDeclarationSchema, OrgScopingEntitlementSchema, PlatformGlobalObjectsSchema, TenancyPostureSchema, TenantLayer0VerdictSchema } from '@objectstack/spec/security'; +import type { CapabilityDeclaration, OrgScopingEntitlement, PlatformGlobalObjects, TenancyPosture, TenantLayer0Verdict } from '@objectstack/spec/security'; // Validate data const result = CapabilityDeclarationSchema.parse(data); @@ -70,3 +70,54 @@ const result = CapabilityDeclarationSchema.parse(data); --- +## TenantLayer0Verdict + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kind** | `'none'` | ✅ | | + +--- + +#### Option 2 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kind** | `'organization'` | ✅ | | +| **organizationId** | `string` | ✅ | | + +--- + +#### Option 3 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kind** | `'organizations'` | ✅ | | +| **organizationIds** | `string[]` | ✅ | | + +--- + +#### Option 4 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kind** | `'deny'` | ✅ | | + +--- + + +--- + diff --git a/packages/objectql/src/engine-data-events.test.ts b/packages/objectql/src/engine-data-events.test.ts index 2c922204fa..992899a5a2 100644 --- a/packages/objectql/src/engine-data-events.test.ts +++ b/packages/objectql/src/engine-data-events.test.ts @@ -637,8 +637,8 @@ describe('#14970 — a published DataEvent names the RECORD\'s organization', () }); /** - * #15225 — the producer half of `BulkDataEvent.organizationId`: the bulk - * sibling of the #14970 block above, and the remaining half of the + * #15225 / #15813 — the producer half of `BulkDataEvent.organizationId`: the + * bulk sibling of the #14970 block above, and the remaining half of the * cross-tenant webhook leak (#13566) whose single-record half #14970 closed. * * `BulkDataEventSchema.organizationId` (PR #15218) is ONE organization for the @@ -646,33 +646,55 @@ describe('#14970 — a published DataEvent names the RECORD\'s organization', () * write was composed under, or absent. ⚠️ `absent` means the OPPOSITE of what * it means one block up: on a `DataEvent` it is a statement about the ROW * ("belongs to no organization"); here it is a statement about PRODUCER - * KNOWLEDGE ("no single organization was asserted for this batch"). A pin - * written with the single-record intuition would assert the wrong thing, so - * every absence pin below names WHY the producer could not assert. + * KNOWLEDGE ("no single organization was asserted for this batch"). + * + * ## What the producer reads, and what it deliberately does NOT (#15813) + * + * The wall is computed by plugin-security and nowhere else. The producer + * reads the verdict the enforcement layer RECORDED on the operation context + * (`OperationContext.tenantLayer0Verdict`, `TenantLayer0VerdictSchema` in + * `@objectstack/spec/security`) — `organization` (or a one-member + * `organizations`) stamps the key; everything else, including NO recorded + * verdict, omits it. It reads NOTHING else: not the enforced posture, not the + * execution context's `tenantId` / `accessible_org_ids` / `posture`, not the + * object schema. The first edition of this block pinned a producer that + * re-derived the wall from those inputs; it could not see the deployment's + * #12699 carve-out and stamped a wrong key on that population (#15706, ruled + * 2026-09-05: the mirror is deleted, not taught one more clause). Those pins + * were RETARGETED here, deliberately and visibly — the object-shape and + * rung-shape populations now live where the wall is computed + * (`plugin-security/src/tenant-layer0-verdict-on-operation.test.ts`), and the + * one end-to-end weld — real engine, real plugin, real event — lives beside + * them. * * What makes these pins discriminate rather than pass against the live * defect ("the key is absent on every event" — an absence-only suite reported * 25/25 green against exactly that on #14970): * - * 1. **The wall, not the active organization.** Under `group` the wall is the - * caller's MEMBERSHIP SET, so the positive `group` pin sets an active - * organization the caller is NOT asserting and expects the set's only - * member; the negative `group` pin keeps an active organization and two - * memberships and expects OMISSION — substituting `tenantId` fails both. + * 1. **The verdict, not the context.** The positive pins record a verdict + * naming an organization the context does NOT carry as `tenantId`, and + * expect the verdict's organization — substituting any context field + * fails them. * 2. **The wall, not the rows.** The stub driver composes no wall at all, so * the rows a sweep touches are whatever was seeded; the negative pins * seed rows across two organizations on purpose, and the answer is still - * decided by the wall's inputs (posture, context), never by a row read. - * 3. **Absence is asserted as OMISSION** (`hasOwnProperty === false`), never - * `=== undefined`: the schema refuses `''` outright (a fabricated empty - * value would throw at the publish site and drop the event), and an - * explicit `undefined` survives `parse` as a PRESENT key. + * decided by the recorded verdict, never by a row read. + * 3. **No verdict ⇒ absent, whatever the posture and context say.** The + * mirror-deleted pin: an engine handed `isolated` by its posture provider, + * a member context with an active organization, and NO recording + * middleware publishes the key ABSENT. Any re-derivation from posture + + * context turns this pin red first. + * 4. **Absence is asserted as OMISSION** (`hasOwnProperty === false`), never + * `=== undefined`: the schema refuses `''` outright, and an explicit + * `undefined` survives `parse` as a PRESENT key. * - * The posture the wall enforces reaches the engine the way SecurityPlugin - * hands it over in a real composition — `setTenancyPostureProvider` — and an - * engine that was handed none is pinned as "no wall to vouch for". + * The verdict reaches the engine the way plugin-security delivers it in a + * real composition — a registered middleware writing + * `ctx.tenantLayer0Verdict` before `next()` — with the posture provider set + * to `isolated` throughout so no pin below can pass by the posture being + * inert. */ -describe('#15225 — a published BulkDataEvent names the organization the tenant WALL named', () => { +describe('#15225 / #15813 — a published BulkDataEvent names the organization the RECORDED Layer 0 verdict named', () => { /** Tenant-scoped: the kernel-injected `organization_id` is declared. */ const invoice = { name: 'invoice', @@ -695,8 +717,13 @@ describe('#15225 — a published BulkDataEvent names the organization the tenant let engine: ObjectQL; let published: RealtimeEventPayload[]; let realtime: IRealtimeService; - /** What SecurityPlugin's injected provider answers; each pin sets it. */ - let enforcedPosture: string | undefined; + /** + * What the recording middleware writes on the operation, per pin. `NOT_SET` + * = the middleware is present but records nothing (it never touched the + * member); the "no middleware at all" pin uses a bare engine instead. + */ + const NOT_SET = Symbol('not-set'); + let recorded: unknown = NOT_SET; const payloadOf = (i = 0) => published[i].payload as Record; const hasOrgKey = (i = 0) => @@ -714,6 +741,7 @@ describe('#15225 — a published BulkDataEvent names the organization the tenant beforeEach(async () => { published = []; + recorded = NOT_SET; realtime = { publish: vi.fn(async (event: RealtimeEventPayload) => { published.push(event); }), subscribe: vi.fn(async () => 'sub-1'), @@ -726,41 +754,49 @@ describe('#15225 — a published BulkDataEvent names the organization the tenant engine.registry.registerObject(invoice); engine.registry.registerObject(task); engine.setRealtimeService(realtime); - enforcedPosture = 'isolated'; - engine.setTenancyPostureProvider(() => enforcedPosture); + engine.setTenancyPostureProvider(() => 'isolated'); + // The enforcement layer's seam, as plugin-security drives it: the verdict + // is written on the operation context the executor closure holds, ahead + // of `next()`. A system context takes the plugin's first exit and records + // nothing (pinned on the plugin side); mirrored here so the harness never + // records a verdict the real middleware would not. + engine.registerMiddleware(async (ctx, next) => { + if (recorded !== NOT_SET && ctx.context?.isSystem !== true && ctx.ast) { + ctx.tenantLayer0Verdict = recorded as any; + } + await next(); + }); vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); }); - /** - * The env-fallback pin below sets `OS_TENANCY_POSTURE` on purpose; put it - * back whatever happened, so no later file in this process inherits a - * walled posture it never asked for. - */ const savedPosture = process.env.OS_TENANCY_POSTURE; afterEach(() => { if (savedPosture === undefined) delete process.env.OS_TENANCY_POSTURE; else process.env.OS_TENANCY_POSTURE = savedPosture; }); - it('isolated: a member\'s predicate UPDATE names the caller\'s organization — the wall\'s equality term', async () => { + it('a recorded `organization` verdict is stamped on a predicate UPDATE — the VERDICT\'s organization, not the context\'s', async () => { + // The discriminating case: the context's active organization is NOT the + // one the wall named. Only a reader of the verdict answers `org_plant_a`. + recorded = { kind: 'organization', organizationId: 'org_plant_a' }; await seed([ - { amount: '1', status: 'open', organization_id: ACTIVE_ORG }, - { amount: '2', status: 'open', organization_id: ACTIVE_ORG }, + { amount: '1', status: 'open', organization_id: 'org_plant_a' }, + { amount: '2', status: 'open', organization_id: 'org_plant_a' }, ]); - await sweepUpdate(member); + await sweepUpdate({ userId: 'usr_plant', tenantId: 'org_hq', posture: 'MEMBER' }); expect(published).toHaveLength(1); expect(published[0].type).toBe('data.records.updated'); const event = bulkEvent(); expect(event.matched).toBe(2); - // PRESENT, and the wall's term: under `isolated` the Layer 0 wall is - // `organization_id = `, so every affected row is in it. expect(hasOrgKey()).toBe(true); - expect(event.organizationId).toBe(ACTIVE_ORG); + expect(event.organizationId).toBe('org_plant_a'); + expect(event.organizationId).not.toBe('org_hq'); }); - it('isolated: the predicate DELETE path stamps it too — the branch with no post-state', async () => { + it('the predicate DELETE path stamps it too — the branch with no post-state', async () => { + recorded = { kind: 'organization', organizationId: ACTIVE_ORG }; await seed([ { amount: '1', status: 'open', organization_id: ACTIVE_ORG }, { amount: '2', status: 'kept', organization_id: ACTIVE_ORG }, @@ -775,84 +811,55 @@ describe('#15225 — a published BulkDataEvent names the organization the tenant expect(bulkEvent().organizationId).toBe(ACTIVE_ORG); }); - it('group with a SINGLETON membership names that member — not the caller\'s active organization', async () => { - enforcedPosture = 'group'; - // The discriminating case: the active organization is one the caller is - // NOT asserting for the batch. Under `group` the wall is the membership - // SET (`organization_id IN accessible_org_ids`), and `tenantId` is only the - // default write target — reading it here would name the wrong organization. - const plantAdmin = { - userId: 'usr_plant', - tenantId: 'org_hq', - accessible_org_ids: ['org_plant_a'], - posture: 'MEMBER', - }; + it('a one-member `organizations` verdict (the `group` wall over a singleton membership) names that member', async () => { + recorded = { kind: 'organizations', organizationIds: ['org_plant_a'] }; await seed([{ amount: '1', status: 'open', organization_id: 'org_plant_a' }]); - await sweepUpdate(plantAdmin); + await sweepUpdate({ userId: 'usr_plant', tenantId: 'org_hq', accessible_org_ids: ['org_plant_a'], posture: 'MEMBER' }); expect(published).toHaveLength(1); expect(hasOrgKey()).toBe(true); expect(bulkEvent().organizationId).toBe('org_plant_a'); - expect(bulkEvent().organizationId).not.toBe('org_hq'); }); - it('group: a membership set that repeats its one organization still names exactly one', async () => { - enforcedPosture = 'group'; - const ctx = { userId: 'usr_plant', tenantId: 'org_plant_a', accessible_org_ids: ['org_plant_a', 'org_plant_a'], posture: 'MEMBER' }; - await seed([{ amount: '1', status: 'open', organization_id: 'org_plant_a' }]); - - await sweepUpdate(ctx); - - expect(hasOrgKey()).toBe(true); - expect(bulkEvent().organizationId).toBe('org_plant_a'); - }); - - it('group across TWO memberships publishes it ABSENT — never the active organization as a stand-in', async () => { - enforcedPosture = 'group'; + it('an `organizations` verdict over TWO organizations publishes it ABSENT — never the active organization as a stand-in', async () => { // The option-C mislabel (PR #14635 open question 1, rejected): the caller - // HAS an active organization, and it must not label a sweep that the wall - // let reach two organizations' rows. - const hqAnalyst = { - userId: 'usr_hq', - tenantId: 'org_plant_a', - accessible_org_ids: ['org_plant_a', 'org_plant_b'], - posture: 'MEMBER', - }; + // HAS an active organization, and it must not label a sweep the wall let + // reach two organizations' rows. + recorded = { kind: 'organizations', organizationIds: ['org_plant_a', 'org_plant_b'] }; await seed([ { amount: '1', status: 'open', organization_id: 'org_plant_a' }, { amount: '2', status: 'open', organization_id: 'org_plant_b' }, ]); - await sweepUpdate(hqAnalyst); + await sweepUpdate({ userId: 'usr_hq', tenantId: 'org_plant_a', accessible_org_ids: ['org_plant_a', 'org_plant_b'], posture: 'MEMBER' }); expect(published).toHaveLength(1); expect(bulkEvent().matched).toBe(2); - // OMITTED, asserted as omission — "not asserted", not "no organization". expect(hasOrgKey()).toBe(false); expect(bulkEvent().organizationId).toBeUndefined(); }); - it('a system-context predicate write publishes it ABSENT — the middleware composes no wall for it', async () => { - // `isSystem` short-circuits the whole security middleware, so no Layer 0 - // wall was composed and the sweep may reach every organization's rows — - // which is exactly what this seed makes it do. The caller's `tenantId` is - // set and must not be substituted. + it('a `none` verdict publishes it ABSENT under an armed posture and a member with an active organization — the #15706 population', async () => { + // The wall RAN and contributed nothing: a deployment-exempted object + // (#12699), a tenancy-disabled object, an exempt PLATFORM_ADMIN. The + // previous producer stamped `org_acme` here from the context; the batch + // below spans two organizations, so that key was WRONG. + recorded = { kind: 'none' }; await seed([ { amount: '1', status: 'open', organization_id: ACTIVE_ORG }, { amount: '2', status: 'open', organization_id: OTHER_ORG }, ]); - await sweepUpdate(sysCtx); + await sweepUpdate(member); expect(published).toHaveLength(1); expect(bulkEvent().matched).toBe(2); expect(hasOrgKey()).toBe(false); - expect(bulkEvent().organizationId).toBeUndefined(); }); - it('single posture (no wall) publishes it ABSENT even though the caller has an active organization', async () => { - enforcedPosture = 'single'; + it('a `deny` verdict publishes it ABSENT (and the event still publishes if the driver matched rows)', async () => { + recorded = { kind: 'deny' }; await seed([{ amount: '1', status: 'open', organization_id: ACTIVE_ORG }]); await sweepUpdate(member); @@ -861,258 +868,79 @@ describe('#15225 — a published BulkDataEvent names the organization the tenant expect(hasOrgKey()).toBe(false); }); - it('no enforcement layer injected a posture ⇒ ABSENT even with OS_TENANCY_POSTURE=isolated in the env — the env fallback is not consulted', async () => { - // An engine no SecurityPlugin ever handed a posture: Layer 0 was never - // composed, and the memory driver reads no `DriverOptions.tenantId` - // either, so nothing constrained this sweep to one organization — - // whatever the operator's env says. The env fallback the #8844 write - // refusal consults (`resolveEnginePosture`) is deliberately NOT consulted - // here. - // - // ⚠️ The env is set to the WALLED posture on purpose. With it unset the - // test process resolves to `single`, and substituting - // `resolveEnginePosture()` at the publish site left every pin green - // (measured by the R1 contract review: 38/38 under the substitution). - // Under `isolated` in the env that substitution stamps the key, and - // THIS pin goes red — the only thing that makes "deliberately not the - // env fallback" a guarantee rather than a comment. - process.env.OS_TENANCY_POSTURE = 'isolated'; - const bare = new ObjectQL(); - const { driver } = makeStubDriver(); - bare.registerDriver(driver, true); - await bare.init(); - bare.registry.registerObject(invoice); - bare.setRealtimeService(realtime); - vi.spyOn((bare as any).logger, 'warn').mockImplementation(() => undefined); - await bare.insert('invoice', [{ amount: '1', status: 'open', organization_id: ACTIVE_ORG }], { context: sysCtx } as any); - published.length = 0; + it('NO recorded verdict ⇒ ABSENT, whatever the posture provider and the context say — the mirror is deleted, not moved', async () => { + // `isolated` from the provider, a member with an active organization, rows + // all in that organization: every input the FORMER producer read says + // `org_acme`. The producer reads none of them. + await seed([ + { amount: '1', status: 'open', organization_id: ACTIVE_ORG }, + { amount: '2', status: 'open', organization_id: ACTIVE_ORG }, + ]); - await bare.update('invoice', { amount: '0' }, { multi: true, where: { status: 'open' }, context: member } as any); + await sweepUpdate(member); expect(published).toHaveLength(1); + expect(bulkEvent().matched).toBe(2); expect(hasOrgKey()).toBe(false); }); - it('a carried PLATFORM_ADMIN rung publishes it ABSENT — the batch may have crossed the wall', async () => { - // ADR-0095 D3: a true PLATFORM_ADMIN crosses the wall where the object's - // posture permits. The engine holds the rung but not the superuser bypass - // bit the exemption also needs, so it declines to assert rather than - // guess which side of the wall this sweep ran on. - const platformAdmin = { userId: 'usr_platform', tenantId: ACTIVE_ORG, posture: 'PLATFORM_ADMIN' }; + it('a system-context predicate write publishes it ABSENT — the middleware takes its first exit and records nothing', async () => { + recorded = { kind: 'organization', organizationId: ACTIVE_ORG }; await seed([ { amount: '1', status: 'open', organization_id: ACTIVE_ORG }, { amount: '2', status: 'open', organization_id: OTHER_ORG }, ]); - await sweepUpdate(platformAdmin); - - expect(published).toHaveLength(1); - expect(hasOrgKey()).toBe(false); - }); - - it('a context carrying NO rung publishes it ABSENT — the exemption is decided by a probe the engine cannot see', async () => { - // A hand-built context (no `posture`) is one the security plugin decides - // by a capability probe over its resolved permission sets. The engine has - // no view of that probe, so it asserts nothing. Every session context the - // authz resolver assembles carries the rung, so this is the hand-built - // population only. - const rungless = { userId: 'usr_member', tenantId: ACTIVE_ORG }; - await seed([{ amount: '1', status: 'open', organization_id: ACTIVE_ORG }]); - - await sweepUpdate(rungless); - - expect(published).toHaveLength(1); - expect(hasOrgKey()).toBe(false); - }); - - it('an object that opted OUT of tenancy publishes it ABSENT under the same wall', async () => { - // ⚠️ Not `task`: registering an object INJECTS the kernel `organization_id` - // column (registry.ts, `TENANT_SCOPE_FIELD_DEF`), and the security plugin - // reads that same injected field set — so a registered `task` IS walled. - // The declared way out is `tenancy: { enabled: false }` (ADR-0066): no - // column is injected, `resolveTenantFieldName` answers null, and Layer 0 - // contributes nothing (`tenancyDisabled`) — there is no wall to name. - const globalSetting = { - name: 'global_setting', - label: 'Global setting', - tenancy: { enabled: false }, - fields: { - id: { name: 'id', type: 'text' as const, primaryKey: true }, - key: { name: 'key', type: 'text' as const }, - status: { name: 'status', type: 'text' as const }, - }, - }; - engine.registry.registerObject(globalSetting as any); - // Measured, not recalled: the opt-out really withheld the injected column. - expect((engine.registry.getObject('global_setting') as any).fields.organization_id).toBeUndefined(); - await engine.insert('global_setting', [{ key: 'a', status: 'open' }], { context: sysCtx } as any); - published.length = 0; - - await engine.update('global_setting', { key: 'swept' }, { multi: true, where: { status: 'open' }, context: member } as any); - - expect(published).toHaveLength(1); - expect(published[0].type).toBe('data.records.updated'); - expect(hasOrgKey()).toBe(false); - }); - - it('`systemFields.tenant: false` + an author-declared `organization_id` publishes it ABSENT — the wall composes nothing there (P1)', async () => { - // The R1 contract review's P1 probe. Layer 0's `tenancyDisabled` input - // folds `systemFields.tenant === false` in beside `tenancy.enabled === - // false` (`getObjectSecurityMeta`, security-plugin.ts), so on this object - // `computeTenantLayer0Filter` returns null — NO wall — while the column - // the author declared is a perfectly readable `organization_id`. R1 read - // the column and stamped the caller's organization onto a batch that - // touched another organization's row: a MISLABEL, the one direction - // this card must never take. The object exit is now the wall's own - // predicate (`carriesTenantScopeColumn`), which answers "not walled". - const sharedCatalog = { - name: 'shared_catalog', - label: 'Shared catalog', - systemFields: { tenant: false }, - fields: { - id: { name: 'id', type: 'text' as const, primaryKey: true }, - status: { name: 'status', type: 'text' as const }, - organization_id: { name: 'organization_id', type: 'text' as const }, - }, - }; - engine.registry.registerObject(sharedCatalog as any); - // Measured: the opt-out withheld the INJECTED column and the author's - // declaration survived — the column is there to be misread. - expect((engine.registry.getObject('shared_catalog') as any).fields.organization_id).toBeDefined(); - await engine.insert('shared_catalog', [ - { status: 'open', organization_id: ACTIVE_ORG }, - { status: 'open', organization_id: OTHER_ORG }, - { status: 'open' }, - ], { context: sysCtx } as any); - published.length = 0; - - await engine.update('shared_catalog', { status: 'swept' }, { multi: true, where: { status: 'open' }, context: member } as any); - - expect(published).toHaveLength(1); - expect(bulkEvent().matched).toBe(3); - expect(hasOrgKey()).toBe(false); - }); - - it('a FEDERATED object (`external` binding) publishes it ABSENT — the wall discounts the platform\'s phantom anchor', async () => { - // [#7835] The registry injects `organization_id` on an external object - // too, but the platform provisions no storage for it, so plugin-security - // reads that anchor as PHANTOM and Layer 0 composes no wall - // (`objectHasOrgIdField: false`). A key here would name an organization - // no wall constrained the batch to — the mislabel direction — so the - // producer answers absent on `external != null`. That exit is a SUPERSET - // of the wall's provenance test: a federated object whose author declared - // a real remote `organization_id` keeps its wall and is answered absent - // too (under-delivery, never a mislabel). That variant is deliberately - // NOT pinned: a future exact provenance predicate may legitimately answer - // it present, and a pin here would forbid that. - const remoteCustomer = { - name: 'remote_customer', - label: 'Remote customer', - external: { remoteName: 'customers', writable: true }, - fields: { - id: { name: 'id', type: 'text' as const, primaryKey: true }, - status: { name: 'status', type: 'text' as const }, - }, - }; - engine.registry.registerObject(remoteCustomer as any); - // Measured: the anchor IS there (injected) — absence is decided by the - // binding, never by a missing column. - expect((engine.registry.getObject('remote_customer') as any).fields.organization_id).toBeDefined(); - await engine.insert('remote_customer', [{ status: 'open', organization_id: ACTIVE_ORG }], { context: sysCtx } as any); - published.length = 0; - - await engine.update('remote_customer', { status: 'swept' }, { multi: true, where: { status: 'open' }, context: member } as any); - - expect(published).toHaveLength(1); - expect(published[0].type).toBe('data.records.updated'); - expect(hasOrgKey()).toBe(false); - }); - - it('a custom `tenancy.tenantField` is NOT an exit by itself — PRESENT while the object still carries `organization_id`, the column the wall keys on', async () => { - // The wall never reads `tenancy.tenantField`: Layer 0 keys on the literal - // `organization_id` (`objectHasOrgIdField`). An object that declares a - // custom tenant column and still carries the kernel-injected - // `organization_id` is walled on `organization_id = `, so - // the batch IS one organization's and the producer says so. R1 answered - // absent here (an under-delivery); the wall's own predicate answers - // present. ⚠️ A claim about what the wall AND-composed, not about the - // driver's native scoping column. - const workspaceDoc = { - name: 'workspace_doc', - label: 'Workspace doc', - tenancy: { tenantField: 'workspace_id' }, - fields: { - id: { name: 'id', type: 'text' as const, primaryKey: true }, - status: { name: 'status', type: 'text' as const }, - workspace_id: { name: 'workspace_id', type: 'text' as const }, - }, - }; - engine.registry.registerObject(workspaceDoc as any); - // Measured: the registry still injected the kernel column beside the - // custom one — the declaration does not withhold it. - expect((engine.registry.getObject('workspace_doc') as any).fields.organization_id).toBeDefined(); - await engine.insert('workspace_doc', [ - { status: 'open', workspace_id: 'ws_1', organization_id: ACTIVE_ORG }, - { status: 'open', workspace_id: 'ws_2', organization_id: ACTIVE_ORG }, - ], { context: sysCtx } as any); - published.length = 0; - - await engine.update('workspace_doc', { status: 'swept' }, { multi: true, where: { status: 'open' }, context: member } as any); + await sweepUpdate(sysCtx); expect(published).toHaveLength(1); expect(bulkEvent().matched).toBe(2); - expect(hasOrgKey()).toBe(true); - expect(bulkEvent().organizationId).toBe(ACTIVE_ORG); + expect(hasOrgKey()).toBe(false); }); - it('a custom `tenancy.tenantField` on an object that carries NO `organization_id` publishes it ABSENT — the custom column is never a substitute', async () => { - // `systemFields: false` is the hard opt-out: the registry injects - // nothing, and — the #8608 shape the registry's predicate exists for — - // plugin-security does NOT read it as `tenancyDisabled`, so the wall is - // decided by the column clause alone: no `organization_id` - // (`objectHasOrgIdField: false`) ⇒ no wall ⇒ nothing to assert. The - // declared `workspace_id` is not read as a stand-in: the wall does not - // key on it either. - const workspaceNote = { - name: 'workspace_note', - label: 'Workspace note', - systemFields: false, - tenancy: { tenantField: 'workspace_id' }, - fields: { - id: { name: 'id', type: 'text' as const, primaryKey: true }, - status: { name: 'status', type: 'text' as const }, - workspace_id: { name: 'workspace_id', type: 'text' as const }, - }, - }; - engine.registry.registerObject(workspaceNote as any); - // Measured: no kernel column arrived. - expect((engine.registry.getObject('workspace_note') as any).fields.organization_id).toBeUndefined(); - await engine.insert('workspace_note', [{ status: 'open', workspace_id: ACTIVE_ORG }], { context: sysCtx } as any); + it('no enforcement layer at all ⇒ ABSENT even with OS_TENANCY_POSTURE=isolated in the env and a posture provider', async () => { + // A bare engine: no middleware ever records a verdict. The env fallback + // the #8844 write refusal consults (`resolveEnginePosture`) is deliberately + // not consulted, and neither is the provider — a posture is not a wall. + process.env.OS_TENANCY_POSTURE = 'isolated'; + const bare = new ObjectQL(); + const { driver } = makeStubDriver(); + bare.registerDriver(driver, true); + await bare.init(); + bare.registry.registerObject(invoice); + bare.setRealtimeService(realtime); + bare.setTenancyPostureProvider(() => 'isolated'); + vi.spyOn((bare as any).logger, 'warn').mockImplementation(() => undefined); + await bare.insert('invoice', [{ amount: '1', status: 'open', organization_id: ACTIVE_ORG }], { context: sysCtx } as any); published.length = 0; - await engine.update('workspace_note', { status: 'swept' }, { multi: true, where: { status: 'open' }, context: member } as any); + await bare.update('invoice', { amount: '0' }, { multi: true, where: { status: 'open' }, context: member } as any); expect(published).toHaveLength(1); - expect(published[0].type).toBe('data.records.updated'); expect(hasOrgKey()).toBe(false); }); - it('an empty active organization OMITS the key AND still publishes — the empty string never reaches the validator', async () => { - // `''` is refused by `z.string().min(1)`: handed to the publish site's - // `parse` it would throw and the event would be dropped altogether. The - // gate is in the resolver, not the error handler — same rule as the - // per-record block. - const orgless = { userId: 'usr_member', tenantId: '', posture: 'MEMBER' }; + it.each([ + ['an unknown kind', { kind: 'organisation', organizationId: ACTIVE_ORG }], + ['an empty organization id', { kind: 'organization', organizationId: '' }], + ['an empty set', { kind: 'organizations', organizationIds: [] }], + ['a duplicated set', { kind: 'organizations', organizationIds: [ACTIVE_ORG, ACTIVE_ORG] }], + ['a filter shape (the wall\'s output, not its verdict)', { organization_id: ACTIVE_ORG }], + ['a bare string', ACTIVE_ORG], + ])('a recorded value that is not a verdict — %s — publishes it ABSENT and still publishes', async (_label, junk) => { + // Junk reads as "no verdict", never as an organization: the schema is the + // gate, and the failure direction that matters is a WRONG key. + recorded = junk; await seed([{ amount: '1', status: 'open', organization_id: ACTIVE_ORG }]); - await sweepUpdate(orgless); + await sweepUpdate(member); expect(published).toHaveLength(1); expect(hasOrgKey()).toBe(false); expect(() => bulkEvent()).not.toThrow(); }); - it('BulkDataEventSchema.parse at the publish site stays the validator, and the key is fed THROUGH it', () => { + it('BulkDataEventSchema.parse at the publish site stays the validator, the key is fed THROUGH it, and the resolver reads the verdict ALONE', () => { // A source pin, on the same terms as the #7809 vocabulary weld next door: // the runtime pins above prove the key is emitted; this one proves it is // emitted INSIDE the `parse` call — a stamp added onto the envelope after @@ -1126,9 +954,23 @@ describe('#15225 — a published BulkDataEvent names the organization the tenant const parseCalls = body.match(/BulkDataEventSchema\.parse\(\{[\s\S]*?\n\s*\}\);/g) ?? []; expect(parseCalls).toHaveLength(1); expect(parseCalls[0]).toContain('organizationId'); - // And the value is resolved by the wall-based helper, never the row helper - // (there is no row) and never `tenantId` on its own. + // The value is resolved by the verdict reader, never the row helper (there + // is no row) and never `tenantId` on its own. expect(body).toContain('bulkEventOrganizationId('); expect(body).not.toContain('eventOrganizationId('); + + // [#15813] The reader composes NOTHING: its body names the verdict schema + // and none of the wall's inputs. Comments stripped first — the docblock + // is allowed to NAME what is not read. + const fnStart = src.indexOf('\nfunction bulkEventOrganizationId('); + expect(fnStart).toBeGreaterThan(-1); + const fnBody = src + .slice(fnStart, src.indexOf('\n}\n', fnStart)) + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\n]*/g, ''); + expect(fnBody).toContain('TenantLayer0VerdictSchema.safeParse('); + for (const input of ['tenantId', 'accessible_org_ids', 'posture', 'isSystem', 'carriesTenantScopeColumn', 'getObject(', 'enforcedTenancyPosture', 'resolveEnginePosture']) { + expect(fnBody, `bulkEventOrganizationId reads '${input}' — the mirror is back`).not.toContain(input); + } }); }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index f3ff938abe..b3426c3bf3 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -139,11 +139,10 @@ import { import { isPlatformObjectOutOfTenantAuditScope } from './tenancy/platform-object-tenancy.js'; import { resolveTenancyPosture } from '@objectstack/types'; import { - AuthzPostureSchema, normalizeTenancyPosture, - postureEnforcesWall, - postureUsesUnionScope, + TenantLayer0VerdictSchema, type TenancyPosture, + type TenantLayer0Verdict, } from '@objectstack/spec/security'; /** @@ -176,11 +175,7 @@ import { SECRET_MASK, } from './secret-fields.js'; import { pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared'; -// [#15225] `carriesTenantScopeColumn` is the wall's own object predicate — -// "does Layer 0 key on this object?" — read from the registry's binding of it -// rather than re-spelled here (the R1 re-spelling mirrored one clause of three -// and mislabelled a batch; see `bulkEventOrganizationId`). -import { SchemaRegistry, computeFQN, carriesTenantScopeColumn, type ArtifactInstallScope } from './registry.js'; +import { SchemaRegistry, computeFQN, type ArtifactInstallScope } from './registry.js'; import { expandSearchToFilter } from './search-filter.js'; import { isSearchCompanionRequested, stripSearchCompanion } from './search-companion.js'; import { ExpressionEngine } from '@objectstack/formula'; @@ -1883,6 +1878,29 @@ export interface OperationContext { options?: any; context?: ExecutionContext; result?: any; + /** + * [#15813 / ADR-0131 D8] What the Layer 0 tenant wall DECIDED for this + * operation — recorded by the enforcement layer (`@objectstack/plugin-security`) + * at the moment it composes the wall onto the operation's predicate, and + * read by the bulk data-event producer ({@link ObjectQL.publishBulkDataEvent}) + * to stamp `BulkDataEvent.organizationId`. + * + * The seam ruled on #15706: the wall is computed ONCE, where every input is + * visible (the posture in force, the caller's organization scope, the + * object's tenancy clauses AND the deployment's #12699 carve-out, which no + * schema carries), and its decision travels here as a value. A reader + * answers from this member ALONE and re-derives nothing — a re-derivation + * is a mirror of the wall, and a mirror structurally sees only the clauses + * it was taught (the #15706 mislabel). + * + * Three states, all honest: ABSENT — no wall was composed on this + * operation (a system context, no enforcement layer mounted, a by-id write + * with no predicate); `{ kind: 'none' }` — the wall ran and contributed no + * predicate; a named verdict (`organization` / `organizations` / `deny`). + * The shape is `TenantLayer0VerdictSchema` (`@objectstack/spec/security`); + * a value that does not parse reads as absent, never as an organization. + */ + tenantLayer0Verdict?: TenantLayer0Verdict; } /** @@ -2256,9 +2274,10 @@ function eventOrganizationId(objectSchema: unknown, row: unknown): string | unde * The ONE coercion an organization value goes through before it becomes an * event's `organizationId` — shared by the per-record producer * ({@link eventOrganizationId}, which reads the row's column) and the bulk - * producer ({@link bulkEventOrganizationId}, which reads the wall's inputs - * off the execution context), so the two cannot disagree about what counts - * as an organization (#15225: one ladder, two readers — never two ladders). + * producer ({@link bulkEventOrganizationId}, which reads the wall's RECORDED + * verdict off the operation context, #15813), so the two cannot disagree + * about what counts as an organization (#15225: one ladder, two readers — + * never two ladders). * * First the write path's own "actually supplied" predicate * ({@link carriesOrganization}), so producer and consumer cannot disagree @@ -2290,103 +2309,61 @@ function eventOrganizationValue(value: unknown): string | undefined { * about a row). `packages/spec/src/api/events.zod.ts` records the divergence * on the member itself. * - * **Why the wall answers it with no second query.** The security layer - * AND-composes its Layer 0 tenant wall (ADR-0095 D1, `tenant-layer.ts`) onto - * the caller's filter before the driver, and Layer 1 cannot widen it: under - * `isolated` the wall is `organization_id = `, - * under `group` it is `organization_id IN ` - * (ADR-0105 D2). So when the wall names exactly one organization, every - * affected row belongs to it — one comparison, never a partition of the - * batch — and the producer can state that from what it already holds: the - * execution context the wall was computed from, and the posture the - * enforcement layer told this engine it enforces. - * - * **What is read, and what the answer is** (the posture table on the card, - * read against `computeTenantLayer0Filter`'s inputs — an exact mirror on the - * POSTURE and CONTEXT inputs, a PARTIAL one on the OBJECT input; the object - * bullet below says which clauses are mirrored and which the seam carries): - * - * - `enforcedPosture` is the SecurityPlugin-injected posture - * ({@link ObjectQL.enforcedTenancyPosture}), ⛔ never the env fallback - * `resolveEnginePosture()` also consults: no enforcement layer means no - * Layer 0 wall was composed at all, and the memory driver reads no - * `DriverOptions.tenantId` either, so a lean embedding has no wall - * ANYWHERE to vouch for. `single` (no wall) ⇒ absent. - * - `isSystem` short-circuits the whole security middleware ⇒ no wall ⇒ - * absent. Same for an absent context. - * - The OBJECT exit. Layer 0 composes nothing when its `tenancyDisabled` - * input is true or the object carries no `organization_id` - * (`objectHasOrgIdField`), and `getObjectSecurityMeta` - * (`security-plugin.ts`) folds THREE clauses into `tenancyDisabled`: - * ① `tenancy.enabled === false`, ② `systemFields.tenant === false`, - * ③ `orgScopingEnabled && platformGlobalObjects.has(object)` — the - * deployment's #12699 carve-out. This producer reads - * {@link carriesTenantScopeColumn}, the registry's binding of the wall's - * predicate (2026-08-14 triage ruling: the wall's derivation is - * authoritative) — clauses ① and ② plus the column clause — so an - * object the wall does not key on answers absent, and a custom - * `tenancy.tenantField` is NOT an exit by itself (the wall never reads - * it; the object is walled iff it carries `organization_id`). ⛔ What is - * NOT mirrored: clause ③ is deployment-declared and invisible to the - * engine — no `platformGlobalObjects` reading exists here — so a - * deployment-exempted object under an armed wall is still stamped with - * the caller's organization while Layer 0 composed no wall. That - * population is the seam #15706 rules on, and the reason this producer's - * PR is Blocked-by it. The wall's fourth object input, the federated - * phantom anchor (#7835: an `external` object's injected - * `organization_id` is a column no storage carries, so - * `objectHasOrgIdField` is false), is answered by the superset - * `external != null` ⇒ absent — conservative for a federated object whose - * author declared a real remote column (the wall stands there; this - * under-delivers rather than re-spelling plugin-security's provenance - * test). - * - The Layer 0 EXEMPTION: a true `PLATFORM_ADMIN` crosses the wall where - * the object's posture permits (ADR-0095 D3). The engine holds the carried - * rung (`ExecutionContext.posture`) but not the superuser write-bypass bit - * the exemption also requires, so it answers conservatively: a carried - * `PLATFORM_ADMIN` rung ⇒ absent (the batch MAY have crossed). A context - * carrying NO rung is one the plugin decides by a capability probe over - * its resolved permission sets — invisible from here — so it is absent too: - * the engine asserts only what it can vouch for. Session contexts assembled - * by the authz resolver always carry the rung. - * - `group`: the membership set, deduplicated — present only when it names - * exactly one organization, and ⛔ never `tenantId` standing in for a - * multi-membership sweep (the option-C mislabel PR #14635's open question 1 - * rejected). An unreadable member makes the set unvouchable ⇒ absent. - * - `isolated`: the caller's active organization (`tenantId`), which IS the - * wall's equality term; missing ⇒ the wall was the deny sentinel ⇒ absent - * (and no row matched anyway). - * - * Returns `undefined` for every "not asserted" case and the caller OMITS the - * key — omission is the schema's one spelling for absence (`''` is refused, - * an explicit `undefined` survives `parse` as a present key), exactly as the - * per-record site does. + * **What is read: the wall's RECORDED verdict, and nothing else** (#15813, + * the seam ruled on #15706 — option (i), ADR-0131 D8 「一道谓词,算一次」). + * The security layer AND-composes its Layer 0 tenant wall (ADR-0095 D1, + * `tenant-layer.ts`) onto the caller's predicate before the driver, and at + * that moment records what it decided on the operation context + * ({@link OperationContext.tenantLayer0Verdict}). Under `isolated` the wall + * is `organization_id = `, under `group` it is + * `organization_id IN ` (ADR-0105 D2), and Layer 1 cannot + * widen it — so when the recorded verdict names exactly one organization, + * every affected row belongs to it, and this producer says so. + * + * **What is deliberately NOT read.** The first edition of this producer + * re-derived the wall here — from the enforced posture, the execution + * context's `tenantId` / `accessible_org_ids` / `posture` rung, and the + * object schema's tenancy clauses. It could not see the third clause + * plugin-security folds into `tenancyDisabled` — the deployment-declared + * `platformGlobalObjects` carve-out (#12699), which no schema carries — and + * stamped the caller's organization onto a batch Layer 0 had never + * constrained: a WRONG key, the #13566 leak shape. The ruling's acceptance + * criterion, verbatim: the verdict recorded must be what the wall decided, + * not a re-statement of its inputs; if the recorded value can be derived by + * the reader from anything else on the context, the mirror has not been + * deleted — it has been moved. So this function reads the verdict ALONE: + * + * - `organization` ⇒ that organization; + * - `organizations` naming exactly one (the set is deduplicated at the + * source and the schema refuses duplicates) ⇒ that organization; two or + * more ⇒ absent (⛔ never `tenantId` standing in for a multi-membership + * sweep — the option-C mislabel PR #14635's open question 1 rejected); + * - `none` (the wall ran and contributed nothing: `single` posture, a + * non-tenant or deployment-exempted object, an exempt `PLATFORM_ADMIN`) + * and `deny` (the fail-closed sentinel) ⇒ absent; + * - NO recorded verdict (a system context exits the middleware first; no + * enforcement layer is mounted; the plugin recorded nothing) ⇒ absent — + * nothing composed a wall, so there is nothing to vouch for; + * - a value that does not parse as a verdict ⇒ absent. Junk never reads as + * an organization: an absent key under-delivers (a tenant-scoped consumer + * declines to fan out), a wrong key delivers a cross-organization batch + * inside the wrong wall. + * + * The organization value then goes through the ONE coercion ladder the + * per-record producer uses ({@link eventOrganizationValue}) — one ladder, two + * readers, never two ladders — and the caller OMITS the key on `undefined`: + * omission is the schema's one spelling for absence (`''` is refused, an + * explicit `undefined` survives `parse` as a present key). */ -function bulkEventOrganizationId( - objectSchema: unknown, - execCtx: ExecutionContext | undefined, - enforcedPosture: TenancyPosture | undefined, -): string | undefined { - if (enforcedPosture === undefined || !postureEnforcesWall(enforcedPosture)) return undefined; - if (!execCtx || execCtx.isSystem === true) return undefined; - if (!objectSchema || typeof objectSchema !== 'object') return undefined; - if (!carriesTenantScopeColumn(objectSchema as ServiceObject)) return undefined; - if ((objectSchema as { external?: unknown }).external != null) return undefined; - const rung = AuthzPostureSchema.safeParse(execCtx.posture); - if (!rung.success || rung.data === 'PLATFORM_ADMIN') return undefined; - if (postureUsesUnionScope(enforcedPosture)) { - const memberships = execCtx.accessible_org_ids; - if (!Array.isArray(memberships) || memberships.length === 0) return undefined; - let named: string | undefined; - for (const member of memberships) { - const id = eventOrganizationValue(member); - if (id === undefined) return undefined; - if (named === undefined) named = id; - else if (named !== id) return undefined; - } - return named; - } - return eventOrganizationValue(execCtx.tenantId); +function bulkEventOrganizationId(recordedVerdict: unknown): string | undefined { + const parsed = TenantLayer0VerdictSchema.safeParse(recordedVerdict); + if (!parsed.success) return undefined; + const verdict = parsed.data; + if (verdict.kind === 'organization') return eventOrganizationValue(verdict.organizationId); + if (verdict.kind === 'organizations' && verdict.organizationIds.length === 1) { + return eventOrganizationValue(verdict.organizationIds[0]); + } + return undefined; } /** @@ -4033,23 +4010,6 @@ export class ObjectQL implements IObjectQLEngine { } } - /** - * [#15225] The tenancy posture the ENFORCEMENT layer told this engine it - * enforces — the SecurityPlugin-injected provider's answer and nothing - * else, `undefined` when no enforcement layer injected one. - * - * Deliberately NOT {@link resolveEnginePosture}: that accessor falls back to - * the env resolution, which is the right fact for #8844's write REFUSAL (a - * lean embedding should still refuse an org-less system write on a walled - * install) and the wrong fact for asserting what a wall named. No - * SecurityPlugin ⇒ no Layer 0 wall composed ⇒ nothing to vouch for, whatever - * the operator configured. Read live per call, never cached — the same - * "no frozen verdict" rule the sibling accessor keeps. - */ - private enforcedTenancyPosture(): TenancyPosture | undefined { - return normalizeTenancyPosture(this.tenancyPostureProvider?.()); - } - /** * [#8844] The install's organizations, capped at two — only "none / exactly * one / several" changes any decision. @@ -6014,13 +5974,14 @@ export class ObjectQL implements IObjectQLEngine { * internals to whatever external URL a webhook points at. See * `BulkDataEventSchema`'s TSDoc for the full reasoning. * - * [#15225] `organizationId` — the ONE organization the tenant wall named - * for this batch, stamped from the execution context and the enforced - * posture the producer already holds (⛔ no second query on the publish - * path), and OMITTED whenever the producer cannot assert one. See - * {@link bulkEventOrganizationId} for the derivation and for why `absent` - * here means "not asserted", not the per-record "belongs to no - * organization". + * [#15225 / #15813] `organizationId` — the ONE organization the tenant + * wall named for this batch, read off the Layer 0 verdict the enforcement + * layer RECORDED on the operation context ({@link OperationContext.tenantLayer0Verdict}) + * — ⛔ no second query on the publish path, and ⛔ no re-derivation of the + * wall from posture, context or schema (the #15706 mirror) — and OMITTED + * whenever the producer cannot assert one. See {@link bulkEventOrganizationId} + * for what is read, what is deliberately not, and for why `absent` here + * means "not asserted", not the per-record "belongs to no organization". * * Same two disciplines as the per-record twin: validate before publish, and * never throw — a realtime transport problem must not roll back a committed @@ -6029,7 +5990,7 @@ export class ObjectQL implements IObjectQLEngine { private async publishBulkDataEvent( action: 'updated' | 'deleted', object: string, - input: { matched: unknown; context?: ExecutionContext }, + input: { matched: unknown; context?: ExecutionContext; tenantLayer0Verdict?: unknown }, ): Promise { if (!this.realtimeService) return; @@ -6058,16 +6019,12 @@ export class ObjectQL implements IObjectQLEngine { try { const timestamp = new Date().toISOString(); const userId = eventUserId(input.context); - // [#15225] The organization the WALL named for the whole batch — read - // off the context the wall was computed from, never off a row (there is - // none) and never `input.context.tenantId` on its own (under `group` the - // wall is the membership set). Omitted, never `''`/`undefined`, because + // [#15225 / #15813] The organization the WALL named for the whole batch + // — read off the verdict the wall RECORDED on the operation, never off + // a row (there is none), never the context, never the posture (the + // mirror #15706 deleted). Omitted, never `''`/`undefined`, because // absence has exactly one spelling in the schema. - const organizationId = bulkEventOrganizationId( - this._registry.getObject(object), - input.context, - this.enforcedTenancyPosture(), - ); + const organizationId = bulkEventOrganizationId(input.tenantLayer0Verdict); const event: BulkDataEvent = BulkDataEventSchema.parse({ id: generateEventUuid(), type: `data.records.${action}`, @@ -11942,6 +11899,9 @@ export class ObjectQL implements IObjectQLEngine { await this.publishBulkDataEvent('updated', object, { matched: result, context: opCtx.context, + // [#15813] The wall's recorded verdict rides the SAME operation + // context the middleware wrote it on. + tenantLayer0Verdict: opCtx.tenantLayer0Verdict, }); } else { const resultId = (typeof result === 'object' && result && 'id' in result) ? (result as any).id : undefined; @@ -13400,6 +13360,8 @@ export class ObjectQL implements IObjectQLEngine { await this.publishBulkDataEvent('deleted', object, { matched: result, context: opCtx.context, + // [#15813] Same seam as the update branch. + tenantLayer0Verdict: opCtx.tenantLayer0Verdict, }); } else { const resultId = (typeof result === 'object' && result && 'id' in result) ? (result as any).id : undefined; diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 6c5bb03639..3e3df79da1 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -902,20 +902,17 @@ function provisionTenantScopeIndex( * strips nothing (`plan.names` is `{ id }`), so the author's declared column is * still present when the re-stamp asks. * - * [#15225] Exported at MODULE level for the engine's bulk-event producer - * (`bulkEventOrganizationId`, engine.ts), which must answer "is this object - * walled?" in the wall's own terms and ⛔ not re-spell them a third time: the - * R1 contract review measured a re-spelling (`resolveTenantFieldName(schema) - * !== DEFAULT_TENANT_FIELD`) that mirrored ONE of the wall's object clauses - * and stamped a batch Layer 0 had never constrained. ⚠️ Deliberately NOT - * added to the package entries — `index.ts` and `core.ts` re-export NAMED - * members of this module, never `export *` — so the published surface of - * `@objectstack/objectql` is unchanged (measured on `dist/*.d.ts`, not - * assumed): this is the registry's binding of plugin-security's predicate, - * not a contract for consumers to build on; the single exported predicate - * (option C above) remains the follow-up. + * [#15225 → #15813] Briefly exported at module level for the engine's + * bulk-event producer, which answered "is this object walled?" from it — a + * binding of TWO of the wall's three object clauses, structurally blind to + * the deployment's #12699 carve-out (the #15706 finding). That producer now + * reads the Layer 0 verdict plugin-security RECORDS on the operation + * (`OperationContext.tenantLayer0Verdict`) and consults no schema predicate at + * all, so this is private again: the registry's own reading, for the two + * sites above, and ⛔ not a contract for any other package to answer the + * wall from — the wall answers for itself (ADR-0131 D8). */ -export function carriesTenantScopeColumn(schema: ServiceObject): boolean { +function carriesTenantScopeColumn(schema: ServiceObject): boolean { // Clause 1 — the wall's own two clauses, spelled here because // plugin-security spells them there (option C, the single exported // predicate, is bounded to no new `@objectstack/spec` export and no diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 5aef543375..73bbedc35f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2,7 +2,8 @@ import { Plugin, PluginContext, POSTURE_LADDER, isRowActive } from '@objectstack/core'; import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; -import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; +import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS type TenantLayer0Verdict, +} from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; // [#8220] The read-scope provenance mark: this middleware is one of the two // merge boundaries that stamp it (see the RLS injection below). @@ -65,7 +66,12 @@ import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js' import { normalizeManagedByVocab } from './normalize-managed-by.js'; import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js'; import { RLSCompiler, RLS_DENY_FILTER, policyDeclaresClause } from './rls-compiler.js'; -import { computeTenantLayer0Filter, andComposeLayers } from './tenant-layer.js'; +import { + computeTenantLayer0Verdict, + tenantLayer0FilterOf, + andComposeLayers, + intersectTenantLayer0Verdicts, +} from './tenant-layer.js'; import { PLATFORM_OWNER_WALL_BYPASS_EVENT, isVerifiedPlatformOwnerRow, @@ -3048,13 +3054,19 @@ export class SecurityPlugin implements Plugin { // below are what must normalise before it can go green again. if (opCtx.ast) { const extra: Record[] = []; - const rlsFilter = await this.computeRlsFilter( + // [#15813] The layered split rather than `computeRlsFilter`, composed + // here with the SAME `andComposeLayers` that wrapper uses — so the + // injected predicate is byte-identical to before, and the Layer 0 + // verdict that produced it is in hand to be RECORDED below. + const layered = await this.computeLayeredRlsFilter( permissionSets, opCtx.object, opCtx.operation, opCtx.context, ); + const rlsFilter = andComposeLayers(layered.layer0, layered.layer1); if (rlsFilter) extra.push(rlsFilter); + let tenantLayer0Verdict = layered.tenantLayer0Verdict; // ADR-0055: a controlled_by_parent object derives its read scope from the // master record — `masterFK IN (accessible master ids)`, AND-ed in. const cbpFilter = await this.computeControlledByParentFilter( @@ -3068,11 +3080,30 @@ export class SecurityPlugin implements Plugin { // against the delegator's own context so its userId/tenant substitutions // are faithful. if (delegatorSets) { - const delRls = await this.computeRlsFilter(delegatorSets, opCtx.object, opCtx.operation, delegatorContext); + const delLayered = await this.computeLayeredRlsFilter(delegatorSets, opCtx.object, opCtx.operation, delegatorContext); + const delRls = andComposeLayers(delLayered.layer0, delLayered.layer1); if (delRls) extra.push(delRls); + // [#15813] Two Layer 0 walls are AND-ed onto this operation; the + // recorded verdict is their intersection — what the composed wall + // decided, not the caller's half alone. + tenantLayer0Verdict = intersectTenantLayer0Verdicts(tenantLayer0Verdict, delLayered.tenantLayer0Verdict); const delCbp = await this.computeControlledByParentFilter(delegatorSets, opCtx.object, delegatorContext); if (delCbp) extra.push(delCbp); } + // [#15813 / ADR-0131 D8] RECORD the Layer 0 verdict on the operation — + // the seam ruled on #15706 (option (i)): the wall states what it + // decided, and the bulk data-event producer in the engine + // (`publishBulkDataEvent` → `bulkEventOrganizationId`) reads it + // instead of re-deriving the wall from posture + context + schema, + // which structurally could not see the deployment's #12699 carve-out + // and mislabelled that population. Written for every operation whose + // predicate the wall was composed onto (this branch: reads and + // predicate writes); a system context exits before this point and a + // by-id write carries no `ast`, so neither records anything — absence + // is a distinct, honest state ("no wall was composed"), never `none`. + // The member is declared on `OperationContext` (`@objectstack/objectql`) + // and its shape by `TenantLayer0VerdictSchema` (`@objectstack/spec`). + opCtx.tenantLayer0Verdict = tenantLayer0Verdict; // [#8220, A of #7929] This is one of the two read-scope MERGE // BOUNDARIES, and the only frame that knows which subtree the caller // did not write — so the provenance mark is stamped here, before the @@ -5448,7 +5479,17 @@ export class SecurityPlugin implements Plugin { operation: string, context: any, opts?: RlsFilterOptions, - ): Promise<{ layer0: Record | null; layer1: Record | null }> { + ): Promise<{ + layer0: Record | null; + layer1: Record | null; + /** + * [#15813] What Layer 0 DECIDED — `layer0` is this verdict's projection + * (`tenantLayer0FilterOf`), never a sibling computation. The engine + * middleware records it on the operation context so a producer downstream + * reads the wall's decision instead of re-deriving the wall. + */ + tenantLayer0Verdict: TenantLayer0Verdict; + }> { // [ADR-0095 D1] Effective filter = Layer0(tenant) AND Layer1(business RLS). // The two are computed independently and never share a compiler, a merge // step, or a bypass bit (closes W1 by construction, W2 structurally). @@ -5701,7 +5742,9 @@ export class SecurityPlugin implements Plugin { // Decides "tenant object?" directly from the field set + tenancy posture (NOT // via extractTargetField's `=`-only shape match), so a `tenancy.enabled:false` // global object correctly contributes nothing (ADR-0095 delta c). - const layer0 = computeTenantLayer0Filter({ + // [#15813] The verdict is computed ONCE; the filter injected below is its + // projection. Recording the verdict costs no second computation. + const tenantLayer0Verdict = computeTenantLayer0Verdict({ tenancyPosture: this.tenancyPosture, organizationId: context?.tenantId, // [ADR-0105 D2] The `group` wall's predicate. Resolved by @@ -5730,6 +5773,7 @@ export class SecurityPlugin implements Plugin { posturePermitsCrossTenant: posturePermits, isPlatformAdmin, }); + const layer0 = tenantLayer0FilterOf(tenantLayer0Verdict); // [#12974] Verified platform OWNER crosses the Layer 0 org wall — READS // ONLY. Maintainer ruling 2026-08-29, verbatim and untranslated: 「能不能 @@ -5781,10 +5825,13 @@ export class SecurityPlugin implements Plugin { suppressedFilter: layer0, }, ); - return { layer0: null, layer1 }; + // [#15813] The recorded verdict follows the wall as APPLIED: lifted here, + // so `none` — a reader must not learn an organization from a wall that + // was not appended. + return { layer0: null, layer1, tenantLayer0Verdict: { kind: 'none' } }; } - return { layer0, layer1 }; + return { layer0, layer1, tenantLayer0Verdict }; } /** diff --git a/packages/plugins/plugin-security/src/tenant-layer.test.ts b/packages/plugins/plugin-security/src/tenant-layer.test.ts index 98ebb03efc..60c6b6f82d 100644 --- a/packages/plugins/plugin-security/src/tenant-layer.test.ts +++ b/packages/plugins/plugin-security/src/tenant-layer.test.ts @@ -15,7 +15,13 @@ import { describe, it, expect } from 'vitest'; import { SysApiKey } from '@objectstack/platform-objects'; -import { computeTenantLayer0Filter, andComposeLayers } from './tenant-layer.js'; +import { + computeTenantLayer0Filter, + computeTenantLayer0Verdict, + tenantLayer0FilterOf, + intersectTenantLayer0Verdicts, + andComposeLayers, +} from './tenant-layer.js'; import { RLS_DENY_FILTER } from './rls-compiler.js'; /** A plain tenant business object with a member caller — the common case. */ @@ -266,3 +272,122 @@ describe('sys_api_key is not org-walled (#8287)', () => { .toEqual({ organization_id: { $in: ['org-a', 'org-b'] } }); }); }); + +/** + * [#15813] The verdict is the wall's ONE computation; the filter is its + * projection. Pinned as values per exit (not as "filter equals projection", + * which the implementation makes true by construction), so a later edit that + * moves an exit — say, teaching `deny` to fall through to `none` — is caught + * on the verdict a downstream reader consumes. + */ +describe('computeTenantLayer0Verdict — what the wall decided, per exit (#15813)', () => { + it('single → none', () => { + expect(computeTenantLayer0Verdict({ ...base, tenancyPosture: 'single' })).toEqual({ kind: 'none' }); + }); + + it('isolated → organization (the active organization, never the membership set)', () => { + expect(computeTenantLayer0Verdict({ ...base, tenancyPosture: 'isolated' })).toEqual({ + kind: 'organization', + organizationId: 'org-a', + }); + }); + + it('isolated with no active organization → deny', () => { + expect(computeTenantLayer0Verdict({ ...base, tenancyPosture: 'isolated', organizationId: undefined })).toEqual({ kind: 'deny' }); + }); + + it('group → organizations (the membership set, DEDUPLICATED)', () => { + expect( + computeTenantLayer0Verdict({ ...base, tenancyPosture: 'group', accessibleOrgIds: ['org-a', 'org-b', 'org-a'] }), + ).toEqual({ kind: 'organizations', organizationIds: ['org-a', 'org-b'] }); + }); + + it('group with an empty or absent membership set → deny', () => { + expect(computeTenantLayer0Verdict({ ...base, tenancyPosture: 'group', accessibleOrgIds: [] })).toEqual({ kind: 'deny' }); + expect(computeTenantLayer0Verdict({ ...base, tenancyPosture: 'group', accessibleOrgIds: undefined })).toEqual({ kind: 'deny' }); + }); + + it.each(['group', 'isolated'] as const)('%s: a tenancy-disabled object → none', (tenancyPosture) => { + expect(computeTenantLayer0Verdict({ ...base, tenancyPosture, tenancyDisabled: true })).toEqual({ kind: 'none' }); + }); + + it.each(['group', 'isolated'] as const)('%s: an object with no organization_id → none', (tenancyPosture) => { + expect(computeTenantLayer0Verdict({ ...base, tenancyPosture, objectHasOrgIdField: false })).toEqual({ kind: 'none' }); + }); + + it.each(['group', 'isolated'] as const)('%s: a platform admin on a posture-permitting object → none; on a public object the wall stands', (tenancyPosture) => { + expect( + computeTenantLayer0Verdict({ ...base, tenancyPosture, isPlatformAdmin: true, posturePermitsCrossTenant: true }), + ).toEqual({ kind: 'none' }); + expect( + computeTenantLayer0Verdict({ ...base, tenancyPosture, isPlatformAdmin: true, posturePermitsCrossTenant: false }).kind, + ).not.toBe('none'); + }); + + it('the filter IS the projection of the verdict, on every exit', () => { + const inputs = [ + { ...base, tenancyPosture: 'single' as const }, + { ...base, tenancyPosture: 'isolated' as const }, + { ...base, tenancyPosture: 'isolated' as const, organizationId: undefined }, + { ...base, tenancyPosture: 'group' as const }, + { ...base, tenancyPosture: 'group' as const, accessibleOrgIds: [] }, + { ...base, tenancyPosture: 'group' as const, tenancyDisabled: true }, + { ...base, tenancyPosture: 'isolated' as const, isPlatformAdmin: true, posturePermitsCrossTenant: true }, + ]; + for (const input of inputs) { + expect(computeTenantLayer0Filter(input)).toEqual(tenantLayer0FilterOf(computeTenantLayer0Verdict(input))); + } + }); + + it('projecting `deny` spreads the sentinel — equal by value, never the frozen object itself', () => { + const projected = tenantLayer0FilterOf({ kind: 'deny' }); + expect(projected).toEqual(RLS_DENY_FILTER); + expect(projected).not.toBe(RLS_DENY_FILTER); + }); + + it('projecting `organizations` copies the set (a later mutation cannot reach the compiled filter)', () => { + const ids = ['org-a']; + const projected = tenantLayer0FilterOf({ kind: 'organizations', organizationIds: ids }) as { organization_id: { $in: string[] } }; + ids.push('org-victim'); + expect(projected.organization_id.$in).toEqual(['org-a']); + }); +}); + +/** + * [#15813 / ADR-0090 D10] Two walls on one operation: the recorded verdict is + * their intersection — what the composed predicate decided. + */ +describe('intersectTenantLayer0Verdicts — the on-behalf-of composition (#15813)', () => { + const org = (organizationId: string) => ({ kind: 'organization' as const, organizationId }); + const orgs = (...organizationIds: string[]) => ({ kind: 'organizations' as const, organizationIds }); + const NONE = { kind: 'none' as const }; + const DENY = { kind: 'deny' as const }; + + it('`none` on one side yields the other — it contributed no predicate', () => { + expect(intersectTenantLayer0Verdicts(NONE, org('a'))).toEqual(org('a')); + expect(intersectTenantLayer0Verdicts(orgs('a', 'b'), NONE)).toEqual(orgs('a', 'b')); + expect(intersectTenantLayer0Verdicts(NONE, NONE)).toEqual(NONE); + }); + + it('`deny` on either side denies — no row clears an empty scope', () => { + expect(intersectTenantLayer0Verdicts(DENY, org('a'))).toEqual(DENY); + expect(intersectTenantLayer0Verdicts(NONE, DENY)).toEqual(DENY); + }); + + it('two equalities: the same organization stays; different organizations deny', () => { + expect(intersectTenantLayer0Verdicts(org('a'), org('a'))).toEqual(org('a')); + expect(intersectTenantLayer0Verdicts(org('a'), org('b'))).toEqual(DENY); + }); + + it('an equality against a set containing it IS the equality; outside the set it denies', () => { + expect(intersectTenantLayer0Verdicts(org('a'), orgs('a', 'b'))).toEqual(org('a')); + expect(intersectTenantLayer0Verdicts(orgs('a', 'b'), org('b'))).toEqual(org('b')); + expect(intersectTenantLayer0Verdicts(org('c'), orgs('a', 'b'))).toEqual(DENY); + }); + + it('two sets intersect; a one-member intersection stays a set (both walls were unions); empty denies', () => { + expect(intersectTenantLayer0Verdicts(orgs('a', 'b'), orgs('b', 'c'))).toEqual(orgs('b')); + expect(intersectTenantLayer0Verdicts(orgs('a', 'b', 'c'), orgs('c', 'a'))).toEqual(orgs('a', 'c')); + expect(intersectTenantLayer0Verdicts(orgs('a', 'b'), orgs('c'))).toEqual(DENY); + }); +}); diff --git a/packages/plugins/plugin-security/src/tenant-layer.ts b/packages/plugins/plugin-security/src/tenant-layer.ts index 002e91a7b8..f33e0947e6 100644 --- a/packages/plugins/plugin-security/src/tenant-layer.ts +++ b/packages/plugins/plugin-security/src/tenant-layer.ts @@ -4,6 +4,7 @@ import { postureEnforcesWall, postureUsesUnionScope, type TenancyPosture, + type TenantLayer0Verdict, } from '@objectstack/spec/security'; import { RLS_DENY_FILTER } from './rls-compiler.js'; @@ -93,43 +94,51 @@ export interface TenantLayer0Input { 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 }); + /** - * Compute the Layer 0 (tenant) filter to AND onto a read/write. + * [#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. * - * - `null` → Layer 0 contributes nothing (`single` posture; non-tenant object; - * or an exempt platform admin). The caller applies only Layer 1. - * - `{ organization_id: }` → the `isolated` wall, AND-composed unconditionally. - * - `{ organization_id: { $in: [...] } }` → the `group` union wall (ADR-0105 D2). - * - {@link RLS_DENY_FILTER} → 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). + * 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. * - * Only the PREDICATE widens between `isolated` and `group`; the composition - * rules do not. Layer 0 is still computed independently of the RLS compiler, - * still AND-composed first, and still crossable only by a true `PLATFORM_ADMIN` - * — so ADR-0095's W1 (business RLS cannot weaken the wall) and W2 (the - * superuser bypass cannot cross it) hold in every posture (ADR-0105 D2/D4). + * - `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). */ -export function computeTenantLayer0Filter( - input: TenantLayer0Input, -): Record | null { +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 null; + 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 null; - if (input.objectHasOrgIdField === false) return null; + 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 null; + 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 @@ -138,13 +147,96 @@ export function computeTenantLayer0Filter( // 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 { ...RLS_DENY_FILTER }; - return { organization_id: { $in: [...orgIds] } }; + 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 { ...RLS_DENY_FILTER }; - return { organization_id: input.organizationId }; + if (!input.organizationId) return LAYER0_DENY; + return { kind: 'organization', organizationId: input.organizationId }; +} + +/** + * Project a Layer 0 verdict onto the `where` predicate the wall AND-composes — + * the ONE spelling of each verdict as a filter. `computeTenantLayer0Filter` + * is `tenantLayer0FilterOf(computeTenantLayer0Verdict(input))` and nothing + * else. + * + * `deny` SPREADS {@link RLS_DENY_FILTER} rather than returning the frozen + * object (`isTenantWallDenial` in `security-plugin.ts` compares by value for + * exactly this reason); `organizations` copies the set so a later mutation of + * the verdict cannot reach a compiled filter. + */ +export function tenantLayer0FilterOf(verdict: TenantLayer0Verdict): Record | null { + switch (verdict.kind) { + case 'none': + return null; + case 'deny': + return { ...RLS_DENY_FILTER }; + case 'organization': + return { organization_id: verdict.organizationId }; + case 'organizations': + return { organization_id: { $in: [...verdict.organizationIds] } }; + } +} + +/** + * Compute the Layer 0 (tenant) filter to AND onto a read/write. + * + * - `null` → Layer 0 contributes nothing (`single` posture; non-tenant object; + * or an exempt platform admin). The caller applies only Layer 1. + * - `{ organization_id: }` → the `isolated` wall, AND-composed unconditionally. + * - `{ organization_id: { $in: [...] } }` → the `group` union wall (ADR-0105 D2). + * - {@link RLS_DENY_FILTER} → 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). + * + * Only the PREDICATE widens between `isolated` and `group`; the composition + * rules do not. Layer 0 is still computed independently of the RLS compiler, + * still AND-composed first, and still crossable only by a true `PLATFORM_ADMIN` + * — so ADR-0095's W1 (business RLS cannot weaken the wall) and W2 (the + * superuser bypass cannot cross it) hold in every posture (ADR-0105 D2/D4). + * + * [#15813] A projection of {@link computeTenantLayer0Verdict} — one predicate, + * computed once (ADR-0131 D8); the middleware records the verdict on the + * operation and injects this projection, from the same computation. + */ +export function computeTenantLayer0Filter( + input: TenantLayer0Input, +): Record | null { + return tenantLayer0FilterOf(computeTenantLayer0Verdict(input)); +} + +/** + * [#15813 / ADR-0090 D10] The verdict of TWO Layer 0 walls AND-composed onto + * one operation — the on-behalf-of shape, where the caller's wall and the + * delegator's wall are both injected and a row must satisfy both. + * + * Set intersection, in the wall's own terms: `deny` on either side denies (no + * row clears an empty scope); `none` on one side yields the other (it + * contributed no predicate); two named scopes intersect, an empty intersection + * is `deny`, and a one-organization intersection that either side spelled as + * the `isolated` equality is reported as `organization` — the AND of `= X` + * with any set containing X IS `= X`. Pure, so the middleware records the + * composed verdict without recomputing either wall. + */ +export function intersectTenantLayer0Verdicts( + a: TenantLayer0Verdict, + b: TenantLayer0Verdict, +): TenantLayer0Verdict { + if (a.kind === 'deny' || b.kind === 'deny') return LAYER0_DENY; + if (a.kind === 'none') return b; + if (b.kind === 'none') return a; + const idsOf = (v: TenantLayer0Verdict): readonly string[] => + v.kind === 'organization' ? [v.organizationId] : v.kind === 'organizations' ? v.organizationIds : []; + const right = new Set(idsOf(b)); + const both = idsOf(a).filter((id) => right.has(id)); + if (both.length === 0) return LAYER0_DENY; + if (both.length === 1 && (a.kind === 'organization' || b.kind === 'organization')) { + return { kind: 'organization', organizationId: both[0] }; + } + return { kind: 'organizations', organizationIds: both }; } /** diff --git a/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts b/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts new file mode 100644 index 0000000000..bdaeffb843 --- /dev/null +++ b/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts @@ -0,0 +1,250 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15813] The security middleware RECORDS its Layer 0 verdict on the + * operation context — `opCtx.tenantLayer0Verdict` — at the moment it composes + * the wall onto the operation's predicate, so a producer downstream + * (`publishBulkDataEvent`, `@objectstack/objectql`) reads what the wall + * decided instead of re-deriving it (the #15706 ruling: seam (i)). + * + * What these pins hold: + * + * 1. The recorded verdict IS the wall that was composed — every case reads + * the verdict AND the injected `ast.where` off ONE middleware pass, so a + * verdict that disagreed with the predicate would fail here first. + * 2. The populations the engine could never answer are answered HERE, from + * inputs only this plugin sees: the deployment's #12699 carve-out (`none` + * under an armed wall — the #15706 population), a `PLATFORM_ADMIN` rung + * on a PUBLIC tenant object (`organization` — the wall stands), a + * `PLATFORM_ADMIN` on a posture-permitting object (`none` — the wall was + * crossed), and a hand-built context carrying no rung whose exemption + * the capability probe decides. + * 3. Nothing is recorded where no wall was composed: a system context (the + * middleware's first exit) and a by-id write (no predicate to compose + * onto). Absence is a distinct state from `none`. + * + * Harness: `deployment-platform-global-exemption.test.ts` — a SecurityPlugin + * over a fake ObjectQL. The registered middleware is captured and driven with + * a hand-built predicate-write `opCtx`; `next` is a no-op, so the assertion is + * about what the middleware left on the context, not about a driver. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const PLAIN_MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as unknown as PermissionSet; + +const ADMIN_SET = defaultPermissionSets.find((s) => s.name === ADMIN_FULL_ACCESS); +if (!ADMIN_SET) throw new Error(`fixture: '${ADMIN_FULL_ACCESS}' is not among the default permission sets`); + +/** An ordinary member of `org-1`, rung carried as the authz resolver would. */ +const MEMBER_CTX = { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' }; + +const localSchema = (name: string, extra: Record = {}) => ({ + name, + fields: { + organization_id: { type: 'text', label: 'Organization' }, + title: { type: 'text', label: 'Title' }, + status: { type: 'text', label: 'Status' }, + }, + ...extra, +}); + +const SCHEMAS: Record> = { + crm_task: localSchema('crm_task'), + sys_widget_registry: localSchema('sys_widget_registry'), + sys_catalog: localSchema('sys_catalog', { tenancy: { enabled: false } }), + crm_secret: localSchema('crm_secret', { access: { default: 'private' } }), +}; + +async function boot(opts: { entitlement?: Record; tenancy?: { posture: string }; sets?: PermissionSet[] } = {}) { + const middlewares: Array<(opCtx: any, next: () => Promise) => Promise> = []; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { + registerMiddleware: (mw: any) => middlewares.push(mw), + getSchema: (name: string) => SCHEMAS[name], + findOne: vi.fn(async () => null), + }, + metadata: { + get: async (_type: string, name: string) => SCHEMAS[name], + list: async () => opts.sets ?? [PLAIN_MEMBER], + }, + 'org-scoping': { name: 'com.objectstack.org-scoping', ...(opts.entitlement ?? {}) }, + }; + if (opts.tenancy) services['tenancy'] = opts.tenancy; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const ctx: Record = { + logger, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as any); + await plugin.start(ctx as any); + if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware'); + return { plugin, logger, middleware: middlewares[0] }; +} + +/** A predicate write — the `multi: true` shape, which carries an `ast`. */ +function sweep(object: string, operation: 'update' | 'delete', context: Record) { + const where = { status: 'open' }; + const opCtx: any = { + object, + operation, + context: { ...context }, + options: { where, multi: true }, + ast: { where }, + }; + if (operation === 'update') opCtx.data = { title: 'swept' }; + return opCtx; +} + +const hasVerdict = (opCtx: any) => Object.prototype.hasOwnProperty.call(opCtx, 'tenantLayer0Verdict'); +const injectedOrgWall = (opCtx: any): unknown => { + // The wall is AND-ed under the caller's predicate; find the organization_id clause. + const walk = (node: any): unknown => { + if (!node || typeof node !== 'object') return undefined; + if ('organization_id' in node) return node.organization_id; + for (const part of node.$and ?? []) { + const hit = walk(part); + if (hit !== undefined) return hit; + } + return undefined; + }; + return walk(opCtx.ast?.where); +}; + +describe('[#15813] the middleware records the Layer 0 verdict it composed — one pass, verdict and predicate agree', () => { + it('`isolated`, a member with an active organization: `organization`, and the injected wall is that equality', async () => { + const { middleware } = await boot(); + const opCtx = sweep('crm_task', 'update', MEMBER_CTX); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); + expect(injectedOrgWall(opCtx)).toBe('org-1'); + }); + + it('the predicate DELETE path records it too', async () => { + const { middleware } = await boot(); + const opCtx = sweep('crm_task', 'delete', MEMBER_CTX); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); + expect(injectedOrgWall(opCtx)).toBe('org-1'); + }); + + it('`group`: `organizations` is the membership SET, and the injected wall is the same set', async () => { + const { middleware } = await boot({ tenancy: { posture: 'group' } }); + const opCtx = sweep('crm_task', 'update', { ...MEMBER_CTX, accessible_org_ids: ['org-1', 'org-2'] }); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organizations', organizationIds: ['org-1', 'org-2'] }); + expect(injectedOrgWall(opCtx)).toEqual({ $in: ['org-1', 'org-2'] }); + }); + + it('`group` with a repeated membership records the organization ONCE — a set, so a reader may test length === 1', async () => { + const { middleware } = await boot({ tenancy: { posture: 'group' } }); + const opCtx = sweep('crm_task', 'update', { ...MEMBER_CTX, accessible_org_ids: ['org-1', 'org-1'] }); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organizations', organizationIds: ['org-1'] }); + expect(injectedOrgWall(opCtx)).toEqual({ $in: ['org-1'] }); + }); + + it('`single`: `none` — the wall ran and contributed nothing; nothing is injected', async () => { + const { middleware } = await boot({ tenancy: { posture: 'single' } }); + const opCtx = sweep('crm_task', 'update', MEMBER_CTX); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' }); + expect(injectedOrgWall(opCtx)).toBeUndefined(); + }); + + it('an org-less caller under `isolated` records `deny` on a predicate DELETE — the fail-closed sentinel, not an organization', async () => { + // `delete` is deliberately outside the ADR-0123 D2 write refusal (it + // places no row), so the operation reaches the wall and the wall denies. + const { middleware } = await boot(); + const opCtx = sweep('crm_task', 'delete', { userId: 'u1', positions: [], permissions: [], posture: 'MEMBER' }); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'deny' }); + }); +}); + +describe('[#15813] the populations the engine could never answer are answered where the wall is computed', () => { + it('the deployment\'s #12699 carve-out: an exempted object under an armed wall records `none` — the #15706 population', async () => { + const { middleware } = await boot({ entitlement: { platformGlobalObjects: ['sys_widget_registry'] } }); + const exempted = sweep('sys_widget_registry', 'update', MEMBER_CTX); + await middleware(exempted, async () => {}); + expect(exempted.tenantLayer0Verdict).toEqual({ kind: 'none' }); + expect(injectedOrgWall(exempted)).toBeUndefined(); + // Firing control on the same deployment: the sibling is walled and says so. + const sibling = sweep('crm_task', 'update', MEMBER_CTX); + await middleware(sibling, async () => {}); + expect(sibling.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); + }); + + it('an object that opted out of tenancy (`tenancy.enabled: false`) records `none`', async () => { + const { middleware } = await boot(); + const opCtx = sweep('sys_catalog', 'update', MEMBER_CTX); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' }); + }); + + it('a `PLATFORM_ADMIN` rung on a PUBLIC tenant object records `organization` — the wall STANDS there (the engine answered this absent)', async () => { + const { middleware } = await boot({ sets: [PLAIN_MEMBER, ADMIN_SET as PermissionSet] }); + const opCtx = sweep('crm_task', 'update', { ...MEMBER_CTX, permissions: [ADMIN_FULL_ACCESS], posture: 'PLATFORM_ADMIN' }); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); + expect(injectedOrgWall(opCtx)).toBe('org-1'); + }); + + it('a `PLATFORM_ADMIN` on a posture-permitting (private) object records `none` — the wall was crossed, so no organization is asserted', async () => { + const { middleware } = await boot({ sets: [PLAIN_MEMBER, ADMIN_SET as PermissionSet] }); + const opCtx = sweep('crm_secret', 'update', { ...MEMBER_CTX, permissions: [ADMIN_FULL_ACCESS], posture: 'PLATFORM_ADMIN' }); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' }); + expect(injectedOrgWall(opCtx)).toBeUndefined(); + }); + + it('a hand-built context carrying NO rung is decided by the capability probe: platform caps cross a private object, a plain member does not', async () => { + const { middleware } = await boot({ sets: [PLAIN_MEMBER, ADMIN_SET as PermissionSet] }); + const { posture: _drop, ...rungless } = MEMBER_CTX; + const admin = sweep('crm_secret', 'update', { ...rungless, permissions: [ADMIN_FULL_ACCESS] }); + await middleware(admin, async () => {}); + expect(admin.tenantLayer0Verdict).toEqual({ kind: 'none' }); + const member = sweep('crm_secret', 'update', { ...rungless }); + await middleware(member, async () => {}); + expect(member.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); + }); +}); + +describe('[#15813] nothing is recorded where no wall was composed — absence is a distinct state', () => { + it('a system context takes the middleware\'s first exit: no verdict member at all', async () => { + const { middleware } = await boot(); + const opCtx = sweep('crm_task', 'update', { isSystem: true, userId: 'usr_system', tenantId: 'org-1' }); + await middleware(opCtx, async () => {}); + expect(hasVerdict(opCtx)).toBe(false); + }); + + it('a by-id write carries no predicate to compose onto: no verdict member', async () => { + const { middleware } = await boot(); + const opCtx: any = { object: 'crm_task', operation: 'update', data: { id: 'r1', title: 'x' }, context: { ...MEMBER_CTX } }; + // The pre-image gate re-reads the row through the (fake) engine and refuses + // a row it cannot see — irrelevant here: the assertion is about what the + // middleware left on the context, and step 3 is never reached without an ast. + await middleware(opCtx, async () => {}).catch(() => undefined); + expect(hasVerdict(opCtx)).toBe(false); + }); + + it('the public `getReadFilter` (no operation context) is byte-identical: the filter is the verdict\'s projection', async () => { + const { plugin } = await boot(); + expect(await (plugin as any).getReadFilter('crm_task', MEMBER_CTX)).toEqual({ organization_id: 'org-1' }); + expect(await (plugin as any).getReadFilter('sys_catalog', MEMBER_CTX)).toBeUndefined(); + }); +}); diff --git a/packages/spec/api-surface/security.json b/packages/spec/api-surface/security.json index d0cba42252..1b5aa5c6ae 100644 --- a/packages/spec/api-surface/security.json +++ b/packages/spec/api-surface/security.json @@ -74,6 +74,8 @@ "TENANCY_POSTURES (const)", "TenancyPosture (type)", "TenancyPostureSchema (const)", + "TenantLayer0Verdict (type)", + "TenantLayer0VerdictSchema (const)", "defineCapability (function)", "definePermissionSet (function)", "defineSharingRule (function)", diff --git a/packages/spec/declaration-map/security.json b/packages/spec/declaration-map/security.json index fa75d9d9d9..15b1b91e2a 100644 --- a/packages/spec/declaration-map/security.json +++ b/packages/spec/declaration-map/security.json @@ -54,7 +54,9 @@ "SharingRuleSchema": "security/SharingRule", "SharingRuleType": "security/SharingRuleType", "TenancyPosture": "security/TenancyPosture", - "TenancyPostureSchema": "security/TenancyPosture" + "TenancyPostureSchema": "security/TenancyPosture", + "TenantLayer0Verdict": "security/TenantLayer0Verdict", + "TenantLayer0VerdictSchema": "security/TenantLayer0Verdict" }, "collisions": [ "BaseSharingRuleSchema" diff --git a/packages/spec/export-origins/security.json b/packages/spec/export-origins/security.json index dee36798f4..1a465a67b0 100644 --- a/packages/spec/export-origins/security.json +++ b/packages/spec/export-origins/security.json @@ -74,6 +74,8 @@ "TENANCY_POSTURES": "src/security/tenancy-posture.ts#TENANCY_POSTURES (const)", "TenancyPosture": "src/security/tenancy-posture.ts#TenancyPosture (type)", "TenancyPostureSchema": "src/security/tenancy-posture.ts#TenancyPostureSchema (const)", + "TenantLayer0Verdict": "src/security/tenant-layer0-verdict.ts#TenantLayer0Verdict (type)", + "TenantLayer0VerdictSchema": "src/security/tenant-layer0-verdict.ts#TenantLayer0VerdictSchema (const)", "defineCapability": "src/security/capabilities.ts#defineCapability (function)", "definePermissionSet": "src/security/permission.zod.ts#definePermissionSet (function)", "defineSharingRule": "src/security/sharing.zod.ts#defineSharingRule (function)", diff --git a/packages/spec/json-schema.manifest/security.json b/packages/spec/json-schema.manifest/security.json index 8961a21047..6a7f6a0551 100644 --- a/packages/spec/json-schema.manifest/security.json +++ b/packages/spec/json-schema.manifest/security.json @@ -30,6 +30,7 @@ "security/SharingLevel", "security/SharingRule", "security/SharingRuleType", - "security/TenancyPosture" + "security/TenancyPosture", + "security/TenantLayer0Verdict" ] } diff --git a/packages/spec/src/security/index.ts b/packages/spec/src/security/index.ts index bb76c1c1b2..544fef7241 100644 --- a/packages/spec/src/security/index.ts +++ b/packages/spec/src/security/index.ts @@ -23,3 +23,4 @@ export * from './explain.zod'; export * from './sharing.zod'; export * from './rls.zod'; export * from './tenancy-posture'; +export * from './tenant-layer0-verdict'; diff --git a/packages/spec/src/security/tenant-layer0-verdict.test.ts b/packages/spec/src/security/tenant-layer0-verdict.test.ts new file mode 100644 index 0000000000..59a0b49b39 --- /dev/null +++ b/packages/spec/src/security/tenant-layer0-verdict.test.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15813] `TenantLayer0VerdictSchema` — the shape the enforcement layer + * records on an operation and a producer reads back. + * + * The reader's safety rests on one property: anything that is NOT one of the + * four verdicts fails to parse, so junk can only ever read as "no verdict" and + * never as an organization. These pins hold the schema to that: every accepted + * shape, and every refusal a reader relies on. + */ + +import { describe, it, expect } from 'vitest'; +import { TenantLayer0VerdictSchema } from './tenant-layer0-verdict'; + +describe('[#15813] TenantLayer0VerdictSchema — the four verdicts', () => { + it.each([ + ['none', { kind: 'none' }], + ['organization', { kind: 'organization', organizationId: 'org_acme' }], + ['organizations (one member)', { kind: 'organizations', organizationIds: ['org_plant_a'] }], + ['organizations (several)', { kind: 'organizations', organizationIds: ['org_plant_a', 'org_plant_b'] }], + ['deny', { kind: 'deny' }], + ])('accepts %s', (_label, value) => { + const parsed = TenantLayer0VerdictSchema.safeParse(value); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data).toEqual(value); + }); +}); + +describe('[#15813] TenantLayer0VerdictSchema — junk is refused, never read as an organization', () => { + it.each([ + ['an unknown kind', { kind: 'organisation', organizationId: 'org_acme' }], + ['an empty organization id', { kind: 'organization', organizationId: '' }], + ['a non-string organization id', { kind: 'organization', organizationId: 42 }], + ['an empty set', { kind: 'organizations', organizationIds: [] }], + ['a set with a duplicate', { kind: 'organizations', organizationIds: ['org_a', 'org_a'] }], + ['a set with an empty member', { kind: 'organizations', organizationIds: ['org_a', ''] }], + ['a set carried as a string', { kind: 'organizations', organizationIds: 'org_a' }], + ['an extra key (strict)', { kind: 'none', organizationId: 'org_acme' }], + ['`organization` carrying a set as well', { kind: 'organization', organizationId: 'org_a', organizationIds: ['org_a'] }], + ['a bare string', 'org_acme'], + ['null', null], + ['undefined', undefined], + ['a filter shape (the wall\'s OUTPUT, not its verdict)', { organization_id: 'org_acme' }], + ])('refuses %s', (_label, value) => { + expect(TenantLayer0VerdictSchema.safeParse(value).success).toBe(false); + }); +}); diff --git a/packages/spec/src/security/tenant-layer0-verdict.ts b/packages/spec/src/security/tenant-layer0-verdict.ts new file mode 100644 index 0000000000..af9f17cf4d --- /dev/null +++ b/packages/spec/src/security/tenant-layer0-verdict.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0095 D1 / ADR-0131 D8 / #15813] The Layer 0 tenant wall's VERDICT for + * one operation — what the wall DECIDED, recorded by the enforcement layer at + * the moment it applies the wall, so a downstream reader consumes the decision + * instead of re-deriving it. + * + * ## Why this is a contract and not a private note + * + * `plugin-security` computes the Layer 0 predicate once per operation + * (`computeTenantLayer0Filter`, `tenant-layer.ts`) from inputs that only IT + * can see in full: the posture in force, the caller's organization scope, the + * object's own tenancy declaration — and the DEPLOYMENT's carve-out + * ({@link OrgScopingEntitlement.platformGlobalObjects}, #12699), which no + * object schema carries. A producer in another package that needs to know what + * the wall decided — the bulk data-event publisher in `@objectstack/objectql`, + * which stamps `BulkDataEvent.organizationId` only when the wall named exactly + * one organization — cannot re-derive it: every re-derivation is a MIRROR of + * the predicate, and a mirror structurally sees only the clauses it was taught. + * The #15706 finding measured exactly that: a deployment-exempted object under + * an armed wall was stamped with the caller's organization while Layer 0 had + * composed no wall at all — a wrong key, the #13566 leak shape. ADR-0131 D8 — + * 「一道谓词,算一次」 — is the rule; this schema is how the one computation + * travels to its readers. The 2026-09-05 ruling on #15706 chose this seam + * over a second per-object provider: (i) records a value that already exists + * at the moment the wall is applied. + * + * ## The four verdicts + * + * | `kind` | what the wall composed | when | + * |-----------------|-------------------------------------------------|------| + * | `none` | nothing — Layer 0 contributed no predicate | `single` posture; a non-tenant object (no `organization_id` column, `tenancy.enabled: false`, `systemFields.tenant: false`, or the deployment's #12699 carve-out); an exempt `PLATFORM_ADMIN` on a posture-permitting object; the #12974 verified-owner READ bypass | + * | `organization` | `organization_id = organizationId` | `isolated` — the hard wall names exactly one organization | + * | `organizations` | `organization_id IN organizationIds` | `group` — the caller's membership set (ADR-0105 D2); a SET: distinct, non-empty | + * | `deny` | the fail-closed sentinel (zero rows / refused) | a walled posture on a tenant object with no organization scope to enforce with | + * + * ## Reading rules + * + * - A reader answers from the verdict ALONE. If the answer could be derived + * from anything else on the context, the mirror has not been deleted — it + * has been moved (the #15706 ruling, verbatim). + * - The shape is `.strict()`. A recorded value that does not parse is JUNK; a + * reader treats junk as "no verdict", never as any organization — the + * failure direction that matters is a WRONG key, not a missing one. + * - ABSENCE of a recorded verdict is a third state, distinct from `none`: the + * enforcement layer composed no wall on this operation at all (a system + * context, no security plugin mounted, an operation carrying no predicate). + * `none` is a wall that RAN and contributed nothing; absence is "nothing to + * vouch for". Both read as "not asserted" to a reader that needs one + * organization. + * - `organizations` with exactly one member IS a wall that named exactly one + * organization. The set is deduplicated at the source and the schema refuses + * duplicates, so a reader may test `length === 1` and read no further. + */ + +import { z } from 'zod'; + +/** One organization id as the wall spells it — never the empty string. */ +const WallOrganizationIdSchema = z.string().min(1); + +export const TenantLayer0VerdictSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('none') }).strict(), + z + .object({ + kind: z.literal('organization'), + organizationId: WallOrganizationIdSchema, + }) + .strict(), + z + .object({ + kind: z.literal('organizations'), + organizationIds: z + .array(WallOrganizationIdSchema) + .min(1) + .refine((ids) => new Set(ids).size === ids.length, { + message: 'organizationIds is a SET: an organization the wall named appears once', + }) + .readonly(), + }) + .strict(), + z.object({ kind: z.literal('deny') }).strict(), +]); +export type TenantLayer0Verdict = z.infer; From c99239920d2f2804e95184599422bbcf9c939179 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 09:56:42 +0000 Subject: [PATCH 2/3] wip(security): pins, end-to-end weld, system-context census row, regenerated spec artifacts (#15813) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/permissions/system-context.mdx | 52 ++--- .../objectql/src/engine-data-events.test.ts | 11 +- .../plugin-security/src/security-plugin.ts | 5 +- .../tenant-layer0-verdict-end-to-end.test.ts | 202 ++++++++++++++++++ ...tenant-layer0-verdict-on-operation.test.ts | 19 +- 5 files changed, 252 insertions(+), 37 deletions(-) create mode 100644 packages/plugins/plugin-security/src/tenant-layer0-verdict-end-to-end.test.ts diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 6d1f6564ca..bfe5b18ea1 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -9,7 +9,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **107 +because the flag is not one concept: it is a single boolean read at **106 distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-three exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, @@ -87,14 +87,14 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `security-plugin.ts:1615` | -| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2541` (the step 3.5 block), skipped by `:1615` | -| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4344` | -| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4495` | -| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4573` | -| 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `security-plugin.ts:1442`, `:1464` | +| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `security-plugin.ts:1620` | +| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2546` (the step 3.5 block), skipped by `:1620` | +| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4374` | +| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4525` | +| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4603` | +| 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `security-plugin.ts:1447`, `:1469` | | 7 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `object-schema-fls.ts:228` | -| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` | +| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3887` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` | | 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1412` | @@ -109,23 +109,23 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11623` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11806` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10356` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11580` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11763` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10313` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1795` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10404`, `readonly-strict-errors.ts:66` | -| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:6223` | -| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3936`, `:3946`, `:3973` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10361`, `readonly-strict-errors.ts:66` | +| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:6180` | +| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3913`, `:3923`, `:3950` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` | -| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6921` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12422` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12351` | -| 30 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | objectql | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: the security middleware composed no tenant wall for a system write, so the producer cannot vouch that every row a predicate write affected belongs to one organization, and the key is omitted rather than filled from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `objectql/src/engine.ts:2371` | +| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6878` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12382` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12311` | +| 30 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `security-plugin.ts:1620` | ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 107 sites**. +The largest single consumer — **17 of the 106 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -180,8 +180,8 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 63 | `objectql/src/engine.ts:3743` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 64 | `objectql/src/engine.ts:14866` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 63 | `objectql/src/engine.ts:3720` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 64 | `objectql/src/engine.ts:14828` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 65 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 66 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -196,7 +196,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:702` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10339`–`10356` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10296`–`10313` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1580` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | @@ -270,7 +270,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 107 read sites +- **Shipped semantics.** `isSystem` is a published contract with 106 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -327,12 +327,12 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 22 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 113 | ✅ | +| — parsed as a property **read** | 112 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **107** | ✅ | -| — behaviour-bearing (rows 1–62 above) | 103 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ | +| — behaviour-bearing (rows 1–62 above) | 102 | ✅ | | — carry the flag onward only (rows 63–66 above) | 4 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | | Files containing at least one elevation read | 45 | ✅ | diff --git a/packages/objectql/src/engine-data-events.test.ts b/packages/objectql/src/engine-data-events.test.ts index 992899a5a2..d23ee08f4b 100644 --- a/packages/objectql/src/engine-data-events.test.ts +++ b/packages/objectql/src/engine-data-events.test.ts @@ -960,14 +960,13 @@ describe('#15225 / #15813 — a published BulkDataEvent names the organization t expect(body).not.toContain('eventOrganizationId('); // [#15813] The reader composes NOTHING: its body names the verdict schema - // and none of the wall's inputs. Comments stripped first — the docblock - // is allowed to NAME what is not read. + // and none of the wall's inputs. The slice starts at the `function` + // keyword, so the docblock above it (which is allowed to NAME what is not + // read) is outside the window; keep the body itself free of prose that + // names an input — the sentence belongs in the docblock. const fnStart = src.indexOf('\nfunction bulkEventOrganizationId('); expect(fnStart).toBeGreaterThan(-1); - const fnBody = src - .slice(fnStart, src.indexOf('\n}\n', fnStart)) - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/\/\/[^\n]*/g, ''); + const fnBody = src.slice(fnStart, src.indexOf('\n}\n', fnStart)); expect(fnBody).toContain('TenantLayer0VerdictSchema.safeParse('); for (const input of ['tenantId', 'accessible_org_ids', 'posture', 'isSystem', 'carriesTenantScopeColumn', 'getObject(', 'enforcedTenancyPosture', 'resolveEnginePosture']) { expect(fnBody, `bulkEventOrganizationId reads '${input}' — the mirror is back`).not.toContain(input); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 73bbedc35f..224699bc71 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1,9 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Plugin, PluginContext, POSTURE_LADDER, isRowActive } from '@objectstack/core'; -import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; -import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS type TenantLayer0Verdict, -} from '@objectstack/spec/security'; +import type { PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; +import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; // [#8220] The read-scope provenance mark: this middleware is one of the two // merge boundaries that stamp it (see the RLS injection below). diff --git a/packages/plugins/plugin-security/src/tenant-layer0-verdict-end-to-end.test.ts b/packages/plugins/plugin-security/src/tenant-layer0-verdict-end-to-end.test.ts new file mode 100644 index 0000000000..ca76f3e926 --- /dev/null +++ b/packages/plugins/plugin-security/src/tenant-layer0-verdict-end-to-end.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15813] The seam, welded end to end: a REAL `ObjectQL` engine, a REAL + * `SecurityPlugin` registered on it the way a kernel composition does, a real + * SQL driver, and a captured realtime service — then a predicate write, and + * the `BulkDataEvent` that comes out. + * + * Why this pin exists beside the two unit suites: the plugin WRITES + * `opCtx.tenantLayer0Verdict` on an untyped context and the engine READS + * `OperationContext.tenantLayer0Verdict` off its own interface. Each side's + * unit pins spell the member in their own package; a drift between the two + * spellings would leave both suites green and the seam dead. Only a run + * through both packages catches it — this one. + * + * Two populations, one deployment, one caller: + * - a walled tenant object ⇒ the event names the caller's organization — + * the wall's equality term, recorded and read; + * - a deployment-exempted object (#12699 `platformGlobalObjects`) ⇒ the + * event names NOTHING — the #15706 population: the wall composed no + * predicate, recorded `none`, and the producer (which reads nothing but + * the recorded verdict) omits the key. The former producer stamped the + * caller's organization here from the context — the wrong key. + * + * Harness lineage: `walled-platform-bucket-diagnostic.test.ts` (the real + * engine over SQLite) and `deployment-platform-global-exemption.test.ts` (the + * plugin's boot with an `org-scoping` declaration). + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { BulkDataEventSchema } from '@objectstack/spec/api'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; + +const PLAIN_MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as unknown as PermissionSet; + +const OBJECTS = [ + { + name: 'qa_invoice', + label: 'Invoice', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + status: { name: 'status', type: 'text' }, + amount: { name: 'amount', type: 'text' }, + }, + }, + { + name: 'qa_widget_registry', + label: 'Widget registry', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + status: { name: 'status', type: 'text' }, + amount: { name: 'amount', type: 'text' }, + }, + }, +]; + +const SYS_CTX = { isSystem: true, userId: 'usr_system', tenantId: 'org_acme' }; +/** An ordinary member of `org_acme`, rung carried as the authz resolver would. */ +const MEMBER_CTX = { userId: 'usr_member', tenantId: 'org_acme', positions: [], permissions: [], posture: 'MEMBER' }; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(opts: { platformGlobalObjects?: string[] } = {}) { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.layer0-verdict-15813', + name: 'Layer 0 verdict weld', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: OBJECTS, + } as any); + await engine.syncSchemas(); + engines.push(engine); + + const published: Array<{ type: string; payload: Record }> = []; + engine.setRealtimeService({ + publish: vi.fn(async (event: any) => { published.push(event); }), + subscribe: vi.fn(async () => 'sub-1'), + unsubscribe: vi.fn(async () => undefined), + } as any); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [PLAIN_MEMBER], + }, + 'org-scoping': { + name: 'com.objectstack.org-scoping', + ...(opts.platformGlobalObjects ? { platformGlobalObjects: opts.platformGlobalObjects } : {}), + }, + tenancy: { posture: 'isolated' }, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + return { engine, published }; +} + +const seed = async (engine: ObjectQL, object: string, rows: Array>) => { + await engine.insert(object, rows, { context: SYS_CTX } as any); +}; +const hasOrgKey = (p: Record) => Object.prototype.hasOwnProperty.call(p, 'organizationId'); + +describe('[#15813] end to end — the plugin records the verdict, the engine publishes what it recorded', () => { + it('a walled object: the bulk event names the organization the wall named', async () => { + const { engine, published } = await boot({ platformGlobalObjects: ['qa_widget_registry'] }); + await seed(engine, 'qa_invoice', [ + { status: 'open', amount: '1', organization_id: 'org_acme' }, + { status: 'open', amount: '2', organization_id: 'org_acme' }, + ]); + published.length = 0; + + await engine.update('qa_invoice', { amount: '0' }, { multi: true, where: { status: 'open' }, context: MEMBER_CTX } as any); + + const bulk = published.filter((e) => e.type === 'data.records.updated'); + expect(bulk).toHaveLength(1); + const event = BulkDataEventSchema.parse(bulk[0].payload); + expect(event.matched).toBe(2); + expect(hasOrgKey(bulk[0].payload)).toBe(true); + expect(event.organizationId).toBe('org_acme'); + }); + + it('a deployment-exempted object under the SAME wall and caller: the key is ABSENT — the #15706 population, closed', async () => { + const { engine, published } = await boot({ platformGlobalObjects: ['qa_widget_registry'] }); + // Rows across two organizations: the wall composes nothing on this object, + // so the sweep reaches both — exactly the batch a wrong key would mislabel. + await seed(engine, 'qa_widget_registry', [ + { status: 'open', amount: '1', organization_id: 'org_acme' }, + { status: 'open', amount: '2', organization_id: 'org_globex' }, + ]); + published.length = 0; + + await engine.update('qa_widget_registry', { amount: '0' }, { multi: true, where: { status: 'open' }, context: MEMBER_CTX } as any); + + const bulk = published.filter((e) => e.type === 'data.records.updated'); + expect(bulk).toHaveLength(1); + const event = BulkDataEventSchema.parse(bulk[0].payload); + // Ground truth, past every scope: both rows are in the table, one per + // organization — the population a wrong key would have mislabelled. + const driver: any = (engine as any).getDriver('qa_widget_registry'); + const raw = await driver.knex('qa_widget_registry').select('organization_id'); + expect(raw.map((r: any) => r.organization_id).sort()).toEqual(['org_acme', 'org_globex']); + // Measured, not assumed: the sweep matched ONE row. Layer 0 composed no + // wall here (the carve-out), but the engine still threads the caller's + // `tenantId` to the driver as `DriverOptions.tenantId` and the SQL driver + // scopes on it — the D8 driver leg, which no #12699 declaration reaches + // (filed as its own finding; not this seam's to change). So the batch + // was narrower than Layer 0 alone implies, and the ABSENT key below is + // an under-delivery in the safe direction — never the wrong key the + // former producer stamped from the context on exactly this object. + expect(event.matched).toBe(1); + expect(hasOrgKey(bulk[0].payload)).toBe(false); + expect(event.organizationId).toBeUndefined(); + }); + + it('the same exempted object with NO deployment declaration walls again — the exemption is the declaration, not the object', async () => { + const { engine, published } = await boot(); + await seed(engine, 'qa_widget_registry', [ + { status: 'open', amount: '1', organization_id: 'org_acme' }, + { status: 'open', amount: '2', organization_id: 'org_globex' }, + ]); + published.length = 0; + + await engine.update('qa_widget_registry', { amount: '0' }, { multi: true, where: { status: 'open' }, context: MEMBER_CTX } as any); + + const bulk = published.filter((e) => e.type === 'data.records.updated'); + expect(bulk).toHaveLength(1); + const event = BulkDataEventSchema.parse(bulk[0].payload); + // The wall held the sweep to one organization, and the event says which. + expect(event.matched).toBe(1); + expect(event.organizationId).toBe('org_acme'); + }); +}); diff --git a/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts b/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts index bdaeffb843..62df099a76 100644 --- a/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts +++ b/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts @@ -44,6 +44,19 @@ const PLAIN_MEMBER: PermissionSet = { const ADMIN_SET = defaultPermissionSets.find((s) => s.name === ADMIN_FULL_ACCESS); if (!ADMIN_SET) throw new Error(`fixture: '${ADMIN_FULL_ACCESS}' is not among the default permission sets`); +/** + * An explicit per-object grant on the PRIVATE object: a wildcard `'*'` grant + * does not reach a private object (ADR-0066), so a plain member holding only + * `member_default` is refused at the CRUD gate before any wall is composed. + * This set lets the rungless-member half of the probe pin REACH the wall, + * carrying no superuser bit and no platform capability. + */ +const SECRET_EDITOR: PermissionSet = { + name: 'secret_editor', + label: 'Secret editor', + objects: { crm_secret: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as unknown as PermissionSet; + /** An ordinary member of `org-1`, rung carried as the authz resolver would. */ const MEMBER_CTX = { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' }; @@ -213,12 +226,14 @@ describe('[#15813] the populations the engine could never answer are answered wh }); it('a hand-built context carrying NO rung is decided by the capability probe: platform caps cross a private object, a plain member does not', async () => { - const { middleware } = await boot({ sets: [PLAIN_MEMBER, ADMIN_SET as PermissionSet] }); + const { middleware } = await boot({ sets: [PLAIN_MEMBER, ADMIN_SET as PermissionSet, SECRET_EDITOR] }); const { posture: _drop, ...rungless } = MEMBER_CTX; const admin = sweep('crm_secret', 'update', { ...rungless, permissions: [ADMIN_FULL_ACCESS] }); await middleware(admin, async () => {}); expect(admin.tenantLayer0Verdict).toEqual({ kind: 'none' }); - const member = sweep('crm_secret', 'update', { ...rungless }); + // Same private object, same absent rung, an explicit grant instead of the + // platform set: the probe finds no platform capability and the wall stands. + const member = sweep('crm_secret', 'update', { ...rungless, permissions: ['secret_editor'] }); await middleware(member, async () => {}); expect(member.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); }); From 2165bc8b7bdd922ed362c79fc41729e656afc9e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 12:51:40 +0000 Subject: [PATCH 3/3] chore(docs): regenerate content/docs/references/index.mdx on the merged tree Discharges the os-regen deferral recorded by the merge commit: the driver kept one side of the generated index, and gen:schema + gen:docs on the merged tree re-derives it with both sides' entries (EvaluatedExpression from main, TenantLayer0Verdict from this branch). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/references/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 7f93854e16..0311171044 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1590 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1591 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -28,12 +28,12 @@ counts are sums of the rows they head. Regenerate with | [Integration Protocol](/docs/references/integration) | 1 | 24 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 30 | 162 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | -| [Security Protocol](/docs/references/security) | 5 | 29 | Permission sets, row-level security, sharing rules, tenancy posture. | +| [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 8 | 27 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1590** | 14 protocol modules | +| **Total** | **200** | **1591** | 14 protocol modules | --- @@ -270,14 +270,14 @@ Declarative test suites — scenarios, steps, actions and assertions. ## Security Protocol -**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 29 schemas** +**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 30 schemas** Permission sets, row-level security, sharing rules, tenancy posture. | File | Schemas | | :--- | :--- | | [`explain.zod.ts`](/docs/references/security/explain) | `AccessMatrix`, `AccessMatrixEntry`, `AuthzPosture`, `ExplainDecision`, `ExplainLayer`, `ExplainMatchedRule`, `ExplainOperation`, `ExplainRecordAttribution`, `ExplainRequest` | -| [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `OrgScopingEntitlement`, `PlatformGlobalObjects`, `TenancyPosture` | +| [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `OrgScopingEntitlement`, `PlatformGlobalObjects`, `TenancyPosture`, `TenantLayer0Verdict` | | [`permission.zod.ts`](/docs/references/security/permission) | `AdminScope`, `EffectiveObjectPermission`, `FieldPermission`, `ObjectAccessScope`, `ObjectPermission`, `PermissionSet` | | [`rls.zod.ts`](/docs/references/security/rls) | `RLSEvaluationResult`, `RLSOperation`, `RLSUserContext`, `RowLevelSecurityPolicy` | | [`sharing.zod.ts`](/docs/references/security/sharing) | `CriteriaSharingRule`, `OWDModel`, `ShareRecipientType`, `SharingLevel`, `SharingRule`, `SharingRuleType` |