Skip to content

Commit 8595273

Browse files
committed
Merge origin/main into claude/issue-21794-lock-refusal-user-message
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
2 parents f6324de + e6dc7a2 commit 8595273

22 files changed

Lines changed: 1640 additions & 90 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(plugin-security)!: a predicate-scoped update or delete matches only the rows the caller can read
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered predicate-write-unreadable-row-not-matched -->
11+
12+
**BREAKING**: a predicate-scoped (`multi: true`) update or delete now matches only the rows the caller can read. A row the read door would not return to the caller is not written, not counted and not refused, so a predicate that reaches only such rows answers exactly what a predicate that matches nothing answers: success, zero rows. This is the by-id write doors' rule ("hidden" and "gone" are one answer to a caller who cannot read the row) carried to the predicate door. It ships as `minor` under the launch-window convention for accept-set narrowings. No export is added or removed, and no error code is new.
13+
14+
**What changed.** The rows a predicate write matched came from its write scope alone. A row the caller cannot read was matched whenever that scope reached it, for example through a write-class row-level policy wider than the read policy, or on an object whose read visibility follows a parent record. A per-row gate then refused the whole write with a `403`, or the row was written and counted. Either answer told a hidden row apart from no row. The write middleware now asks the read door which rows the caller's own predicate returns, through a read in the caller's context that every data middleware's visibility applies to, and narrows the write to those rows: readable ∩ writable. A read the read door refuses (no read grant on the object) keeps the write's previous answer. A store fault on that read propagates as raised.
15+
16+
**What is refused or narrowed now that was not.**
17+
- A predicate write no longer writes, counts or refuses rows its caller cannot read, including rows its write scope reaches.
18+
- A predicate write whose predicate matches more than 10 000 rows the caller can read is refused with `400 INVALID_FILTER`, before anything is written, rather than narrowed by a cut-off list. The limit is the platform's existing row ceiling for one predicate write.
19+
20+
**FROM → TO.**
21+
- A predicate update or delete reaching rows the caller cannot read: FROM a per-row gate's `403`, or those rows written and counted → TO those rows not matched; success with zero rows when no readable row matches.
22+
- A predicate update or delete whose readable match exceeds 10 000 rows: FROM attempted → TO `400 INVALID_FILTER`, nothing written.
23+
24+
**If you are affected.** An operator who needs a user to change rows grants that user read access to them first. A caller that read a predicate write's `403` as "a row exists here" reads the result as the count of rows it can see. A predicate whose readable match is over the ceiling is narrowed and written in batches.
25+
26+
**Unchanged.**
27+
- A caller who can read a matched row but may not write it keeps its answer.
28+
- Writes the platform issues under the caller's context keep their previous answer, because the caller never addressed them: a cascade, a hook's own write, and the referential clear of a lookup.
29+
- By-id writes keep their answers. System-context writes are not narrowed.
30+
- `security/explain` takes no predicate, so it has no predicate-write verdict to change.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
'@objectstack/rest': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
The object-schema field mask (ADR-0106 D1) judges an action param that names another object's field through `objectOverride` against that object, not the one being served.
8+
9+
Clause-②: yes (widening)
10+
11+
**What a user saw.** A `delegated_admin` may invite members, and the invite door admits them, but `GET /meta/object/sys_user` served that principal no `invite_user` action. The action's `role` param is `{ field: 'role', objectOverride: 'sys_member' }`: it names `sys_member.role`. The mask read every param's `field` as a field of the served object, so a caller denied `sys_user.role` lost the whole action. A plain `member` lost it the same way. The member is now served the action too, and still not offered it: the action's `requiresMembershipReach` predicate excludes the member grade.
12+
13+
**The rule.** A param whose `objectOverride` names another object reads that object's field. It is judged against the caller's readable fields on that object, and it is not a reference to the served object's fields. The action is still dropped when the caller cannot read the field there, and when that object's readable fields cannot be determined (no answer from the security service, a security service that throws, or an object that does not exist). Nothing about the other object is served on a guess. The rest of the param is still read against the served object: `visible`, an option's `visibleWhen`, `defaultValue`, and an explicit `name` that differs from `field`. A `name` that only repeats `field` is read as that field. With `defaultFromRow`, the param also reads `field` from the served object's row, so `field` is judged against the served object too. An exempt caller (platform admin, `isSystem`) is served the whole schema, as before.
14+
15+
**The API (`@objectstack/metadata-core`), additive.**
16+
17+
- `relateObjectSchemaMaskPosture(posture, ...documents)` completes a `project` posture for the documents it is about to mask. It reads the caller's readable fields on each other object their action params name through `objectOverride`. It runs after the fetch, because only the document names those objects. It returns every other posture, and any document with no such param, unchanged, and it never throws.
18+
- The `project` member of `ObjectSchemaMaskPosture` gains two optional fields. `relate` asks the posture's question (same caller, same security service) about another object. `resolveObjectSchemaMaskPosture` sets it. `related` holds the answers. A `project` posture built without `related` gets no answers, so `applyObjectSchemaMask` drops every action with such a param.
19+
- `applyObjectSchemaMask` folds each related read it withholds into the fingerprint, written as `object.field`. Two callers who are denied the same fields on the served object but differ on the other object get different validators. An unrestricted caller's ETag is unchanged.
20+
- The shared contract fixture `FLS_CONTRACT_OBJECT` (`@objectstack/metadata-core/testing`) gains two actions whose params read `contact` fields through `objectOverride`. The contract's projection cases now require the readable one to be served and the denied one to be dropped. An exit that never relates its posture fails the contract by name.
21+
22+
**Every exit relates its posture (`@objectstack/rest`, `@objectstack/runtime`).** These exits relate the posture after the fetch, before the projection: the shared item, layered and list chains, `RestServer`'s cached read and published read, and the runtime dispatcher's mask. The `/meta` diff route masks `fields` only and needs no relate step.
23+
24+
**Measured on a showcase boot.** We read every object schema (78 objects, by-name read and list read) as five principals: a platform admin, an org owner, an admin, a `delegated_admin` and a `member`. Before and after this change, the only served action that moved is `sys_user.invite_user`, which is now served to the `delegated_admin` and the `member`. This repository has two authored params with `objectOverride`: `sys_user.invite_user`'s `role` and `sys_member.invite_user`'s `email` (on `sys_invitation`). The second was served to all five principals before and after.

