diff --git a/.changeset/25062-objectql-write-wall-verdict.md b/.changeset/25062-objectql-write-wall-verdict.md new file mode 100644 index 00000000000..a4e40e9df01 --- /dev/null +++ b/.changeset/25062-objectql-write-wall-verdict.md @@ -0,0 +1,16 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): the write preview (`validate()`, the import dry run) answers a row's `organization_id` from the Layer 0 tenant wall's own verdict + +Clause-②: yes (widening) + +Metadata-protocol: none + +`validate()` runs no middleware, so the tenant write wall never judged a preview row. On a walled deployment (`group` or `isolated`) the preview reported a caller's `organization_id` as dropped where the write stores it as sent, and answered valid a row naming an organization the write refuses with 403. + +- **New, optional: `ObjectQL.registerWriteWallVerdict(fn)`**, beside `registerWriteGateProbe`, with its types `WriteWallVerdict` and `WriteWallRowVerdict`. The layer that owns the wall registers it (`@objectstack/plugin-security` does), and the engine never decides the verdict itself. +- **`validate()` reads each row's verdict.** A row whose organization the wall judges keeps `organization_id` through the static `readonly` strip, so no `droppedFields` entry names it, as on the write. A row the wall refuses answers `valid: false` with the write's own code (`PERMISSION_DENIED`) and message, under `organization_id` for a refused value and `_record` for the caller's state, and it drops nothing. Rows stay independent, and the set's `valid` stays the conjunction. A row the strips changed is asked about again, as the write's stored-image half judges it. +- **Unregistered, nothing changes**: the strip runs as before, which is what the write does in a composition without the security plugin. +- **Named limit:** the preview runs no hooks, so an organization a `beforeInsert` / `beforeUpdate` hook writes is judged by the write alone. diff --git a/.changeset/25062-plugin-security-write-wall-verdict.md b/.changeset/25062-plugin-security-write-wall-verdict.md new file mode 100644 index 00000000000..e78e9f3ccb9 --- /dev/null +++ b/.changeset/25062-plugin-security-write-wall-verdict.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): step 3.7's tenant write wall is composed by one method, and the write preview asks it about each row + +Clause-②: yes (widening) + +Metadata-protocol: none + +The engine's write preview (`validate()`, the import dry run) runs no middleware, so it never heard the Layer 0 tenant wall's verdict on a row's `organization_id`. + +- **One spelling of the wall.** Step 3.7's judgement (the no-active-organization pre-test with its delegator leg, the supplied-value forge guard, the emptied organization on update, the #24784 / #24821 no-active-organization insert arms, and whether the wall judges the organization) is composed by one method, `composeWriteTenantWall`. The middleware's payload half and its stored-image seam both call it. Behaviour, refusals and log lines are unchanged. +- **New: `SecurityPlugin.writeWallVerdict(object, operation, context, rows)`**, registered on the engine as `registerWriteWallVerdict`. It answers each row from that same method: admitted (and whether the wall judges its organization), or refused with the write's `PermissionDeniedError` code and message. It answers `null` for a system context, a context with no user, and a caller the context prologue refuses before the wall is reached. On an engine without the seam the plugin logs one warning at start and the preview keeps its old answer. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 13e16cd89f5..e084f60b8d5 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -103,6 +103,7 @@ that silently does not happen. | 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` | | 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` | | 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | +| 6c | The tenant wall's per-row verdict for the write PREVIEW answers **"not asked"** | plugin-security | Get: `writeWallVerdict` returns `null`, so `ObjectQL.validate()` treats the rows exactly as it does with no verdict registered: no row is refused by the Layer 0 tenant wall, and the elevated preview's `readonly` strips stay skipped as they always were. That is what the elevated write does: the whole security middleware short-circuits before step 3.7, so the wall never judges an elevated row's organization. Lose: nothing the write enforces. The verdict exists so a preview answers a row's organization as its write answers it (#25062), and an elevated write meets no tenant wall to answer for | `packages/plugins/plugin-security/src/security-plugin.ts#writeWallVerdict` | | 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 8 | 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 | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | | 8b | Authoring-trail reads admitted | rest, runtime | Get: a metadata item's version history, audit rows and stored-version diff (`GET /meta/:type/:name/history`, `/audit`, `/diff`) without `studio.access` or `manage_metadata`, and a package's commit timeline (`GET /packages/:id/commits`) without `studio.access`. Note: narrower than the schema-mask exemption the draft switches ask (`isObjectSchemaMaskExempt`): neither door admits `setup.access`, which organization admins hold | `packages/rest/src/rest-server.ts#mayReadAuthoringTrail`, `packages/runtime/src/domains/packages.ts#requireCommitTrailReadCapability` | diff --git a/packages/objectql/src/engine-insert-wall-judged-organization.test.ts b/packages/objectql/src/engine-insert-wall-judged-organization.test.ts index 35483465896..bf26f5ca928 100644 --- a/packages/objectql/src/engine-insert-wall-judged-organization.test.ts +++ b/packages/objectql/src/engine-insert-wall-judged-organization.test.ts @@ -21,8 +21,9 @@ // the strip takes the value exactly as before (reported, withheld from the // hook, refused under strict). Seam presence is not the signal: a business // `check` installs a seam with no wall behind it; -// - `validate()` runs no middleware, so it holds no seam, and its preview is -// unchanged. +// - `validate()` runs no middleware, so it holds no seam: with no write-wall +// verdict registered its preview is unchanged. The verdict it reads instead +// ([#25062]) is pinned in `validate-write-wall-verdict.test.ts`. import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectQL } from './engine.js'; @@ -263,7 +264,7 @@ describe('[#24822] engine.insert spares organization_id only where the seam decl expect(stored()).toEqual([NAMED, NAMED]); }); - it('validate() runs no middleware, so it holds no seam: its preview still reports the strip it always reported', async () => { + it('validate() runs no middleware, so it holds no seam: with no write-wall verdict registered its preview still reports the strip', async () => { const preview = await engine.validate(OBJECT, { name: 'one', organization_id: NAMED }, { mode: 'insert', context: SESSION as any }); expect(preview.results[0]?.droppedFields).toEqual([{ object: OBJECT, fields: ['organization_id'], reason: 'readonly' }]); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 240d4b5be0b..148e6fd1d0a 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2425,9 +2425,10 @@ function callerSuppliedRow(row: unknown, sent: ReadonlySet | undefined): * 1. `wallJudgesOrganization`: the seam the wall installed on THIS write * declares that it judges the stored rows' organization * (`PostHookWriteImageCheck.judgesOrganization`). The caller reads that - * member once, for a non-system caller only. Nothing here derives it: - * where no wall judged the column, the strip takes a caller's value - * exactly as before. + * member once, for a non-system caller only. [#25062] The write preview + * reads the same value from the wall's verdict on each row + * (`WriteWallRowVerdict`). Nothing here derives it: where no wall judged + * the column, the strip takes a caller's value exactly as before. * 2. The object is one the INSERT strip judges, with the column in that * subject ({@link staticReadonlyInsertSubject}). So never the reserved * `sys_` namespace or a platform-internal `managedBy` bucket, whose own @@ -2467,7 +2468,7 @@ function staticReadonlySparesOrganization(schema: unknown, wallJudgesOrganizatio * * ONE function for both passes that read the subject, the pre-hook withhold * and the post-hook strip, so the hook view and the stored row cannot - * disagree about the column. + * disagree about the column. [#25062] The write preview's strip reads it too. */ function insertStaticReadonlySubject( schema: unknown, @@ -2493,7 +2494,8 @@ function insertStaticReadonlySubject( * strips (what is stored), and {@link staticReadonlyStoredView}, which the * `readonlyWhen` strips and the master-detail settlement read. `update()` * reads the wall's flag once and hands that one value to all of them, so the - * hook view and the stored row cannot disagree about the column. + * hook view and the stored row cannot disagree about the column. [#25062] The + * `update`-mode preview's strip reads it too. */ function updateStaticReadonlySubject(schema: unknown, wallJudgesOrganization: boolean): unknown { if (!staticReadonlySparesOrganization(schema, wallJudgesOrganization)) return schema; @@ -3848,7 +3850,9 @@ export interface PostHookWriteImageCheck { * Absent or `false`, the strip runs exactly as before. The engine never * sets it, and never derives it from the posture or the context: that * would be a mirror of the wall, and a mirror is wider than the wall - * wherever the wall is not mounted. + * wherever the wall is not mounted. [#25062] The write preview, which + * installs no seam, reads the same value from the wall's verdict on each + * row instead ({@link WriteWallRowVerdict}, `ObjectQL.registerWriteWallVerdict`). */ judgesOrganization?: boolean; } @@ -3983,6 +3987,43 @@ export type FieldOwnedFileReadAuthorizer = ( context: ExecutionContext, ) => Promise>; +/** + * [#25062] The Layer 0 tenant write wall's answer for ONE row of a write + * preview ({@link ObjectQL.validate}): admitted, saying whether the wall judges + * the row's `organization_id` (the value + * {@link PostHookWriteImageCheck.judgesOrganization} is set from on the + * write), or refused with the write's own `code` and `message`. A refused + * `field` is `organization_id` when the wall refused a value the row carries, + * and `_record` when it refused the caller's state (no active organization). + */ +export type WriteWallRowVerdict = + | { readonly admitted: true; readonly judgesOrganization: boolean } + | { + readonly admitted: false; + readonly field: 'organization_id' | '_record'; + readonly code: string; + readonly message: string; + }; + +/** + * [#25062] "What does the Layer 0 tenant write wall answer for each of these + * rows?" One verdict per row, in order, or `null` when the wall is not asked + * for this write (the engine then judges the rows as if nothing were + * registered). + * + * The engine declares this shape but never implements it. The wall has one + * definition, the enforcement layer's own (`@objectstack/plugin-security`'s + * step 3.7), and the verdict travels here as a value: a copy of the wall in + * the engine would be the mirror the `judgesOrganization` member forbids. See + * `ObjectQL.registerWriteWallVerdict`. + */ +export type WriteWallVerdict = ( + object: string, + operation: 'insert' | 'update', + context: unknown, + rows: readonly Record[], +) => Promise; + /** * The stack collections the engine decomposes into individual registry items — * ONE list, read by the ONE body both registration seams run @@ -6207,8 +6248,15 @@ export class ObjectQL implements IObjectQLEngine { * D10 dangling delegator), the ADR-0066 D3 capability AND-gate for both * principals, the `allowCreate`/`allowEdit` CRUD grant, the ADR-0090 D10 * delegator's independent grant, the middleware's own step 2.5 FLS write gate - * over the keys THIS payload names, and its ADR-0123 D2 organization wall - * (step 3.7). The gate says nothing about any refusal not in that list. + * over the keys THIS payload names, and its ADR-0123 D2 no-active-organization + * refusal (step 3.7's scope pre-test, asked with no row, so it refuses a + * caller class). The gate says nothing about any refusal not in that list. + * [#25062] ⛔ In particular it is not step 3.7's judgement of a ROW's + * organization (the forge guard, an emptied organization, an insert with + * nowhere to land): it is handed no row to judge, and it answers `true` for + * a caller whose payload names an organization the write refuses. That is + * the write-wall verdict's question, asked per row + * ({@link registerWriteWallVerdict}). * `@objectstack/plugin-security`'s `can-write-object-admission.test.ts` pins * the probe's answer equal to the registered middleware's on its equivalence * block's cases, and pins one D12 UPDATE case as a direction: the probe @@ -6237,8 +6285,11 @@ export class ObjectQL implements IObjectQLEngine { * The step 2.7 `using` pre-image, the ADR-0055 controlled-by-parent * master-edit check (step 2.8), the RLS `check` post-image (step 3.6) and * the Layer 0 tenant post-image (step 3.7) each judge a ROW: a prior - * image, a master record, or a pre-image merged with the change set. The - * preview reads nothing and holds none of them, so it judges none of them. + * image, a master record, or a pre-image merged with the change set. This + * gate is handed none of them, so it judges none of them. [#25062] The + * tenant wall's judgement of the row the preview holds comes through + * {@link registerWriteWallVerdict} instead: it decides what the preview + * answers for that row, never whether this gate's related read happens. * - **Payload-VALUE refusals — the same caller passes by not sending the * value.** The masked-echo write refusal (step 2.5a) and the `owner_id` * ownership forge (step 3.5) refuse a VALUE, not a caller: the identical @@ -6294,6 +6345,41 @@ export class ObjectQL implements IObjectQLEngine { this.logger.debug('Registered write-gate probe for validate() relationship resolution'); } + /** + * [#25062] The Layer 0 tenant write wall's verdict on each row of a write + * preview — supplied by the security plugin, never decided here. + * + * `validate()` runs no middleware, so the wall's judgement of a row's + * `organization_id` never reached it: the preview ran the static-`readonly` + * strip unconditionally, reported a caller's organization DROPPED where the + * write stores it as sent (the wall judges it, `judgesOrganization`), and + * answered VALID a row naming an organization the write refuses 403. The + * write-gate probe cannot carry this: it answers whether a CALLER may write, + * one boolean for the set; the wall judges a VALUE a row carries. Two + * questions, two members. ⛔ Nor is the verdict derived here from the posture, + * `tenantId` or `accessible_org_ids`: that would be a mirror of the wall. + * + * `rows` are the preview's own: after the defaults (insert) and before any + * hook, the caller's rows on an update. `validate()` reads each row's verdict + * and nothing else: an admitted row whose organization the wall judges keeps + * the column out of the static-`readonly` strip, exactly where + * `insert()` / `update()` keep it; a refused row is answered `valid: false` + * with the write's code and message, on its own, as the preview answers + * every other refusal. + * + * Unregistered is a DEFINED state, as for the members beside it: no wall, no + * verdict, and the strip runs as it always has, which is also what the write + * does in a composition without the security plugin. A seam that throws + * rejects the preview, as a refusal to resolve fails the write. + */ + private _writeWallVerdict?: WriteWallVerdict; + + /** Wire the write wall's per-row verdict for `validate()` (#25062). Last registration wins. */ + registerWriteWallVerdict(fn: WriteWallVerdict): void { + this._writeWallVerdict = fn; + this.logger.debug('Registered write-wall verdict for validate() organization judgement'); + } + /** * [#18783] Where the acting subject's EFFECTIVE object permissions come from — * the map `current_user.can(object, verb)` is answered from: in a per-option @@ -15635,6 +15721,27 @@ export class ObjectQL implements IObjectQLEngine { * caller cannot read binds as a missing one. ⛔ Unlike the write, a header * column the read door served transformed or not at all is judged as empty. * + * ## The Layer 0 tenant wall answers for its own rows (#25062) + * + * A row's `organization_id` is judged on the write by the tenant wall, which + * lives in the enforcement layer's middleware and so never runs here. The + * preview asks that wall for its verdict on each row + * ({@link registerWriteWallVerdict}) and answers from it, and it asks where + * the write does: on the row before the strips, and again on a row the + * strips changed, which is the row the write's stored-image half judges. A + * row whose organization the wall judges keeps the column through the + * static `readonly` strip (no `droppedFields` entry), as the write stores + * it; a row the wall refuses is `valid: false` with the write's own code + * (`PERMISSION_DENIED`) and message, under `organization_id` when the wall + * refused the value and `_record` when it refused the caller's state, and it + * drops nothing. Unregistered, the strip runs as before. + * + * Named limit: the wall judges the row as the preview holds it, after the + * defaults and before any hook. An organization a `beforeInsert` / + * `beforeUpdate` hook writes is judged by the write's stored-image half + * alone, so a row a hook places outside the caller's scope previews valid + * and is refused by the write. + * * Nothing is written and no sequence is consumed. Reads do happen: the * stored row above, the master-detail headers, and the related rows a * traversing rule names (see the `previewRelatedForRow` block below). @@ -15741,6 +15848,41 @@ export class ObjectQL implements IObjectQLEngine { ) as Record) : rawRows.slice(); + // [#25062] THE LAYER 0 TENANT WALL'S VERDICT on each row, from the layer + // that owns the wall ({@link registerWriteWallVerdict}), over the rows as + // the preview holds them: after the defaults, before any hook. The write + // asks its wall twice, and so does this: on the payload before the strips + // (here; the write's middleware), and on the rows the strips leave (below; + // the write's stored-image seam). A refused row is answered with the + // write's refusal (the `results` block below) and reaches nothing the write + // does not run for it; an admitted row whose organization the wall judges + // keeps the column through the static `readonly` strip, as `insert()` / + // `update()` keep it. `null` (nothing registered, or the wall not asked) + // leaves every row to the strip, exactly as before. ⛔ Nothing here reads + // the posture or the context's organizations: the verdict is the wall's + // own, read as a value. + const askWriteWall = async (asked: Record[]) => { + if (!this._writeWallVerdict || asked.length === 0) return null; + const verdicts = await this._writeWallVerdict(object, mode, options?.context, asked); + if (verdicts && verdicts.length !== asked.length) { + throw new Error( + `[ObjectQL] the write-wall verdict answered ${verdicts.length} verdict(s) for ` + + `${asked.length} row(s) of '${object}'`, + ); + } + return verdicts; + }; + const refusalIn = (verdict: WriteWallRowVerdict | undefined) => + verdict && verdict.admitted === false ? verdict : undefined; + const wallVerdicts = await askWriteWall(rows); + const wallRefusals = rows.map((_, i) => refusalIn(wallVerdicts?.[i])); + const wallRefusalOf = (i: number) => wallRefusals[i]; + const wallJudgesOrganizationOf = (i: number): boolean => { + const verdict = wallVerdicts?.[i]; + return verdict?.admitted === true && verdict.judgesOrganization === true; + }; + const heldBeforeStrips = rows.slice(); + // [#20805] 3. The caller-write strips, over the defaulted rows with the // caller's rows as `supplied` — where the write runs them (after the // defaults; the write's hooks, which this preview does not run, sit in @@ -15777,6 +15919,7 @@ export class ObjectQL implements IObjectQLEngine { const preserveAudit = options?.context?.preserveAudit === true; if (mode === 'insert') { for (let i = 0; i < rows.length; i++) { + if (wallRefusalOf(i)) continue; const stripped = stripRuntimeOwnedFields( schemaForValidation as any, rows[i], rawRows[i] ?? {}, undefined, { preserveAudit }, ) as Record; @@ -15784,26 +15927,33 @@ export class ObjectQL implements IObjectQLEngine { collectTaken(i, rows[i]!, stripped); rows[i] = stripped; } - const readonlySubject = staticReadonlyInsertSubject(schemaForValidation as any); - if (readonlySubject) { - for (let i = 0; i < rows.length; i++) { - const pass = await this.staticReadonlyCreateStrip( - object, readonlySubject, rows[i]!, rawRows[i] ?? {}, options?.context, - { logger: undefined, strictReadonlyWrites: false, hookWrittenKeys: undefined, nowSnap: nowSnapshot, permissionResolution }, - ); - recordTaken(i, pass.taken); - // The write fails on this resolution failure outside partial mode; - // a preview has no partial mode, so it fails the same way. - if (pass.error !== undefined) throw pass.error; - rows[i] = pass.row; - } + // [#25062] The subject per row, as the write's strip judges that row: + // without `organization_id` where the wall judges it + // (`insertStaticReadonlySubject`, the write's own view). + for (let i = 0; i < rows.length; i++) { + if (wallRefusalOf(i)) continue; + const readonlySubject = insertStaticReadonlySubject(schemaForValidation, wallJudgesOrganizationOf(i)); + if (!readonlySubject) continue; + const pass = await this.staticReadonlyCreateStrip( + object, readonlySubject, rows[i]!, rawRows[i] ?? {}, options?.context, + { logger: undefined, strictReadonlyWrites: false, hookWrittenKeys: undefined, nowSnap: nowSnapshot, permissionResolution }, + ); + recordTaken(i, pass.taken); + // The write fails on this resolution failure outside partial mode; + // a preview has no partial mode, so it fails the same way. + if (pass.error !== undefined) throw pass.error; + rows[i] = pass.row; } } else { for (let i = 0; i < rows.length; i++) { + if (wallRefusalOf(i)) continue; const supplied: Record = { ...(rawRows[i] ?? {}) }; delete supplied.id; + // [#25062] The update's own view (`updateStaticReadonlySubject`): + // the whole schema, without `organization_id` where the wall judges it. const stripped = stripReadonlyFields( - schemaForValidation as any, rows[i], supplied, undefined, { preserveAudit }, + updateStaticReadonlySubject(schemaForValidation, wallJudgesOrganizationOf(i)) as any, + rows[i], supplied, undefined, { preserveAudit }, ) as Record; if (stripped === rows[i]) continue; collectTaken(i, rows[i]!, stripped); @@ -15811,9 +15961,32 @@ export class ObjectQL implements IObjectQLEngine { } } } + // [#25062] …and the wall's STORED-IMAGE half, on each row the strips + // changed: the row as the write would store it, which the wall has not + // seen. Where the wall does not judge the organization (a platform + // operator crossing it with no active organization), the strip takes a + // caller's organization and the stored row has nowhere to land; the write + // refuses it there, after its strips, and so does this. A row the strips + // left alone was answered above. Only a refusal is read here: the spare + // was decided on the first answer, as the write decides it before its + // strips. + const restripped = rows.flatMap((row, i) => (!wallRefusals[i] && row !== heldBeforeStrips[i] ? [i] : [])); + const storedImageVerdicts = await askWriteWall(restripped.map((i) => rows[i]!)); + restripped.forEach((rowIndex, k) => { + wallRefusals[rowIndex] = refusalIn(storedImageVerdicts?.[k]); + }); + const onFieldsDropped = options?.onFieldsDropped; if (typeof onFieldsDropped === 'function') { - const drops = droppedFieldEvents(object, computedStrip.dropped, readonlyDropped); + // [#25062] A row the wall refuses drops nothing: the write that refuses + // it reports no drop. So the batch union is the admitted rows' alone. + const admittedTook = (perRow: readonly (readonly string[])[], key: string) => + perRow.some((taken, i) => !wallRefusals[i] && taken.includes(key)); + const drops = droppedFieldEvents( + object, + computedStrip.dropped.filter((key) => admittedTook(computedStrip.droppedPerRow, key)), + readonlyDropped.filter((key) => admittedTook(readonlyDroppedPerRow, key)), + ); for (const drop of drops) { try { onFieldsDropped(drop); @@ -15992,6 +16165,16 @@ export class ObjectQL implements IObjectQLEngine { message: `${field} has an invalid ${type} value: ${detail}`, }); }; + // [#25062] A row the tenant wall refuses is answered with the write's + // refusal, which the write gives before it validates anything. + const wallRefusal = wallRefusalOf(i); + if (wallRefusal) { + return { + valid: false, + errors: [{ field: wallRefusal.field, code: wallRefusal.code, message: wallRefusal.message }], + warnings, + }; + } try { // [#21663] `'include'` in both modes: the caller-write strips ran // above, so this is the payload the write stores — and the write diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index f1daf2d8dcc..6ea31c76052 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -163,6 +163,10 @@ const READERS: Record = { disposition: 'not-a-read', why: "compares a field name with the object's tenant column, on a relation its callers already read through cascadeRelationBehavior; it reads no row", }, + 'engine.ts# :: organization_id': { + disposition: 'not-a-read', + why: "names the field a write-wall verdict (WriteWallRowVerdict, #25062) refuses, in the verdict's type; the plugin that owns the wall decides it, and the engine reads no row", + }, 'lifecycle/lifecycle-service.ts#tenantWindowsFor :: isFederatedUnprovisionedInjectedColumn()': { disposition: 'skips', why: 'a federated object whose organization_id is the injection has no tenant partition', diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index f4d3494f181..e1163cccbdc 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -156,7 +156,7 @@ export type { CompanionFieldMeta, CompanionObjectMeta } from './search-companion // Export Engine export { ObjectQL, ObjectRepository, ScopedContext } from './engine.js'; -export type { HookHandler, HookEntry, OperationContext, EngineMiddleware, HeldFileResolver, FieldOwnedFileReadAuthorizer } from './engine.js'; +export type { HookHandler, HookEntry, OperationContext, EngineMiddleware, HeldFileResolver, FieldOwnedFileReadAuthorizer, WriteWallVerdict, WriteWallRowVerdict } from './engine.js'; export type { AdmittedValueShapeViolationTally } from './engine.js'; // The declarative datasource definition the engine indexes, and the element // type of `ObjectQL.listDatasourceDefs()`. Exported so a consumer sweeping for diff --git a/packages/objectql/src/validate-write-wall-verdict.test.ts b/packages/objectql/src/validate-write-wall-verdict.test.ts new file mode 100644 index 00000000000..dbf125de8d6 --- /dev/null +++ b/packages/objectql/src/validate-write-wall-verdict.test.ts @@ -0,0 +1,285 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#25062] `validate()` reads the Layer 0 tenant write wall's verdict on each +// row through `registerWriteWallVerdict`, and nothing else, to answer a row's +// `organization_id` as the write does. +// +// ## The defect +// +// The preview runs no middleware, so the wall never judged its rows. It ran +// the static-`readonly` strip unconditionally: a caller's organization the +// wall admits (and the write stores as sent, `judgesOrganization`) was +// reported DROPPED, and an organization the wall refuses (the write answers +// 403) was answered VALID. +// +// ## What this file holds the engine to, with a stand-in verdict +// +// - unregistered, or answering `null`: the strip runs exactly as before; +// - admitted, the wall judging the organization: the column stays on the row +// and is not reported, while every other read-only key is still taken, and +// an AUTHORED read-only `organization_id` keeps its strip; +// - admitted, the wall not judging it: the strip takes it, as before; +// - refused: THAT row answers `valid: false` with the verdict's field, code +// and message, its neighbours are judged as before, the set's `valid` is +// the conjunction, and the refused row reaches no strip (the write refuses +// it before any strip runs), so the batch listener hears nothing from it; +// - the verdict is asked with the rows as the preview holds them (after the +// defaults, before any hook), the mode and the caller's context; +// - an `update`-mode preview spares the column on the update's own view; +// - a verdict that answers the wrong number of rows, or throws, rejects the +// preview. +// +// The producer is `@objectstack/plugin-security` (`writeWallVerdict`, from the +// method its step 3.7 calls). The two packages together, against the real +// write, are pinned in `organizations/src/validate-preview-wall-verdict.test.ts` +// and, at the HTTP import door, `rest/src/import-dryrun-tenant-wall-parity.test.ts`. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; +import type { WriteWallRowVerdict, WriteWallVerdict } from './engine.js'; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return row?.[k] === v; + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + // The caller's bound, applied AFTER the filter and by PRESENCE + // (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + const row = { ...data }; + storeFor(object).set(String(data.id), row); + return row; + }, + async update() { return null; }, async updateMany() { return 0; }, + async delete() { return true; }, async count() { return 0; }, + async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +const OBJECT = 'qa_ledger'; +const AUTHORED = 'qa_authored'; +const NAMED = 'org_c'; +const SESSION = { userId: 'usr_member', tenantId: 'org_a' }; +const DENIED = 'PERMISSION_DENIED'; +const PLACEMENT = "[Security] Access denied: the insert would place 'qa_ledger' in another tenant (organization_id is outside the caller's organization scope)"; +const NO_ORGANIZATION = "[Security] Access denied: 'qa_ledger' is scoped to an organization, and this session has no active organization — so this insert has no organization to place the record in. Join or select an active organization and retry."; + +const ADMIT_JUDGED: WriteWallRowVerdict = { admitted: true, judgesOrganization: true }; +const ADMIT_UNJUDGED: WriteWallRowVerdict = { admitted: true, judgesOrganization: false }; +const refused = (field: 'organization_id' | '_record', message: string): WriteWallRowVerdict => + ({ admitted: false, field, code: DENIED, message }); + +describe('[#25062] validate() answers a row\'s organization from the write wall\'s verdict', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + /** Every call the stand-in verdict received. */ + let asked: Array<{ object: string; operation: string; context: unknown; rows: Record[] }>; + + beforeEach(async () => { + asked = []; + const logger: any = { + warn() {}, debug() {}, info() {}, error() {}, trace() {}, fatal() {}, + child() { return logger; }, + }; + engine = new ObjectQL({ logger }); + const d = makeDriver(); + storeFor = d.storeFor; + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject({ + name: OBJECT, + fields: { + name: { type: 'text' }, + tier: { type: 'text', defaultValue: 'standard' }, + // Another read-only key the caller may send: the spare never covers it. + ref_code: { type: 'text', readonly: true }, + }, + } as any); + // An app object whose AUTHOR declares its own read-only organization_id: + // the wall's verdict does not spare it, as it does not on the write. + engine.registry.registerObject({ + name: AUTHORED, + fields: { name: { type: 'text' }, organization_id: { type: 'text', readonly: true } }, + } as any); + }); + + /** Register a stand-in verdict answering `answer(rows)` and recording the call. */ + const register = (answer: (rows: readonly Record[]) => readonly WriteWallRowVerdict[] | null) => { + const verdict: WriteWallVerdict = async (object, operation, context, rows) => { + asked.push({ object, operation, context, rows: rows.map((r) => ({ ...r })) }); + return answer(rows); + }; + engine.registerWriteWallVerdict(verdict); + }; + + const preview = async (data: unknown, mode: 'insert' | 'update' = 'insert', object = OBJECT) => { + const heard: Array<{ fields: string[]; reason: string }> = []; + const result = await engine.validate(object, data as any, { + mode, + context: SESSION as any, + onFieldsDropped: (e: any) => heard.push({ fields: [...e.fields], reason: e.reason }), + }); + return { result, heard }; + }; + const ORG_DROP = { object: OBJECT, fields: ['organization_id'], reason: 'readonly' }; + + it('PRECONDITION: the platform injects organization_id as a read-only column on this object', () => { + const field = (engine.getSchema(OBJECT) as any)?.fields?.organization_id; + expect(field?.readonly, 'the strip has the column to take').toBe(true); + }); + + it('UNREGISTERED: the strip takes the organization and reports it, exactly as before', async () => { + const { result, heard } = await preview({ name: 'one', organization_id: NAMED }); + + expect(result.valid).toBe(true); + expect(result.results[0]?.droppedFields).toEqual([ORG_DROP]); + expect(heard).toEqual([{ fields: ['organization_id'], reason: 'readonly' }]); + }); + + it('NULL: a verdict that does not ask the wall leaves the strip as before', async () => { + register(() => null); + const { result } = await preview({ name: 'one', organization_id: NAMED }); + + expect(asked.length).toBeGreaterThan(0); + expect(result.results[0]).toEqual({ valid: true, errors: [], warnings: [], droppedFields: [ORG_DROP] }); + }); + + it('ADMITTED and JUDGED: the organization stays on the row and is not reported; another read-only key still is', async () => { + register((rows) => rows.map(() => ADMIT_JUDGED)); + const { result, heard } = await preview({ name: 'one', organization_id: NAMED, ref_code: 'X-1' }); + + expect(result.valid).toBe(true); + expect(result.results[0]?.droppedFields).toEqual([{ object: OBJECT, fields: ['ref_code'], reason: 'readonly' }]); + expect(heard).toEqual([{ fields: ['ref_code'], reason: 'readonly' }]); + }); + + it('ADMITTED, NOT JUDGED: the strip takes the organization, as before', async () => { + register((rows) => rows.map(() => ADMIT_UNJUDGED)); + const { result } = await preview({ name: 'one', organization_id: NAMED }); + + expect(result.results[0]?.droppedFields).toEqual([ORG_DROP]); + }); + + it('ADMITTED and JUDGED on an AUTHORED read-only organization_id: the author\'s declaration keeps the strip', async () => { + register((rows) => rows.map(() => ADMIT_JUDGED)); + const { result } = await preview({ name: 'one', organization_id: NAMED }, 'insert', AUTHORED); + + expect(result.results[0]?.droppedFields).toEqual([{ object: AUTHORED, fields: ['organization_id'], reason: 'readonly' }]); + }); + + it('REFUSED: that row answers the write\'s refusal, its neighbours are judged as before, and the set is the conjunction', async () => { + register((rows) => rows.map((row) => (row.name === 'forged' ? refused('organization_id', PLACEMENT) : ADMIT_JUDGED))); + const { result, heard } = await preview([ + { name: 'first', organization_id: 'org_a' }, + { name: 'forged', organization_id: NAMED, ref_code: 'X-2' }, + { name: 'third', ref_code: 'X-3' }, + ]); + + expect(result.valid).toBe(false); + expect(result.results[0]).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result.results[1]).toEqual({ + valid: false, + errors: [{ field: 'organization_id', code: DENIED, message: PLACEMENT }], + warnings: [], + }); + expect(result.results[2]).toEqual({ + valid: true, errors: [], warnings: [], + droppedFields: [{ object: OBJECT, fields: ['ref_code'], reason: 'readonly' }], + }); + // The refused row reaches no strip: the batch listener hears the third + // row's `ref_code` and nothing of the second's. + expect(heard).toEqual([{ fields: ['ref_code'], reason: 'readonly' }]); + }); + + it('REFUSED on the caller\'s state: the refusal names `_record`', async () => { + register((rows) => rows.map(() => refused('_record', NO_ORGANIZATION))); + const { result } = await preview({ name: 'one' }); + + expect(result.valid).toBe(false); + expect(result.results[0]?.errors).toEqual([{ field: '_record', code: DENIED, message: NO_ORGANIZATION }]); + }); + + it('ASKED with the rows as the preview holds them: after the defaults, before any hook, with the mode and the context', async () => { + let hookRan = false; + engine.registerHook('beforeInsert', async () => { hookRan = true; }, { object: OBJECT }); + register((rows) => rows.map(() => ADMIT_JUDGED)); + await preview({ name: 'one', organization_id: NAMED }); + + expect(asked, 'the strips left the row alone, so it is asked once').toHaveLength(1); + expect(asked[0]).toMatchObject({ object: OBJECT, operation: 'insert', context: SESSION }); + expect(asked[0]!.rows).toEqual([expect.objectContaining({ name: 'one', organization_id: NAMED, tier: 'standard' })]); + expect(hookRan, 'the preview runs no hook').toBe(false); + }); + + it('ASKED AGAIN about a row the strips changed, as the write\'s stored-image half judges it, and only about that row', async () => { + // The stand-in answers as a crossing operator's wall does: it does not + // judge the organization, and a stored row naming none has nowhere to land. + register((rows) => rows.map((row) => (row.organization_id == null ? refused('_record', NO_ORGANIZATION) : ADMIT_UNJUDGED))); + const { result, heard } = await preview([ + { name: 'named', organization_id: NAMED }, + { name: 'unnamed' }, + ]); + + expect(asked).toHaveLength(2); + expect(asked[0]!.rows.map((r) => r.name)).toEqual(['named', 'unnamed']); + expect(asked[1]!.rows, 'the stripped row, as the write would store it').toEqual([ + expect.not.objectContaining({ organization_id: expect.anything() }), + ]); + expect(asked[1]!.rows[0]).toMatchObject({ name: 'named' }); + expect(result.results[0]).toEqual({ + valid: false, + errors: [{ field: '_record', code: DENIED, message: NO_ORGANIZATION }], + warnings: [], + }); + // The second row named no organization to begin with: refused on the + // first answer, never stripped and never asked again. + expect(result.results[1]?.valid).toBe(false); + expect(heard, 'a refused row drops nothing').toEqual([]); + }); + + it('UPDATE mode: a judged organization stays on the update\'s own view; an unjudged one is taken', async () => { + storeFor(OBJECT).set('r1', { id: 'r1', name: 'stored', organization_id: 'org_a' }); + + register((rows) => rows.map(() => ADMIT_JUDGED)); + const judged = await preview({ id: 'r1', organization_id: NAMED }, 'update'); + expect(asked[0]).toMatchObject({ operation: 'update', rows: [{ id: 'r1', organization_id: NAMED }] }); + expect(judged.result.results[0]).toEqual({ valid: true, errors: [], warnings: [] }); + + register((rows) => rows.map(() => ADMIT_UNJUDGED)); + const unjudged = await preview({ id: 'r1', organization_id: NAMED }, 'update'); + expect(unjudged.result.results[0]?.droppedFields).toEqual([ORG_DROP]); + }); + + it('a verdict that answers the wrong number of rows rejects the preview', async () => { + register(() => [ADMIT_JUDGED]); + await expect(preview([{ name: 'one' }, { name: 'two' }])).rejects.toThrow(/answered 1 verdict\(s\) for 2 row\(s\)/); + }); + + it('a verdict that throws rejects the preview, as a refusal to resolve fails the write', async () => { + engine.registerWriteWallVerdict(async () => { throw new Error('wall unavailable'); }); + await expect(preview({ name: 'one' })).rejects.toThrow('wall unavailable'); + }); +}); diff --git a/packages/plugins/organizations/src/update-repoint-organization-wall.test.ts b/packages/plugins/organizations/src/update-repoint-organization-wall.test.ts index ed151628a25..8f599f86dff 100644 --- a/packages/plugins/organizations/src/update-repoint-organization-wall.test.ts +++ b/packages/plugins/organizations/src/update-repoint-organization-wall.test.ts @@ -49,8 +49,10 @@ * crossing the wall, a tenancy-disabled object, and `preserveAudit`, whose * update-only exemption never reaches `organization_id`. A system context * never met the strip. - * - The `update`-mode `validate()` preview runs no middleware, so it is - * wall-blind: it still reports the drop of an admitted re-point. + * - The `update`-mode `validate()` preview runs no middleware, so it was + * wall-blind: it reported the drop of an admitted re-point and admitted a + * refused one. It now reads the wall's own per-row verdict (#25062), and + * answers a re-point as its write does. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -578,17 +580,29 @@ describe('[#24822] where no wall judges the column, the update strip takes the r }); }); -describe('[#24822] the `update`-mode validate() preview is wall-blind (it runs no middleware)', () => { - it('it still reports an admitted re-point as dropped, while the write stores it; and a refused one as dropped, while the write refuses it', async () => { +describe('[#24822][#25062] the `update`-mode validate() preview answers a re-point as its write does', () => { + it('an admitted re-point is reported as no drop, and the write stores it; a refused one is refused by both, with the write\'s code and message', async () => { const b = await boot('group'); const admitted = await b.engine.validate(INJECTED, { id: 'r1', organization_id: SISTER_ORG }, { mode: 'update', context: GROUP_CTX as never }); const refused = await b.engine.validate(INJECTED, { id: 'r1', organization_id: FOREIGN_ORG }, { mode: 'update', context: GROUP_CTX as never }); - expect(admitted.results[0]?.droppedFields).toEqual(READONLY_DROP(INJECTED)); - expect(refused.results[0]?.droppedFields).toEqual(READONLY_DROP(INJECTED)); + expect(admitted.valid).toBe(true); + expect(admitted.results[0]?.droppedFields).toBeUndefined(); + expect(refused.valid).toBe(false); + expect(refused.results[0]?.droppedFields).toBeUndefined(); + const refusal = refused.results[0]?.errors[0]; + expect(refusal?.field).toBe('organization_id'); + expect(refusal?.code).toBe('PERMISSION_DENIED'); + + const refusedWrite = await repoint(b, INJECTED, FOREIGN_ORG, GROUP_CTX, 'id'); + expectWallRefusal(refusedWrite.outcome, INJECTED); + expect(refusal?.message).toBe(refusedWrite.outcome.message); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED)); + const write = await repoint(b, INJECTED, SISTER_ORG, GROUP_CTX, 'id'); expect(write.outcome.ok, write.outcome.message).toBe(true); + expect(write.dropped).toEqual([]); expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED, SISTER_ORG)); }); }); diff --git a/packages/plugins/organizations/src/validate-preview-wall-verdict.test.ts b/packages/plugins/organizations/src/validate-preview-wall-verdict.test.ts new file mode 100644 index 00000000000..b1031a73495 --- /dev/null +++ b/packages/plugins/organizations/src/validate-preview-wall-verdict.test.ts @@ -0,0 +1,280 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25062] The write PREVIEW (`engine.validate()`, the import dry run) answers + * a walled row's `organization_id` as the write does — on the real stack. + * + * ## The defect, as measured on `origin/main` f7d740376 + * + * `validate()` runs no middleware, so the Layer 0 tenant write wall never + * judged a preview row. Two cells disagreed with the write: + * + * (a) an organization the wall ADMITS (a `group` member's sister + * organization, or an `isolated` member's own named explicitly): the + * write stores it as sent (`judgesOrganization`, #24822) and reports no + * drop, while the preview reported `organization_id` dropped; + * (b) an organization the wall REFUSES (an `isolated` member naming a + * foreign one): the write answers 403 `PERMISSION_DENIED`, while the + * preview answered valid. + * + * Ruling 6336640643 (letter A): `@objectstack/plugin-security` hands the + * engine the wall's own verdict per row (`registerWriteWallVerdict`), from the + * method its step 3.7 calls, and `validate()` reads that value and nothing + * else. + * + * ## What is pinned + * + * Every case runs BOTH halves against one live stack (real `ObjectQL`, real + * SQLite driver, the real `OrganizationsPlugin` and `SecurityPlugin`) and + * asserts they agree: the preview's per-row answer against the write's + * outcome, its drops and its stored row. The two cells; the caller-state + * refusals (ADR-0123 D2, #24784) and the crossing operator's (#24821), which + * the preview reaches by asking the wall again about the row its strip + * changed; and three controls that must not move: the `single` posture, an + * object the wall does not cover, and a composition with no wall registered. + * The HTTP import door is pinned in `@objectstack/rest`'s + * `import-dryrun-tenant-wall-parity.test.ts`. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { OrganizationsPlugin } from './organizations-plugin.js'; + +const OWN_ORG = 'org_alpha'; +const FOREIGN_ORG = 'org_north'; +const SISTER_ORG = 'org_south'; +const LEDGER = 'qa_ledger'; +const VAULT = 'qa_vault'; +const NOTE = 'qa_note'; + +const FIELDS = { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, +}; +const OBJECTS = [ + { name: LEDGER, label: 'Ledger', fields: FIELDS }, + // A private object: where a true platform operator crosses the wall (ADR-0095 D3). + { name: VAULT, label: 'Vault', access: { default: 'private' }, fields: FIELDS }, + // An object the wall does not cover: no tenant column is injected. + { name: NOTE, label: 'Note', tenancy: { enabled: false }, fields: FIELDS }, +]; + +const MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as unknown as PermissionSet; +const PLATFORM_ADMIN: PermissionSet = { + name: 'admin_full_access', + label: 'Platform Administrator', + objects: { + '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + }, + systemPermissions: ['manage_platform_settings', 'manage_metadata'], +} as unknown as PermissionSet; + +const SYS_CTX = { isSystem: true }; +const MEMBER_CTX = { userId: 'usr_member', tenantId: OWN_ORG, positions: [], permissions: [], posture: 'MEMBER' }; +const GROUP_CTX = { ...MEMBER_CTX, accessible_org_ids: [OWN_ORG, SISTER_ORG] }; +const ORGLESS_CTX = { userId: 'usr_member', positions: [], permissions: [], posture: 'MEMBER' }; +const GROUP_NO_ACTIVE_CTX = { ...ORGLESS_CTX, accessible_org_ids: [OWN_ORG, SISTER_ORG] }; +const ADMIN_NO_ORG_CTX = { userId: 'usr_admin', positions: [], permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN' }; + +type Posture = 'isolated' | 'group' | 'single'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +interface Booted { + engine: ObjectQL; + table: (object?: string) => Promise>>; +} + +/** `security: false` boots the organizations runtime alone: no wall, and no verdict registered. */ +async function boot(posture: Posture, opts: { security?: boolean } = {}): Promise { + const engine = new ObjectQL(); + engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' } as never) as never, true); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.validate-preview-wall-verdict', + name: 'The preview answers a walled row as the write does', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: OBJECTS, + } as never); + await engine.syncSchemas(); + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER, PLATFORM_ADMIN], + }, + tenancy: { posture }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, service: unknown) => { services[name] = service; }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + hook: vi.fn(), + }; + const organizations = new OrganizationsPlugin({ ensureDefaultOrganization: false }); + await organizations.init(ctx as never); + await organizations.start(ctx as never); + if (opts.security !== false) { + const security = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await security.init(ctx as never); + await security.start(ctx as never); + } + vi.spyOn((engine as unknown as { logger: { warn: (m: unknown) => void } }).logger, 'warn').mockImplementation(() => {}); + + await engine.insert(LEDGER, { id: 'r1', name: 'seeded', organization_id: OWN_ORG }, { context: SYS_CTX } as never); + + const table = async (object = LEDGER) => { + const driver = (engine as unknown as { getDriver(o: string): { knex: unknown } }).getDriver(object); + const knex = driver.knex as (t: string) => { select: (...c: string[]) => Promise>> }; + const columns = object === NOTE ? ['id'] : ['id', 'organization_id']; + const rows = await knex(object).select(...columns); + return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id))); + }; + return { engine, table }; +} + +interface Outcome { ok: boolean; code?: string; status?: number; message?: string } +const attempt = async (run: () => Promise): Promise => { + try { await run(); return { ok: true }; } catch (e) { + const err = e as { code?: string; statusCode?: number; status?: number; message?: string }; + return { ok: false, code: err.code, status: err.statusCode ?? err.status, message: String(err.message ?? e) }; + } +}; + +/** The preview, then the write, of one row: what each answers, what the write drops, and what it stored. */ +const previewThenWrite = async (b: Booted, row: Record, context: object, object = LEDGER) => { + const preview = await b.engine.validate(object, { ...row }, { mode: 'insert', context: context as never }); + const dropped: unknown[] = []; + const write = await attempt(() => b.engine.insert(object, { ...row } as never, { + context, onFieldsDropped: (e: unknown) => dropped.push(e), + } as never)); + return { preview, row0: preview.results[0]!, write, dropped, stored: await b.table(object) }; +}; + +const ORG_DROP = { object: LEDGER, fields: ['organization_id'], reason: 'readonly' }; + +describe('[#25062] cell (a): an organization the wall admits is stored as sent, and the preview reports no drop', () => { + it('`group`: a sister organization', async () => { + const b = await boot('group'); + const r = await previewThenWrite(b, { id: 'r2', name: 'n', organization_id: SISTER_ORG }, GROUP_CTX); + + expect(r.write).toEqual({ ok: true }); + expect(r.dropped).toEqual([]); + expect(r.stored).toContainEqual({ id: 'r2', organization_id: SISTER_ORG }); + expect(r.row0).toEqual({ valid: true, errors: [], warnings: [] }); + }); + + it('`isolated`: the active organization, named explicitly', async () => { + const b = await boot('isolated'); + const r = await previewThenWrite(b, { id: 'r2', name: 'n', organization_id: OWN_ORG }, MEMBER_CTX); + + expect(r.write).toEqual({ ok: true }); + expect(r.dropped).toEqual([]); + expect(r.row0).toEqual({ valid: true, errors: [], warnings: [] }); + }); +}); + +describe('[#25062] cell (b): an organization the wall refuses is refused by the write, and the preview answers that refusal', () => { + it('`isolated`: a foreign organization — the write\'s 403, its code and its message, under `organization_id`', async () => { + const b = await boot('isolated'); + const r = await previewThenWrite(b, { id: 'r2', name: 'n', organization_id: FOREIGN_ORG }, MEMBER_CTX); + + expect(r.write).toMatchObject({ ok: false, code: 'PERMISSION_DENIED', status: 403 }); + expect(r.stored).toEqual([{ id: 'r1', organization_id: OWN_ORG }]); + expect(r.preview.valid).toBe(false); + expect(r.row0).toEqual({ + valid: false, + errors: [{ field: 'organization_id', code: 'PERMISSION_DENIED', message: r.write.message }], + warnings: [], + }); + }); + + it('`group`: an organization outside the membership set', async () => { + const b = await boot('group'); + const r = await previewThenWrite(b, { id: 'r2', name: 'n', organization_id: FOREIGN_ORG }, GROUP_CTX); + + expect(r.write).toMatchObject({ ok: false, code: 'PERMISSION_DENIED', status: 403 }); + expect(r.row0.errors).toEqual([{ field: 'organization_id', code: 'PERMISSION_DENIED', message: r.write.message }]); + }); +}); + +describe('[#25062] a caller-state refusal is the write\'s too, under `_record`', () => { + it('[ADR-0123 D2] `isolated`, a session with no active organization', async () => { + const b = await boot('isolated'); + const r = await previewThenWrite(b, { id: 'r2', name: 'n' }, ORGLESS_CTX); + + expect(r.write).toMatchObject({ ok: false, code: 'PERMISSION_DENIED', status: 403 }); + expect(r.write.message).toContain('has no active organization'); + expect(r.row0.errors).toEqual([{ field: '_record', code: 'PERMISSION_DENIED', message: r.write.message }]); + }); + + it('[#24784] `group`, a member with a membership set and no active organization, a row naming none', async () => { + const b = await boot('group'); + const r = await previewThenWrite(b, { id: 'r2', name: 'n' }, GROUP_NO_ACTIVE_CTX); + + expect(r.write).toMatchObject({ ok: false, code: 'PERMISSION_DENIED', status: 403 }); + expect(r.row0.errors).toEqual([{ field: '_record', code: 'PERMISSION_DENIED', message: r.write.message }]); + }); + + it('[#24821] a crossing operator with no active organization names one: the strip takes it, the stored row has nowhere to land, and both refuse', async () => { + const b = await boot('isolated'); + const r = await previewThenWrite(b, { id: 'v1', name: 'n', organization_id: SISTER_ORG }, ADMIN_NO_ORG_CTX, VAULT); + + expect(r.write).toMatchObject({ ok: false, code: 'PERMISSION_DENIED', status: 403 }); + expect(r.write.message).toContain('has no active organization'); + expect(r.row0).toEqual({ + valid: false, + errors: [{ field: '_record', code: 'PERMISSION_DENIED', message: r.write.message }], + warnings: [], + }); + }); +}); + +describe('[#25062] controls: where the wall does not judge the row, nothing moves', () => { + it('`single`: no wall — the strip takes the organization on the write and in the preview alike', async () => { + const b = await boot('single'); + const r = await previewThenWrite(b, { id: 'r2', name: 'n', organization_id: FOREIGN_ORG }, MEMBER_CTX); + + expect(r.write).toEqual({ ok: true }); + expect(r.dropped).toEqual([ORG_DROP]); + expect(r.row0).toEqual({ valid: true, errors: [], warnings: [], droppedFields: [ORG_DROP] }); + }); + + it('an object the wall does not cover: a session with no active organization is admitted by both', async () => { + const b = await boot('isolated'); + const r = await previewThenWrite(b, { id: 'n1', name: 'n' }, ORGLESS_CTX, NOTE); + + expect(r.write).toEqual({ ok: true }); + expect(r.stored).toEqual([{ id: 'n1' }]); + expect(r.row0).toEqual({ valid: true, errors: [], warnings: [] }); + }); + + it('no wall registered (the organizations runtime alone): the preview runs the strip the write runs', async () => { + const b = await boot('isolated', { security: false }); + const r = await previewThenWrite(b, { id: 'r2', name: 'n', organization_id: FOREIGN_ORG }, MEMBER_CTX); + + expect(r.write).toEqual({ ok: true }); + expect(r.dropped).toEqual([ORG_DROP]); + expect(r.row0).toEqual({ valid: true, errors: [], warnings: [], droppedFields: [ORG_DROP] }); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 130693ab657..15d9a5abd15 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -273,6 +273,68 @@ interface WriteImageCheckSeam { judgesOrganization?: boolean; } +/** + * [#25062] One refusal of the Layer 0 tenant write wall (step 3.7), held as a + * VALUE: the error the write throws, the warn line it logs first, and which + * value the wall refused (`organization_id`, a value the row carries, or + * `_record`, the caller's own state). Built by the judgements of + * {@link SecurityPlugin.composeWriteTenantWall} and nowhere else. The + * middleware logs and throws it; the write preview answers it as a row's + * verdict ({@link SecurityPlugin.writeWallVerdict}). + */ +interface WriteWallRefusal { + readonly field: 'organization_id' | '_record'; + readonly warning: string; + readonly error: PermissionDeniedError; +} + +/** + * [#25062] Step 3.7's Layer 0 tenant write wall, composed ONCE for one write by + * {@link SecurityPlugin.composeWriteTenantWall}. Every judgement below reads + * the same parts, computed once with this request's caller and delegator + * contexts, so the payload half, the stored-image half and the write preview + * hold a row to one wall. + */ +interface WriteTenantWall { + /** [ADR-0123 D2] The scope pre-test's refusal of the whole write, or `null`. */ + readonly preTest: WriteWallRefusal | null; + /** + * The payload half: the rows as the caller SENT them. Every supplied + * organization must clear the wall, and on an update none may be emptied. + */ + judgePayload(rows: readonly Record[]): WriteWallRefusal | null; + /** The stored-image half, or `null` where the wall installs no seam. */ + readonly storedImage: { + /** [#24784 · #24821] An insert row with nowhere to land, judged before any other. */ + judgeLanding(rows: readonly Record[]): WriteWallRefusal | null; + /** One stored row: its organization against the wall, and on an update not emptied. */ + judgeRow(row: Record): WriteWallRefusal | null; + /** [#24822] The value `PostHookWriteImageCheck.judgesOrganization` is set from. */ + readonly judgesOrganization: boolean; + } | null; +} + +/** + * [#25062] The wall's answer for ONE row of a write preview, as the engine's + * `registerWriteWallVerdict` takes it: admitted (and whether the wall judges + * the row's organization), or refused with the write's own code and message. + * + * Structurally identical to `WriteWallRowVerdict` in `@objectstack/objectql`, + * and declared here for the reason {@link WriteImageCheckSeam} is: the engine + * is a devDependency of this package. The two spellings are welded by tests + * that run both packages (`validate-preview-wall-verdict.test.ts` in + * `@objectstack/organizations`, `import-dryrun-tenant-wall-parity.test.ts` in + * `@objectstack/rest`). + */ +type WriteWallRowVerdict = + | { readonly admitted: true; readonly judgesOrganization: boolean } + | { + readonly admitted: false; + readonly field: 'organization_id' | '_record'; + readonly code: string; + readonly message: string; + }; + /** * [ADR-0066 D3/⑤] Object `requiredPermissions` normalized into per-CRUD buckets. * `all` holds capabilities required for EVERY operation (the `string[]` form); @@ -1907,6 +1969,33 @@ export class SecurityPlugin implements Plugin { ); } + // [#25062] Hand the engine the Layer 0 tenant write wall's verdict PER ROW, + // so the write preview answers a row's organization as the write does: a + // value the wall admits is not reported dropped where the write stores it, + // and a value it refuses is answered `PERMISSION_DENIED`, not valid. The + // probe above answers a caller class and is not this question; see + // {@link writeWallVerdict}, which answers from the method step 3.7 calls. + if (typeof (ql as any).registerWriteWallVerdict === 'function') { + (ql as any).registerWriteWallVerdict( + ( + object: string, + operation: 'insert' | 'update', + context: any, + rows: readonly Record[], + ): Promise => this.writeWallVerdict(object, operation, context, rows), + ); + } else { + // Absence must be loud, for the probe's reason: the write is walled + // here and its preview cannot ask the wall. + ctx.logger.warn( + '[security] this ObjectQL exposes no write-wall verdict seam (registerWriteWallVerdict), so ' + + 'the write PREVIEW (validate() / the dryRun import) cannot ask the Layer 0 tenant wall about ' + + 'a row: it reports a caller-supplied organization_id as dropped where the write stores it, ' + + 'and answers valid where the write refuses it. Upgrade @objectstack/objectql to a version ' + + 'that offers the seam.', + ); + } + // [#18783] Hand the engine the subject's EFFECTIVE object permissions, so // `current_user.can(object, verb)` in an option's `visibleWhen` is answered // on the write path instead of failing open. The same method the @@ -4011,236 +4100,26 @@ export class SecurityPlugin implements Plugin { !!r && typeof r === 'object' && !Array.isArray(r), ); - // ── [ADR-0123 D2] NO ACTIVE ORGANIZATION → refuse the write, loudly ── - // - // The forge guard below validates SUPPLIED values only, and says so. It - // is the right shape for what it guards (a payload naming ANOTHER - // tenant), but it leaves the opposite case wide open: a payload naming - // NO tenant, written by a caller who HAS no tenant. Nothing downstream - // fills it either — auto-stamping lives in the enterprise - // `@objectstack/organizations` runtime and has nothing to stamp when the - // caller carries no active organization. So the row landed with - // `organization_id` NULL, and the read wall then hid it from EVERY - // reader including the author who had just created it: a write that - // succeeds and a record nobody can reach. - // - // Both halves were individually defensible, which is why this sat. The - // rule that resolves them (ADR-0123 D2, from the #8247 ruling): under an - // authenticated session with no active organization, tenant-scoped READS - // resolve to nothing (Layer 0's deny sentinel — unchanged, below in - // `computeTenantLayer0Filter`) and tenant-scoped WRITES are REFUSED, - // with a message that NAMES the missing active organization. If a write - // cannot say which tenant its row belongs to, it does not land. - // - // The verdict is DERIVED, never re-derived: `computeWriteTenantCheckFilter` - // is the same Layer 0 the read side computes, so every exemption stays in - // one place — a `tenancy.enabled:false` platform-global object, an object - // with no `organization_id` column, a federated phantom anchor (#7835), a - // true PLATFORM_ADMIN on a posture-permitting object (ADR-0095 D3), and - // the whole `single` posture all yield `null` here and are untouched. - // System / boot writes never reach this line at all (`isSystem` - // short-circuits the middleware). Under `group`, "no organization scope" - // means an EMPTY membership set — ADR-0105 D2's own fail-closed rule. - // A member WITH a membership set but no active organization passes this - // pre-test and still has nowhere for a NEW row to land; that insert is - // refused with this same answer on its stored image, below ([#24784]). - // So is the insert of a platform operator crossing the wall with no - // active organization: crossing exempts it from this pre-test, not - // from needing somewhere to land ([#24821]). - // - // The cheap posture/context pre-test comes first on purpose: the common - // deployment is `single` (Layer 0 inert), and the common caller HAS an - // active organization. Neither pays for the layered compile inside - // `organizationWallRefusal` — the one verdict `canWriteObject` asks too. - // - // Ordered AHEAD of the forge guard deliberately. A caller with no - // organization scope at all who also supplies a foreign `organization_id` - // would satisfy both; "you have no active organization" is the actionable - // half — the supplied value is not the reason the write cannot land. - // `delete` is deliberately absent: it places no row and decides no - // tenant, so its target is selected through the Layer 0 ROW wall, which - // already resolves to nothing (ADR-0123 D2, stated there as a boundary - // rather than left as an omission). - // - // The refusal has ONE spelling, thrown from two points: here on the - // scope pre-test, and on the stored image of a union-scope insert with - // no active organization (below). Only the operator half differs, - // because the two say different things about the context. - const refuseNoActiveOrganization = (refused: 'caller' | 'delegator', developerMessage: string): never => { - const principal = refused === 'delegator' ? 'the delegating principal' : 'this session'; - this.logger.warn?.( - `[Security] Layer 0 tenant wall REFUSED ${opCtx.operation} '${opCtx.object}' — ` + - `${principal} has no active organization to place the record in (ADR-0123 D2, fail-closed)`, - ); - throw new PermissionDeniedError( - `[Security] Access denied: '${opCtx.object}' is scoped to an organization, and ` + - `${principal} has no active organization — so this ${opCtx.operation} has no ` + - `organization to place the record in. Join or select an active organization and retry.`, - { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, - developerMessage, - ); - }; - const denied = await this.organizationWallRefusal( - permissionSets, + // [#25062] The wall itself, its scope pre-test, its parts and every + // judgement of a row, is composed by ONE method, + // {@link SecurityPlugin.composeWriteTenantWall}, which carries each + // arm's reasoning; this step throws what it answers. The write PREVIEW + // asks the same method for each row ({@link SecurityPlugin.writeWallVerdict}, + // which `validate()` reads through `registerWriteWallVerdict`), so the + // preview and the write hold a row to one spelling of the wall. + const wall = await this.composeWriteTenantWall( opCtx.object, opCtx.operation, opCtx.context, - delegatorSets, - delegatorContext, + writeRows, + !Array.isArray(opCtx.data), + { permissionSets, delegatorSets, delegatorContext }, + { positions, permissionSets: explicitPermissionSets }, ); - if (denied) { - refuseNoActiveOrganization( - denied, - `[ADR-0123 D2] Tenant-scoped writes are refused when the execution context carries no active ` + - `organization: tenancy posture '${this.tenancyPosture}' walls '${opCtx.object}', and ` + - `${denied === 'delegator' ? "the delegator's" : "the caller's"} context resolved neither ` + - `\`tenantId\` nor a non-empty \`accessible_org_ids\`. Reads under this state resolve to nothing; ` + - `writes are refused rather than landing a row with a NULL organization that no reader — ` + - `including its own author — could ever see. System contexts and platform operators are unaffected.`, - ); - } - - // A row carries an organization to judge only when it NAMES one: an - // absent or empty value is the auto-stamp's to fill (ADR-0095 D1, - // "Not touched": judging it would refuse a legitimate insert before - // the stamp runs). One predicate, read by both halves below. - const namesOrganization = (r: Record): boolean => - r.organization_id != null && r.organization_id !== ''; - const suppliedRows = writeRows.filter(namesOrganization); - // [#22278] …except on UPDATE, where no stamp runs: a row that SETS the - // column to `null` or `''` empties the organization of a row that has - // one, and that is refused wherever the wall applies. Own-property, so - // an update that never names the column is untouched; an `undefined` - // value is a column the statement leaves alone, not an emptied one. - const emptiesOrganization = (r: Record): boolean => - opCtx.operation === 'update' && - Object.prototype.hasOwnProperty.call(r, 'organization_id') && - (r.organization_id === null || r.organization_id === ''); - // The wall is computed when a payload names an organization (as - // before) or when a walled posture may have a hook name one ([#20013] - // below). The `single` posture composes no Layer 0 at all, so it pays - // for nothing it did not already pay for. - let tenantParts: Record[] = []; - // The CALLER's own Layer 0 for this write — `null` where the wall does - // not apply to it (a non-tenant object, a phantom anchor, a platform - // operator crossing on a posture-permitting object, `single`). - let callerTenantCheck: Record | null = null; - // Whether that `null` is a platform operator CROSSING the wall on a - // posture-permitting object (ADR-0095 D3) — the wall stands for every - // other caller on the object. - let callerCrossesTenantWall = false; - if (suppliedRows.length > 0 || this.orgScopingEnabled) { - const callerWall = await this.computeWriteTenantWall( - permissionSets, - opCtx.object, - opCtx.operation, - opCtx.context, - ); - const tenantCheck = callerWall.layer0; - callerTenantCheck = tenantCheck; - callerCrossesTenantWall = callerWall.crossedByPlatformAdmin; - // [ADR-0090 D10] The post-image must also satisfy the delegator's tenant - // wall — an on-behalf-of write may not land a row in a tenant the - // delegator itself could not reach. - const delTenantCheck = delegatorSets - ? await this.computeWriteTenantCheckFilter(delegatorSets, opCtx.object, opCtx.operation, delegatorContext) - : null; - tenantParts = [tenantCheck, delTenantCheck].filter(Boolean) as Record[]; - } - const clearsWall = (row: Record): boolean => - tenantParts.every((f) => matchesFilterCondition(row as any, f as any)); - // The ONE refusal, shared by both halves — so a value the caller sent - // and a value a hook wrote are refused identically. - const denyTenantPlacement = (source: string): never => { - this.logger.warn?.( - `[Security] Layer 0 tenant CHECK FAILED on ${opCtx.operation} '${opCtx.object}' — write denied ` + - `(fail-closed); ${source} organization_id is outside the caller's organization scope`, - ); - throw new PermissionDeniedError( - `[Security] Access denied: the ${opCtx.operation} would place '${opCtx.object}' in another tenant ` + - `(organization_id is outside the caller's organization scope)`, - { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, - ); - }; - // [#22278] The refusal of an emptied organization, shared by both - // halves the same way. Its own sentence: the row is not placed in - // another tenant, it is left in none. - const denyOrganizationRemoval = (source: string): never => { - this.logger.warn?.( - `[Security] Layer 0 tenant CHECK FAILED on ${opCtx.operation} '${opCtx.object}' — write denied ` + - `(fail-closed); ${source} organization_id is empty`, - ); - throw new PermissionDeniedError( - `[Security] Access denied: the ${opCtx.operation} would leave '${opCtx.object}' with no organization ` + - `(an organization-scoped row keeps an organization on update)`, - { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, - `[ADR-0095 D1] tenancy posture '${this.tenancyPosture}' walls '${opCtx.object}', so a non-system update ` + - `must leave the row in an organization the Layer 0 wall admits. An empty organization_id is in none: ` + - `the row would drop out of every reader's scope, and an organization-scoped grant row would apply in ` + - `every organization. System contexts and platform operators on posture-permitting objects are unaffected.`, - ); - }; - // ── [ADR-0123 D2 · ADR-0105 D2/D5 · #24784] A union-scope INSERT with no active organization ── - // - // Under a union-scope posture (`group`) the scope pre-test above reads - // the MEMBERSHIP SET, so a member whose session has no active - // organization passes it — rightly, for everything the set bounds: - // reads, and updates and deletes, whose rows the membership wall - // selected and which keep the organization they already have. An - // insert is the one write that needs somewhere NEW to land, and that is - // the ACTIVE organization: the engine stamps a new row from `tenantId` - // (ADR-0105 D5), and the column is read-only to a session payload. So - // that member's row had no organization to land in, and the engine met - // it as an organization-less SYSTEM write and refused it - // `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` with a 500. ADR-0123 D2's - // answer for the state is the 403 `isolated` gives the same session; - // this gives it here, before the engine is reached. - // - // Judged on the STORED image (the seam below), not on the payload: the - // image is the row after the `beforeInsert` chain, the same rows the - // engine's own organization check reads one step later. So the refusal - // covers exactly the rows that would have met that 500, and nothing the - // store admitted before is refused now. Only the CALLER's own wall - // decides whether the object is walled for it — a platform operator - // crossing on a posture-permitting object is the arm below — and the - // delegator keeps the pre-test's leg, unchanged. `isolated` never - // reaches this: its pre-test reads the active organization itself. - const insertLacksActiveOrganization = - opCtx.operation === 'insert' && - postureUsesUnionScope(this.tenancyPosture) && - callerTenantCheck != null && - !carriesActiveOrganization(opCtx.context); - // ── [ADR-0123 D2/D4 · ADR-0131 D9 · ADR-0095 D3 · #24821] …and a platform operator's ── - // - // A true `PLATFORM_ADMIN` CROSSES the wall on a posture-permitting - // object (ADR-0095 D3): its Layer 0 is `null` there, so neither the - // pre-test nor the arm above reaches it, under `isolated` or `group`. - // Crossing lets the operator read every organization's rows and update - // them where they are; it does not give a NEW row an organization. The - // row lands where the session's does — the engine stamps it from - // `tenantId` and strips the column from a session payload — and with - // no active organization it has none. ADR-0131 D9 refuses that write in - // every walled posture ("nothing defaults to any owner other than the - // derivable one"), and the engine did refuse it, as an - // organization-less SYSTEM write: `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, - // status 500, its prose withheld from the wire. The condition is the - // member's — no active organization, a row with nowhere to land — so - // the answer is the member's too: one refusal, one spelling (ADR-0112 - // D3), naming what is missing (ADR-0123 D4). - // - // On the same STORED image as the arm above, for the same reason: a - // row a hook places, or one that names its organization where the - // engine keeps a session's value, carries one and lands as before. - // Reads and updates by the operator are untouched. - const operatorInsertLacksActiveOrganization = - opCtx.operation === 'insert' && - callerCrossesTenantWall && - !carriesActiveOrganization(opCtx.context); - // EVERY supplied row must clear the wall — one forged row in a bulk - // payload denies the whole write (fail closed, no partial landing). - if (tenantParts.length > 0 && !suppliedRows.every(clearsWall)) denyTenantPlacement('a supplied'); - // [#22278] …and no row of an UPDATE payload may empty the column. - if (tenantParts.length > 0 && writeRows.some(emptiesOrganization)) denyOrganizationRemoval('a supplied'); + // The scope pre-test first ("you have no active organization" is the + // actionable half), then the payload as the caller SENT it. + const payloadRefusal = wall.preTest ?? wall.judgePayload(writeRows); + if (payloadRefusal) this.raiseWriteWallRefusal(payloadRefusal); // ── [#20013] The wall on the STORED row, after the hooks ───────────── // @@ -4259,8 +4138,8 @@ export class SecurityPlugin implements Plugin { // (`OperationContext.postHookWriteImageCheck`): the rows the insert // chain produced, the prior row merged with the final payload on a // by-id update, and every matched row merged with it on a predicate - // update. The predicate is the SAME `tenantParts` — computed here, with - // this request's caller and delegator contexts — so a stored row is + // update. The predicate is the SAME `tenantParts` — composed once above, + // with this request's caller and delegator contexts — so a stored row is // held to exactly the wall a supplied value is. On an update the image // carries the row's current organization whenever the final payload // leaves the column alone; that row was selected under this same wall @@ -4294,82 +4173,26 @@ export class SecurityPlugin implements Plugin { // organization installs the seam too: its own Layer 0 is `null`, so // `tenantParts` can be empty while its rows still need somewhere to // land. With no wall part the per-row loop below admits every row. - if ( - (tenantParts.length > 0 || operatorInsertLacksActiveOrganization) && - (opCtx.operation === 'insert' || !Array.isArray(opCtx.data)) - ) { + if (wall.storedImage) { + const storedImage = wall.storedImage; const businessCheck = writeImageCheckSeam; writeImageCheckSeam = { evaluate: (rows) => { - // First, as the pre-test's refusal is first: the session's state - // is the actionable half. One row with nowhere to land refuses - // the whole write — no partial landing. - const someRowHasNowhereToLand = (): boolean => - rows.some((row) => !!row && typeof row === 'object' && !namesOrganization(row)); - if (operatorInsertLacksActiveOrganization && someRowHasNowhereToLand()) { - refuseNoActiveOrganization( - 'caller', - `[ADR-0123 D2 · ADR-0131 D9] Tenant-scoped inserts are refused when the execution context carries ` + - `no active organization: tenancy posture '${this.tenancyPosture}' walls '${opCtx.object}', and ` + - `this caller is a platform operator crossing the wall there (ADR-0095 D3). Crossing lets it read ` + - `every organization's rows and update them where they are; a new row still lands in the active ` + - `organization, and the caller's context carries no \`tenantId\` while the stored row names no ` + - `organization, so this insert has none to land in. It is refused rather than reaching the engine ` + - `as an organization-less system write. System contexts are unaffected.`, - ); - } - if (insertLacksActiveOrganization && someRowHasNowhereToLand()) { - refuseNoActiveOrganization( - 'caller', - `[ADR-0123 D2] Tenant-scoped writes are refused when the execution context carries no active ` + - `organization: tenancy posture '${this.tenancyPosture}' walls '${opCtx.object}', and the ` + - `caller's context carries no \`tenantId\`. Under this posture the membership set ` + - `(\`accessible_org_ids\`) bounds what the caller reads, while a new row lands in the active ` + - `organization (ADR-0105 D5), so this insert has none to land in. Reads under this state keep ` + - `spanning the membership set; the insert is refused rather than reaching the engine as an ` + - `organization-less system write. System contexts and platform operators are unaffected.`, - ); - } + const landing = storedImage.judgeLanding(rows); + if (landing) this.raiseWriteWallRefusal(landing); businessCheck?.evaluate(rows); for (const row of rows) { if (!row || typeof row !== 'object') continue; - if (namesOrganization(row) && !clearsWall(row)) denyTenantPlacement("the stored row's"); - if (emptiesOrganization(row)) denyOrganizationRemoval("the stored row's"); + const refused = storedImage.judgeRow(row); + if (refused) this.raiseWriteWallRefusal(refused); } }, - // ── [#24822 · ADR-0105 D5] The write's organization is JUDGED here ── - // - // The loop above holds a stored row's `organization_id` to the wall - // whenever the wall has parts, and the payload half above already - // refused every supplied value outside them (and, on an update, - // every emptied one). So the engine may leave a caller's explicit - // value on the row for this verdict instead of stripping it as - // `readonly`: on an insert, rather than storing the active - // organization in its place; on an update, rather than keeping the - // row's own organization against a re-point the wall admitted. - // Both replacements were silent: a 2xx, with the row in another - // organization than the one the wall admitted. The member says so, - // and the engine reads nothing else. - // - // Set ONLY where the wall's own parts are composed in (`tenantParts` - // non-empty). The crossing operator's seam - // (`operatorInsertLacksActiveOrganization` with no parts) judges no - // organization: its loop admits every row. The business `check` - // seam of step 3.6 never reaches this composition. Neither sets it, - // so the strip there runs as before. - // - // And only on the two operations whose stored rows this loop is - // handed: an insert (its rows), and an update whose payload is ONE - // row (the by-id image, and every row the predicate path matched, - // each merged with that payload). An ARRAY update payload never - // reaches this composition (the condition above), so it is stated - // again here rather than inferred: the member is the seam's own - // claim about what it judges, and it must stay true if that - // condition ever moves. - ...(tenantParts.length > 0 && - (opCtx.operation === 'insert' || (opCtx.operation === 'update' && !Array.isArray(opCtx.data))) - ? { judgesOrganization: true } - : {}), + // [#24822 · ADR-0105 D5] Whether the wall JUDGES the write's + // organization, so the engine leaves a caller's explicit value on + // the row for this verdict instead of stripping it as `readonly`. + // Composed with the parts (see `judgesOrganization` there), and + // set only where it holds, so the strip runs as before elsewhere. + ...(storedImage.judgesOrganization ? { judgesOrganization: true } : {}), }; opCtx.postHookWriteImageCheck = writeImageCheckSeam; tenantWallSeamInstalled = true; @@ -6711,7 +6534,10 @@ export class SecurityPlugin implements Plugin { * step 3.7 throws on, asked under that step's guard (a payload is * supplied, the context names a user) at that step's point, after arm * 9. It is handed no row — the payload decides only whether it is - * asked — so it refuses a CALLER CLASS, as arms 2 and 3 do. + * asked — so it refuses a CALLER CLASS, as arms 2 and 3 do. [#25062] + * The wall's judgement of a ROW (the forge guard, an emptied + * organization, an insert with nowhere to land) is not asked here: the + * preview asks it per row through {@link writeWallVerdict}. * * `can-write-object-admission.test.ts` pins this method's answer equal to the * registered middleware's on its equivalence block's cases, and pins one @@ -8486,6 +8312,443 @@ export class SecurityPlugin implements Plugin { : null; } + /** + * [#25062] Step 3.7's Layer 0 tenant write wall, composed ONCE for one write: + * the scope pre-test, the parts every row is held to, and the judgements that + * read them. This is the ONE spelling of the wall's judgement of a row. The + * middleware's payload half and its stored-image seam (step 3.7) call it, and + * so does the write preview ({@link writeWallVerdict}, which the engine asks + * through `registerWriteWallVerdict`). Nothing here logs or throws: each + * judgement answers a {@link WriteWallRefusal}, which the middleware logs and + * throws ({@link raiseWriteWallRefusal}) and the preview answers as that + * row's verdict. + * + * `writeRows` are the payload's rows as sent. They decide only whether the + * parts are computed when no walled posture is in force. `singlePayload` says + * whether an UPDATE's payload is one row: an array update installs no seam. + * `principals` is the context prologue's answer + * ({@link resolveOperationPrincipals}), and `refusal` the context's + * `positions` and `permissions`, which every refusal's details carry. + */ + private async composeWriteTenantWall( + object: string, + operation: 'insert' | 'update', + context: any, + writeRows: readonly Record[], + singlePayload: boolean, + principals: { permissionSets: PermissionSet[]; delegatorSets: PermissionSet[] | null; delegatorContext: any }, + refusal: { positions: unknown; permissionSets: unknown }, + ): Promise { + const { permissionSets, delegatorSets, delegatorContext } = principals; + const { positions, permissionSets: explicitPermissionSets } = refusal; + + // ── [ADR-0123 D2] NO ACTIVE ORGANIZATION → refuse the write, loudly ── + // + // The forge guard below validates SUPPLIED values only, and says so. It + // is the right shape for what it guards (a payload naming ANOTHER + // tenant), but it leaves the opposite case wide open: a payload naming + // NO tenant, written by a caller who HAS no tenant. Nothing downstream + // fills it either — auto-stamping lives in the enterprise + // `@objectstack/organizations` runtime and has nothing to stamp when the + // caller carries no active organization. So the row landed with + // `organization_id` NULL, and the read wall then hid it from EVERY + // reader including the author who had just created it: a write that + // succeeds and a record nobody can reach. + // + // Both halves were individually defensible, which is why this sat. The + // rule that resolves them (ADR-0123 D2, from the #8247 ruling): under an + // authenticated session with no active organization, tenant-scoped READS + // resolve to nothing (Layer 0's deny sentinel — unchanged, below in + // `computeTenantLayer0Filter`) and tenant-scoped WRITES are REFUSED, + // with a message that NAMES the missing active organization. If a write + // cannot say which tenant its row belongs to, it does not land. + // + // The verdict is DERIVED, never re-derived: `computeWriteTenantCheckFilter` + // is the same Layer 0 the read side computes, so every exemption stays in + // one place — a `tenancy.enabled:false` platform-global object, an object + // with no `organization_id` column, a federated phantom anchor (#7835), a + // true PLATFORM_ADMIN on a posture-permitting object (ADR-0095 D3), and + // the whole `single` posture all yield `null` here and are untouched. + // System / boot writes never reach this line at all (`isSystem` + // short-circuits the middleware). Under `group`, "no organization scope" + // means an EMPTY membership set — ADR-0105 D2's own fail-closed rule. + // A member WITH a membership set but no active organization passes this + // pre-test and still has nowhere for a NEW row to land; that insert is + // refused with this same answer on its stored image, below ([#24784]). + // So is the insert of a platform operator crossing the wall with no + // active organization: crossing exempts it from this pre-test, not + // from needing somewhere to land ([#24821]). + // + // The cheap posture/context pre-test comes first on purpose: the common + // deployment is `single` (Layer 0 inert), and the common caller HAS an + // active organization. Neither pays for the layered compile inside + // `organizationWallRefusal` — the one verdict `canWriteObject` asks too. + // + // Ordered AHEAD of the forge guard deliberately. A caller with no + // organization scope at all who also supplies a foreign `organization_id` + // would satisfy both; "you have no active organization" is the actionable + // half — the supplied value is not the reason the write cannot land. + // `delete` is deliberately absent: it places no row and decides no + // tenant, so its target is selected through the Layer 0 ROW wall, which + // already resolves to nothing (ADR-0123 D2, stated there as a boundary + // rather than left as an omission). + // + // The refusal has ONE spelling, answered from two points: here on the + // scope pre-test, and on the stored image of a union-scope insert with + // no active organization (below). Only the operator half differs, + // because the two say different things about the context. + const refuseNoActiveOrganization = (refused: 'caller' | 'delegator', developerMessage: string): WriteWallRefusal => { + const principal = refused === 'delegator' ? 'the delegating principal' : 'this session'; + return { + field: '_record', + warning: + `[Security] Layer 0 tenant wall REFUSED ${operation} '${object}' — ` + + `${principal} has no active organization to place the record in (ADR-0123 D2, fail-closed)`, + error: new PermissionDeniedError( + `[Security] Access denied: '${object}' is scoped to an organization, and ` + + `${principal} has no active organization — so this ${operation} has no ` + + `organization to place the record in. Join or select an active organization and retry.`, + { operation, object, positions, permissionSets: explicitPermissionSets }, + developerMessage, + ), + }; + }; + const denied = await this.organizationWallRefusal( + permissionSets, + object, + operation, + context, + delegatorSets, + delegatorContext, + ); + if (denied) { + // The whole write is refused here, so nothing below is composed: the + // parts are never computed for a write the pre-test refuses. + return { + preTest: refuseNoActiveOrganization( + denied, + `[ADR-0123 D2] Tenant-scoped writes are refused when the execution context carries no active ` + + `organization: tenancy posture '${this.tenancyPosture}' walls '${object}', and ` + + `${denied === 'delegator' ? "the delegator's" : "the caller's"} context resolved neither ` + + `\`tenantId\` nor a non-empty \`accessible_org_ids\`. Reads under this state resolve to nothing; ` + + `writes are refused rather than landing a row with a NULL organization that no reader — ` + + `including its own author — could ever see. System contexts and platform operators are unaffected.`, + ), + judgePayload: () => null, + storedImage: null, + }; + } + + // A row carries an organization to judge only when it NAMES one: an + // absent or empty value is the auto-stamp's to fill (ADR-0095 D1, + // "Not touched": judging it would refuse a legitimate insert before + // the stamp runs). One predicate, read by both halves below. + const namesOrganization = (r: Record): boolean => + r.organization_id != null && r.organization_id !== ''; + // [#22278] …except on UPDATE, where no stamp runs: a row that SETS the + // column to `null` or `''` empties the organization of a row that has + // one, and that is refused wherever the wall applies. Own-property, so + // an update that never names the column is untouched; an `undefined` + // value is a column the statement leaves alone, not an emptied one. + const emptiesOrganization = (r: Record): boolean => + operation === 'update' && + Object.prototype.hasOwnProperty.call(r, 'organization_id') && + (r.organization_id === null || r.organization_id === ''); + // The wall is computed when a payload names an organization (as + // before) or when a walled posture may have a hook name one ([#20013] + // below). The `single` posture composes no Layer 0 at all, so it pays + // for nothing it did not already pay for. + let tenantParts: Record[] = []; + // The CALLER's own Layer 0 for this write — `null` where the wall does + // not apply to it (a non-tenant object, a phantom anchor, a platform + // operator crossing on a posture-permitting object, `single`). + let callerTenantCheck: Record | null = null; + // Whether that `null` is a platform operator CROSSING the wall on a + // posture-permitting object (ADR-0095 D3) — the wall stands for every + // other caller on the object. + let callerCrossesTenantWall = false; + if (writeRows.some(namesOrganization) || this.orgScopingEnabled) { + const callerWall = await this.computeWriteTenantWall( + permissionSets, + object, + operation, + context, + ); + const tenantCheck = callerWall.layer0; + callerTenantCheck = tenantCheck; + callerCrossesTenantWall = callerWall.crossedByPlatformAdmin; + // [ADR-0090 D10] The post-image must also satisfy the delegator's tenant + // wall — an on-behalf-of write may not land a row in a tenant the + // delegator itself could not reach. + const delTenantCheck = delegatorSets + ? await this.computeWriteTenantCheckFilter(delegatorSets, object, operation, delegatorContext) + : null; + tenantParts = [tenantCheck, delTenantCheck].filter(Boolean) as Record[]; + } + const clearsWall = (row: Record): boolean => + tenantParts.every((f) => matchesFilterCondition(row as any, f as any)); + // The ONE refusal, shared by both halves — so a value the caller sent + // and a value a hook wrote are refused identically. + const denyTenantPlacement = (source: string): WriteWallRefusal => ({ + field: 'organization_id', + warning: + `[Security] Layer 0 tenant CHECK FAILED on ${operation} '${object}' — write denied ` + + `(fail-closed); ${source} organization_id is outside the caller's organization scope`, + error: new PermissionDeniedError( + `[Security] Access denied: the ${operation} would place '${object}' in another tenant ` + + `(organization_id is outside the caller's organization scope)`, + { operation, object, positions, permissionSets: explicitPermissionSets }, + ), + }); + // [#22278] The refusal of an emptied organization, shared by both + // halves the same way. Its own sentence: the row is not placed in + // another tenant, it is left in none. + const denyOrganizationRemoval = (source: string): WriteWallRefusal => ({ + field: 'organization_id', + warning: + `[Security] Layer 0 tenant CHECK FAILED on ${operation} '${object}' — write denied ` + + `(fail-closed); ${source} organization_id is empty`, + error: new PermissionDeniedError( + `[Security] Access denied: the ${operation} would leave '${object}' with no organization ` + + `(an organization-scoped row keeps an organization on update)`, + { operation, object, positions, permissionSets: explicitPermissionSets }, + `[ADR-0095 D1] tenancy posture '${this.tenancyPosture}' walls '${object}', so a non-system update ` + + `must leave the row in an organization the Layer 0 wall admits. An empty organization_id is in none: ` + + `the row would drop out of every reader's scope, and an organization-scoped grant row would apply in ` + + `every organization. System contexts and platform operators on posture-permitting objects are unaffected.`, + ), + }); + // ── [ADR-0123 D2 · ADR-0105 D2/D5 · #24784] A union-scope INSERT with no active organization ── + // + // Under a union-scope posture (`group`) the scope pre-test above reads + // the MEMBERSHIP SET, so a member whose session has no active + // organization passes it — rightly, for everything the set bounds: + // reads, and updates and deletes, whose rows the membership wall + // selected and which keep the organization they already have. An + // insert is the one write that needs somewhere NEW to land, and that is + // the ACTIVE organization: the engine stamps a new row from `tenantId` + // (ADR-0105 D5), and the column is read-only to a session payload. So + // that member's row had no organization to land in, and the engine met + // it as an organization-less SYSTEM write and refused it + // `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` with a 500. ADR-0123 D2's + // answer for the state is the 403 `isolated` gives the same session; + // this gives it here, before the engine is reached. + // + // Judged on the STORED image (the seam below), not on the payload: the + // image is the row after the `beforeInsert` chain, the same rows the + // engine's own organization check reads one step later. So the refusal + // covers exactly the rows that would have met that 500, and nothing the + // store admitted before is refused now. Only the CALLER's own wall + // decides whether the object is walled for it — a platform operator + // crossing on a posture-permitting object is the arm below — and the + // delegator keeps the pre-test's leg, unchanged. `isolated` never + // reaches this: its pre-test reads the active organization itself. + const insertLacksActiveOrganization = + operation === 'insert' && + postureUsesUnionScope(this.tenancyPosture) && + callerTenantCheck != null && + !carriesActiveOrganization(context); + // ── [ADR-0123 D2/D4 · ADR-0131 D9 · ADR-0095 D3 · #24821] …and a platform operator's ── + // + // A true `PLATFORM_ADMIN` CROSSES the wall on a posture-permitting + // object (ADR-0095 D3): its Layer 0 is `null` there, so neither the + // pre-test nor the arm above reaches it, under `isolated` or `group`. + // Crossing lets the operator read every organization's rows and update + // them where they are; it does not give a NEW row an organization. The + // row lands where the session's does — the engine stamps it from + // `tenantId` and strips the column from a session payload — and with + // no active organization it has none. ADR-0131 D9 refuses that write in + // every walled posture ("nothing defaults to any owner other than the + // derivable one"), and the engine did refuse it, as an + // organization-less SYSTEM write: `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, + // status 500, its prose withheld from the wire. The condition is the + // member's — no active organization, a row with nowhere to land — so + // the answer is the member's too: one refusal, one spelling (ADR-0112 + // D3), naming what is missing (ADR-0123 D4). + // + // On the same STORED image as the arm above, for the same reason: a + // row a hook places, or one that names its organization where the + // engine keeps a session's value, carries one and lands as before. + // Reads and updates by the operator are untouched. + const operatorInsertLacksActiveOrganization = + operation === 'insert' && + callerCrossesTenantWall && + !carriesActiveOrganization(context); + const walled = tenantParts.length > 0; + + return { + preTest: null, + judgePayload: (rows) => { + // EVERY supplied row must clear the wall — one forged row in a bulk + // payload denies the whole write (fail closed, no partial landing). + if (walled && !rows.filter(namesOrganization).every(clearsWall)) return denyTenantPlacement('a supplied'); + // [#22278] …and no row of an UPDATE payload may empty the column. + if (walled && rows.some(emptiesOrganization)) return denyOrganizationRemoval('a supplied'); + return null; + }, + // [#20013] The seam is installed whenever the wall applies to the write, + // and [#24821] for a crossing platform operator's insert with no active + // organization too: its own Layer 0 is `null`, so the parts can be empty + // while its rows still need somewhere to land. With no wall part + // `judgeRow` admits every row. Array UPDATE payloads are left as step + // 3.6 leaves them: the engine takes one payload per update. + storedImage: + (walled || operatorInsertLacksActiveOrganization) && (operation === 'insert' || singlePayload) + ? { + judgeLanding: (rows) => { + // First, as the pre-test's refusal is first: the session's + // state is the actionable half. One row with nowhere to land + // refuses the whole write — no partial landing. + const someRowHasNowhereToLand = (): boolean => + rows.some((row) => !!row && typeof row === 'object' && !namesOrganization(row)); + if (operatorInsertLacksActiveOrganization && someRowHasNowhereToLand()) { + return refuseNoActiveOrganization( + 'caller', + `[ADR-0123 D2 · ADR-0131 D9] Tenant-scoped inserts are refused when the execution context carries ` + + `no active organization: tenancy posture '${this.tenancyPosture}' walls '${object}', and ` + + `this caller is a platform operator crossing the wall there (ADR-0095 D3). Crossing lets it read ` + + `every organization's rows and update them where they are; a new row still lands in the active ` + + `organization, and the caller's context carries no \`tenantId\` while the stored row names no ` + + `organization, so this insert has none to land in. It is refused rather than reaching the engine ` + + `as an organization-less system write. System contexts are unaffected.`, + ); + } + if (insertLacksActiveOrganization && someRowHasNowhereToLand()) { + return refuseNoActiveOrganization( + 'caller', + `[ADR-0123 D2] Tenant-scoped writes are refused when the execution context carries no active ` + + `organization: tenancy posture '${this.tenancyPosture}' walls '${object}', and the ` + + `caller's context carries no \`tenantId\`. Under this posture the membership set ` + + `(\`accessible_org_ids\`) bounds what the caller reads, while a new row lands in the active ` + + `organization (ADR-0105 D5), so this insert has none to land in. Reads under this state keep ` + + `spanning the membership set; the insert is refused rather than reaching the engine as an ` + + `organization-less system write. System contexts and platform operators are unaffected.`, + ); + } + return null; + }, + judgeRow: (row) => { + if (namesOrganization(row) && !clearsWall(row)) return denyTenantPlacement("the stored row's"); + if (emptiesOrganization(row)) return denyOrganizationRemoval("the stored row's"); + return null; + }, + // ── [#24822 · ADR-0105 D5] The write's organization is JUDGED here ── + // + // `judgeRow` holds a stored row's `organization_id` to the wall + // whenever the wall has parts, and the payload half above already + // refused every supplied value outside them (and, on an update, + // every emptied one). So the engine may leave a caller's explicit + // value on the row for this verdict instead of stripping it as + // `readonly`: on an insert, rather than storing the active + // organization in its place; on an update, rather than keeping the + // row's own organization against a re-point the wall admitted. + // Both replacements were silent: a 2xx, with the row in another + // organization than the one the wall admitted. The member says so, + // and the engine reads nothing else. + // + // Set ONLY where the wall's own parts are composed in (`tenantParts` + // non-empty). The crossing operator's seam + // (`operatorInsertLacksActiveOrganization` with no parts) judges no + // organization: its `judgeRow` admits every row. The business + // `check` seam of step 3.6 never reaches this composition. Neither + // sets it, so the strip there runs as before. + // + // And only on the two operations whose stored rows this seam is + // handed: an insert (its rows), and an update whose payload is ONE + // row (the by-id image, and every row the predicate path matched, + // each merged with that payload). An ARRAY update payload never + // reaches this composition (the condition above), so it is stated + // again here rather than inferred: the member is the seam's own + // claim about what it judges, and it must stay true if that + // condition ever moves. [#25062] The write preview reads this + // same value for each row it admits, and spares the column where + // the write does. + judgesOrganization: + walled && (operation === 'insert' || (operation === 'update' && singlePayload)), + } + : null, + }; + } + + /** [#25062] Step 3.7's way to refuse: the refusal's warn line, then its error. */ + private raiseWriteWallRefusal(refusal: WriteWallRefusal): never { + this.logger.warn?.(refusal.warning); + throw refusal.error; + } + + /** + * [#25062] The Layer 0 tenant write wall's verdict on each row of a write + * PREVIEW, for `ObjectQL.validate()` (the import dry run). Registered on the + * engine as `registerWriteWallVerdict`, beside the write-gate probe. + * + * The probe answers a CALLER CLASS with one boolean for the whole set + * ({@link canWriteObject}); the wall judges a VALUE a row carries, so a + * caller the probe admits can still name an organization the write refuses, + * or one the write stores as sent while the preview reported it dropped. + * Two questions, two members. This one answers per row, from + * {@link composeWriteTenantWall}, the method step 3.7 itself calls, over the + * principals the middleware's own prologue resolves + * ({@link resolveOperationPrincipals}). A row is refused with the write's own + * `PermissionDeniedError` code and message: the scope pre-test, then the + * payload half (the forge guard, and on an update the emptied + * organization), then the stored-image half's no-active-organization arms + * ([#24784], [#24821]) as far as a row the preview holds shows them. A + * refusal of a value the row carries names `organization_id`; a refusal of + * the caller's state names `_record`. An admitted row says whether the wall + * judges its organization, the value `judgesOrganization` is set from on + * the write, so the engine spares the column from its static-`readonly` + * strip exactly where `insert()` / `update()` spare it. + * + * Each row is judged as its own write, as the preview answers every other + * refusal, and the engine keeps the set's `valid` the conjunction. A bulk + * write is refused whole by any one refused row, so the two agree on the + * set. + * + * `null` is "the wall is not asked here", and the engine then runs today's + * strip: a system context (the middleware's bypass), a context that names no + * user (step 3.7's own guard), and a caller the context prologue refuses. + * The write refuses that caller before its wall is reached, so there is no + * wall verdict to hand over; the preview runs none of the write's caller + * gates, and it says nothing about this one either. + * + * The rows are the preview's: after the defaults, before any hook. An + * organization a `beforeInsert` / `beforeUpdate` hook writes is judged by + * the write's stored-image half alone — the preview's named limit. + */ + async writeWallVerdict( + object: string, + operation: 'insert' | 'update', + context: any, + rows: readonly Record[], + ): Promise { + if (!context || typeof context !== 'object' || context.isSystem || !context.userId) return null; + if (operation !== 'insert' && operation !== 'update') return null; + let principals: Awaited>; + try { + principals = await this.resolveOperationPrincipals(operation, object, context); + } catch { + return null; + } + const isRow = (row: unknown): row is Record => + !!row && typeof row === 'object' && !Array.isArray(row); + const wall = await this.composeWriteTenantWall( + object, + operation, + context, + rows.filter(isRow), + true, + principals, + { positions: context.positions ?? [], permissionSets: context.permissions ?? [] }, + ); + return rows.map((row): WriteWallRowVerdict => { + if (!isRow(row)) return { admitted: true, judgesOrganization: false }; + const refused = wall.preTest ?? wall.judgePayload([row]) ?? wall.storedImage?.judgeLanding([row]) ?? null; + return refused + ? { admitted: false, field: refused.field, code: refused.error.code, message: refused.error.message } + : { admitted: true, judgesOrganization: wall.storedImage?.judgesOrganization === true }; + }); + } + /** * Resolve a controlled_by_parent object's master-detail relation (the FK field * key + the master object name), or null. Cached. diff --git a/packages/plugins/plugin-security/src/write-wall-verdict-equivalence.test.ts b/packages/plugins/plugin-security/src/write-wall-verdict-equivalence.test.ts new file mode 100644 index 00000000000..1c9e9197ba2 --- /dev/null +++ b/packages/plugins/plugin-security/src/write-wall-verdict-equivalence.test.ts @@ -0,0 +1,273 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25062] `writeWallVerdict` against the engine middleware's step 3.7. + * + * ## Why this file exists, and why it is an EQUIVALENCE + * + * `ObjectQL.validate()` previews a write without running middleware, so the + * Layer 0 tenant wall never judged its rows: the import dry run reported a + * caller's organization DROPPED where the write stores it as sent, and answered + * VALID a row naming an organization the write refuses 403. Ruling 6336640643 + * (letter A) hands the preview the wall's own verdict per row: step 3.7's + * judgement is composed by ONE method (`composeWriteTenantWall`) that the + * middleware calls, and `writeWallVerdict` answers each row from that method. + * + * So this file asserts no expected verdict per case on its own. For each case + * it drives the REAL registered middleware with the write, and asks the seam + * about the same row under the same context, and requires the two to EQUAL: + * admitted or refused, the refusal's code and message, and whether the wall + * judges the organization (`judgesOrganization` on the installed seam). Two + * doors that merely agree today drift the first time one grows an arm; this + * is the pin that keeps them one door. The `field` the preview reports a + * refusal under, which the write has no slot for, is pinned beside it. + * + * ## The harness + * + * Real `ObjectQL`, real `SqlDriver`, real `SecurityPlugin`, booted as + * `insert-seam-judges-organization.test.ts` boots them. The middleware runs + * through the engine's own chain with a terminal that stands in for the + * engine's executor: it runs the installed `postHookWriteImageCheck` on the + * image the engine would hand it (the rows as sent on an insert, the stored + * row merged with the payload on a by-id update) and runs no strip. What the + * engine's own strips then do to a row is the engine's half, pinned in + * `@objectstack/objectql`'s `validate-write-wall-verdict.test.ts`. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; + +const OWN_ORG = 'org_a'; +const SISTER_ORG = 'org_c'; +const FOREIGN_ORG = 'org_x'; + +const FIELDS = { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, +}; + +const OBJECTS = [ + { name: 'qa_account', label: 'Account', fields: FIELDS }, + // A private object: where a true platform operator CROSSES the wall + // (ADR-0095 D3), as `insert-seam-judges-organization.test.ts` uses it. + { name: 'qa_vault', label: 'Vault', access: { default: 'private' }, fields: FIELDS }, +]; + +const MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as unknown as PermissionSet; + +const PLATFORM_ADMIN: PermissionSet = { + name: 'admin_full_access', + label: 'Platform Administrator', + objects: { + '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + }, + systemPermissions: ['manage_platform_settings', 'manage_metadata'], +} as unknown as PermissionSet; + +const SYS_CTX = { isSystem: true, userId: 'usr_system', tenantId: OWN_ORG }; +const MEMBER_CTX = { userId: 'usr_a', tenantId: OWN_ORG, positions: [], permissions: [], posture: 'MEMBER' }; +/** ADR-0123 D2: an authenticated session with no active organization. */ +const ORGLESS_CTX = { userId: 'usr_a', positions: [], permissions: [], posture: 'MEMBER' }; +const GROUP_CTX = { ...MEMBER_CTX, accessible_org_ids: [OWN_ORG, SISTER_ORG] }; +/** [#24784] A `group` member with a membership set and no active organization. */ +const GROUP_NO_ACTIVE_CTX = { ...ORGLESS_CTX, accessible_org_ids: [OWN_ORG, SISTER_ORG] }; +const ADMIN_CTX = { userId: 'usr_admin', tenantId: OWN_ORG, positions: [], permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN' }; +/** [#24821] A platform operator crossing the wall with no active organization. */ +const ADMIN_NO_ORG_CTX = { userId: 'usr_admin', positions: [], permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN' }; + +/** The stored row every by-id update case names, in the caller's own organization. */ +const PRIOR = { id: 'r1', name: 'one', organization_id: OWN_ORG }; + +type Posture = 'isolated' | 'group' | 'single'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(posture: Posture) { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.write-wall-verdict-equivalence', + name: 'The write-wall verdict equals the middleware', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: OBJECTS, + } as never); + await engine.syncSchemas(); + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER, PLATFORM_ADMIN], + }, + }; + if (posture !== 'single') { + services['org-scoping'] = { name: 'com.objectstack.org-scoping' }; + services.tenancy = { posture }; + } + const ctx = { + 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 as never); + await plugin.start(ctx as never); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + await engine.insert('qa_account', { ...PRIOR }, { context: SYS_CTX } as never); + return { engine, plugin }; +} + +type Verdict = + | { admitted: true; judgesOrganization: boolean } + | { admitted: false; code: unknown; message: string }; + +/** What the REAL middleware decides about this write, through the engine's own chain. */ +async function middlewareVerdict( + engine: ObjectQL, + object: string, + operation: 'insert' | 'update', + context: Record, + data: Record | Record[], +): Promise { + const opCtx: any = { + object, + operation, + context: { ...context }, + data: Array.isArray(data) ? data.map((row) => ({ ...row })) : { ...data }, + // The engine's by-id update: the row named as `options.where.id`, no AST. + options: operation === 'update' ? { where: { id: PRIOR.id } } : {}, + }; + const terminal = async () => { + const seam = opCtx.postHookWriteImageCheck; + if (!seam) return; + seam.honoured = true; + const sent = Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]; + await seam.evaluate(operation === 'insert' ? sent : sent.map((row: Record) => ({ ...PRIOR, ...row }))); + }; + try { + await (engine as unknown as { executeWithMiddleware(o: unknown, t: () => Promise): Promise }) + .executeWithMiddleware(opCtx, terminal); + return { admitted: true, judgesOrganization: opCtx.postHookWriteImageCheck?.judgesOrganization === true }; + } catch (e) { + const err = e as { code?: unknown; message?: string }; + return { admitted: false, code: err.code, message: String(err.message ?? e) }; + } +} + +/** What the seam answers about the same rows, one verdict per row. */ +async function seamVerdicts( + plugin: SecurityPlugin, + object: string, + operation: 'insert' | 'update', + context: Record, + rows: Record[], +) { + const answered = await plugin.writeWallVerdict(object, operation, { ...context }, rows.map((row) => ({ ...row }))); + if (!answered) throw new Error('the seam did not ask the wall for a non-system user context'); + return answered; +} + +const asVerdict = (v: Awaited>[number]): Verdict => + v.admitted + ? { admitted: true, judgesOrganization: v.judgesOrganization } + : { admitted: false, code: v.code, message: v.message }; + +describe('[#25062] writeWallVerdict equals step 3.7, case for case', () => { + const CASES: Array<{ + label: string; + posture: Posture; + context: Record; + operation: 'insert' | 'update'; + object?: string; + row: Record; + /** The field the PREVIEW reports a refusal under — the write has no slot for it. */ + refusedField?: 'organization_id' | '_record'; + }> = [ + // ── isolated ── + { label: 'isolated member names its active organization', posture: 'isolated', context: MEMBER_CTX, operation: 'insert', row: { id: 'n1', name: 'n', organization_id: OWN_ORG } }, + { label: 'isolated member names a foreign organization (the forge guard)', posture: 'isolated', context: MEMBER_CTX, operation: 'insert', row: { id: 'n1', name: 'n', organization_id: FOREIGN_ORG }, refusedField: 'organization_id' }, + { label: 'isolated member names none', posture: 'isolated', context: MEMBER_CTX, operation: 'insert', row: { id: 'n1', name: 'n' } }, + { label: 'isolated session with no active organization (ADR-0123 D2 pre-test)', posture: 'isolated', context: ORGLESS_CTX, operation: 'insert', row: { id: 'n1', name: 'n' }, refusedField: '_record' }, + { label: 'no active organization AND a foreign value: the pre-test answers first', posture: 'isolated', context: ORGLESS_CTX, operation: 'insert', row: { id: 'n1', name: 'n', organization_id: FOREIGN_ORG }, refusedField: '_record' }, + { label: 'crossing operator with no active organization, a row naming none (#24821)', posture: 'isolated', context: ADMIN_NO_ORG_CTX, operation: 'insert', object: 'qa_vault', row: { id: 'n1', name: 'n' }, refusedField: '_record' }, + { label: 'crossing operator with no active organization, a row naming one', posture: 'isolated', context: ADMIN_NO_ORG_CTX, operation: 'insert', object: 'qa_vault', row: { id: 'n1', name: 'n', organization_id: SISTER_ORG } }, + { label: 'crossing operator with an active organization: no seam at all', posture: 'isolated', context: ADMIN_CTX, operation: 'insert', object: 'qa_vault', row: { id: 'n1', name: 'n', organization_id: SISTER_ORG } }, + { label: 'isolated by-id update re-pointing to a foreign organization', posture: 'isolated', context: MEMBER_CTX, operation: 'update', row: { name: 'm', organization_id: FOREIGN_ORG }, refusedField: 'organization_id' }, + { label: 'isolated by-id update emptying the organization (#22278)', posture: 'isolated', context: MEMBER_CTX, operation: 'update', row: { name: 'm', organization_id: '' }, refusedField: 'organization_id' }, + { label: 'isolated by-id update naming its own organization', posture: 'isolated', context: MEMBER_CTX, operation: 'update', row: { name: 'm', organization_id: OWN_ORG } }, + { label: 'isolated by-id update leaving the column alone', posture: 'isolated', context: MEMBER_CTX, operation: 'update', row: { name: 'm' } }, + // ── group ── + { label: 'group member names a sister organization', posture: 'group', context: GROUP_CTX, operation: 'insert', row: { id: 'n1', name: 'n', organization_id: SISTER_ORG } }, + { label: 'group member names a foreign organization', posture: 'group', context: GROUP_CTX, operation: 'insert', row: { id: 'n1', name: 'n', organization_id: FOREIGN_ORG }, refusedField: 'organization_id' }, + { label: 'group member with no active organization, a row naming none (#24784)', posture: 'group', context: GROUP_NO_ACTIVE_CTX, operation: 'insert', row: { id: 'n1', name: 'n' }, refusedField: '_record' }, + { label: 'group member with no active organization, a row naming a sister', posture: 'group', context: GROUP_NO_ACTIVE_CTX, operation: 'insert', row: { id: 'n1', name: 'n', organization_id: SISTER_ORG } }, + { label: 'group by-id update re-pointing to a sister organization', posture: 'group', context: GROUP_CTX, operation: 'update', row: { name: 'm', organization_id: SISTER_ORG } }, + // ── single ── + { label: 'single member names a foreign organization: no wall', posture: 'single', context: MEMBER_CTX, operation: 'insert', row: { id: 'n1', name: 'n', organization_id: FOREIGN_ORG } }, + { label: 'single session with no active organization: no wall', posture: 'single', context: ORGLESS_CTX, operation: 'insert', row: { id: 'n1', name: 'n' } }, + ]; + + for (const c of CASES) { + it(c.label, async () => { + const { engine, plugin } = await boot(c.posture); + const object = c.object ?? 'qa_account'; + + const write = await middlewareVerdict(engine, object, c.operation, c.context, c.row); + const [seam] = await seamVerdicts(plugin, object, c.operation, c.context, [c.row]); + + expect(asVerdict(seam!)).toEqual(write); + // The case means what its label says: a refusal is the wall's 403, and + // the preview files it under the value or the caller's state. + if (c.refusedField) { + expect(write).toMatchObject({ admitted: false, code: 'PERMISSION_DENIED' }); + expect(seam).toMatchObject({ admitted: false, field: c.refusedField }); + } else { + expect(write.admitted, write.admitted ? '' : write.message).toBe(true); + } + }); + } + + it('rows are judged one by one, and the set agrees with the write: one refused row refuses the bulk insert', async () => { + const { engine, plugin } = await boot('isolated'); + const rows = [ + { id: 'n1', name: 'n', organization_id: OWN_ORG }, + { id: 'n2', name: 'n', organization_id: FOREIGN_ORG }, + ]; + + const write = await middlewareVerdict(engine, 'qa_account', 'insert', MEMBER_CTX, rows); + const seam = await seamVerdicts(plugin, 'qa_account', 'insert', MEMBER_CTX, rows); + + expect(seam.map(asVerdict)).toEqual([{ admitted: true, judgesOrganization: true }, write]); + expect(write).toMatchObject({ admitted: false, code: 'PERMISSION_DENIED' }); + }); + + it('a system context and a context that names no user are not asked: `null`, as step 3.7 never runs for them', async () => { + const { plugin } = await boot('isolated'); + + expect(await plugin.writeWallVerdict('qa_account', 'insert', SYS_CTX, [{ organization_id: FOREIGN_ORG }])).toBeNull(); + expect(await plugin.writeWallVerdict('qa_account', 'insert', { tenantId: OWN_ORG, positions: [], permissions: [] }, [{ organization_id: FOREIGN_ORG }])).toBeNull(); + }); +}); diff --git a/packages/rest/src/import-dryrun-tenant-wall-parity.test.ts b/packages/rest/src/import-dryrun-tenant-wall-parity.test.ts new file mode 100644 index 00000000000..c6ea0efc0aa --- /dev/null +++ b/packages/rest/src/import-dryrun-tenant-wall-parity.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Import dry run == real write, for a row's ORGANIZATION under the Layer 0 + * tenant write wall (#25062). + * + * The dry run asks `DataProtocol.validateData` (`engine.validate()`), which + * runs no middleware, so the tenant wall that judges a row's + * `organization_id` on the write never judged a dry-run row. Measured at this + * door on `origin/main` f7d740376: + * + * - M5a, `group`, a row naming a sister organization: the dry run answered + * `ok` with `organization_id` in `droppedFields`, while the commit stored + * the row in that organization as sent and reported no drop; + * - M5b, `isolated`, a row naming a foreign organization: the dry run + * answered `ok`, while the commit refused the row `PERMISSION_DENIED`. + * + * Ruling 6336640643 (letter A): the security plugin hands the engine the + * wall's own verdict per row, and the preview reads it. Like + * `import-dryrun-parity.test.ts`, every case here runs the SAME payload both + * ways against one live stack (real `ObjectQL`, real SQLite, the real + * `SecurityPlugin`, the real protocol and route) and asserts the two row + * reports agree. The `single` posture is the control: no wall, the strip + * takes the value on both. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { RestServer } from './rest-server'; + +const OWN_ORG = 'org_alpha'; +const FOREIGN_ORG = 'org_north'; +const SISTER_ORG = 'org_south'; +const LEDGER = 'qa_ledger'; + +const MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as unknown as PermissionSet; + +const SYS_CTX = { isSystem: true }; +const MEMBER_CTX = { userId: 'usr_member', tenantId: OWN_ORG, positions: [], permissions: [], posture: 'MEMBER' }; +const GROUP_CTX = { ...MEMBER_CTX, accessible_org_ids: [OWN_ORG, SISTER_ORG] }; + +type Posture = 'isolated' | 'group' | 'single'; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +async function boot(posture: Posture, context: Record) { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registry.registerObject({ + name: LEDGER, + label: 'Ledger', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text', label: 'Name' }, + }, + } as any); + await engine.syncSchemas(); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER], + }, + }; + if (posture !== 'single') { + services['org-scoping'] = { name: 'com.objectstack.org-scoping' }; + services.tenancy = { posture }; + } + const ctx = { + 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 security = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await security.init(ctx as never); + await security.start(ctx as never); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + await engine.insert(LEDGER, { id: 'r1', name: 'seeded', organization_id: OWN_ORG }, { context: SYS_CTX } as never); + + const noop = () => {}; + const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ ...context }); + rest.registerRoutes(); + const route = rest.getRoutes().find( + (r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/import', + ) as any; + + const imp = async (body: Record) => { + const res = makeRes(); + await route.handler({ params: { object: LEDGER }, body, headers: {} } as any, res); + return res._json; + }; + /** The SAME rows, dry run then commit: the contract under test is agreement. */ + const bothWays = async (rows: unknown[]) => ({ + dry: await imp({ format: 'json', dryRun: true, rows }), + real: await imp({ format: 'json', rows }), + }); + const stored = async () => { + const driver = (engine as unknown as { getDriver(o: string): { knex: unknown } }).getDriver(LEDGER); + const knex = driver.knex as (t: string) => { select: (...c: string[]) => Promise>> }; + const rows = await knex(LEDGER).select('id', 'organization_id'); + return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id))); + }; + return { bothWays, stored }; +} + +/** + * What a row report says about the verdict, its sentence and the drops — the + * halves that must agree. Not `field`: a refused dry-run row names the value + * the wall refused (`organization_id`), which the commit's row, built from a + * thrown 403 with no field on it, does not carry. + */ +const verdictOf = (report: any, i = 0) => { + const r = report.results[i]; + return { ok: r.ok, action: r.action, code: r.code, error: r.error, droppedFields: r.droppedFields }; +}; + +describe('import dry run == real write, for the row\'s organization (#25062)', () => { + it('M5a, `group`: a sister organization the wall admits — no drop on either, and the commit stores it as sent', async () => { + const door = await boot('group', GROUP_CTX); + const { dry, real } = await door.bothWays([{ id: 'r2', name: 'n', organization_id: SISTER_ORG }]); + + expect(verdictOf(dry)).toEqual(verdictOf(real)); + expect(verdictOf(real)).toEqual({ ok: true, action: 'created', code: undefined, error: undefined, droppedFields: undefined }); + expect(await door.stored()).toContainEqual({ id: 'r2', organization_id: SISTER_ORG }); + }); + + it('M5b, `isolated`: a foreign organization the wall refuses — `PERMISSION_DENIED` on both, and nothing stored', async () => { + const door = await boot('isolated', MEMBER_CTX); + const { dry, real } = await door.bothWays([{ id: 'r2', name: 'n', organization_id: FOREIGN_ORG }]); + + expect(verdictOf(dry)).toEqual(verdictOf(real)); + expect(verdictOf(real)).toMatchObject({ ok: false, action: 'failed', code: 'PERMISSION_DENIED' }); + expect(dry.results[0].field, 'the dry run names the value the wall refused').toBe('organization_id'); + expect(await door.stored()).toEqual([{ id: 'r1', organization_id: OWN_ORG }]); + }); + + it('a mixed batch: each row answered as its commit answers it', async () => { + const door = await boot('isolated', MEMBER_CTX); + const rows = [ + { id: 'r2', name: 'mine', organization_id: OWN_ORG }, + { id: 'r3', name: 'forged', organization_id: FOREIGN_ORG }, + ]; + const { dry, real } = await door.bothWays(rows); + + expect([verdictOf(dry, 0), verdictOf(dry, 1)]).toEqual([verdictOf(real, 0), verdictOf(real, 1)]); + expect(verdictOf(real, 1)).toMatchObject({ ok: false, code: 'PERMISSION_DENIED' }); + }); + + it('CONTROL, `single`: no wall — the strip takes the value on both, and both report it', async () => { + const door = await boot('single', MEMBER_CTX); + const { dry, real } = await door.bothWays([{ id: 'r2', name: 'n', organization_id: FOREIGN_ORG }]); + + expect(verdictOf(dry)).toEqual(verdictOf(real)); + expect(verdictOf(real)).toEqual({ + ok: true, action: 'created', code: undefined, error: undefined, + droppedFields: [{ object: LEDGER, fields: ['organization_id'], reason: 'readonly' }], + }); + }); +});