You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit b5853da
Browse filesBrowse the repository at this point in the historyBrowse files
fix(spec, lint): state the RLS check default per operation across the applicable policies, as the runtime applies it (#19962)
Fixes#19953
Clause-②: no
Contract text only; no schema shape, accepted value or runtime behaviour
changes (triage direction A, `5808181305`). The published
`RowLevelSecurityPolicySchema.check` said it "defaults to USING clause
if not specified", which reads per policy. The write gate
(`writeCheckPolicies`, since #19952) decides the default once per write
operation across the applicable policies. The texts now say so. They
also say the check runs on the new row of a single-record insert and of
a by-id update. An array insert and a `multi: true` update are not
post-image checked (#19964, #19950). Round 2 made that change after the
review FAIL `5813145732`, and limited the old 「OR-combine (most
permissive wins)」 wording to reads.
- `packages/spec/src/security/rls.zod.ts`: the `check` describe and
TSDoc. The two reference pages under `content/docs/references/security/`
are regenerated from the describe.
- `content/docs/permissions/rls.mdx`: two sentences.
- `packages/lint/src/validate-rls-predicate-enforceability.ts`
(`domain:devx`, declared on the claim): the header, and the consequence
texts for `using` and `check`.
- Changeset: `@objectstack/spec` and `@objectstack/lint` at `patch`.
The dev measured the runtime composition before writing; the table is in
report `5812757565`. The ADR-0058 D4 clarifying note was reverted out of
this PR (seat ruling `5812826208`), because `docs/adr/**` is governed;
it stays owed separately. Filed from the measurement: #19964 (an array
insert runs no row-level check) and #19965 (a `check` on a `select` /
`delete` policy is never evaluated).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(spec, lint): the RLS `check` → `using` default is stated per operation across the applicable policies, as the runtime applies it, not per policy (#19953)
7
+
8
+
Clause-②: no
9
+
10
+
Text only. No schema shape, accepted value or runtime behaviour changes.
11
+
12
+
`RowLevelSecurityPolicySchema.check` said the clause "defaults to USING clause if not specified", which reads as a rule for each policy on its own. The write gate decides the default once per write operation, across every applicable policy:
13
+
14
+
- When any applicable policy for the operation declares `check`, only the declared checks decide, OR-combined. A policy with only a `using` beside them adds nothing to the check.
15
+
- Only when none declares `check` does each applicable policy's `using` stand in as its check, OR-combined.
16
+
17
+
A policy is applicable when it is not `enabled: false`, its `object` is the written object or `'*'`, its `operation` is the write's own or `'all'`, and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated.
18
+
19
+
The check runs on the new row of a single-record insert and of a by-id update. An array insert and a `multi: true` update are not post-image checked; those are tracked in #19964 and #19950, and the texts now say so instead of implying every insert and update is checked.
20
+
21
+
-**`@objectstack/spec`**: the `check` describe and TSDoc state this composition and that scope. The `rowLevelSecurity[].priority` refusal no longer gives "applicable policies OR-combine (most permissive wins)" as its reason, which is not true of the write check, and the file overview limits "OR-combine" to reads. The generated reference pages (`references/security/rls`, `references/security/permission`) are regenerated from the describe.
22
+
-**`@objectstack/lint`**: the `rls-predicate-*` findings on a `using` now also say what the dropped `using` does to an insert. On an `insert` or `all` policy, when no applicable policy for the insert declares a `check`, that `using` is also the single-record insert check. If nothing else in that set compiles, every single-record insert the policy governs is refused with `PermissionDeniedError`. The findings on a `check` now say the refusal is a blanket one only when no other applicable policy declares a `check` that compiles.
|`object`|`string`| Target object — or `'*'` to apply to every object |
61
61
|`operation`|`'select' \| 'insert' \| 'update' \| 'delete' \| 'all'`| Which operation the policy guards. A `select` policy also bounds writes when no write-class policy applies — see below |
62
62
|`using`|`string`| Predicate for rows the user may **see / act on** (compiled into the query filter) |
63
-
|`check`|`string`| Predicate rows must satisfy **after a write**. Omit it and `using`is reused|
63
+
|`check`|`string`| Predicate the new row must satisfy **after a single-record insert or a by-id update**; an array insert and a `multi: true` update are not checked. Omit it and `using`stands in, unless another applicable policy for the same operation declares a `check` — see [the fail-closed contract](#the-fail-closed-contract)|
64
64
|`positions`|`string[]`| Which positions the policy applies to. Omit = everyone |
65
65
|`enabled`|`boolean`| Default `true`. `false` switches the policy off — a disabled policy is not evaluated |
66
66
@@ -182,8 +182,9 @@ Layer 1 (business RLS) ─┘
182
182
183
183
-**Tenant isolation is a separate layer that always applies first** and is
184
184
never OR-ed away by a business policy.
185
-
-**Multiple policies on the same object are OR-ed** — a row is admitted if it
186
-
matches *any* applicable policy. This is the intended way to express
185
+
-**Multiple policies on the same object are OR-ed** — on a read, a row is
186
+
admitted if it matches *any* applicable policy (for writes, see item 4 of
187
+
the fail-closed contract below). This is the intended way to express
187
188
alternatives.
188
189
- The full evaluation pipeline around RLS is: object CRUD → FLS → OWD baseline →
189
190
depth → sharing → RLS. RLS narrows what the earlier layers already allowed;
@@ -202,7 +203,10 @@ Four ways a policy denies rather than leaks:
202
203
2. A referenced context variable is missing, null, or an empty array → that
203
204
policy drops out (it cannot match).
204
205
3. A policy references a column the object doesn't have → deny.
205
-
4.`check` is omitted → `using` is reused for writes, never "anything goes".
206
+
4.`check` is omitted → `using` stands in as the `check`. The choice is
207
+
made per operation across all the applicable policies, not policy by
208
+
policy: when any of them declares a `check`, only the declared checks
209
+
decide, and a USING-only sibling's `using` is not part of the check.
206
210
207
211
<Callouttype="warn">
208
212
**The one non-obvious case: no applicable policy means *no restriction*, not
Copy file name to clipboardExpand all lines: content/docs/protocol/objectql/security.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -141,7 +141,7 @@ Return filtered result
141
141
142
142
## 2. Row-Level Security
143
143
144
-
Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a **CEL** `using` clause (for SELECT/UPDATE/DELETE) and/or a `check` clause (for INSERT/UPDATE) — canonical CEL since ADR-0058; a legacy SQL-style `=`/`IN (...)` predicate still compiles via a **deprecated bridge** (warns). Multiple policies for one object are combined with OR (most-permissive wins). Available context variables are the **unique identifiers and membership sets** the runtime pre-resolves: equality predicates may use `current_user.id`, `current_user.email` (the unique, seedable owner anchor), or `current_user.organization_id`; set-membership predicates may use `id in current_user.org_user_ids`, `'manager' in current_user.positions`, or any §7.3.1 set staged in `ExecutionContext.rlsMembership`. Display `name` and arbitrary user fields are **intentionally not** resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision.
144
+
Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a **CEL** `using` clause (for SELECT/UPDATE/DELETE) and/or a `check` clause (for the new row of a single-record INSERT or a by-id UPDATE; an array insert and a `multi: true` update are not checked) — canonical CEL since ADR-0058; a legacy SQL-style `=`/`IN (...)` predicate still compiles via a **deprecated bridge** (warns). On reads, multiple policies for one object are combined with OR; for the write `check`, see `RowLevelSecurityPolicySchema.check`. Available context variables are the **unique identifiers and membership sets** the runtime pre-resolves: equality predicates may use `current_user.id`, `current_user.email` (the unique, seedable owner anchor), or `current_user.organization_id`; set-membership predicates may use `id in current_user.org_user_ids`, `'manager' in current_user.positions`, or any §7.3.1 set staged in `ExecutionContext.rlsMembership`. Display `name` and arbitrary user fields are **intentionally not** resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision.
145
145
146
146
Policies can be attached to a permission set via its `rowLevelSecurity` array, or registered as standalone metadata.
Copy file name to clipboardExpand all lines: content/docs/references/security/permission.mdx
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -168,10 +168,10 @@ const result = AdminScopeSchema.parse(data);
168
168
|**object**|`string`| ✅ | Target object name |
169
169
|**operation**|`Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>`| ✅ | Database operation this policy applies to |
170
170
|**using**|`string`| optional | Filter condition for SELECT/UPDATE/DELETE, authored in canonical CEL (ADR-0058 D1). It enforces when the predicate lowers to an ObjectQL filter: a field compared against a literal or a `current_user.*` context value using `==`, `!=`, `<`, `<=`, `>` or `>=`; `in` against a `current_user.*` array or an inline literal list (e.g. status in ['draft', 'pending']); these combined with `&&` / `\|\|`; or the bare allow-all `true`. Anything that does not lower fails closed — the policy matches zero rows. The legacy SQL-ish spellings are still accepted through a transitional bridge that rewrites `=` to `==` and `IN` to `in` (deprecated under ADR-0058 D1); SQL `AND` / `OR` / `NOT IN` / `IS NULL` / `LIKE` are NOT bridged and fail closed. Optional for INSERT-only policies. |
171
-
|**check**|`string`| optional | Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level)|
171
+
|**check**|`string`| optional | Validation condition matched against the new row of a single-record INSERT or a by-id UPDATE (enforced at application level); an array insert and a `multi: true` update are not post-image checked. The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated.|
172
172
|**positions**|`string[]`| optional | Positions this policy applies to (omit for all) |
173
173
|**enabled**|`boolean`| optional (default: `true`) | Whether this policy is active |
174
-
|**priority**|`never`| optional |[REMOVED]`rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
174
+
|**priority**|`never`| optional |[REMOVED]`rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
Copy file name to clipboardExpand all lines: content/docs/references/security/rls.mdx
+4-5Lines changed: 4 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -78,7 +78,7 @@ Salesforce:
78
78
ObjectStack RLS:
79
79
- A constrained CEL predicate grammar: comparisons and set-membership against literals or `current_user.*` values, composable with `&&` / `||`; anything that does not lower to a filter fails closed
80
80
- Subquery-shaped needs are pre-resolved by the runtime (§7.3.1)
81
-
- Multiple policies OR-combine for union (any-match-allows) semantics
81
+
- Multiple policies OR-combine on reads (anymatchallows); for writes see `check`
82
82
83
83
### Best Practices
84
84
@@ -92,8 +92,7 @@ ObjectStack RLS:
92
92
93
93
1.**Defense in Depth**: RLS is one layer; use with object permissions
94
94
2.**Default Deny**: If no policy matches, access is denied
95
-
3.**Policy Precedence**: More permissive policy wins (OR logic)
96
-
4.**Context Variables**: Ensure current_user context is always set
95
+
3.**Context Variables**: Ensure current_user context is always set
97
96
98
97
See also: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
99
98
@@ -172,10 +171,10 @@ const result = RLSEvaluationResultSchema.parse(data);
172
171
|**object**|`string`| ✅ | Target object name |
173
172
|**operation**|`Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>`| ✅ | Database operation this policy applies to |
174
173
|**using**|`string`| optional | Filter condition for SELECT/UPDATE/DELETE, authored in canonical CEL (ADR-0058 D1). It enforces when the predicate lowers to an ObjectQL filter: a field compared against a literal or a `current_user.*` context value using `==`, `!=`, `<`, `<=`, `>` or `>=`; `in` against a `current_user.*` array or an inline literal list (e.g. status in ['draft', 'pending']); these combined with `&&` / `\|\|`; or the bare allow-all `true`. Anything that does not lower fails closed — the policy matches zero rows. The legacy SQL-ish spellings are still accepted through a transitional bridge that rewrites `=` to `==` and `IN` to `in` (deprecated under ADR-0058 D1); SQL `AND` / `OR` / `NOT IN` / `IS NULL` / `LIKE` are NOT bridged and fail closed. Optional for INSERT-only policies. |
175
-
|**check**|`string`| optional | Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level)|
174
+
|**check**|`string`| optional | Validation condition matched against the new row of a single-record INSERT or a by-id UPDATE (enforced at application level); an array insert and a `multi: true` update are not post-image checked. The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated.|
176
175
|**positions**|`string[]`| optional | Positions this policy applies to (omit for all) |
177
176
|**enabled**|`boolean`| optional (default: `true`) | Whether this policy is active |
178
-
|**priority**|`never`| optional |[REMOVED]`rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
177
+
|**priority**|`never`| optional |[REMOVED]`rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
0 commit comments