‎content/docs/permissions/attachments-access.mdx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ resolve the filter fails closed (deny-all).
6666

6767
Deleting a `sys_attachment` is allowed when the caller is **the uploader** OR
6868
**can edit the parent record** (`sharing.canEdit` — public-model parents are
69-
editable by design). A multi-delete requires *every* matched row to pass.
69+
editable by design). A multi-delete (`multi: true`) matches only attachments
70+
the caller can read: an attachment whose parent is out of the caller's sight is
71+
not matched, not removed and not counted, so a predicate that reaches only such
72+
attachments answers exactly what a predicate that matches nothing answers. Every
73+
matched row must then pass.
7074

7175
| Code | Status | When |
7276
| --- | --- | --- |
@@ -76,7 +80,8 @@ editable by design). A multi-delete requires *every* matched row to pass.
7680

7781
An update of another user's attachment follows the same rule — the uploader or
7882
a parent editor — and a by-id update of an attachment the caller cannot read
79-
gets the same not-found answer.
83+
gets the same not-found answer. A multi-update matches only attachments the
84+
caller can read, as a multi-delete does.
8085

8186
The platform baseline also ships a parent-blind row-level delete floor
8287
(`owner_only_deletes`: you may delete only the rows you created, for members

‎content/docs/permissions/permissions-matrix.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ flowchart TD
360360
| 7 | **Field-Level Security** | Which fields is the user allowed to see/edit? |
361361

362362
<Callout type="tip">
363-
**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation, and a row the caller cannot read answers exactly what a missing id answers (`404 RECORD_NOT_FOUND`). This keeps security checks efficient even on tables with millions of rows.
363+
**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation, and a row the caller cannot read answers exactly what a missing id answers (`404 RECORD_NOT_FOUND`). A predicate (`multi: true`) update or delete matches only the rows the caller can read: a row the read door would not return is not written, not counted and not refused, and above 10 000 such rows the write is refused (`400 INVALID_FILTER`) rather than narrowed by a cut-off list. This keeps security checks efficient even on tables with millions of rows.
364364
</Callout>
365365

366366
## See also

‎content/docs/permissions/rls.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,11 @@ object) is not narrowed, because their readable set is already unbounded.
8888
modify what you cannot see" is a floor, not a default the write filter
8989
overrides: before a single-record `update` / `delete` runs, the target row is
9090
re-read under the caller's **own read scope** together with the write filter,
91-
and a row that read cannot see is refused with `403 PERMISSION_DENIED`. On a
91+
and a row that read cannot see answers exactly what a missing id answers
92+
(`404 RECORD_NOT_FOUND`). A predicate (`multi: true`) `update` / `delete` is
93+
narrowed the same way: it matches only the rows the caller's read returns for
94+
its predicate, so a row the widener admits but the caller cannot read is not
95+
written and not counted. On a
9296
`public_read` object that read scope is every row (minus any `select`
9397
narrowing you authored), so an `update` widener works as written. On a
9498
**`private`** object — the default OWD — the read scope is the caller's own

