Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/29543-field-permission-superuser-vote.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/spec': patch
---

docs(spec): the `FieldPermission.when` description names the View All / Modify All field vote

Clause-②: no
Metadata-protocol: none

Description text only: no key, accepted value or default of any schema changes. The published `when` description (and the `FieldPermissionSchema` docblock) said that only the sets declaring a field vote on it. Since `@objectstack/plugin-security` and `@objectstack/core` shipped the super-user field vote, a set holding the super-user bypass on an object (its own entry for the object, otherwise its `'*'` wildcard) votes on every field of that object, on every row: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. The description now says so, and the generated JSON Schema and reference pages carry it. Nothing to migrate.
14 changes: 11 additions & 3 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,17 @@ implementation detail:
<Callout type="warn">
**FLS posture (ADR-0066 ⑧):** runtime field security is block-list-shaped —
an **undeclared** field is visible by default, and field grants union (one
set's `readable: true` out-votes another's `false`). Until the muting layer
lands, protect sensitive fields by granting them only in the sets that need
them, and treat "sensitive field on a public object" as a review smell.
set's `readable: true` out-votes another's `false`). A set that holds View All
(`viewAllRecords`) on an object also votes `readable: true` on every field of
that object, and a set that holds Modify All (`modifyAllRecords`) votes
`readable: true` and `editable: true`; View All never grants an edit. So no
set's `readable: false` hides a field from a caller who holds a View All or
Modify All set on its object; a field's `requiredPermissions` capability gate
still applies to every caller. Otherwise union without deny is unchanged.
Until the muting layer lands, protect sensitive fields by granting them only
in the sets that need them, grant the object without those two bits to a set
that must withhold a field from its own holders, and treat "sensitive field on
a public object" as a review smell.
</Callout>

## Package provenance & composition (ADR-0086)
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1667,7 +1667,7 @@ One changed key path of the pending draft, and the last write that changed it.
| :--- | :--- | :--- | :--- |
| **readable** | `boolean` | optional (default: `true`) | Field read access |
| **editable** | `boolean` | optional (default: `false`) | Field edit access |
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets is unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing, unless it holds the super-user bypass on the object (its own entry for the object, otherwise its `'*'` wildcard). Such a set votes on every field of the object, on every row, whatever `when` says: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |


---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const result = AdminScopeSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **readable** | `boolean` | optional (default: `true`) | Field read access |
| **editable** | `boolean` | optional (default: `false`) | Field edit access |
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets is unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing, unless it holds the super-user bypass on the object (its own entry for the object, otherwise its `'*'` wildcard). Such a set votes on every field of the object, on every row, whatever `when` says: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |


---
Expand Down Expand Up @@ -159,7 +159,7 @@ const result = AdminScopeSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **readable** | `boolean` | optional (default: `true`) | Field read access |
| **editable** | `boolean` | optional (default: `false`) | Field edit access |
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets is unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing, unless it holds the super-user bypass on the object (its own entry for the object, otherwise its `'*'` wildcard). Such a set votes on every field of the object, on every row, whatever `when` says: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |

### Nested Shape: `PermissionSet.rowLevelSecurity[number]`

Expand Down
Loading
Loading