Skip to content

Commit b5853da

Browse 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>
1 parent 4ac9319 commit b5853da

7 files changed

Lines changed: 128 additions & 41 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/lint": patch
4+
---
5+
6+
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.

‎content/docs/permissions/rls.mdx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export const ContributorAccess = definePermissionSet({
6060
| `object` | `string` | Target object — or `'*'` to apply to every object |
6161
| `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 |
6262
| `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) |
6464
| `positions` | `string[]` | Which positions the policy applies to. Omit = everyone |
6565
| `enabled` | `boolean` | Default `true`. `false` switches the policy off — a disabled policy is not evaluated |
6666

@@ -182,8 +182,9 @@ Layer 1 (business RLS) ─┘
182182

183183
- **Tenant isolation is a separate layer that always applies first** and is
184184
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
187188
alternatives.
188189
- The full evaluation pipeline around RLS is: object CRUD → FLS → OWD baseline →
189190
depth → sharing → RLS. RLS narrows what the earlier layers already allowed;
@@ -202,7 +203,10 @@ Four ways a policy denies rather than leaks:
202203
2. A referenced context variable is missing, null, or an empty array → that
203204
policy drops out (it cannot match).
204205
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.
206210

207211
<Callout type="warn">
208212
**The one non-obvious case: no applicable policy means *no restriction*, not

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ Return filtered result
141141
142142
## 2. Row-Level Security
143143
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.
145145
146146
Policies can be attached to a permission set via its `rowLevelSecurity` array, or registered as standalone metadata.
147147

‎content/docs/references/security/permission.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,10 @@ const result = AdminScopeSchema.parse(data);
168168
| **object** | `string` | ✅ | Target object name |
169169
| **operation** | `Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>` | ✅ | Database operation this policy applies to |
170170
| **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. |
172172
| **positions** | `string[]` | optional | Positions this policy applies to (omit for all) |
173173
| **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. |
175175
| **tags** | `string[]` | optional | Policy categorization tags |
176176

177177
### Nested Shape: `PermissionSet.adminScope`

‎content/docs/references/security/rls.mdx‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Salesforce:
7878
ObjectStack RLS:
7979
- 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
8080
- 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 (any match allows); for writes see `check`
8282

8383
### Best Practices
8484

@@ -92,8 +92,7 @@ ObjectStack RLS:
9292

9393
1. **Defense in Depth**: RLS is one layer; use with object permissions
9494
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
9796

9897
See also: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
9998

@@ -172,10 +171,10 @@ const result = RLSEvaluationResultSchema.parse(data);
172171
| **object** | `string` | ✅ | Target object name |
173172
| **operation** | `Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>` | ✅ | Database operation this policy applies to |
174173
| **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. |
176175
| **positions** | `string[]` | optional | Positions this policy applies to (omit for all) |
177176
| **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. |
179178
| **tags** | `string[]` | optional | Policy categorization tags |
180179

181180

0 commit comments

Comments
 (0)