‎packages/metadata-core/src/object-schema-fls-contract.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@
3434
* pointers, name lists, an expression, a field-group predicate, an index,
3535
* list views (a column list, a filter, a view KEYED by a field's name, an
3636
* object-form column naming one through its nested `prefix` / `summary`),
37-
* actions (a visibility predicate) — and, inside the READABLE fields, a name
37+
* actions (a visibility predicate, and — #21884 — a param that reads a field of
38+
* ANOTHER object through `objectOverride`, which every exit must relate after
39+
* its fetch and judge against THAT object) — and, inside the READABLE fields, a name
3840
* list, a `dependsOn`, a predicate, a formula `expression` and an inline grid
3941
* (`inlineColumns` by name and by computed `expr`, `inlineAmountField`) that
4042
* read a sibling. The inline grid sits on `name` only so that a field every
@@ -100,6 +102,12 @@ export const FLS_CONTRACT_OBJECT = {
100102
actions: [
101103
{ name: 'regrade', label: 'Regrade', type: 'script', visible: 'record.salary_grade != null' },
102104
{ name: 'rename', label: 'Rename', type: 'script', visible: 'record.name != null' },
105+
// [#21884] Params reading `contact`'s fields. The security double answers
106+
// the same set for every object, so `contact.name` is readable wherever
107+
// `account.name` is: an exit that never relates its posture withholds
108+
// `invite` (fail closed) and fails the `retained` half by name.
109+
{ name: 'invite', label: 'Invite', type: 'script', params: [{ field: 'name', objectOverride: 'contact' }] },
110+
{ name: 'escalate', label: 'Escalate', type: 'script', params: [{ field: 'bonus_formula', objectOverride: 'contact' }] },
103111
],
104112
fields: {
105113
id: { type: 'text', label: 'Id' },
@@ -195,7 +203,10 @@ const RETAINED_FOR_ID_AND_NAME: readonly FlsContractRetention[] = [
195203
compact: { label: 'Compact', type: 'grid', columns: [{ field: 'name', width: 200 }] },
196204
}),
197205
},
198-
{ what: 'the action whose predicate reads a readable field', holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename']) },
206+
{
207+
what: 'the action whose predicate reads a readable field, and the one whose `objectOverride` param reads a field readable on THAT object',
208+
holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename', 'invite']),
209+
},
199210
];
200211

201212
/**
@@ -229,6 +240,10 @@ const RETAINED_WITH_BONUS_READABLE: readonly FlsContractRetention[] = [
229240
&& d?.fields?.name?.inlineColumns?.[2]?.expr === 'bonus_formula * 2'
230241
&& d?.fields?.name?.inlineAmountField === 'bonus_formula',
231242
},
243+
{
244+
what: 'both actions whose `objectOverride` params read fields readable on THAT object',
245+
holds: (d) => sameList(d?.actions?.map((a: any) => a?.name), ['rename', 'invite', 'escalate']),
246+
},
232247
];
233248

234249
/** An unmasked answer is the whole fixture — every reference in every position. */
@@ -241,7 +256,7 @@ const RETAINED_UNMASKED: readonly FlsContractRetention[] = [
241256
what: 'every inline-grid column, every list view and every action',
242257
holds: (d) => d?.fields?.name?.inlineColumns?.length === 4
243258
&& Object.keys(d?.listViews ?? {}).length === 4 && d?.listViews?.compact?.columns?.length === 3
244-
&& d?.actions?.length === 2,
259+
&& d?.actions?.length === 4,
245260
},
246261
];
247262

0 commit comments

Comments
 (0)