Skip to content

Commit e43cda1

Browse files
committed
chore(changeset): the Layer 0 tenant wall on the stored row
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
1 parent db01082 commit e43cda1

1 file changed

Lines changed: 29 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
fix(plugin-security)!: the Layer 0 tenant write wall now holds for the row a write stores, after the `beforeInsert` / `beforeUpdate` chain (#20013)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- 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. -->
10+
11+
**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).
12+
13+
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.
14+
15+
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.
16+
17+
**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.
18+
19+
- **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).
20+
- **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.
21+
22+
**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.
23+
24+
**What does not change.**
25+
26+
- 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.
27+
- An insert that leaves `organization_id` absent is not judged on it: the platform fills it with the caller's active organization, as before.
28+
- 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.
29+
- 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.

0 commit comments

Comments
 (0)