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
2 changes: 1 addition & 1 deletion .changeset/19953-rls-check-default-composition-text.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
"@objectstack/spec": patch
"@objectstack/lint": patch
Expand All @@ -16,7 +16,7 @@

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.

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.
When this text change was written, the check ran on the new row of a single-record insert and of a by-id update only, and the texts said so. The same release extends it: every row of an array insert and of a `multi: true` update is judged (#19964, #19950), and a by-id update is also judged on the row its `beforeUpdate` hooks leave (#19989). The texts now state that every row an insert or an update writes is judged (#19967).

- **`@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.
- **`@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.
16 changes: 16 additions & 0 deletions .changeset/19967-rls-using-check-texts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": patch
---

fix(spec): the RLS `using` / `check` texts say what the write gate enforces today — every row an insert or an update writes is checked, a check-only `update` policy is legal, and an `insert` policy's `using` is its check when none is declared (#19967)

Clause-②: no

Text only. No schema shape, accepted value or runtime behaviour changes.

- **`RowLevelSecurityPolicySchema.check`**: the describe and TSDoc said the check ran on the new row of a single-record insert or a by-id update, and that an array insert and a `multi: true` update were not post-image checked. Every insert and update shape is now judged: each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the change set as sent.
- **`RowLevelSecurityPolicySchema.using`**: the describe called it a filter for SELECT/UPDATE/DELETE and "optional for INSERT-only policies", and the TSDoc said UPDATE requires it. It now says, per operation, which rows it admits, that it stands in as the check on an insert or update when no applicable policy declares `check` (on an `insert` policy that is its only effect), that a `select` or `delete` policy needs it, and that an `insert`, `update` or `all` policy may declare `check` alone.
- **The "at least one of `using` or `check`" refusal**: its head is unchanged. It no longer says an UPDATE policy must provide `using` or that an INSERT policy must provide `check`. It names what each operation takes.
- **OR-combination**: the schema overview, the `priority` tombstone notes and the `os migrate meta --from 16` prose for the `priority` removal no longer say applicable policies OR-combine with "most permissive wins" without qualification. That holds on reads. On a write, the check is chosen per operation across the applicable policies first, and the chosen predicates then OR-combine.
- **Default deny**: the overview now limits "default deny" to the policies that apply. A caller to whom no policy applies is not restricted by the policies; the tenant wall still applies.
- The generated reference pages (`references/security/rls`, `references/security/permission`) and `docs/protocol-upgrade-guide.md` are regenerated from these sources.
2 changes: 1 addition & 1 deletion .changeset/rls-check-defaults-to-using.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
"@objectstack/plugin-security": minor
---
Expand All @@ -10,7 +10,7 @@

**BREAKING**: this narrows the set of writes the write gate accepts. A write that is admitted today can be refused after this change. It ships as `minor` under the launch-window convention, the same way the insert-side `check` reorder did (#16805).

The published contract has always said this. `RowLevelSecurityPolicySchema.check` reads "defaults to USING clause if not specified", and PostgreSQL treats a policy without `WITH CHECK` the same way. The write gate did not do it. It compiled only the policies that declared `check`, so a policy with only a `using` never checked a write. With `using: "record.status != 'closed'"`, a caller could INSERT a closed row. The row was stored even though the same caller could not read it afterwards.
The published contract has always said this. `RowLevelSecurityPolicySchema.check` read "defaults to USING clause if not specified" (it now states the default per operation across the applicable policies, #19953), and PostgreSQL treats a policy without `WITH CHECK` the same way. The write gate did not do it. It compiled only the policies that declared `check`, so a policy with only a `using` never checked a write. With `using: "record.status != 'closed'"`, a caller could INSERT a closed row. The row was stored even though the same caller could not read it afterwards.

**Writes that are now refused.** Each refusal is the existing row-level CHECK denial, `403 PERMISSION_DENIED`, and nothing is stored. There is no transition switch.

Expand Down
10 changes: 8 additions & 2 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,14 @@ implementation detail:
Keys never collide across packages because object api names are
package-namespaced.
3. **RLS: OR within an object, AND with tenant-global.** Multiple row policies
for the same object/operation OR-combine; the tenant wall (Layer 0, ADR-0095
D1) is a separate always-first AND conjunct, not an OR-mergeable policy.
for the same object/operation OR-combine their `using` on reads and on the
rows a write may target. The `check` on the rows an insert or update writes
is chosen per operation first: when any applicable policy declares `check`,
only the declared checks decide (OR-combined) and a USING-only policy adds
nothing; otherwise each `using` stands in (OR-combined) — see
[the fail-closed contract](/docs/permissions/rls#the-fail-closed-contract).
The tenant wall (Layer 0, ADR-0095 D1) is a separate always-first AND
conjunct, not an OR-mergeable policy.
`viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated)
short-circuit the object's *business* RLS. Crossing the **tenant wall**,
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1). That posture
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/rls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const ContributorAccess = definePermissionSet({
| `object` | `string` | Target object — or `'*'` to apply to every object |
| `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 |
| `using` | `string` | Predicate for rows the user may **see / act on** (compiled into the query filter) |
| `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) |
| `check` | `string` | Predicate **every row an insert or an update writes** must satisfy: each row of an insert (an array insert included) as its `beforeInsert` hooks leave it, and each row an update changes (by id or `multi: true`) after its `beforeUpdate` hooks. One failing row refuses the whole write. 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) |
| `positions` | `string[]` | Which positions the policy applies to. Omit = everyone |
| `enabled` | `boolean` | Default `true`. `false` switches the policy off — a disabled policy is not evaluated |

Expand Down
2 changes: 1 addition & 1 deletion content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ Return filtered result

## 2. Row-Level Security

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.
Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a **CEL** `using` clause (the rows a SELECT reads and an UPDATE or DELETE may target) and/or a `check` clause (judged on every row an INSERT or UPDATE writes, an array insert and a `multi: true` update included; when no applicable policy declares `check`, each `using` stands in) — 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.

Policies can be attached to a permission set via its `rowLevelSecurity` array, or registered as standalone metadata.

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 @@ -167,8 +167,8 @@ const result = AdminScopeSchema.parse(data);
| **description** | `string` | optional | Policy description and business justification |
| **object** | `string` | ✅ | Target object name |
| **operation** | `Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>` | ✅ | Database operation this policy applies to |
| **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. |
| **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. Refused on a policy whose `operation` is `select` or `delete`, which write no new row to check: limit the rows such a policy admits with `using`, and declare the `check` on an `insert`, `update` or `all` policy. |
| **using** | `string` | optional | Row predicate, authored in canonical CEL (ADR-0058 D1): the rows a `select` policy lets a caller read, and the existing rows an `update` or `delete` policy lets a caller change or remove. On an insert or an update, when no applicable policy for that operation declares `check`, each applicable policy's `using` also stands in as its check on every row written (see `check`); on an `insert` policy that is its only effect. 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. Needed on a `select` or `delete` policy (a `check` there is refused); optional on an `insert`, `update` or `all` policy that declares `check`. |
| **check** | `string` | optional | Validation condition judged on every row an insert or an update writes (enforced at application level): each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent. 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. Refused on a policy whose `operation` is `select` or `delete`, which write no new row to check: limit the rows such a policy admits with `using`, and declare the `check` on an `insert`, `update` or `all` policy. |
| **positions** | `string[]` | optional | Positions this policy applies to (omit for all) |
| **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active |
| **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. |
Expand Down
Loading
Loading