From 6489500f1866ad05f9e66e459093fc6fec7fea68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 08:48:51 +0000 Subject: [PATCH 1/2] test(security): pin the four Layer 0 shapes as RECORDED VERDICTS at the middleware line (#15887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing here claims anything is broken — all four shapes hold on `main` today. What was missing is a reading on the RECORDING side. Each rested on the projection's own pins, i.e. on the identity "the recorded verdict IS the object the predicate was projected from" — the identity a future change to this seam would break first, and when it breaks the projection-side pins stay green. Adds middleware-level pins in `tenant-layer0-verdict-on-operation.test.ts` that read `opCtx.tenantLayer0Verdict` — the recorded object itself, not the downstream filter and not `getReadFilter`'s projection — for: * `systemFields.tenant: false` beside an author-declared `organization_id` (P1): `none`, with the readable column asserted as present so the trap is explicit, plus a walled sibling as the firing control; * the #7835 phantom anchor: a federated object carrying the anchor spread from `TENANT_SCOPE_FIELD_DEF` records `none`, while a federated object whose author declared a real remote `organization_id` records `organization` — the exit is provenance, never `external != null`; * a custom `tenancy.tenantField`, both directions: not an exit by itself (still `organization`, and the predicate names `organization_id`, never `workspace_id`), and never a substitute (no kernel column ⇒ `none`); * the ADR-0090 D10 on-behalf-of INTERSECTION at the middleware line, which had no pin at all. `intersectTenantLayer0Verdicts` is unit-pinned in `tenant-layer.test.ts`; what had no reading is the line that calls it. The case asserts an organization set NEITHER injected wall names alone, with the same fixture minus the delegation link as the control, plus the fail-closed `deny` leg on the read shape. `boot()` grows an optional delegator seed: without it the tables are empty, `findOne` answers `null` for every object as before, and no `find` is exposed — the existing cases are byte-identical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- ...tenant-layer0-verdict-on-operation.test.ts | 296 +++++++++++++++++- 1 file changed, 294 insertions(+), 2 deletions(-) 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 62df099a76..fc4bc4e392 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 @@ -22,6 +22,16 @@ * 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`. + * 4. [#15887] The four shapes that were CORRECT BY CONSTRUCTION but pinned + * only one layer away — `systemFields.tenant: false` beside an + * author-declared column, the #7835 phantom anchor, a custom + * `tenancy.tenantField`, and the ADR-0090 D10 on-behalf-of INTERSECTION, + * whose middleware line carried no pin at all. Each rested on the + * projection's own pins (`security-plugin.test.ts`, + * `federated-tenant-layer0.test.ts`, `tenant-layer.test.ts`), i.e. on the + * identity「the recorded verdict IS what the predicate was projected from」 + * — the very identity that would break first. The pins below read the + * RECORDED object, so a divergence is caught on the recording side. * * Harness: `deployment-platform-global-exemption.test.ts` — a SecurityPlugin * over a fake ObjectQL. The registered middleware is captured and driven with @@ -30,6 +40,7 @@ */ import { describe, it, expect, vi } from 'vitest'; +import { TENANT_SCOPE_FIELD_DEF } from '@objectstack/metadata-core'; import type { PermissionSet } from '@objectstack/spec/security'; import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; import { SecurityPlugin } from './security-plugin.js'; @@ -70,21 +81,120 @@ const localSchema = (name: string, extra: Record = {}) => ({ ...extra, }); +/** `localSchema` plus extra columns — the custom-tenant-column shapes need one. */ +const withFields = ( + schema: Record, + fields: Record, +): Record => ({ + ...schema, + fields: { ...(schema.fields as Record), ...fields }, +}); + 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' } }), + + // ── [#15887] the three object shapes ────────────────────────────────────── + // + // P1. The object opted OUT of the tenant system field while the author's own + // `organization_id` stays declared and perfectly readable. + // `getObjectSecurityMeta` folds `systemFields.tenant === false` into + // `tenancyDisabled` beside `tenancy.enabled === false`, so the wall composes + // NOTHING here. The readable column is the trap: a reader answering from the + // column instead of from the wall stamps this batch with the caller's + // organization — a MISLABEL, on rows the wall never constrained. + shared_catalog: localSchema('shared_catalog', { systemFields: { tenant: false } }), + + // [#7835] Federated, carrying the anchor `applySystemFields` injects — + // spread from the shipped constant exactly as the registry does + // (`additions.organization_id = { ...TENANT_SCOPE_FIELD_DEF }`), so + // `hasPhantomTenantAnchor` reads the PLATFORM's provenance and Layer 0 treats + // the object as carrying no tenant column. The platform issues no DDL for an + // `external` object, so that column exists in the registry and nowhere else. + ext_customer: { + name: 'ext_customer', + external: { remoteName: 'customers' }, + fields: { + organization_id: { ...TENANT_SCOPE_FIELD_DEF }, + title: { type: 'text', label: 'Title' }, + status: { type: 'text', label: 'Status' }, + }, + }, + // The provenance control, federated too: this `organization_id` is the + // AUTHOR's — a real remote column — so the wall is doing real work and stays. + // The phantom exit is about PROVENANCE, never about `external != null`. + ext_ledger: { + name: 'ext_ledger', + external: { remoteName: 'ledgers' }, + fields: { + organization_id: { type: 'text', label: 'Org (a real remote column)' }, + title: { type: 'text', label: 'Title' }, + status: { type: 'text', label: 'Status' }, + }, + }, + + // A custom tenant column declared BESIDE the kernel one — the wall keys on + // the literal `organization_id` and never reads `tenancy.tenantField`. + workspace_doc: withFields(localSchema('workspace_doc', { tenancy: { tenantField: 'workspace_id' } }), { + workspace_id: { type: 'text', label: 'Workspace' }, + }), + // The same declaration on an object that carries NO `organization_id`: the + // custom column is never read as a stand-in, so there is no wall to record. + workspace_note: { + name: 'workspace_note', + tenancy: { tenantField: 'workspace_id' }, + fields: { + workspace_id: { type: 'text', label: 'Workspace' }, + title: { type: 'text', label: 'Title' }, + status: { type: 'text', label: 'Status' }, + }, + }, }; -async function boot(opts: { entitlement?: Record; tenancy?: { posture: string }; sets?: PermissionSet[] } = {}) { +/** + * [#15887 / ADR-0090 D10] Seed for the on-behalf-of leg: the `sys_*` rows the + * delegator resolution reads (`resolveDelegatorContext` -> `buildContextForUser` + * -> core's `resolveUserAuthzGrants`). `memberOf` becomes `sys_member` rows, + * which is where the delegator's OWN `accessible_org_ids` come from — and that + * set is the one Layer 0 input a delegated context does NOT inherit from the + * live principal, so it is the only way the two walls can differ at all. + * An empty `memberOf` still seeds the `sys_user` row: a MISSING delegator is a + * different contract (a fail-closed refusal before any wall is composed). + */ +type DelegatorSeed = { userId: string; memberOf: string[] }; + +async function boot(opts: { + entitlement?: Record; + tenancy?: { posture: string }; + sets?: PermissionSet[]; + delegator?: DelegatorSeed; +} = {}) { const middlewares: Array<(opCtx: any, next: () => Promise) => Promise> = []; + // [#15887] Only the delegated leg needs a readable store; without a seed the + // tables are empty and `findOne` answers `null` for every object exactly as + // before, so the cases above are byte-identical. `find` is added ONLY under a + // seed — the non-delegated path never issues one, and a `ql` without `find` + // is what the earlier cases were measured against. + const del = opts.delegator; + const tables: Record[]> = del + ? { + sys_user: [{ id: del.userId, email: `${del.userId}@example.test` }], + sys_member: del.memberOf.map((organization_id) => ({ user_id: del.userId, organization_id })), + } + : {}; + const matches = (row: Record, where: Record | undefined): boolean => + Object.entries(where ?? {}).every(([k, v]) => row[k] === v); + const rowsOf = (object: string, where: Record | undefined) => + (tables[object] ?? []).filter((r) => matches(r, where)); const services: Record = { manifest: { register: vi.fn() }, objectql: { registerMiddleware: (mw: any) => middlewares.push(mw), getSchema: (name: string) => SCHEMAS[name], - findOne: vi.fn(async () => null), + findOne: vi.fn(async (object: string, o: any) => rowsOf(object, o?.where)[0] ?? null), + ...(del ? { find: async (object: string, o: any) => rowsOf(object, o?.where) } : {}), }, metadata: { get: async (_type: string, name: string) => SCHEMAS[name], @@ -123,6 +233,18 @@ function sweep(object: string, operation: 'update' | 'delete', context: Record) { + const where = { status: 'open' }; + return { object, operation: 'find', context: { ...context }, options: { where }, ast: { where } } as any; +} + 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. @@ -137,6 +259,22 @@ const injectedOrgWall = (opCtx: any): unknown => { }; return walk(opCtx.ast?.where); }; +/** + * [#15887] EVERY `organization_id` clause in the composed tree, in composition + * order. The on-behalf-of leg injects TWO walls (the caller's, then the + * delegator's — `extra` is pushed in that order and spread into one `$and`), + * and the single-hit walker above would report only the first. + */ +const injectedOrgWalls = (opCtx: any): unknown[] => { + const out: unknown[] = []; + const walk = (node: any): void => { + if (!node || typeof node !== 'object') return; + if ('organization_id' in node) out.push(node.organization_id); + for (const part of node.$and ?? []) walk(part); + }; + walk(opCtx.ast?.where); + return out; +}; 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 () => { @@ -263,3 +401,157 @@ describe('[#15813] nothing is recorded where no wall was composed — absence is expect(await (plugin as any).getReadFilter('sys_catalog', MEMBER_CTX)).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// [#15887] The four shapes that were correct BY CONSTRUCTION and pinned only +// one layer away. Nothing below claims any of them is broken — every one of +// them holds on `main` today. What was missing is a reading on the RECORDING +// side: each rested on the projection's own pins, i.e. on the identity 「the +// verdict recorded is the object the predicate was projected from」. That +// identity is exactly what a future change to this seam would break first, and +// when it breaks, the projection-side pins stay green. +// +// So each case reads `opCtx.tenantLayer0Verdict` — the recorded object itself, +// never the downstream filter and never `getReadFilter`'s projection — off ONE +// middleware pass, and reads the injected predicate beside it as the control. +// --------------------------------------------------------------------------- +describe('[#15887] the three object shapes record their verdict HERE, not one layer away', () => { + it('`systemFields.tenant: false` beside an AUTHOR-DECLARED `organization_id` records `none` (P1) — a readable column is not a wall', async () => { + const { middleware } = await boot(); + // The fixture's premise, asserted rather than recalled: the column really + // is there to be misread. (That the REGISTRY leaves an authored column + // standing under this opt-out is the registry's own fact, pinned where the + // registry lives; here it is the input.) + expect((SCHEMAS.shared_catalog.fields as Record).organization_id).toBeDefined(); + + const opCtx = sweep('shared_catalog', 'update', MEMBER_CTX); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' }); + expect(injectedOrgWall(opCtx)).toBeUndefined(); + + // Firing control on the same boot: the sibling with no opt-out IS walled, + // so `none` above is this object's verdict and not a dead middleware. + const sibling = sweep('crm_task', 'update', MEMBER_CTX); + await middleware(sibling, async () => {}); + expect(sibling.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); + }); + + it('[#7835] a FEDERATED object carrying the PLATFORM\'s injected anchor records `none` — the phantom column is not a wall', async () => { + const { middleware } = await boot(); + const opCtx = sweep('ext_customer', 'update', MEMBER_CTX); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' }); + expect(injectedOrgWall(opCtx)).toBeUndefined(); + }); + + it('[#7835] a FEDERATED object whose AUTHOR declared a real remote `organization_id` records `organization` — the exit is PROVENANCE, not `external`', async () => { + // The half that keeps the phantom exit from becoming "suppress Layer 0 for + // every federated object", which would delete a wall that is doing its job. + const { middleware } = await boot(); + const opCtx = sweep('ext_ledger', 'update', MEMBER_CTX); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); + expect(injectedOrgWall(opCtx)).toBe('org-1'); + }); + + it('a custom `tenancy.tenantField` is NOT an exit by itself: still `organization`, and the wall names `organization_id`', async () => { + // Layer 0 keys on the literal `organization_id` and never reads + // `tenancy.tenantField`. An object declaring a custom tenant column while + // still carrying the kernel one is walled on the kernel one — so the + // recorded verdict names the organization, and the injected predicate names + // `organization_id`, never `workspace_id`. + const { middleware } = await boot(); + const opCtx = sweep('workspace_doc', 'update', MEMBER_CTX); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); + expect(injectedOrgWall(opCtx)).toBe('org-1'); + expect(JSON.stringify(opCtx.ast.where)).not.toContain('workspace_id'); + }); + + it('a custom `tenancy.tenantField` on an object carrying NO `organization_id` records `none` — the custom column is never a substitute', async () => { + // The other direction of the same fact: the declaration does not make + // `workspace_id` a tenant column the wall can key on, so with the kernel + // column absent there is no wall — and `none` is the honest verdict, not a + // wall silently relocated onto the author's column. + const { middleware } = await boot(); + expect((SCHEMAS.workspace_note.fields as Record).organization_id).toBeUndefined(); + const opCtx = sweep('workspace_note', 'update', MEMBER_CTX); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' }); + expect(injectedOrgWall(opCtx)).toBeUndefined(); + }); +}); + +describe('[#15887 / ADR-0090 D10] the on-behalf-of INTERSECTION is recorded at the middleware line', () => { + // `intersectTenantLayer0Verdicts` is unit-pinned in `tenant-layer.test.ts`. + // What had no pin is the LINE that calls it: that after a delegated pass the + // recorded verdict is the intersection of the two walls the middleware + // AND-composed — not the caller's half, which is what the site would record + // if that call were ever dropped. These cases therefore assert something + // NEITHER wall states on its own, which a re-run of the unit assertion in + // this file could not do. + const DELEGATOR = 'u2'; + // `group` is the only posture in which the two walls can differ: the + // delegator's `accessible_org_ids` are resolved from ITS OWN memberships and + // are deliberately not inherited, while `tenantId` is (so under `isolated` + // both halves resolve to the same organization by construction). + const GROUP = { posture: 'group' }; + const CALLER_ORGS = ['org-1', 'org-2', 'org-3']; + + it('the recorded verdict is the INTERSECTION — an organization set neither wall names alone', async () => { + const { middleware } = await boot({ + tenancy: GROUP, + delegator: { userId: DELEGATOR, memberOf: ['org-2', 'org-3', 'org-9'] }, + }); + const opCtx = sweep('crm_task', 'update', { + ...MEMBER_CTX, + accessible_org_ids: CALLER_ORGS, + onBehalfOf: { userId: DELEGATOR }, + }); + await middleware(opCtx, async () => {}); + + // Both walls really were composed onto this one operation, in order. + expect(injectedOrgWalls(opCtx)).toEqual([ + { $in: ['org-1', 'org-2', 'org-3'] }, + { $in: ['org-2', 'org-3', 'org-9'] }, + ]); + // And the recorded verdict is what a row must satisfy to clear BOTH — a + // set that is neither injected clause. Dropping the intersection at the + // call site records the caller's three organizations while the composed + // predicate admits two: the recorded verdict would then over-state the + // batch's reach, on a wall the middleware itself narrowed. + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organizations', organizationIds: ['org-2', 'org-3'] }); + }); + + it('the SAME fixture without the delegation link records the caller\'s half — the link is what moves the answer', async () => { + // Identical boot and identical caller, minus `onBehalfOf`: the case above + // is a reading about the intersection, not about the `group` posture. + const { middleware } = await boot({ + tenancy: GROUP, + delegator: { userId: DELEGATOR, memberOf: ['org-2', 'org-3', 'org-9'] }, + }); + const opCtx = sweep('crm_task', 'update', { ...MEMBER_CTX, accessible_org_ids: CALLER_ORGS }); + await middleware(opCtx, async () => {}); + expect(injectedOrgWalls(opCtx)).toEqual([{ $in: ['org-1', 'org-2', 'org-3'] }]); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organizations', organizationIds: CALLER_ORGS }); + }); + + it('a delegator with NO membership makes the recorded verdict `deny` — the composed wall fails closed, the caller\'s half does not', async () => { + // A READ (see `readSweep`): the write twin is refused by the ADR-0123 D2 + // check before the wall is composed, so the write shape cannot observe this + // at all. The caller's own half names three organizations; the delegator's + // empty access set denies; the AND of the two admits no row, and the + // recorded verdict says so rather than naming an organization. + const { middleware } = await boot({ + tenancy: GROUP, + delegator: { userId: DELEGATOR, memberOf: [] }, + }); + const opCtx = readSweep('crm_task', { + ...MEMBER_CTX, + accessible_org_ids: CALLER_ORGS, + onBehalfOf: { userId: DELEGATOR }, + }); + await middleware(opCtx, async () => {}); + expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'deny' }); + }); +}); From c0ac779ca3bc975d6705dcdb72f55421e239f326 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 09:04:15 +0000 Subject: [PATCH 2/2] test(security): the delegator-seed double answers its bound and refuses combinators (#15887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the derived gate family, both about the fake engine the on-behalf-of leg reads through — not about the pins: * `check:objectql-double-limit`: core's grants resolution hands every read a `limit` (200 on the `sys_member` legs), and the double ignored it. The bound is now applied AFTER the filter and BY PRESENCE, the shape the gate names. * `check:where-matcher`: `matches` compared a `$`-prefixed key as a FIELD NAME, which matches nothing and says nothing. The double now REFUSES the combinators and operators it does not implement — plain equality is all the delegator resolution ever asks it for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- ...tenant-layer0-verdict-on-operation.test.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 fc4bc4e392..cf69765958 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 @@ -184,8 +184,16 @@ async function boot(opts: { sys_member: del.memberOf.map((organization_id) => ({ user_id: del.userId, organization_id })), } : {}; + // Plain equality is all the delegator resolution ever asks for (`{ id }`, + // `{ user_id }`). A combinator read as a FIELD NAME would match nothing and + // say nothing, so this double REFUSES what it does not implement rather than + // answering quietly — `check:where-matcher` refuses exactly the quiet shape. const matches = (row: Record, where: Record | undefined): boolean => - Object.entries(where ?? {}).every(([k, v]) => row[k] === v); + Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`); + if (v && typeof v === 'object') throw new Error(`fake engine: unsupported operator on '${k}'`); + return row[k] === v; + }); const rowsOf = (object: string, where: Record | undefined) => (tables[object] ?? []).filter((r) => matches(r, where)); const services: Record = { @@ -194,7 +202,18 @@ async function boot(opts: { registerMiddleware: (mw: any) => middlewares.push(mw), getSchema: (name: string) => SCHEMAS[name], findOne: vi.fn(async (object: string, o: any) => rowsOf(object, o?.where)[0] ?? null), - ...(del ? { find: async (object: string, o: any) => rowsOf(object, o?.where) } : {}), + ...(del + ? { + // The caller's bound is applied AFTER the filter and BY PRESENCE: + // core's grants resolution hands every one of these reads a `limit`, + // and a double that silently ignores it cannot report what the real + // engine would (`check:objectql-double-limit`). + find: async (object: string, o: any) => { + const rows = rowsOf(object, o?.where); + return typeof o?.limit === 'number' ? rows.slice(0, o.limit) : rows; + }, + } + : {}), }, metadata: { get: async (_type: string, name: string) => SCHEMAS[name],