Skip to content
29 changes: 29 additions & 0 deletions .changeset/20013-tenant-wall-post-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@objectstack/plugin-security": minor
---

fix(plugin-security)!: the Layer 0 tenant write wall now holds for the row a write stores, after the `beforeInsert` / `beforeUpdate` chain (#20013)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) an enforcement change on the write gate: no authorable key, spelling or stored shape moves, so a stored `sys_metadata` row needs no conversion and an upgrader has nothing to hand-edit. The remedy for a newly refused write is to change the hook or the data it derives the organization from. -->

**BREAKING**: this narrows the set of writes the write gate accepts. Under a walled tenancy posture (`isolated` or `group`), a write whose `beforeInsert` or `beforeUpdate` hook sets `organization_id` to an organization outside the caller's organization scope is now refused. It is admitted today, and the row is stored in that organization. It ships as `minor` under the launch-window convention, as the row-level `check` changes did (#19950, #19989).

The Layer 0 tenant wall (ADR-0095 D1, ADR-0105 D5) holds a write's `organization_id` to the same filter the read side uses, so a non-platform user can only place a row in an organization they hold. The wall judged the payload as the caller sent it, before the engine ran the hook chain. A value a hook wrote into `organization_id` after that point was never judged, whether the hook derived it from another field, a parent record or a lookup.

The wall now also judges the row the engine is about to store, through the seam the row-level `check` already uses (`OperationContext.postHookWriteImageCheck`): an insert's rows once the `beforeInsert` chain has run (every row of an array insert), the one row of a by-id update, and every matched row of a predicate update, each merged with the final payload. It is installed whenever the wall applies to the write, with or without a business `check`. The existing judgement of the payload as sent stays, so this change only ever refuses more.

**Writes that are now refused.** Each refusal is the wall's existing denial, `403 PERMISSION_DENIED` ("the insert/update would place '…' in another tenant"), and nothing is stored. There is no transition switch.

- **An insert, a by-id update or a predicate update whose hook chain leaves `organization_id` outside the caller's organization scope**: another organization under `isolated`, one outside the membership set under `group`. For an on-behalf-of write the delegator's scope applies too (ADR-0090 D10).
- **A walled write on a host that installs the judgement and never runs it**, for example a custom write executor in place of the engine. It is refused after the write with an `error` log saying the tenant wall was not evaluated on the stored row, as a write with an uncalled row-level `check` already is. The platform's own permission-set data door (ADR-0094), which executes its writes without the engine and so runs no hook chain, is not affected.

**Remedy.** A hook that must place a row in another organization does so in a separate write under a system context, which the wall does not gate. Otherwise fix the hook, or the data it derives the organization from, so the stored row stays in the caller's organization scope.

**What does not change.**

- A write whose hooks leave `organization_id` in the caller's scope, or leave it alone, is admitted as before. An update that does not touch the column keeps the row's current organization.
- An insert that leaves `organization_id` absent is not judged on it: the platform fills it with the caller's active organization, as before.
- A supplied out-of-scope `organization_id` is refused before anything runs, as before, even when a hook would replace it with an in-scope one.
- A system-context write, a platform administrator on an object whose posture lets them cross the wall, and every write under the `single` posture are not gated by the wall, as before.
10 changes: 10 additions & 0 deletions packages/plugins/plugin-security/src/authz-matrix-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,18 @@ function makeHarness(opts: {
// merged with the payload) before the statement, and a terminal that skipped
// it would be refused fail-closed by the middleware. Only the by-id path is
// modelled, through the producer's own dispatch predicate.
//
// [#20013] …and the INSERT path, because the Layer 0 tenant wall now
// installs the same seam on a walled insert: `ObjectQL.insert` runs it on
// the rows the `beforeInsert` chain produced, which here (no hooks) are the
// rows as sent.
const runByIdWriteImageCheck = async (opCtx: any) => {
const seam = opCtx?.postHookWriteImageCheck;
if (seam && opCtx.operation === 'insert') {
seam.honoured = true;
await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]);
return;
}
if (!seam || opCtx.operation !== 'update') return;
const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options);
if (dispatch.kind !== 'by-id') return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,19 @@ async function middlewareAdmits(
data,
};
try {
await middleware(opCtx, async () => {});
// [#20013] The terminal stands in for the engine, and the engine runs the
// installed `postHookWriteImageCheck` on an INSERT's rows once its
// `beforeInsert` chain has run — here (no hooks) the rows as sent. Under a
// walled posture the Layer 0 tenant wall installs that seam on every
// insert it walls, and a terminal that skipped it would be refused
// fail-closed. Only the insert path is modelled: every walled case in this
// file that reaches the terminal is an insert.
await middleware(opCtx, async () => {
const seam = opCtx.postHookWriteImageCheck;
if (!seam || opCtx.operation !== 'insert') return;
seam.honoured = true;
await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]);
});
return true;
} catch {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,16 @@ function makeEngine() {
const rows = await this.find(object, { ...options, limit: 1 });
return rows[0] ?? null;
},
async insert(object: string, data: any) {
// [#20013] The INSERT twin of the seam run below: the engine hands an
// installed write-image check the rows its `beforeInsert` chain produced —
// here (no hooks) the rows as sent. The Layer 0 tenant wall installs that
// seam on every walled insert, with or without a `check`.
async insert(object: string, data: any, opCtx?: any) {
const seam = opCtx?.postHookWriteImageCheck;
if (seam) {
seam.honoured = true;
await seam.evaluate(Array.isArray(data) ? data : [data]);
}
(tables[object] ??= []).push({ ...data });
return data;
},
Expand Down Expand Up @@ -390,7 +399,7 @@ async function makeStack(opts: { orgScoping?: boolean } = {}): Promise<Stack> {
await securityMw(opCtx, async () => {
await sharingMw(opCtx, async () => {
if (opCtx.operation === 'delete') await engine.delete(opCtx.object, opCtx.options);
else if (opCtx.operation === 'insert') await engine.insert(opCtx.object, opCtx.data);
else if (opCtx.operation === 'insert') await engine.insert(opCtx.object, opCtx.data, opCtx);
else await engine.update(opCtx.object, opCtx.data, opCtx.options, opCtx);
reached = true;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,9 +387,18 @@ function makeStore(rows: Record<string, Row[]>) {
* the payload, before the statement; a terminal that skipped it would be
* refused fail-closed by the security middleware. Only the by-id path is
* modelled, through the producer's own dispatch predicate.
*
* [#20013] …and the INSERT path: the Layer 0 tenant wall installs the same
* seam on every walled insert, and `ObjectQL.insert` runs it on the rows
* its `beforeInsert` chain produced — here (no hooks) the rows as sent.
*/
async runByIdWriteImageCheck(opCtx: any) {
const seam = opCtx?.postHookWriteImageCheck;
if (seam && opCtx.operation === 'insert') {
seam.honoured = true;
await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]);
return;
}
if (!seam || opCtx.operation !== 'update') return;
const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options);
if (dispatch.kind !== 'by-id') return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,18 @@ function makeStore(rows: Record<string, Row[]>) {
* the payload, before the statement; a terminal that skipped it would be
* refused fail-closed by the security middleware. Only the by-id path is
* modelled, through the producer's own dispatch predicate.
*
* [#20013] …and the INSERT path: the Layer 0 tenant wall installs the same
* seam on every walled insert, and `ObjectQL.insert` runs it on the rows
* its `beforeInsert` chain produced — here (no hooks) the rows as sent.
*/
async runByIdWriteImageCheck(opCtx: any) {
const seam = opCtx?.postHookWriteImageCheck;
if (seam && opCtx.operation === 'insert') {
seam.honoured = true;
await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]);
return;
}
if (!seam || opCtx.operation !== 'update') return;
const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options);
if (dispatch.kind !== 'by-id') return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
// controls pin that a dependency that ANSWERS — including one that answers
// "no restriction" — is reported exactly as before.
import { describe, it, expect, vi } from 'vitest';
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { SharingService, buildSharingMiddleware } from '@objectstack/plugin-sharing';
import { PermissionSetSchema } from '@objectstack/spec/security';
import type { PermissionSet } from '@objectstack/spec/security';
Expand Down Expand Up @@ -244,6 +244,19 @@ async function makeStack(
// A write stops at the engine's door: reaching it is the admission
// under test, and no write verb is exercised on this double.
if (operation === 'read') rows = await engine.find(OBJECT, { where: opCtx.ast.where });
// [#20013] …except the one step the engine takes at that door
// before its statement: it runs the installed stored-row seam
// (`postHookWriteImageCheck`) on the row it writes, the prior row
// merged with the payload. The Layer 0 tenant wall installs that
// seam on every walled update (this stack is walled), and a door
// that skipped it would be refused fail-closed after `next()`.
const seam = opCtx.postHookWriteImageCheck;
if (operation === 'update' && seam) {
const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options);
const prior = dispatch.kind === 'by-id' ? await engine.find(OBJECT, { where: { id: dispatch.id } }) : [];
seam.honoured = true;
await seam.evaluate(prior.map((r: Record<string, unknown>) => ({ ...r, ...opCtx.data })));
}
});
});
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,24 @@ function makeEngine(schema: Record<string, unknown>, rowOverrides: Partial<Recor
// Both write verbs open with the PRODUCER's dispatch predicate (#6277), so
// a call shape `ObjectQL` would refuse fails loudly instead of collecting
// a green from gates that never ran.
async update(object: string, data: any, options?: any) {
//
// [#20013] `opCtx` is the operation the middleware chain ran on. The
// engine hands an installed write-image check the rows it is about to
// store — the by-id row, or every matched row, merged with the payload —
// before it writes. The Layer 0 tenant wall installs that seam on every
// walled update (this stack is walled), and a double that skipped it would
// be refused, fail-closed, by the security middleware.
async update(object: string, data: any, options?: any, opCtx?: any) {
const dispatch = assertEngineUpdateDispatch(data, options);
const rows = (tables[object] ??= []);
const targets = dispatch.kind === 'by-id'
? rows.filter((r) => r.id === dispatch.id)
: rows.filter((r) => matches(r, options?.where));
const seam = opCtx?.postHookWriteImageCheck;
if (seam) {
seam.honoured = true;
await seam.evaluate(targets.map((r) => ({ ...r, ...data })));
}
for (const r of targets) Object.assign(r, data);
return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
},
Expand Down Expand Up @@ -265,7 +277,7 @@ async function makeStack(
await securityMw(opCtx, async () => {
await sharingMw(opCtx, async () => {
if (operation === 'delete') await engine.delete(opCtx.object, opCtx.options);
else await engine.update(opCtx.object, opCtx.data, opCtx.options);
else await engine.update(opCtx.object, opCtx.data, opCtx.options, opCtx);
reached = true;
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,22 @@ async function boot(opts: BootOpts = {}) {
warn,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readFilter: (object: string, context: any) => (plugin as any).getReadFilter(object, context),
// [#20013] The terminal stands in for the engine, which runs the installed
// `postHookWriteImageCheck` on an INSERT's rows once its `beforeInsert`
// chain has run — here (no hooks) the rows as sent. The Layer 0 tenant wall
// installs that seam on every insert it walls, and a terminal that skipped
// it would be refused fail-closed. Only the insert path is modelled: every
// walled write in this file that reaches the terminal is an insert.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run: async (opCtx: any) => { await middleware!(opCtx, async () => {}); return opCtx; },
run: async (opCtx: any) => {
await middleware!(opCtx, async () => {
const seam = opCtx.postHookWriteImageCheck;
if (!seam || opCtx.operation !== 'insert') return;
seam.honoured = true;
await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]);
});
return opCtx;
},
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,24 @@ function makeEngine() {
// (#4550 / #5480 / #6277), never a hand-mirrored guard: a fixture that
// drifts to a call shape `ObjectQL` would refuse fails loudly here instead
// of collecting a green from gates that never ran.
async update(object: string, data: any, options?: any) {
//
// [#20013] `opCtx` is the operation the middleware chain ran on. The
// engine hands an installed write-image check the rows it is about to
// store — the by-id row, or every matched row, merged with the payload —
// before it writes. The Layer 0 tenant wall installs that seam on every
// walled update (this stack is walled), and a double that skipped it would
// be refused, fail-closed, by the security middleware.
async update(object: string, data: any, options?: any, opCtx?: any) {
const dispatch = assertEngineUpdateDispatch(data, options);
const rows = (tables[object] ??= []);
const targets = dispatch.kind === 'by-id'
? rows.filter((r) => r.id === dispatch.id)
: rows.filter((r) => matches(r, options?.where));
const seam = opCtx?.postHookWriteImageCheck;
if (seam) {
seam.honoured = true;
await seam.evaluate(targets.map((r) => ({ ...r, ...data })));
}
for (const r of targets) Object.assign(r, data);
return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
},
Expand Down Expand Up @@ -354,7 +366,7 @@ async function makeStack(): Promise<Stack> {
await securityMw(opCtx, async () => {
await sharingMw(opCtx, async () => {
if (operation === 'delete') await engine.delete(opCtx.object, opCtx.options);
else await engine.update(opCtx.object, opCtx.data, opCtx.options);
else await engine.update(opCtx.object, opCtx.data, opCtx.options, opCtx);
reached = true;
});
});
Expand Down
Loading
Loading