From de2dfacf15b018e4182129fb836f71febf383df7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:41:27 +0000 Subject: [PATCH] docs(spec): the field-permission `when` description and the authorization guide name the View All / Modify All field vote A set holding the super-user bypass on an object votes on every field of it: viewAllRecords votes readable, modifyAllRecords votes readable and editable, View All never grants an edit. The `when` describe, the FieldPermissionSchema docblock and the FLS posture callout in authorization.mdx still said only declaring sets vote. Reference pages regenerated. Claude-Session: https://claude.ai/code/session_01JQwxbXETMiU6EPkjZvr57y Co-authored-by: Claude --- .../29543-field-permission-superuser-vote.md | 10 ++++++++++ content/docs/permissions/authorization.mdx | 14 +++++++++++--- content/docs/references/api/protocol.mdx | 2 +- .../docs/references/security/permission.mdx | 4 ++-- packages/spec/src/security/permission.zod.ts | 19 ++++++++++++++----- 5 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 .changeset/29543-field-permission-superuser-vote.md diff --git a/.changeset/29543-field-permission-superuser-vote.md b/.changeset/29543-field-permission-superuser-vote.md new file mode 100644 index 00000000000..125682f63aa --- /dev/null +++ b/.changeset/29543-field-permission-superuser-vote.md @@ -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. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index eee8940bbe5..85faf26ce99 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -153,9 +153,17 @@ implementation detail: **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. ## Package provenance & composition (ADR-0086) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 9d122a73e74..a4d5ebeb990 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -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. | --- diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index ea851226052..41b780aea76 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -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. | --- @@ -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]` diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index 2dccf577365..44c577690c0 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -932,8 +932,13 @@ const FIELD_PERMISSION_WHEN_ON_READABLE = * entry may carry a row condition, `when`, in the language the RLS `using` * predicate is written in. It narrows WHERE the entry votes: on a row the * condition matches, the entry votes as declared; on any other row the entry - * is absent, so the most-permissive merge and the "declaring sets vote" rule - * (`PermissionEvaluator.getFieldPermissions`) are unchanged. The write gate + * is absent, so the most-permissive merge + * (`PermissionEvaluator.getFieldPermissions`) is unchanged. Beside the + * declaring sets, a set holding the super-user bypass on the object votes on + * every field of it, on every row (ADR-0066 ⑧ note of 2026-11-02, ruling of + * record 6573563853): `viewAllRecords` votes `readable: true`, + * `modifyAllRecords` votes `readable: true` and `editable: true`; View All + * never grants an edit. Otherwise union without deny is unchanged. The write gate * judges it on the stored row before an update and on the incoming row of an * insert. It reaches the EDIT vote only: reads are row-blind (per-row masking * waits for a measured need), so `readable: false` beside a `when` is refused @@ -981,9 +986,13 @@ export const FieldPermissionSchema = lazySchema(() => strictObject( + '(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 ' + + '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 '