From 22e257dfe0462d7933fdc9816f481125debe6fef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 07:19:02 +0000 Subject: [PATCH 1/3] fix(objectql,plugin-security): an update's organization_id re-point the Layer 0 wall judges is stored as sent Step 3.7 now declares judgesOrganization on the stored-image seam it composes for a single-payload update as well as an insert (tenantParts non-empty). The engine reads that member once per update and hands it to every static-readonly pass (the pre-hook hide pass, the by-id and predicate strips, and the stored view the readonlyWhen strips and the master-detail settlement read), through one helper shared with the insert side. Only the platform-injected column on an object the insert strip judges is spared, so sys_ and platform-internal objects and an authored read-only organization_id keep the strip. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- packages/objectql/src/engine.ts | 200 ++++++++++++++---- .../plugin-security/src/security-plugin.ts | 76 ++++--- 2 files changed, 205 insertions(+), 71 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index e2033a844a9..bd71f0ff98d 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2411,23 +2411,56 @@ function callerSuppliedRow(row: unknown, sent: ReadonlySet | undefined): } /** - * [#24822] The fields ONE insert's static-`readonly` passes may judge: the - * {@link staticReadonlyInsertSubject} view, without `organization_id` when the - * Layer 0 wall judges that column on this insert's stored rows AND the column - * is the platform's injected tenant column. + * [#24822] Whether ONE write's static-`readonly` passes leave a non-system + * caller's `organization_id` on the row for the Layer 0 wall's verdict instead + * of taking it. The ONE answer both write verbs read: the insert's subject + * ({@link insertStaticReadonlySubject}) and the update's + * ({@link updateStaticReadonlySubject}). + * + * Three things must hold, and the first is the wall's own word: * - * The wall says so itself, on the seam it installs - * (`PostHookWriteImageCheck.judgesOrganization`). The caller passes that - * member's answer, 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. + * 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. + * 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 + * field-write governance a spare must not pre-empt. The UPDATE strip runs + * on those objects too (it applies neither exclusion), so on an update + * this condition is what keeps a `sys_` row's organization exactly as + * before: the update spare covers exactly the objects the insert spare + * covers. + * 3. The column is the platform's injected tenant column, the one + * `TENANT_SCOPE_FIELD_DEF` declares read-only on the platform's behalf. An + * AUTHORED `organization_id` declared `readonly: true` is the author's own + * declaration, and declaration is enforcement: it keeps the strip. Which + * one this is, is the registry's own provenance reader, never a second + * copy. * - * Why the column is left on the row: the wall has already refused, before - * this engine ran, every supplied value it does not admit. It judges the - * stored row again after the hooks. So a value that reaches the strip is one - * the wall admitted, and taking it stored the active organization in its - * place. That is a silent replacement of an admitted input, which ADR-0105 D5 - * and the organizations runtime's stamp rule out. + * Why the column is left on the row where all three hold: the wall has + * already refused, before this engine ran, every supplied value it does not + * admit, and it judges the stored rows again after the hooks: an insert's + * rows, an update's prior row merged with the final payload, and on the + * predicate path every matched row merged with it. So a value that reaches the + * strip is one the wall admitted, and taking it put another organization in + * its place (the active one stamped on an insert, the row's own kept on an + * update). That is a silent replacement of an admitted input, which ADR-0105 + * D5 and the organizations runtime's stamp rule out. + */ +function staticReadonlySparesOrganization(schema: unknown, wallJudgesOrganization: boolean): boolean { + if (!wallJudgesOrganization) return false; + const subject = staticReadonlyInsertSubject(schema as any); + if (!subject || !Object.prototype.hasOwnProperty.call(subject.fields, SystemFieldName.ORGANIZATION_ID)) return false; + return resolveInjectedColumnProvenance(schema, SystemFieldName.ORGANIZATION_ID) !== 'author'; +} + +/** + * [#24822] The fields ONE insert's static-`readonly` passes may judge: the + * {@link staticReadonlyInsertSubject} view, without `organization_id` where + * {@link staticReadonlySparesOrganization} spares it. * * 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 @@ -2438,19 +2471,35 @@ function insertStaticReadonlySubject( wallJudgesOrganization: boolean, ): ReturnType { const subject = staticReadonlyInsertSubject(schema as any); - if (!subject || !wallJudgesOrganization) return subject; - if (!Object.prototype.hasOwnProperty.call(subject.fields, SystemFieldName.ORGANIZATION_ID)) return subject; - // An AUTHORED `organization_id` declared `readonly: true` is the author's - // own declaration, and declaration is enforcement: it keeps the strip. Only - // the platform's injected tenant column is spared, the one - // `TENANT_SCOPE_FIELD_DEF` declares read-only on the platform's behalf. Which - // one this is, is the registry's own provenance reader, never a second copy. - if (resolveInjectedColumnProvenance(schema, SystemFieldName.ORGANIZATION_ID) === 'author') return subject; + if (!subject || !staticReadonlySparesOrganization(schema, wallJudgesOrganization)) return subject; const fields = { ...subject.fields }; delete fields[SystemFieldName.ORGANIZATION_ID]; return Object.keys(fields).length > 0 ? { name: subject.name, fields } : null; } +/** + * [#24822] The schema view ONE update's static-`readonly` passes judge: the + * object's whole registered schema, because the update strip applies neither + * of the insert subject's exclusions, without `organization_id` where + * {@link staticReadonlySparesOrganization} spares it. `schema` itself + * otherwise, so every update the spare does not reach is judged exactly as + * before. + * + * ONE function for every pass that asks the update's static strip: the + * pre-hook hide pass (what `beforeUpdate` is shown), the by-id and predicate + * 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. + */ +function updateStaticReadonlySubject(schema: unknown, wallJudgesOrganization: boolean): unknown { + if (!staticReadonlySparesOrganization(schema, wallJudgesOrganization)) return schema; + const { name, fields } = schema as { name?: string; fields: Record }; + const kept = { ...fields }; + delete kept[SystemFieldName.ORGANIZATION_ID]; + return { name, fields: kept }; +} + /** * [#22306] The insert side of the #16344 invariant — a hook is never handed a * value that will not be stored. Takes out of ONE caller row every value the @@ -3767,22 +3816,32 @@ export interface PostHookWriteImageCheck { evaluate(rows: readonly Record[]): void | Promise; honoured?: boolean; /** - * [#24822] Declared by the installer, and only by it: on an `insert`, - * `evaluate` holds every row's `organization_id` to the Layer 0 tenant wall. - * It admits the value or refuses the write by throwing. + * [#24822] Declared by the installer, and only by it: `evaluate` holds every + * row it is handed to the Layer 0 tenant wall on `organization_id`. It + * admits the value or refuses the write by throwing. On an `insert` those + * rows are the inserted rows; on an `update` they are the stored images: + * the one prior row merged with the final payload on the by-id path, and + * every matched row merged with it on the predicate path. * `@objectstack/plugin-security` sets it at step 3.7, only where it - * composes the wall's own parts into the insert's seam. A seam that carries - * a business `check` alone does not judge the organization, and neither - * does a platform operator's seam that crosses the wall, so neither sets it. - * - * {@link ObjectQL.insert} reads this member, and nothing else, to decide one - * thing: whether a non-system caller's explicit `organization_id` stays on - * the row for that judgement or is taken by the static-`readonly` strip - * (the platform's tenant column declares `readonly: true`). Set, the value - * is stored as sent where the wall admits it and refused where it does not. - * It is never silently replaced by the active organization (ADR-0105 D5). - * Only the platform's injected column is spared. An AUTHORED - * `organization_id` declared `readonly: true` keeps the strip. + * composes the wall's own parts into the insert's or the single-payload + * update's seam. A seam that carries a business `check` alone does not + * judge the organization, and neither does a platform operator's seam that + * crosses the wall, so neither sets it; nor is it set on an update whose + * payload is an array, where step 3.7 installs no seam of its own. + * + * {@link ObjectQL.insert} and {@link ObjectQL.update} read this member, and + * nothing else, to decide one thing: whether a non-system caller's explicit + * `organization_id` stays on the row for that judgement or is taken by the + * static-`readonly` strip (the platform's tenant column declares + * `readonly: true`). Set, the value is stored as sent where the wall admits + * it and refused where it does not: an insert's is never silently replaced + * by the active organization (ADR-0105 D5), and an update's re-point is + * never silently dropped in favour of the row's own organization. Only the + * platform's injected column is spared, and only on an object the insert's + * static-`readonly` strip judges, so never a `sys_` or platform-internal + * object. An AUTHORED `organization_id` declared `readonly: true` keeps the + * strip. An emptied value on an update is not spared into storage: the wall + * refuses it (`null` or `''`), on the payload and on the stored image. * 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 @@ -4178,7 +4237,13 @@ function staticReadonlyStripTakes( data: Record, key: string, supplied: Readonly>, - strip: { runs: boolean; preserveAudit: boolean; hookWrittenKeys: ReadonlySet | undefined }, + strip: { + runs: boolean; + preserveAudit: boolean; + hookWrittenKeys: ReadonlySet | undefined; + /** [#24822] The wall's flag, read once by `update()` — see {@link staticReadonlyStoredView}. */ + wallJudgesOrganization?: boolean; + }, ): boolean { if (!strip.runs) return false; return !(key in staticReadonlyStoredView(schema, data, supplied, strip)); @@ -4204,16 +4269,31 @@ function staticReadonlyStripTakes( * when the strip does not run (a system caller), and a key a hook wrote, a * value a hook overwrote or a column `preserveAudit` keeps all stay, because * the write stores them. Silent for the same reason. + * + * [#24822] ...and over the same view of the schema: the one + * {@link updateStaticReadonlySubject} answers for `strip.wallJudgesOrganization`, + * the flag `update()` reads once off the wall's seam and hands to the strips + * themselves. So where the wall judges the update's organization, a re-point + * the strip leaves on the row is in this view too, and a `readonlyWhen` + * predicate reading `record.organization_id` reads the organization the row is + * stored in. The master-detail settlement forwards the update's descriptor + * object unchanged, so the flag reaches {@link staticReadonlyStripTakes} on that + * path too. Absent, the view is the whole schema's, exactly as before. */ function staticReadonlyStoredView( schema: unknown, data: Record, supplied: Readonly>, - strip: { runs: boolean; preserveAudit: boolean; hookWrittenKeys: ReadonlySet | undefined }, + strip: { + runs: boolean; + preserveAudit: boolean; + hookWrittenKeys: ReadonlySet | undefined; + wallJudgesOrganization?: boolean; + }, ): Record { if (!strip.runs) return data; return stripReadonlyFields( - schema as any, data, supplied, undefined, + updateStaticReadonlySubject(schema, strip.wallJudgesOrganization === true) as any, data, supplied, undefined, { preserveAudit: strip.preserveAudit, hookWrittenKeys: strip.hookWrittenKeys }, ) ?? data; } @@ -17412,6 +17492,20 @@ export class ObjectQL implements IObjectQLEngine { // judged against the prior record — per ROW on the predicate path, where // one shared payload cannot carry a per-row verdict — and #9107 // deliberately leaves it hook-writable. + // + // [#24822] Read ONCE, here, and handed to every static-`readonly` pass + // below (this hide pass, the by-id and predicate strips, and the views + // the `readonlyWhen` strips and the master-detail settlement read off + // them): whether the Layer 0 wall judges this update's `organization_id` + // on the stored rows. The wall declares it on the seam it installed; + // the engine reads that member and nothing else + // (`updateStaticReadonlySubject`). The middleware chain has run by now, + // so the seam is in place. Where it holds, an admitted re-point is shown + // to `beforeUpdate` and stored as sent, instead of being taken and the + // row's own organization kept. + const wallJudgesOrganization = + !opCtx.context?.isSystem && opCtx.postHookWriteImageCheck?.judgesOrganization === true; + const readonlyUpdateSubject = updateStaticReadonlySubject(updateSchema, wallJudgesOrganization); let readonlyHiddenFromHooks: Record | undefined; if (!opCtx.context?.isSystem) { const preHookPayload = opCtx.data as Record | null | undefined; @@ -17421,7 +17515,7 @@ export class ObjectQL implements IObjectQLEngine { // No logger, deliberately: this pass is SILENT by construction. The // strip below owns every word said about these keys. const hidden = stripReadonlyFields( - updateSchema as any, preHookPayload, suppliedDataOnly, undefined, + readonlyUpdateSubject as any, preHookPayload, suppliedDataOnly, undefined, { preserveAudit: opCtx.context?.preserveAudit === true }, ) as Record; if (hidden !== preHookPayload) { @@ -18169,7 +18263,10 @@ export class ObjectQL implements IObjectQLEngine { // [#19887] The static strip's verdict, described once: the // settlement asks it of the FK, and both `readonlyWhen` strips // build their `record` view from the payload it leaves. - const staticStrip = { runs: staticReadonlyStripRuns, preserveAudit: opCtx.context?.preserveAudit === true, hookWrittenKeys }; + // [#24822] ...with the wall's flag read once above, so the views + // these consumers read agree with the strip below about + // `organization_id`. + const staticStrip = { runs: staticReadonlyStripRuns, preserveAudit: opCtx.context?.preserveAudit === true, hookWrittenKeys, wallJudgesOrganization }; const landing = wantsParentBinding ? await this.settleMasterDetailLanding({ schema: updateSchema, data: preRoWhen, supplied: suppliedValues, @@ -18247,7 +18344,14 @@ export class ObjectQL implements IObjectQLEngine { // a read-only column loses its write to any caller that // echoed the same value back, which on `null` is every // whole-record form round-trip. - hookContext.input.data = stripReadonlyFields(updateSchema as any, preRo, suppliedValues, this.logger, { preserveAudit: opCtx.context?.preserveAudit === true, addressKey: idAddressesThisRow ? 'id' : undefined, strictReadonlyWrites, hookWrittenKeys }) as any; + // [#24822] Over `readonlyUpdateSubject`: without + // `organization_id` where the wall's seam judges this + // update's organization (read once, above, for the hide pass + // too). An admitted re-point is then stored as sent, not + // taken with the row's own organization kept; it is not + // reported, and not refused under `strictReadonlyWrites`, + // which refuses exactly what this strip takes. + hookContext.input.data = stripReadonlyFields(readonlyUpdateSubject as any, preRo, suppliedValues, this.logger, { preserveAudit: opCtx.context?.preserveAudit === true, addressKey: idAddressesThisRow ? 'id' : undefined, strictReadonlyWrites, hookWrittenKeys }) as any; reportDroppedFields(preRo, hookContext.input.data as Record, 'readonly'); } // [#5126] Both strip passes are done; refuse now if the caller @@ -18483,7 +18587,7 @@ export class ObjectQL implements IObjectQLEngine { const staticReadonlyStripRunsMulti = !opCtx.context?.isSystem; // [#19887] Described once, three consumers — as on the by-id // branch. - const staticStripMulti = { runs: staticReadonlyStripRunsMulti, preserveAudit: opCtx.context?.preserveAudit === true, hookWrittenKeys }; + const staticStripMulti = { runs: staticReadonlyStripRunsMulti, preserveAudit: opCtx.context?.preserveAudit === true, hookWrittenKeys, wallJudgesOrganization }; const landingMulti = hasParentScopedReadonlyWhenInPayload(updateSchema as any, preRoWhenMulti) || schemaHasParentRequiredWhenMulti @@ -18532,7 +18636,11 @@ export class ObjectQL implements IObjectQLEngine { // one they were each added to close, and a bulk write that // reached a different verdict about authorship than a by-id // write is the #3106 / #4441 divergence verbatim. - hookContext.input.data = stripReadonlyFields(updateSchema as any, preRoMulti, suppliedValues, this.logger, { preserveAudit: opCtx.context?.preserveAudit === true, strictReadonlyWrites, hookWrittenKeys }) as any; + // [#24822] The same `readonlyUpdateSubject` as the by-id + // branch: the wall's seam judges every matched row merged + // with this payload, so an admitted re-point is stored as + // sent on every one of them. + hookContext.input.data = stripReadonlyFields(readonlyUpdateSubject as any, preRoMulti, suppliedValues, this.logger, { preserveAudit: opCtx.context?.preserveAudit === true, strictReadonlyWrites, hookWrittenKeys }) as any; reportDroppedFields(preRoMulti, hookContext.input.data as Record, 'readonly'); } // [#5126] Same refusal on the predicate path. A bulk strip is diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 11c1a8d455f..f49e566f96d 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -261,12 +261,14 @@ interface WriteImageCheckSeam { /** Set by the engine immediately before `evaluate` runs. */ honoured?: boolean; /** - * [#24822] Set by step 3.7, and only by it: on an insert, `evaluate` holds - * every row's `organization_id` to the Layer 0 tenant wall's own parts. The - * engine then leaves a caller's explicit value on the row for that verdict - * instead of stripping it as `readonly`. Never set on a seam that carries the - * business `check` alone, or on a crossing platform operator's seam: neither - * judges the organization. + * [#24822] Set by step 3.7, and only by it: `evaluate` holds every stored + * row's `organization_id` to the Layer 0 tenant wall's own parts, on an + * insert and on a single-payload update (the by-id image, and every row the + * predicate path matched). The engine then leaves a caller's explicit value, + * or an update's re-point, on the row for that verdict instead of stripping + * it as `readonly`. Never set on a seam that carries the business `check` + * alone, on a crossing platform operator's seam, or on an update whose + * payload is an array: none of them judges the organization. */ judgesOrganization?: boolean; } @@ -3935,13 +3937,22 @@ export class SecurityPlugin implements Plugin { // Both close identically here: a SUPPLIED (non-empty) `organization_id` in // the write payload must satisfy the SAME Layer 0 filter the read side uses // (isolation active, tenant object, platform-admin posture exemption, - // fail-closed on a missing active org). For UPDATE this makes - // `organization_id` effectively immutable in non-platform user contexts: the - // only value that passes is the caller's active org (which — since the - // pre-image already scoped the target to that org — equals the row's current - // org), so a re-point to any OTHER tenant is denied. A bulk update carrying a - // cross-tenant `organization_id` change-set is caught too (the check inspects - // the change-set value here, and [#20013] every stored row in the engine). + // fail-closed on a missing active org). For UPDATE that bounds a re-point + // by the caller's organization scope, and [#24822] a re-point the wall + // admits is STORED as sent. Under `isolated` the only value that passes is + // the caller's active org (which, since the pre-image already scoped the + // target to that org, equals the row's current org), so a re-point to any + // OTHER tenant is denied. Under `group` a re-point to another organization + // of the membership set is admitted and stored there, and one outside the + // set is denied. On an app object's platform-injected column, the seam + // below declares `judgesOrganization`, so the engine's static-`readonly` + // strip leaves the admitted value on the row for this wall's verdict + // instead of taking it and keeping the row's own organization in silence. + // A `sys_` or platform-internal object, and an AUTHORED read-only + // `organization_id`, keep that strip. A bulk update carrying a + // cross-tenant `organization_id` change-set is caught too (the check + // inspects the change-set value here, and [#20013] every stored row in the + // engine). // // [#22278] The invariant, stated whole: wherever the wall applies to a // non-system UPDATE, the row's organization after the write is one the @@ -4326,24 +4337,39 @@ export class SecurityPlugin implements Plugin { if (emptiesOrganization(row)) denyOrganizationRemoval("the stored row's"); } }, - // ── [#24822 · ADR-0105 D5] The insert's organization is JUDGED here ── + // ── [#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. So on an insert the - // engine may leave a caller's explicit value on the row for this - // verdict instead of stripping it as `readonly` and storing the - // active organization in its place. That replacement was 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 + // 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. An update's organization is - // not this member's. - ...(opCtx.operation === 'insert' && tenantParts.length > 0 ? { judgesOrganization: true } : {}), + // 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 } + : {}), }; opCtx.postHookWriteImageCheck = writeImageCheckSeam; tenantWallSeamInstalled = true; From 19388d79d630def04daf1d71f914e08f30da3bda Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 07:41:56 +0000 Subject: [PATCH 2/3] test(objectql,plugin-security,organizations): pin the update re-point the Layer 0 wall judges, and the changeset The engine half with a stand-in installer, step 3.7's setter on real ObjectQL + SqlDriver + SecurityPlugin (sys_ scope and delegated update included), and the boundary matrix on the real organizations stack: group and isolated, by id and by predicate, admitted and refused, emptied, array payload, F4, strict, preserveAudit, the no-wall paths and the wall-blind update preview. The insert-seam file's update cell flips to the update half's answer. Census rows follow the shared spare helper. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .changeset/24822-update-repoint-honoured.md | 35 ++ ...ne-update-wall-judged-organization.test.ts | 347 ++++++++++ .../federated-injected-column-readers.test.ts | 20 +- .../update-repoint-organization-wall.test.ts | 594 ++++++++++++++++++ .../insert-seam-judges-organization.test.ts | 14 +- .../update-seam-judges-organization.test.ts | 449 +++++++++++++ 6 files changed, 1450 insertions(+), 9 deletions(-) create mode 100644 .changeset/24822-update-repoint-honoured.md create mode 100644 packages/objectql/src/engine-update-wall-judged-organization.test.ts create mode 100644 packages/plugins/organizations/src/update-repoint-organization-wall.test.ts create mode 100644 packages/plugins/plugin-security/src/update-seam-judges-organization.test.ts diff --git a/.changeset/24822-update-repoint-honoured.md b/.changeset/24822-update-repoint-honoured.md new file mode 100644 index 00000000000..9549fdb07f3 --- /dev/null +++ b/.changeset/24822-update-repoint-honoured.md @@ -0,0 +1,35 @@ +--- +'@objectstack/objectql': minor +'@objectstack/plugin-security': patch +--- + +fix(objectql,plugin-security): an update's re-point of `organization_id` that the Layer 0 wall admits is stored as sent, not silently dropped + +Clause-②: yes (widening) — an update re-point of `organization_id` the Layer 0 wall admits is now stored instead of silently dropped, and an in-process `strictReadonlyWrites: true` update naming an organization the wall admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`. + +**What was wrong.** Under the `group` posture, a member whose active organization is A can name C, another organization it belongs to, in an update's `organization_id`. The Layer 0 write wall admitted C, on the payload and on the row about to be stored. Then the engine's static-`readonly` update strip took it, because the platform's injected tenant column is `readonly: true`, and the row stayed in A. The update answered 2xx, by id and by predicate alike, and the only signals were a `droppedFields` entry and a WARN. An object that declares its own writable `organization_id` stored the same re-point, so the app objects on the injected column were the inconsistent half. This is the update half of the insert fix shipped beside it. + +**What changed.** + +- **`@objectstack/objectql`.** + - `ObjectQL.update` reads `PostHookWriteImageCheck.judgesOrganization` (reached as `OperationContext.postHookWriteImageCheck`) once per update, for a non-system caller, and hands that one value to every static-`readonly` pass: the pre-hook hide pass, the by-id strip, the predicate strip, and the stored view the `readonlyWhen` strips and the master-detail settlement read. + - Where it is set, and the object's `organization_id` is the platform's injected column on an object the insert strip also judges, those passes leave the caller's re-point on the row. The wall then stores it as sent where it admits it and refuses it where it does not. + - The decision is one helper, shared with `ObjectQL.insert`, so the two write verbs spare exactly the same objects. + - The member's published doc now covers updates. No member is added. +- **`@objectstack/plugin-security`.** + - Step 3.7 sets `judgesOrganization` on the stored-image seam it composes for a single-payload update, only where it composes the wall's own parts in, as it already did for an insert. + - It does not set the member on a business-`check`-only seam, on a crossing platform operator's update (which gets no wall seam), or on an update whose payload is an array (step 3.7 composes no seam for one). + - The wall's admit/refuse set is unchanged: a re-point outside the caller's scope is still refused `403 PERMISSION_DENIED`, and so is an emptied one (`null` or `''`). + +**What a caller sees.** Every change below is a widening. None of them admits a `(caller, organization)` pair the wall does not already admit, and the engine's own gates (the wall, the strip and the strict flag) refuse nothing they accepted before. + +- **The re-point is stored as sent.** Under `group`, an update naming another organization of the caller's membership set moves the row there, by id and by predicate. Under `isolated`, an update naming the active organization is stored as before. +- **No longer reported.** Such a re-point is no longer reported as dropped: not in `droppedFields`, not through the `onFieldsDropped` listener, and not by the read-only WARN. +- **`strictReadonlyWrites` stores it.** An in-process `engine.update(..., { strictReadonlyWrites: true })` naming such an organization is now stored, where it used to be refused `ERR_READONLY_FIELD_REJECTED`: strict refuses exactly what the strip takes. No REST door can set that flag. +- **`beforeUpdate` hooks see it.** On a walled update, `beforeUpdate` hooks now see the caller's `organization_id`, because the value will be stored. Before, it was withheld from them. +- **A member with no active organization.** Under `group`, such a member re-points within its membership set the same way, and outside it is refused. +- **An on-behalf-of update (ADR-0090 D10).** Take a platform operator who crosses the wall and acts for a walled delegator. The re-point is held to the delegator's wall: stored as sent where that wall admits it, refused `403` where it does not. Before, an admitted re-point was taken and the row kept its organization. +- **Outcomes that read the stored organization now follow the admitted value.** An organization-scoped unique key, a validation rule, a `readonlyWhen` / `requiredWhen` predicate, and a hook that reads `organization_id` now see the organization the row is moved to, not the one it used to keep. So a re-point that collides with an organization-scoped unique key in its new organization is refused there, as it already was on an object that declares its own `organization_id`; before, the re-point was dropped and the write reported success. +- **Only the platform's injected column on an app object is spared.** A `sys_` object, a platform-internal `managedBy` bucket (`engine-owned`, `append-only`, `better-auth`), and an object whose author declares its own `organization_id` with `readonly: true` keep the update strip: the value is still taken, reported, and refused under strict, exactly as before. The organization-scoped grant tables (`sys_user_position`, `sys_user_permission_set`) declare a writable `organization_id` the strip never took, so their re-points keep meeting their own guards exactly as before. +- **Where no wall judges the column, nothing changes.** The re-point is still taken and reported, and strict still refuses it. That includes `single`, a composition without the security plugin, a tenancy-disabled object, a platform operator crossing the wall on its own behalf, and `preserveAudit`, whose update-only exemption never reaches `organization_id`. +- **The `update`-mode `validate()` preview is unchanged.** It runs no middleware, so it holds no seam: it still reports an admitted re-point as dropped while the write stores it. diff --git a/packages/objectql/src/engine-update-wall-judged-organization.test.ts b/packages/objectql/src/engine-update-wall-judged-organization.test.ts new file mode 100644 index 00000000000..a8269d6042c --- /dev/null +++ b/packages/objectql/src/engine-update-wall-judged-organization.test.ts @@ -0,0 +1,347 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#24822] `engine.update` leaves a non-system caller's `organization_id` +// re-point out of its static-`readonly` passes ONLY when the stored-image seam +// declares that it judges the write's organization: +// `PostHookWriteImageCheck.judgesOrganization`. The insert half is +// `engine-insert-wall-judged-organization.test.ts`; both read the one helper +// that decides the spare. +// +// The engine side of the seam, pinned with a stand-in installer. The installer +// in production is `@objectstack/plugin-security`'s step 3.7. Its side, and the +// two packages together on a real stack, are pinned in +// `plugin-security/src/update-seam-judges-organization.test.ts` and +// `organizations/src/update-repoint-organization-wall.test.ts`. +// +// What this file holds the engine to: +// - the member SET, by id and by predicate: the re-point is stored as sent, +// shown to `beforeUpdate`, handed to the seam on every stored image, not +// reported, and not refused under `strictReadonlyWrites`. Every OTHER +// read-only key is still taken, and so is an AUTHORED read-only +// `organization_id`: only the platform's injected column is spared; +// - ...and only on an object the INSERT strip judges. A `sys_` object and a +// platform-internal `managedBy` bucket keep the update strip even with the +// member set: the update spare covers exactly the objects the insert spare +// covers; +// - ONE read of the member for every pass: a `readonlyWhen` lock that reads +// `record.organization_id` reads the organization the row is stored in; +// - the member ABSENT or `false` on an installed seam, and no seam at all: +// the strip takes the re-point exactly as before (withheld from the hook, +// reported, WARNed, refused under strict), and `preserveAudit` does not +// spare it either; +// - `validate()` runs no middleware, so it holds no seam: its update preview +// still reports the drop (wall-blind, as on insert). + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } 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; + }); + }; + let n = 0; + 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) { + n += 1; + const id = (data.id as string) ?? `r_${n}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const row = { ...s.get(id), ...data, id }; + s.set(id, row); + return row; + }, + async updateMany(object: string, ast: any, data: Record) { + const s = storeFor(object); + let count = 0; + for (const row of [...s.values()]) { + if (!matches(row, ast?.where)) continue; + s.set(row.id, { ...row, ...data, id: row.id }); + count += 1; + } + return count; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count() { return 0; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r, undefined))); + }, + 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 LOCKED = 'qa_locked'; +const SYS_OBJECT = 'sys_qa_ledger'; +const ENGINE_OWNED = 'qa_engine_owned'; +const ACTIVE = 'org_a'; +const NAMED = 'org_c'; +const SESSION = { userId: 'usr_member', tenantId: ACTIVE }; +const SYSTEM = { isSystem: true }; + +/** How the stand-in installer behaves on this engine's next update. */ +type SeamMode = 'judges' | 'judges-false' | 'no-member' | 'none'; + +describe('[#24822] engine.update spares an organization_id re-point only where the seam declares the organization judged', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + let seamMode: SeamMode; + /** `organization_id` as `beforeUpdate` saw it: `'absent'` when the key was not on the payload. */ + let hookSaw: unknown[]; + /** `organization_id` on every stored image the seam judged. */ + let seamJudged: unknown[]; + let warns: string[]; + + beforeEach(async () => { + warns = []; + hookSaw = []; + seamJudged = []; + seamMode = 'judges'; + const logger: any = { + warn: (m: string) => warns.push(String(m)), + 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' } } } as any); + // F4: an app object whose AUTHOR declares its own read-only organization_id. + engine.registry.registerObject({ + name: AUTHORED, + fields: { name: { type: 'text' }, organization_id: { type: 'text', readonly: true } }, + } as any); + // A conditional lock that reads the organization the row is stored in. + engine.registry.registerObject({ + name: LOCKED, + fields: { name: { type: 'text' }, amount: { type: 'number', readonlyWhen: `record.organization_id == '${NAMED}'` } }, + } as any); + // The two shapes the INSERT strip never judges, each carrying the injected + // read-only column: the reserved `sys_` namespace, and a platform-internal + // `managedBy` bucket. + engine.registry.registerObject({ name: SYS_OBJECT, fields: { name: { type: 'text' } } } as any); + engine.registry.registerObject({ name: ENGINE_OWNED, managedBy: 'engine-owned', fields: { name: { type: 'text' } } } as any); + + for (const object of [OBJECT, AUTHORED, LOCKED, SYS_OBJECT, ENGINE_OWNED]) { + for (const id of ['r1', 'r2']) { + await engine.insert(object, { id, name: 'seeded', organization_id: ACTIVE, ...(object === LOCKED ? { amount: 1 } : {}) } as any, { context: SYSTEM } as any); + } + engine.registerHook('beforeUpdate', async (ctx: any) => { + const data = (ctx.input?.data ?? {}) as Record; + hookSaw.push(Object.prototype.hasOwnProperty.call(data, 'organization_id') ? data.organization_id : 'absent'); + }, { object }); + } + + // The stand-in installer: what an enforcement layer puts on an update. + engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.operation === 'update' && seamMode !== 'none') { + opCtx.postHookWriteImageCheck = { + evaluate: (rows: readonly Record[]) => { + for (const row of rows) seamJudged.push(row.organization_id); + }, + ...(seamMode === 'judges' ? { judgesOrganization: true } : {}), + ...(seamMode === 'judges-false' ? { judgesOrganization: false } : {}), + }; + } + await next(); + }); + }); + + type Dropped = Array<{ fields: string[]; reason: string }>; + /** One by-id re-point of `r1`, collecting what the write reports. */ + const repointById = async (object: string, extra: Record = {}, data: Record = {}) => { + const dropped: Dropped = []; + await engine.update(object, { id: 'r1', organization_id: NAMED, ...data } as any, { + context: SESSION, + onFieldsDropped: (e: any) => dropped.push({ fields: e.fields, reason: e.reason }), + ...extra, + } as any); + return dropped; + }; + /** One predicate re-point of every seeded row. */ + const repointByPredicate = async (object: string, extra: Record = {}) => { + const dropped: Dropped = []; + await engine.update(object, { organization_id: NAMED } as any, { + where: { name: 'seeded' }, + multi: true, + context: SESSION, + onFieldsDropped: (e: any) => dropped.push({ fields: e.fields, reason: e.reason }), + ...extra, + } as any); + return dropped; + }; + const stored = (object: string = OBJECT) => [...storeFor(object).values()].map((r) => r.organization_id); + const readonlyWarns = () => warns.filter((w) => w.includes("Field 'organization_id'")); + const strictRefusal = async (run: () => Promise) => { + let thrown: { code?: unknown; fields?: unknown; operation?: unknown } | undefined; + try { + await run(); + } catch (e) { + thrown = e as typeof thrown; + } + return thrown; + }; + + it('PRECONDITION: the platform injects organization_id as a read-only column on every object here but the authored one', () => { + for (const object of [OBJECT, LOCKED, SYS_OBJECT, ENGINE_OWNED]) { + expect((engine.getSchema(object) as any)?.fields?.organization_id?.readonly, object).toBe(true); + } + }); + + it('SET, by id: the re-point is stored as sent, shown to beforeUpdate, handed to the seam, and not reported', async () => { + const dropped = await repointById(OBJECT); + + expect(stored()).toEqual([NAMED, ACTIVE]); + expect(dropped).toEqual([]); + expect(readonlyWarns()).toEqual([]); + expect(hookSaw, 'the hook is shown the organization that will be stored').toEqual([NAMED]); + expect(seamJudged, 'the seam judges the stored image').toEqual([NAMED]); + }); + + it('SET, by predicate: every matched row is stored with the re-point, and the seam judges every one of them', async () => { + const dropped = await repointByPredicate(OBJECT); + + expect(stored()).toEqual([NAMED, NAMED]); + expect(dropped).toEqual([]); + expect(readonlyWarns()).toEqual([]); + expect(seamJudged).toEqual([NAMED, NAMED]); + }); + + it('SET spares organization_id ONLY: another read-only key the caller sends is still taken and reported', async () => { + const dropped = await repointById(OBJECT, {}, { created_by: 'usr_forged' }); + + expect(stored()).toEqual([NAMED, ACTIVE]); + expect(dropped).toEqual([{ fields: ['created_by'], reason: 'readonly' }]); + expect(storeFor(OBJECT).get('r1')?.created_by, 'the forged audit value is not stored').not.toBe('usr_forged'); + }); + + it('SET: strictReadonlyWrites does not refuse it, by id or by predicate, because strict refuses only what the strip takes', async () => { + await repointById(OBJECT, { strictReadonlyWrites: true }); + expect(stored()).toEqual([NAMED, ACTIVE]); + + await repointByPredicate(OBJECT, { strictReadonlyWrites: true }); + expect(stored()).toEqual([NAMED, NAMED]); + }); + + it('SET: a readonlyWhen lock reading record.organization_id reads the organization the row is stored in', async () => { + const dropped = await repointById(LOCKED, {}, { amount: 999 }); + + expect(storeFor(LOCKED).get('r1'), 'stored in the re-pointed organization, where the lock holds').toMatchObject({ organization_id: NAMED, amount: 1 }); + expect(dropped).toEqual([{ fields: ['amount'], reason: 'readonly_when' }]); + }); + + it('SET, but an AUTHORED read-only organization_id: the author\'s declaration keeps the strip, and strict refuses it', async () => { + const dropped = await repointById(AUTHORED); + + expect(stored(AUTHORED), 'the caller\'s re-point is not stored').toEqual([ACTIVE, ACTIVE]); + expect(dropped).toEqual([{ fields: ['organization_id'], reason: 'readonly' }]); + + const thrown = await strictRefusal(() => repointById(AUTHORED, { strictReadonlyWrites: true })); + expect(thrown?.code).toBe('ERR_READONLY_FIELD_REJECTED'); + expect(thrown?.fields).toEqual(['organization_id']); + }); + + for (const [object, why] of [ + [SYS_OBJECT, 'a `sys_` object'], + [ENGINE_OWNED, 'a platform-internal `managedBy` bucket'], + ] as const) { + it(`SET, but ${why}: the update strip keeps taking the re-point, by id and by predicate, as before`, async () => { + const byId = await repointById(object); + expect(hookSaw, 'withheld from the hook, as before').toEqual(['absent']); + const byPredicate = await repointByPredicate(object); + + expect(stored(object), 'the row keeps its organization').toEqual([ACTIVE, ACTIVE]); + expect(byId).toEqual([{ fields: ['organization_id'], reason: 'readonly' }]); + expect(byPredicate).toEqual([{ fields: ['organization_id'], reason: 'readonly' }]); + + const thrown = await strictRefusal(() => repointById(object, { strictReadonlyWrites: true })); + expect(thrown?.code).toBe('ERR_READONLY_FIELD_REJECTED'); + }); + } + + for (const mode of ['no-member', 'judges-false', 'none'] as const) { + describe(`NOT SET (${mode === 'none' ? 'no seam at all' : mode === 'no-member' ? 'a seam that does not declare it' : 'the member false'}): the strip takes the re-point exactly as before`, () => { + beforeEach(() => { seamMode = mode; }); + + it('by id: taken, withheld from beforeUpdate, reported, and WARNed', async () => { + const dropped = await repointById(OBJECT); + + expect(stored(), 'the row keeps its organization').toEqual([ACTIVE, ACTIVE]); + expect(dropped).toEqual([{ fields: ['organization_id'], reason: 'readonly' }]); + expect(readonlyWarns()).toHaveLength(1); + expect(hookSaw, 'withheld from the hook, as #16344 requires').toEqual(['absent']); + expect(seamJudged).toEqual(mode === 'none' ? [] : [ACTIVE]); + }); + + it('by predicate: taken and reported, and every matched row keeps its organization', async () => { + const dropped = await repointByPredicate(OBJECT); + + expect(stored()).toEqual([ACTIVE, ACTIVE]); + expect(dropped).toEqual([{ fields: ['organization_id'], reason: 'readonly' }]); + }); + + it('refused under strictReadonlyWrites, and nothing moves', async () => { + const thrown = await strictRefusal(() => repointById(OBJECT, { strictReadonlyWrites: true })); + + expect(thrown, 'expected the strict write to be refused').toBeDefined(); + expect(thrown?.code).toBe('ERR_READONLY_FIELD_REJECTED'); + expect(thrown?.fields).toEqual(['organization_id']); + expect(thrown?.operation).toBe('update'); + expect(stored()).toEqual([ACTIVE, ACTIVE]); + }); + + it('preserveAudit does not spare it: the historical-import exemption never reaches organization_id', async () => { + const dropped = await repointById(OBJECT, { context: { ...SESSION, preserveAudit: true } }); + + expect(stored()).toEqual([ACTIVE, ACTIVE]); + expect(dropped).toEqual([{ fields: ['organization_id'], reason: 'readonly' }]); + }); + }); + } + + it('a system context stores what it sends with or without the member: the strips never run for it', async () => { + seamMode = 'none'; + await engine.update(OBJECT, { id: 'r1', organization_id: NAMED } as any, { context: SYSTEM } as any); + seamMode = 'judges'; + await engine.update(OBJECT, { id: 'r2', organization_id: NAMED } as any, { context: SYSTEM } as any); + + expect(stored()).toEqual([NAMED, NAMED]); + }); + + it('validate() runs no middleware, so it holds no seam: its update preview still reports the strip (wall-blind)', async () => { + const preview = await engine.validate(OBJECT, { id: 'r1', organization_id: NAMED }, { mode: 'update', context: SESSION as any }); + + expect(preview.results[0]?.droppedFields).toEqual([{ object: OBJECT, fields: ['organization_id'], reason: 'readonly' }]); + }); +}); diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 28d6f24c523..f1daf2d8dcc 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -367,20 +367,32 @@ const READERS: Record = { 'tells an authored organization_id from the injection while re-planning registered schemas ' + '(ADR-0131 D7); it reads definitions, never a row', }, - // [#24822] The insert's static-`readonly` subject, without the tenant column - // where the Layer 0 wall's seam declares it judges the insert's organization. - 'engine.ts#insertStaticReadonlySubject :: resolveInjectedColumnProvenance()': { + // [#24822] Whether a write's static-`readonly` passes spare the tenant column + // where the Layer 0 wall's seam declares it judges the write's organization, + // and the insert's and the update's subjects built from that one answer. + 'engine.ts#staticReadonlySparesOrganization :: resolveInjectedColumnProvenance()': { disposition: 'not-a-read', why: - "asks whether the insert's organization_id is the platform's injected column or the author's; " + + "asks whether the write's organization_id is the platform's injected column or the author's; " + 'it reads a registered definition, never storage', }, + 'engine.ts#staticReadonlySparesOrganization :: organization_id': { + disposition: 'not-a-read', + why: + "asks whether the insert strip's subject holds the column; it reads a schema view, never storage", + }, 'engine.ts#insertStaticReadonlySubject :: organization_id': { disposition: 'not-a-read', why: "leaves the column out of an insert's static-readonly subject where the wall's seam judges it; " + 'it reads a schema view and the caller row in hand, never storage', }, + 'engine.ts#updateStaticReadonlySubject :: organization_id': { + disposition: 'not-a-read', + why: + "leaves the column out of an update's static-readonly schema view where the wall's seam judges it; " + + 'it reads a registered definition and the payload in hand, never storage', + }, 'tenancy/system-write-organization.ts# :: organization_id': { disposition: 'not-a-read', why: 'the declaration of the default tenant column name', diff --git a/packages/plugins/organizations/src/update-repoint-organization-wall.test.ts b/packages/plugins/organizations/src/update-repoint-organization-wall.test.ts new file mode 100644 index 00000000000..ed151628a25 --- /dev/null +++ b/packages/plugins/organizations/src/update-repoint-organization-wall.test.ts @@ -0,0 +1,594 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24822] An UPDATE that re-points `organization_id` meets the Layer 0 write + * wall, and gets the wall's answer: a re-point the caller's organization scope + * admits is STORED as sent, one it does not is refused `403 + * PERMISSION_DENIED`. ⛔ Never silently dropped, and never wider than the wall. + * The insert half of the same card is `create-explicit-organization-wall.test.ts`. + * + * ## The defect this pins shut + * + * Under `group`, a member whose active organization is A and whose membership + * set holds C updated a row of an app object, naming `organization_id: C`. The + * wall (`@objectstack/plugin-security` step 3.7) judged C and admitted it, on + * the payload and on the stored image. Then the engine's static-`readonly` + * update strip took it, because the platform's injected tenant column declares + * `readonly: true`, and the row stayed in A: a 2xx whose only signals were a + * `droppedFields` entry and a WARN. A DECLARED-column object stored the same + * re-point, so the injected-column objects were the inconsistent half. + * + * Now step 3.7's seam declares `judgesOrganization` on a single-payload update + * too, and the engine leaves the re-point on the row for the wall's verdict, + * by id and by predicate, through one helper shared with the insert half. + * + * ## What is real here + * + * The engine (`ObjectQL`), the driver (`SqliteWasmDriver`), THIS package's + * `OrganizationsPlugin` registered as the `org-scoping` service, and the real + * `SecurityPlugin`: the stack the insert half measured on. An innermost probe + * middleware records each update's seam and the organizations it judged. + * + * ## The matrix + * + * - `group`: a re-point to C stored, by id and by predicate, nothing reported, + * shown to `beforeUpdate`; D refused; strict stores C; a member with NO + * active organization re-points the same way. + * - `isolated`: naming the active organization is stored and not reported; + * another one is refused. + * - [#22278] an EMPTIED organization (`null`, `''`) stays refused on both + * paths, under both postures, and no stamp refills it. + * - An ARRAY update payload, where step 3.7 composes no seam: no spare. + * - The gates kept: the falsy payload id beside `where.id` (#19989), and the + * step 2.7 pre-image selection (a row outside the scope is never re-pointed + * into it). + * - F4: an AUTHORED read-only `organization_id` keeps the strip. + * - Every path where no wall judges the column keeps the strip, cell for cell: + * `single` with and without a business `check`, `single` under strict, + * `group` and `isolated` without the security plugin, a platform operator + * 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. + */ + +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 type { DroppedFieldsEvent } from '@objectstack/spec/data'; +import { OrganizationsPlugin } from './organizations-plugin.js'; + +/** The caller's active organization. */ +const OWN_ORG = 'org_alpha'; +/** A sister organization the caller ALSO holds under `group` only. */ +const SISTER_ORG = 'org_south'; +/** Another tenant: the caller holds no membership in it. */ +const FOREIGN_ORG = 'org_north'; + +const DECLARED = 'qa_assignment'; +const INJECTED = 'qa_ledger'; +const PRIVATE = 'qa_vault'; +const UNWALLED = 'qa_catalog'; +const AUTHORED = 'qa_authored'; + +const OBJECTS = [ + { + name: DECLARED, + label: 'Assignment', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + organization_id: { name: 'organization_id', type: 'text' }, + }, + }, + { + name: INJECTED, + label: 'Ledger', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + }, + }, + { + name: PRIVATE, + label: 'Vault', + access: { default: 'private' }, + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + }, + }, + { + name: AUTHORED, + label: 'Authored', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + organization_id: { name: 'organization_id', type: 'text', readonly: true }, + }, + }, + { + name: UNWALLED, + label: 'Catalog', + tenancy: { enabled: false }, + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + organization_id: { name: 'organization_id', type: 'text', readonly: true }, + }, + }, +]; + +const MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as unknown as PermissionSet; + +/** The same member under a business update `check`: step 3.6 then installs a seam of its own. */ +const MEMBER_WITH_CHECK: PermissionSet = { + ...MEMBER, + rowLevelSecurity: [ + { name: 'ledger_not_blocked', object: INJECTED, operation: 'update', check: "record.name != 'blocked'" }, + ], +} 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 GROUP_NO_ACTIVE_ORG_CTX = { userId: 'usr_member', positions: [], permissions: [], posture: 'MEMBER', accessible_org_ids: [OWN_ORG, SISTER_ORG] }; +const ADMIN_CTX = { userId: 'usr_admin', tenantId: OWN_ORG, positions: [], permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN' }; +const GROUP_ADMIN_CTX = { ...ADMIN_CTX, accessible_org_ids: [OWN_ORG, SISTER_ORG] }; + +type Posture = 'isolated' | 'group' | 'single'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** What one update's stored-image seam looked like, and the organizations it judged. */ +interface SeamSighting { installed: boolean; judgesOrganization: unknown; judged: unknown[] | null } + +interface Booted { + engine: ObjectQL; + /** `organization_id` as the engine handed it to `beforeUpdate`: `'absent'` when not on the payload. */ + seenByHooks: unknown[]; + table: (name: string) => Promise>>; + seams: SeamSighting[]; + warns: string[]; +} + +async function boot( + posture: Posture = 'isolated', + { + organizations: mountOrganizations = true, + security: mountSecurity = true, + member = MEMBER, + }: { organizations?: boolean; security?: boolean; member?: PermissionSet } = {}, +): 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.update-repoint-organization-wall', + name: 'An update re-pointing an organization meets the Layer 0 write wall', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: OBJECTS, + } as never); + await engine.syncSchemas(); + engines.push(engine); + + const seenByHooks: unknown[] = []; + for (const { name } of OBJECTS) { + engine.on('beforeUpdate', name, (async (ctx: { input: { data: Record } }) => { + const data = ctx.input.data; + seenByHooks.push(Object.prototype.hasOwnProperty.call(data, 'organization_id') ? data.organization_id : 'absent'); + }) as never); + } + + 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 = mountOrganizations ? new OrganizationsPlugin({ ensureDefaultOrganization: false }) : null; + const security = mountSecurity ? new SecurityPlugin({ fallbackPermissionSet: 'member_default' }) : null; + await organizations?.init(ctx as never); + await security?.init(ctx as never); + await organizations?.start(ctx as never); + await security?.start(ctx as never); + const warns: string[] = []; + vi.spyOn((engine as unknown as { logger: { warn: (m: unknown) => void } }).logger, 'warn') + .mockImplementation((m: unknown) => { warns.push(String(m)); }); + + // Registered LAST, so it runs innermost: every other middleware has + // installed what it installs by the time this reads it. + const seams: SeamSighting[] = []; + engine.registerMiddleware((async ( + opCtx: { operation: string; postHookWriteImageCheck?: { judgesOrganization?: unknown; evaluate: (rows: readonly Record[]) => unknown } }, + next: () => Promise, + ) => { + if (opCtx.operation === 'update') { + const sighting: SeamSighting = { + installed: opCtx.postHookWriteImageCheck !== undefined, + judgesOrganization: opCtx.postHookWriteImageCheck?.judgesOrganization, + judged: null, + }; + seams.push(sighting); + const seam = opCtx.postHookWriteImageCheck; + if (seam) { + const evaluate = seam.evaluate.bind(seam); + seam.evaluate = (rows) => { + sighting.judged = rows.map((row) => row?.organization_id); + return evaluate(rows); + }; + } + } + await next(); + }) as never); + + for (const { name } of OBJECTS) { + await engine.insert(name, { id: 'r1', name: 'seeded', organization_id: OWN_ORG }, { context: SYS_CTX } as never); + } + // [step 2.7] A row OUTSIDE the caller's scope, which no re-point may pull in. + await engine.insert(INJECTED, { id: 'r9', name: 'elsewhere', organization_id: FOREIGN_ORG }, { context: SYS_CTX } as never); + seenByHooks.length = 0; + seams.length = 0; + warns.length = 0; + + const table = async (name: string) => { + const driver = (engine as unknown as { getDriver(o: string): { knex: unknown } }).getDriver(name); + const knex = driver.knex as (t: string) => { select: (...c: string[]) => Promise>> }; + const rows = await knex(name).select('id', 'organization_id'); + return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id))); + }; + return { engine, seenByHooks, table, seams, warns }; +} + +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) }; + } +}; + +/** + * One update with the listener the REST update doors wire, so `dropped` is + * the response's `droppedFields`. `by: 'id'` updates `r1` by its id; + * `by: 'predicate'` updates every row named `seeded` through `multi: true`. + */ +const repoint = async ( + b: Booted, + object: string, + organization: unknown, + context: object, + by: 'id' | 'predicate', + extra: Record = {}, +) => { + const dropped: DroppedFieldsEvent[] = []; + const onFieldsDropped = (e: DroppedFieldsEvent) => { dropped.push(e); }; + const outcome = await attempt(() => by === 'id' + ? b.engine.update(object, { id: 'r1', organization_id: organization } as never, { context, onFieldsDropped, ...extra } as never) + : b.engine.update(object, { organization_id: organization } as never, { where: { name: 'seeded' }, multi: true, context, onFieldsDropped, ...extra } as never)); + return { outcome, dropped }; +}; + +/** The engine's read-only WARN lines that name `organization_id`. */ +const organizationWarnings = (b: Booted) => b.warns.filter((line) => line.includes("Field 'organization_id'")); + +const READONLY_DROP = (object: string) => [{ object, fields: ['organization_id'], reason: 'readonly' }]; +const SEEDED = (object: string, organization: string = OWN_ORG) => + object === INJECTED + ? [{ id: 'r1', organization_id: organization }, { id: 'r9', organization_id: FOREIGN_ORG }] + : [{ id: 'r1', organization_id: organization }]; +const PATHS = ['id', 'predicate'] as const; + +const expectWallRefusal = (outcome: Outcome, object: string) => { + expect(outcome.ok, 'expected a refusal, got a completed write').toBe(false); + expect(outcome.code, 'ADR-0112 error code').toBe('PERMISSION_DENIED'); + expect(outcome.status, 'ADR-0112 HTTP status').toBe(403); + expect(outcome.message).toContain(`the update would place '${object}' in another tenant`); +}; + +const expectEmptiedRefusal = (outcome: Outcome, object: string) => { + expect(outcome.ok, 'expected a refusal, got a completed write').toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.message).toContain(`the update would leave '${object}' with no organization`); +}; + +/** The wall judged the stored image, declared it, and saw `organization` on it. */ +const judgedAs = (organization: string): SeamSighting[] => [{ installed: true, judgesOrganization: true, judged: [organization] }]; + +describe('[#24822] `group`: a re-point the membership set admits is stored as sent', () => { + for (const by of PATHS) { + it(`by ${by}: a re-point to a sister organization is stored THERE, shown to beforeUpdate, and nothing is reported`, async () => { + const b = await boot('group'); + + const { outcome, dropped } = await repoint(b, INJECTED, SISTER_ORG, GROUP_CTX, by); + + expect(outcome.ok, outcome.message).toBe(true); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED, SISTER_ORG)); + expect(dropped, 'a value stored as sent is not a dropped field').toEqual([]); + expect(organizationWarnings(b)).toEqual([]); + expect(b.seenByHooks, 'beforeUpdate sees the organization that will be stored').toEqual([SISTER_ORG]); + expect(b.seams, 'the wall judged the stored image and declared it').toEqual(judgedAs(SISTER_ORG)); + }); + + it(`by ${by}: a re-point outside the membership set is still refused, and nothing moves`, async () => { + const b = await boot('group'); + + const { outcome } = await repoint(b, INJECTED, FOREIGN_ORG, GROUP_CTX, by); + + expectWallRefusal(outcome, INJECTED); + expect(b.seenByHooks, 'refused before the engine ran its hooks').toEqual([]); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED)); + }); + + it(`by ${by}: an in-process strictReadonlyWrites re-point to a sister organization is stored, not refused`, async () => { + const b = await boot('group'); + + const { outcome } = await repoint(b, INJECTED, SISTER_ORG, GROUP_CTX, by, { strictReadonlyWrites: true }); + + expect(outcome.ok, outcome.message).toBe(true); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED, SISTER_ORG)); + }); + } + + it('naming the row\'s own organization is stored as sent and no longer reported', async () => { + const b = await boot('group'); + + const { outcome, dropped } = await repoint(b, INJECTED, OWN_ORG, GROUP_CTX, 'id'); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped).toEqual([]); + expect(organizationWarnings(b)).toEqual([]); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED)); + }); + + it('a member with NO active organization re-points within its membership set the same way; outside it, refused', async () => { + const b = await boot('group'); + + const admitted = await repoint(b, INJECTED, SISTER_ORG, GROUP_NO_ACTIVE_ORG_CTX, 'id'); + expect(admitted.outcome.ok, admitted.outcome.message).toBe(true); + expect(admitted.dropped).toEqual([]); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED, SISTER_ORG)); + + const refused = await repoint(b, INJECTED, FOREIGN_ORG, GROUP_NO_ACTIVE_ORG_CTX, 'id'); + expectWallRefusal(refused.outcome, INJECTED); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED, SISTER_ORG)); + }); + + it('CONTROL: a DECLARED-column object already stored the same re-point, by id and by predicate', async () => { + const b = await boot('group'); + + const viaId = await repoint(b, DECLARED, SISTER_ORG, GROUP_CTX, 'id'); + expect(viaId.outcome.ok, viaId.outcome.message).toBe(true); + const viaPredicate = await repoint(b, DECLARED, OWN_ORG, GROUP_CTX, 'predicate'); + expect(viaPredicate.outcome.ok, viaPredicate.outcome.message).toBe(true); + expect([...viaId.dropped, ...viaPredicate.dropped]).toEqual([]); + expect(await b.table(DECLARED)).toEqual(SEEDED(DECLARED)); + }); + + it('[step 2.7] a row outside the scope is never re-pointed into it: the scoped selection does not reach it', async () => { + const b = await boot('group'); + + const outcome = await attempt(() => b.engine.update(INJECTED, { id: 'r9', organization_id: SISTER_ORG } as never, { context: GROUP_CTX } as never)); + + expect(outcome.ok, 'expected the row outside the scope to be unreachable').toBe(false); + expect(outcome.code).toBe('RECORD_NOT_FOUND'); + expect(outcome.status).toBe(404); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED)); + }); + + it('[#19989] a falsy payload id beside where.id is refused, and nothing moves', async () => { + const b = await boot('group'); + + const outcome = await attempt(() => b.engine.update(INJECTED, { id: '', organization_id: SISTER_ORG } as never, { where: { id: 'r1' }, context: GROUP_CTX } as never)); + + expect(outcome.ok, 'expected a refusal').toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED)); + }); +}); + +describe('[#24822] `isolated`: the wall is the active organization', () => { + for (const by of PATHS) { + it(`by ${by}: naming the active organization is stored as sent, with nothing reported`, async () => { + const b = await boot('isolated'); + + const { outcome, dropped } = await repoint(b, INJECTED, OWN_ORG, MEMBER_CTX, by); + + expect(outcome.ok, outcome.message).toBe(true); + expect(dropped).toEqual([]); + expect(organizationWarnings(b)).toEqual([]); + expect(b.seams).toEqual(judgedAs(OWN_ORG)); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED)); + }); + + it(`by ${by}: a re-point to any other organization is still refused, and nothing moves`, async () => { + const b = await boot('isolated'); + + const { outcome } = await repoint(b, INJECTED, SISTER_ORG, MEMBER_CTX, by); + + expectWallRefusal(outcome, INJECTED); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED)); + }); + } +}); + +describe('[#22278] an EMPTIED organization stays refused on update, and no stamp refills it', () => { + for (const posture of ['group', 'isolated'] as const) { + for (const by of PATHS) { + for (const emptied of [null, ''] as const) { + it(`\`${posture}\`, by ${by}, ${JSON.stringify(emptied)}: refused, and the row keeps its organization`, async () => { + const b = await boot(posture); + + const { outcome } = await repoint(b, INJECTED, emptied, posture === 'group' ? GROUP_CTX : MEMBER_CTX, by); + + expectEmptiedRefusal(outcome, INJECTED); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED)); + }); + } + } + } +}); + +describe('[#24822] an ARRAY update payload: step 3.7 composes no seam, so nothing is spared', () => { + it('no seam declares the organization judged, the update completes nothing, and the row keeps its organization', async () => { + const b = await boot('group'); + + const outcome = await attempt(() => b.engine.update(INJECTED, [{ organization_id: SISTER_ORG }] as never, { where: { id: 'r1' }, context: GROUP_CTX } as never)); + + expect(outcome.ok, 'an array payload is not a write the engine completes').toBe(false); + expect(b.seams).toEqual([{ installed: false, judgesOrganization: undefined, judged: null }]); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED)); + }); +}); + +describe('[#24822] F4: an AUTHORED read-only organization_id on a walled object keeps the update strip', () => { + for (const by of PATHS) { + it(`by ${by}: the wall judges and declares it, and the author's declaration keeps the strip: taken, reported, refused under strict`, async () => { + const b = await boot('group'); + + const { outcome, dropped } = await repoint(b, AUTHORED, SISTER_ORG, GROUP_CTX, by); + expect(outcome.ok, outcome.message).toBe(true); + expect(await b.table(AUTHORED), 'the row keeps its organization').toEqual(SEEDED(AUTHORED)); + expect(dropped).toEqual(READONLY_DROP(AUTHORED)); + expect(organizationWarnings(b)).toHaveLength(1); + expect(b.seams, 'the seam declares it, and the engine still keeps the strip').toEqual(judgedAs(OWN_ORG)); + + const strict = await repoint(b, AUTHORED, SISTER_ORG, GROUP_CTX, by, { strictReadonlyWrites: true }); + expect(strict.outcome.code).toBe('ERR_READONLY_FIELD_REJECTED'); + expect(await b.table(AUTHORED)).toEqual(SEEDED(AUTHORED)); + }); + } +}); + +describe('[#24822] where no wall judges the column, the update strip takes the re-point exactly as before', () => { + /** Taken, reported, WARNed and withheld from the hook; the row keeps its organization. */ + const expectTaken = async (b: Booted, object: string, result: { outcome: Outcome; dropped: DroppedFieldsEvent[] }) => { + expect(result.outcome.ok, result.outcome.message).toBe(true); + expect(await b.table(object), 'the row keeps its organization').toEqual(SEEDED(object)); + expect(result.dropped).toEqual(READONLY_DROP(object)); + expect(organizationWarnings(b)).toHaveLength(1); + expect(b.seenByHooks, 'withheld from the hook').toEqual(['absent']); + }; + + for (const by of PATHS) { + it(`\`single\` without this package, by ${by}: taken; no seam`, async () => { + const b = await boot('single', { organizations: false }); + + await expectTaken(b, INJECTED, await repoint(b, INJECTED, SISTER_ORG, MEMBER_CTX, by)); + expect(b.seams).toEqual([{ installed: false, judgesOrganization: undefined, judged: null }]); + }); + + it(`\`single\` with a business update \`check\`, by ${by}: its seam is installed and does NOT declare the organization judged, so the re-point is taken`, async () => { + const b = await boot('single', { organizations: false, member: MEMBER_WITH_CHECK }); + + await expectTaken(b, INJECTED, await repoint(b, INJECTED, SISTER_ORG, MEMBER_CTX, by)); + expect(b.seams).toEqual([{ installed: true, judgesOrganization: undefined, judged: [OWN_ORG] }]); + }); + + for (const posture of ['group', 'isolated'] as const) { + it(`\`${posture}\` without the security plugin, by ${by}: no wall, so the re-point is taken`, async () => { + const b = await boot(posture, { security: false }); + + await expectTaken(b, INJECTED, await repoint(b, INJECTED, SISTER_ORG, posture === 'group' ? GROUP_CTX : MEMBER_CTX, by)); + expect(b.seams).toEqual([{ installed: false, judgesOrganization: undefined, judged: null }]); + }); + + it(`\`${posture}\`: a platform operator crossing the wall on a private object, by ${by}: no seam, so the re-point is taken`, async () => { + const b = await boot(posture); + + await expectTaken(b, PRIVATE, await repoint(b, PRIVATE, FOREIGN_ORG, posture === 'group' ? GROUP_ADMIN_CTX : ADMIN_CTX, by)); + expect(b.seams).toEqual([{ installed: false, judgesOrganization: undefined, judged: null }]); + }); + } + } + + it('`single` without a wall: an in-process strictReadonlyWrites re-point is still refused, and nothing moves', async () => { + const b = await boot('single', { organizations: false }); + + const { outcome } = await repoint(b, INJECTED, SISTER_ORG, MEMBER_CTX, 'id', { strictReadonlyWrites: true }); + + expect(outcome.code).toBe('ERR_READONLY_FIELD_REJECTED'); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED)); + }); + + it('a tenancy-disabled object with an authored read-only organization_id: no wall, so the re-point is taken', async () => { + const b = await boot('group'); + + await expectTaken(b, UNWALLED, await repoint(b, UNWALLED, SISTER_ORG, GROUP_CTX, 'id')); + expect(b.seams).toEqual([{ installed: false, judgesOrganization: undefined, judged: null }]); + }); + + it('`preserveAudit`: its update-only exemption never reaches organization_id, so with no wall the re-point is taken', async () => { + const b = await boot('single', { organizations: false }); + + await expectTaken(b, INJECTED, await repoint(b, INJECTED, SISTER_ORG, { ...MEMBER_CTX, preserveAudit: true }, 'id')); + }); + + it('a system context stores what it sends, by id and by predicate, as before: it meets neither the wall nor the strip', async () => { + const b = await boot('group'); + + const viaId = await repoint(b, INJECTED, FOREIGN_ORG, SYS_CTX, 'id'); + expect(viaId.outcome.ok, viaId.outcome.message).toBe(true); + expect(viaId.dropped).toEqual([]); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED, FOREIGN_ORG)); + expect(b.seams).toEqual([{ installed: false, judgesOrganization: undefined, judged: null }]); + }); +}); + +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 () => { + 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)); + const write = await repoint(b, INJECTED, SISTER_ORG, GROUP_CTX, 'id'); + expect(write.outcome.ok, write.outcome.message).toBe(true); + expect(await b.table(INJECTED)).toEqual(SEEDED(INJECTED, SISTER_ORG)); + }); +}); diff --git a/packages/plugins/plugin-security/src/insert-seam-judges-organization.test.ts b/packages/plugins/plugin-security/src/insert-seam-judges-organization.test.ts index eca6316ae1a..133024cbd92 100644 --- a/packages/plugins/plugin-security/src/insert-seam-judges-organization.test.ts +++ b/packages/plugins/plugin-security/src/insert-seam-judges-organization.test.ts @@ -27,9 +27,9 @@ * organizations runtime in this composition, an absent value is the * driver's stamp, as before. * - UNSET: the seam a business `check` installs under `single`, where no wall - * exists; a platform operator's seam that crosses the wall with no active - * organization (#24821); and an update's seam, whose organization this - * member is not about. + * exists; and a platform operator's seam that crosses the wall with no active + * organization (#24821). A walled update's seam carries the member too (the + * card's UPDATE half): `update-seam-judges-organization.test.ts`. * - NO SEAM: a platform operator crossing on a private object with an active * organization, and a system context. * - DELEGATED (ADR-0090 D10): a crossing operator acting for a walled @@ -266,13 +266,17 @@ describe('[#24822] step 3.7 declares the insert\'s organization judged only wher expect(await b.table('qa_vault')).toEqual([{ id: 'r2', organization_id: OWN_ORG }]); }); - it('an update\'s seam does not carry the member: it declares an INSERT\'s organization only', async () => { + // [#24822] This cell used to pin that an update's seam carried no member: + // the insert half declared an INSERT's organization only. The UPDATE half + // sets it on a walled single-payload update too, and + // `update-seam-judges-organization.test.ts` holds the update matrix. + it('a walled update\'s seam carries the member as well (the UPDATE half)', async () => { const b = await boot('isolated'); const outcome = await attempt(() => b.engine.update('qa_account', { id: 'r1', name: 'renamed' } as never, { context: MEMBER_CTX } as never)); expect(outcome.ok, outcome.message).toBe(true); - expect(b.sightings).toEqual([{ operation: 'update', installed: true, judgesOrganization: undefined }]); + expect(b.sightings).toEqual([{ operation: 'update', installed: true, judgesOrganization: true }]); }); it('a system context installs no seam', async () => { diff --git a/packages/plugins/plugin-security/src/update-seam-judges-organization.test.ts b/packages/plugins/plugin-security/src/update-seam-judges-organization.test.ts new file mode 100644 index 00000000000..15e8cc9b476 --- /dev/null +++ b/packages/plugins/plugin-security/src/update-seam-judges-organization.test.ts @@ -0,0 +1,449 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24822] Step 3.7 declares, on a single-payload update's stored-image seam, + * that the seam judges the update's organization (`judgesOrganization`), as it + * does on an insert's, and it declares that nowhere else. The insert half is + * `insert-seam-judges-organization.test.ts`. + * + * ## Why the member is on the update seam too + * + * The wall has always judged an update's re-point of `organization_id`: the + * payload as sent, and since #20013 the stored image (the prior row merged with + * the final payload by id, every matched row merged with it by predicate). But + * the engine's static-`readonly` strip then took the admitted re-point (the + * platform's tenant column declares `readonly: true`), and the row kept its own + * organization: a 2xx, with the row in another organization than the one the + * caller named and the wall admitted. The engine reads this member, and nothing + * else, to leave the re-point on the row for the wall's verdict. + * + * ## What this file pins + * + * Real `ObjectQL` (source), real `SqlDriver`, real `SecurityPlugin`; an + * innermost probe middleware reads the seam once every other middleware ran. + * + * - SET: a walled update by id and by predicate, under `isolated` and `group`, + * alone or composed with a business `check`. An admitted re-point is then + * stored as sent. + * - UNSET: the seam a business `check` installs under `single`, where no wall + * exists. + * - NO SEAM: a platform operator crossing on a private object, a system + * context, and an ARRAY update payload (step 3.7 composes no seam for it). + * - `sys_` SCOPE: step 3.7 composes its wall on a tenant-scoped `sys_` object + * and the member is set there too, but the engine spares the column only on + * an object the insert strip judges, so a `sys_` row keeps its organization + * and the drop is reported, as before. The organization-scoped grant tables + * declare a WRITABLE `organization_id` the strip never took: their re-point + * meets their own guards (the RBAC administration gate, the membership + * check), exactly as before. + * - DELEGATED (ADR-0090 D10): a crossing operator acting for a walled + * delegator. The wall's parts are the delegator's alone, so the member is + * SET, and the re-point is held to the delegator's wall. + */ + +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'; +import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; +import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; + +const OWN_ORG = 'org_a'; +const SISTER_ORG = 'org_c'; +const FOREIGN_ORG = 'org_d'; +const DELEGATOR = 'usr_delegator'; +const GRANTEE = 'usr_grantee'; + +const FIELDS = { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, +}; + +/** A tenant-scoped object in the reserved namespace, carrying the injected read-only column. */ +const SYS_OBJECT = 'sys_qa_ledger'; + +const MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + rowLevelSecurity: [ + { + name: 'checked_not_forbidden', + object: 'qa_checked', + operation: 'all', + using: "record.name != 'forbidden'", + check: "record.name != 'forbidden'", + }, + ], +} 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; + +/** A private object is outside the `'*'` grant, so the delegator holds it by name. */ +const DELEGATOR_SET: PermissionSet = { + name: 'qa_vault_writer', + label: 'Vault writer', + objects: { qa_vault: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +} as unknown as PermissionSet; +const SETS = [MEMBER, PLATFORM_ADMIN, DELEGATOR_SET]; + +const SYS_CTX = { isSystem: true, userId: 'usr_system', tenantId: OWN_ORG }; +const MEMBER_CTX = { userId: 'usr_a', tenantId: OWN_ORG, positions: [], permissions: [], posture: 'MEMBER' }; +const GROUP_CTX = { ...MEMBER_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' }; +const GROUP_ADMIN_CTX = { ...ADMIN_CTX, accessible_org_ids: [OWN_ORG, SISTER_ORG] }; +const OPERATOR_FOR_DELEGATOR = { + userId: 'usr_operator', + tenantId: OWN_ORG, + positions: [], + permissions: ['admin_full_access'], + posture: 'PLATFORM_ADMIN', + onBehalfOf: { userId: DELEGATOR }, +}; + +type Posture = 'isolated' | 'group' | 'single'; + +interface Sighting { installed: boolean; judgesOrganization: unknown } + +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.update-seam-judges-organization', + name: 'The update seam declares the organization judged', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet, + SysOrganization, SysUser, SysMember, + { name: 'qa_account', label: 'Account', fields: FIELDS }, + { name: 'qa_checked', label: 'Checked', fields: FIELDS }, + { name: 'qa_vault', label: 'Vault', access: { default: 'private' }, fields: FIELDS }, + { name: SYS_OBJECT, label: 'Ledger', fields: FIELDS }, + ], + } as never); + await engine.syncSchemas(); + engines.push(engine); + + for (const id of [OWN_ORG, SISTER_ORG, FOREIGN_ORG]) { + await engine.insert('sys_organization', { id, name: id, slug: id }, { context: SYS_CTX } as never); + } + for (const user of [DELEGATOR, GRANTEE]) { + await engine.insert('sys_user', { id: user, name: user, email: `${user}@example.test` }, { context: SYS_CTX } as never); + for (const org of [OWN_ORG, SISTER_ORG]) { + await engine.insert('sys_member', { id: `mem_${user}_${org}`, user_id: user, organization_id: org, role: 'member' }, { context: SYS_CTX } as never); + } + } + const driverOf = (o: string) => (engine as unknown as { getDriver(x: string): { create(x: string, r: Record): Promise; knex: unknown } }).getDriver(o); + for (const name of ['qa_vault_writer', 'member_default']) { + await driverOf('sys_permission_set').create('sys_permission_set', { + id: `ps_${name}`, name, label: name, organization_id: null, managed_by: 'admin', active: true, + object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), + }); + } + await driverOf('sys_position').create('sys_position', { id: 'pos_clerk', name: 'pos_clerk', label: 'Clerk' }); + // The delegator holds its set through a stored grant in each organization. + for (const org of [OWN_ORG, SISTER_ORG]) { + await engine.insert('sys_user_permission_set', { id: `ups_${org}`, user_id: DELEGATOR, permission_set: 'qa_vault_writer', organization_id: org }, { context: SYS_CTX } as never); + } + // The grant rows whose organization the `sys_` scope cells re-point. + await engine.insert('sys_user_permission_set', { id: 'ups_grantee', user_id: GRANTEE, permission_set: 'member_default', organization_id: OWN_ORG }, { context: SYS_CTX } as never); + await engine.insert('sys_user_position', { id: 'upos_grantee', user_id: GRANTEE, position: 'pos_clerk', organization_id: OWN_ORG }, { context: SYS_CTX } as never); + for (const object of ['qa_account', 'qa_checked', 'qa_vault', SYS_OBJECT]) { + for (const id of ['r1', 'r2']) { + await engine.insert(object, { id, name: 'seeded', organization_id: OWN_ORG }, { context: SYS_CTX } as never); + } + } + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' + ? SETS.find((set) => (set as { name?: string }).name === name) ?? null + : engine.getSchema(name) ?? null, + list: async () => SETS, + }, + }; + 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); + + // Registered LAST, so it runs innermost and reads the seam as installed. + const sightings: Sighting[] = []; + engine.registerMiddleware((async ( + opCtx: { operation: string; postHookWriteImageCheck?: { judgesOrganization?: unknown } }, + next: () => Promise, + ) => { + if (opCtx.operation === 'update') { + sightings.push({ + installed: opCtx.postHookWriteImageCheck !== undefined, + judgesOrganization: opCtx.postHookWriteImageCheck?.judgesOrganization, + }); + } + await next(); + }) as never); + + const table = async (name: string) => { + const knex = driverOf(name).knex as (t: string) => { select: (...c: string[]) => Promise>> }; + const rows = await knex(name).select('id', 'organization_id'); + return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id))); + }; + /** One update, collecting what the write reports. */ + const update = async (object: string, data: unknown, options: Record) => { + const dropped: Array<{ fields: string[]; reason: string }> = []; + const outcome = await attempt(() => engine.update(object, data as never, { + ...options, + onFieldsDropped: (e: { fields: string[]; reason: string }) => dropped.push({ fields: e.fields, reason: e.reason }), + } as never)); + return { outcome, dropped }; + }; + return { engine, sightings, table, update }; +} + +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) }; + } +}; + +const JUDGED: Sighting = { installed: true, judgesOrganization: true }; +const NO_SEAM: Sighting = { installed: false, judgesOrganization: undefined }; +const SEEDED = (org: string = OWN_ORG) => [{ id: 'r1', organization_id: org }, { id: 'r2', organization_id: OWN_ORG }]; +const READONLY_DROP = [{ fields: ['organization_id'], reason: 'readonly' }]; +const byId = (organization: string) => ({ id: 'r1', organization_id: organization }); +const predicateOptions = (context: object) => ({ where: { id: 'r1' }, multi: true, context }); + +const expectWallRefusal = (outcome: Outcome, object: string) => { + expect(outcome.ok, 'expected the wall to refuse').toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.message).toContain(`the update would place '${object}' in another tenant`); +}; + +describe('[#24822] step 3.7 declares a single-payload update\'s organization judged only where the wall\'s parts are composed in', () => { + it('`isolated`: an update that leaves the organization alone is SET, and the row keeps its organization', async () => { + const b = await boot('isolated'); + + const { outcome } = await b.update('qa_account', { id: 'r1', name: 'renamed' }, { context: MEMBER_CTX }); + + expect(outcome.ok, outcome.message).toBe(true); + expect(b.sightings).toEqual([JUDGED]); + expect(await b.table('qa_account')).toEqual(SEEDED()); + }); + + it('`group`: a re-point to a sister organization is SET and stored there, by id and by predicate, with nothing reported', async () => { + const one = await boot('group'); + const viaId = await one.update('qa_account', byId(SISTER_ORG), { context: GROUP_CTX }); + expect(viaId.outcome.ok, viaId.outcome.message).toBe(true); + expect(viaId.dropped).toEqual([]); + expect(one.sightings).toEqual([JUDGED]); + expect(await one.table('qa_account')).toEqual(SEEDED(SISTER_ORG)); + + const two = await boot('group'); + const viaPredicate = await two.update('qa_account', { organization_id: SISTER_ORG }, predicateOptions(GROUP_CTX)); + expect(viaPredicate.outcome.ok, viaPredicate.outcome.message).toBe(true); + expect(viaPredicate.dropped).toEqual([]); + expect(two.sightings).toEqual([JUDGED]); + expect(await two.table('qa_account')).toEqual(SEEDED(SISTER_ORG)); + }); + + it('`group`: a re-point outside the membership set is still refused, by id and by predicate, and nothing moves', async () => { + const b = await boot('group'); + + const viaId = await b.update('qa_account', byId(FOREIGN_ORG), { context: GROUP_CTX }); + const viaPredicate = await b.update('qa_account', { organization_id: FOREIGN_ORG }, predicateOptions(GROUP_CTX)); + + expectWallRefusal(viaId.outcome, 'qa_account'); + expectWallRefusal(viaPredicate.outcome, 'qa_account'); + expect(await b.table('qa_account')).toEqual(SEEDED()); + }); + + it('a walled update under a business `check`: the composed seam carries the member, and the check still runs', async () => { + const b = await boot('group'); + + const admitted = await b.update('qa_checked', { id: 'r1', name: 'fine', organization_id: SISTER_ORG }, { context: GROUP_CTX }); + const refused = await b.update('qa_checked', { id: 'r2', name: 'forbidden', organization_id: SISTER_ORG }, { context: GROUP_CTX }); + + expect(admitted.outcome.ok, admitted.outcome.message).toBe(true); + expect(refused.outcome.ok, 'the business check still refuses its own row').toBe(false); + expect(refused.outcome.code).toBe('PERMISSION_DENIED'); + expect(refused.outcome.status).toBe(403); + // The refused one never reaches the engine: step 3.6 refuses its by-id + // image (the stored row merged with the change set) before `next()`. + expect(b.sightings).toEqual([JUDGED]); + expect(await b.table('qa_checked')).toEqual(SEEDED(SISTER_ORG)); + }); + + it('`single` with a business `check`: the seam is installed and the member is UNSET, so a re-point is taken as before', async () => { + const b = await boot('single'); + + const { outcome, dropped } = await b.update('qa_checked', { id: 'r1', name: 'fine', organization_id: SISTER_ORG }, { context: MEMBER_CTX }); + + expect(outcome.ok, outcome.message).toBe(true); + expect(b.sightings).toEqual([{ installed: true, judgesOrganization: undefined }]); + expect(dropped).toEqual(READONLY_DROP); + expect(await b.table('qa_checked'), 'the row keeps its organization').toEqual(SEEDED()); + }); + + it('a platform operator crossing on a private object: no seam at all, and the re-point is taken as before', async () => { + const b = await boot('group'); + + const { outcome, dropped } = await b.update('qa_vault', byId(FOREIGN_ORG), { context: GROUP_ADMIN_CTX }); + + expect(outcome.ok, outcome.message).toBe(true); + expect(b.sightings).toEqual([NO_SEAM]); + expect(dropped).toEqual(READONLY_DROP); + expect(await b.table('qa_vault')).toEqual(SEEDED()); + }); + + it('a system context installs no seam, and stores what it sends', async () => { + const b = await boot('group'); + + const { outcome } = await b.update('qa_account', byId(FOREIGN_ORG), { context: SYS_CTX }); + + expect(outcome.ok, outcome.message).toBe(true); + expect(b.sightings).toEqual([NO_SEAM]); + expect(await b.table('qa_account')).toEqual(SEEDED(FOREIGN_ORG)); + }); + + it('an ARRAY update payload: step 3.7 composes no seam for it, so nothing declares the organization judged, and nothing moves', async () => { + const b = await boot('group'); + + const { outcome } = await b.update('qa_account', [{ organization_id: SISTER_ORG }], { where: { id: 'r1' }, context: GROUP_CTX }); + + expect(outcome.ok, 'an array payload is not a write the engine completes').toBe(false); + expect(b.sightings).toEqual([NO_SEAM]); + expect(await b.table('qa_account')).toEqual(SEEDED()); + }); +}); + +describe('[#24822] the `sys_` scope: the update spare covers exactly the objects the insert spare covers', () => { + it(`a tenant-scoped \`sys_\` object: step 3.7 sets the member, and the row still keeps its organization, reported as before`, async () => { + const b = await boot('group'); + + const viaId = await b.update(SYS_OBJECT, byId(SISTER_ORG), { context: GROUP_CTX }); + const viaPredicate = await b.update(SYS_OBJECT, { organization_id: SISTER_ORG }, predicateOptions(GROUP_CTX)); + const strict = await b.update(SYS_OBJECT, byId(SISTER_ORG), { context: GROUP_CTX, strictReadonlyWrites: true }); + + expect(viaId.outcome.ok, viaId.outcome.message).toBe(true); + expect(viaPredicate.outcome.ok, viaPredicate.outcome.message).toBe(true); + expect(b.sightings, 'the wall judges the sys_ row and says so').toEqual([JUDGED, JUDGED, JUDGED]); + expect(viaId.dropped).toEqual(READONLY_DROP); + expect(viaPredicate.dropped).toEqual(READONLY_DROP); + expect(strict.outcome.code, 'strict still refuses the strip it reports').toBe('ERR_READONLY_FIELD_REJECTED'); + expect(await b.table(SYS_OBJECT), 'the engine keeps the strip on a sys_ object').toEqual(SEEDED()); + }); + + for (const [object, rowId] of [['sys_user_position', 'upos_grantee'], ['sys_user_permission_set', 'ups_grantee']] as const) { + it(`${object}: a member's re-point is refused by the RBAC administration gate, as before`, async () => { + const b = await boot('group'); + + const { outcome } = await b.update(object, { id: rowId, organization_id: SISTER_ORG }, { context: GROUP_CTX }); + + expect(outcome.ok).toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.message).toContain('requires tenant-level administration'); + expect((await b.table(object)).find((row) => row.id === rowId)?.organization_id).toBe(OWN_ORG); + }); + + it(`${object}: the column is declared WRITABLE, so the strip never took it: an administrator's re-point meets the grant's own guards, as before`, async () => { + const b = await boot('group'); + expect((b.engine.getSchema(object) as { fields?: Record })?.fields?.organization_id?.readonly, 'not read-only').not.toBe(true); + + const outside = await b.update(object, { id: rowId, organization_id: FOREIGN_ORG }, { context: GROUP_ADMIN_CTX }); + const inside = await b.update(object, { id: rowId, organization_id: SISTER_ORG }, { context: GROUP_ADMIN_CTX }); + + expectWallRefusal(outside.outcome, object); + expect(inside.outcome.ok, inside.outcome.message).toBe(true); + expect(inside.dropped).toEqual([]); + expect((await b.table(object)).find((row) => row.id === rowId)?.organization_id).toBe(SISTER_ORG); + }); + } +}); + +/** + * [#24822 · ADR-0090 D10] An ON-BEHALF-OF update whose CALLER crosses the wall + * (a platform operator on a private object: its own Layer 0 is `null`) and + * whose DELEGATOR is walled. Step 3.7's wall parts are then the delegator's + * alone, so the seam judges the re-point against the delegator's wall and + * declares it. + */ +describe('[#24822 · ADR-0090 D10] a delegated re-point by a crossing operator is held to the delegator\'s wall', () => { + it('`group`: an organization the delegator holds is stored as sent, by id and by predicate, with nothing reported', async () => { + const one = await boot('group'); + const viaId = await one.update('qa_vault', byId(SISTER_ORG), { context: OPERATOR_FOR_DELEGATOR }); + expect(viaId.outcome.ok, viaId.outcome.message).toBe(true); + expect(viaId.dropped).toEqual([]); + expect(one.sightings).toEqual([JUDGED]); + expect(await one.table('qa_vault')).toEqual(SEEDED(SISTER_ORG)); + + const two = await boot('group'); + const viaPredicate = await two.update('qa_vault', { organization_id: SISTER_ORG }, predicateOptions(OPERATOR_FOR_DELEGATOR)); + expect(viaPredicate.outcome.ok, viaPredicate.outcome.message).toBe(true); + expect(await two.table('qa_vault')).toEqual(SEEDED(SISTER_ORG)); + }); + + it('`group`: an organization the delegator does not hold is refused by the delegator\'s wall, and nothing moves', async () => { + const b = await boot('group'); + + const { outcome } = await b.update('qa_vault', byId(FOREIGN_ORG), { context: OPERATOR_FOR_DELEGATOR }); + + expectWallRefusal(outcome, 'qa_vault'); + expect(await b.table('qa_vault')).toEqual(SEEDED()); + }); + + it('`isolated`: the active organization is stored as sent and not reported; another one is refused', async () => { + const b = await boot('isolated'); + + const same = await b.update('qa_vault', byId(OWN_ORG), { context: OPERATOR_FOR_DELEGATOR }); + const other = await b.update('qa_vault', { id: 'r2', organization_id: SISTER_ORG }, { context: OPERATOR_FOR_DELEGATOR }); + + expect(same.outcome.ok, same.outcome.message).toBe(true); + expect(same.dropped).toEqual([]); + expectWallRefusal(other.outcome, 'qa_vault'); + expect(await b.table('qa_vault')).toEqual(SEEDED()); + }); +}); From 1d0646678753f656526badd0100b1c6350e7af28 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:09:06 +0000 Subject: [PATCH 3/3] test(plugin-security): the organization-removal pin's header quotes step 3.7 as it now reads Step 3.7 no longer calls an update's organization_id effectively immutable: a re-point the Layer 0 wall admits is stored. The header's citation and its note on the injected column follow it. Comment only. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .../src/tenant-wall-organization-removal.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts b/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts index 50fa25612ff..3d858cfbc5b 100644 --- a/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts +++ b/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts @@ -8,9 +8,10 @@ * ## The guarantee * * Step 3.7 of the security middleware (ADR-0095 D1) states that, outside a - * system context and a platform administrator's posture exemption, - * `organization_id` is effectively immutable on update: the stored row must - * keep satisfying the same Layer 0 filter the read side uses. It judged only + * system context and a platform administrator's posture exemption, an + * update's `organization_id` stays inside the Layer 0 wall: the stored row must + * keep satisfying the same Layer 0 filter the read side uses ([#24822] a + * re-point the wall admits is stored, one it refuses is refused). It judged only * an organization a row NAMES, so an update that EMPTIED the column was never * judged at all — neither as the payload was sent nor on the stored row. On an * ordinary tenant object such a row drops out of every reader's scope; on the @@ -43,8 +44,9 @@ * The controls read each store as the engine writes it: on an ordinary tenant * object `organization_id` is the registry's injected `readonly` column, whose * caller-sent value the engine's static-readonly strip drops for a non-system - * writer (a hook's value it keeps); the grant tables declare the column - * writable, so a sent value is stored as sent. + * writer where no wall judges it, and leaves for the wall's verdict where one + * does ([#24822]; a hook's value it keeps either way); the grant tables declare + * the column writable, so a sent value is stored as sent. */ import { describe, it, expect, afterEach, vi } from 'vitest';