Skip to content
Merged
15 changes: 15 additions & 0 deletions .changeset/by-id-write-row-reread-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/plugin-security': patch
---

fix(plugin-security): a by-id write's row re-read propagates a store fault, and judges the platform's own predicates without the caller-predicate guard

Clause-②: yes (widening)
Metadata-protocol: none

Before an update or delete by id, the security middleware re-reads the addressed row under the caller's context with the platform's write predicates for that row (the tenancy wall and the row-level write predicates). Two rules hold for that re-read, and the second for the controlled-by-parent master read a detail write asks as well:

- **A store fault propagates as raised.** A re-read the store cannot answer (an unavailable datasource, a driver failure) is the write's answer, unchanged — for `ERR_DATASOURCE_UNAVAILABLE` the transports answer `503` — and nothing is written. Only a declared refusal (a 4xx) keeps the record-level `403 PERMISSION_DENIED`, one answer whatever the id names. The controlled-by-parent master read keeps its `403` for every failure.
- **The platform's own predicates are not the caller's predicate.** The field-level predicate guard refuses a caller's filter on a field its field rules do not let it read, and judges only the caller's predicate: a predicate subtree the platform composed and marked as its own is not judged. That holds for every such subtree that reaches the guard inside a caller-context read — the by-id write re-read and the controlled-by-parent master read, the analytics read-scope merge handed to an aggregate under the caller's context, and a record-sharing read scope composed before the security middleware. Only platform-composed predicates are exempt; nothing the caller writes is. Under the `group` and `isolated` tenancy postures, a caller whose field rules hide the organization column updates and deletes the records of its organization by id, writes a controlled-by-parent detail under a master of its organization, and aggregates over the records of its organization. A record of another organization and an id that names no record answer that caller exactly as they answer any other caller.

Unchanged: a caller's own filter on a field it cannot read is refused, on a read, an analytics query and a predicate write; a record the caller reads but may not write keeps its `403`; a system write is not the gate's.
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
*
* 1. the CRUD **object gate** — `member_default.objects.sys_user.allowEdit`;
* 2. the **row scope** — the by-id write pre-image check, which re-reads the
* target row through the caller's compiled write-RLS filter and denies when
* it comes back empty (`sys_user_self`);
* target row with its `{ id }` address AND-ed with one nested `$and` of the
* caller's compiled write-RLS parts (the platform's own predicate, never
* the caller's) and denies when it comes back empty (`sys_user_self`);
* 3. the ADR-0092 D2 **identity write guard** — an engine `beforeUpdate` hook
* that strips non-whitelisted columns and throws when nothing editable
* survives.
Expand Down Expand Up @@ -298,8 +299,9 @@ describe('sys_user self-service — the row scope the write actually composes',
const r = await route(ME, { locale: 'ja-JP' });
expect(r.refusedBy).toBeNull();
// One pre-image re-read, and its `where` is the `{id}` address ANDed with
// the compiled RLS parts. Asserted verbatim because every other assertion in
// this file leans on `matchesFilter` reading it correctly, and because it is
// one nested `$and` of the compiled RLS parts. Asserted verbatim because
// every other assertion in this file leans on `matchesFilter` reading it
// correctly (nested `$and` included), and because it is
// the security property in one line: the caller's id, and nothing wider.
//
// Then exactly one more read: the caller's OWN row by id, which step 3.6
Expand All @@ -308,7 +310,7 @@ describe('sys_user self-service — the row scope the write actually composes',
// already admitted above, never a scope: it cannot widen what the member may
// write, and the check it feeds can only refuse more.
expect(r.preImageWheres).toHaveLength(2);
expect(r.preImageWheres[0]).toEqual({ $and: [{ id: ME }, { id: ME }] });
expect(r.preImageWheres[0]).toEqual({ $and: [{ id: ME }, { $and: [{ id: ME }] }] });
expect(r.preImageWheres[1]).toEqual({ id: ME });
});

Expand Down Expand Up @@ -352,7 +354,7 @@ describe('sys_user self-service — the four pins, each attributed to a layer',
// re-read HAPPENED, was scoped to the caller, and came back empty.
// …and, the row being refused, the read question that keeps the 403: the
// member reads the peer's row, so it is not answered as a missing one.
expect(r.preImageWheres).toEqual([{ $and: [{ id: PEER }, { id: ME }] }, { id: PEER }]);
expect(r.preImageWheres).toEqual([{ $and: [{ id: PEER }, { $and: [{ id: ME }] }] }, { id: PEER }]);
expect(r.error?.name).toBe('PermissionDeniedError');
expect(r.error?.code).toBe('PERMISSION_DENIED');
});
Expand Down Expand Up @@ -400,7 +402,7 @@ describe('sys_user self-service — the four pins, each attributed to a layer',
// The pre-image re-read ran and SUCCEEDED (the row was visible), and the
// post-image check read the caller's own row and passed — proof the first
// two layers admitted the write before the guard refused it.
expect(r.preImageWheres).toEqual([{ $and: [{ id: ME }, { id: ME }] }, { id: ME }]);
expect(r.preImageWheres).toEqual([{ $and: [{ id: ME }, { $and: [{ id: ME }] }] }, { id: ME }]);
// ADR-0112 envelope, both discriminators (the REST boundary derives 403 from
// `status`, and `mapDataError` keys on `code`).
expect(r.error?.code).toBe('PERMISSION_DENIED');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,11 @@ async function writeFilter(cell: any, roleCtx: any): Promise<unknown> {
if (!reRead) {
return threw ? `CRUD_DENY:${threw?.name ?? 'err'}` : 'BYPASS(no-write-filter)';
}
return reRead[1].where.$and.slice(1);
// The re-read is `{ $and: [{ id }, PLATFORM] }`: PLATFORM is one `$and` of
// the write predicates, marked as the platform's own (never the caller's
// predicate), and its members are the matrix this gate locks.
const [, platform] = reRead[1].where.$and;
return platform.$and;
}

/**
Expand Down
Loading
Loading