Skip to content

Commit 3f86dc5

Browse files
fix(spec): state the RLS using / check texts as the write gate enforces them (#20268)
Fixes #19967 Clause-②: no Text only. No schema shape, accepted value or runtime behaviour moves. Every sentence below was re-measured against the code on `origin/main` at `0d3ec47137`, where PR #19962 (`b5853da1ca`), PR #19988 (`009da14713`), PR #20012 (`44639665ee`, #19989) and PR #20167 (`b276d4463f`) are all merged. Each `git merge-base --is-ancestor` probe returned exit 0. ## What `main` enforces (the measurement the texts now state) - **Write `check` selection, per operation.** `writeCheckPolicies` (`packages/plugins/plugin-security/src/security-plugin.ts:890-899`) returns the policies that declare `check` when any applicable policy does. Otherwise it returns every applicable policy with a `using`, and keeps the platform ownership floor only where the write's row gate kept it. `compileFilter` (`rls-compiler.ts:600-602`) reads `check` for a policy that declares one and `using` for the rest, then OR-combines (`:676`). - **A USING-only `insert` policy's `using` is the insert check** when no applicable policy declares `check`. Pinned by `rls-check-defaults-to-using.test.ts`, case "an insert-class USING-only policy gates INSERT the same way", which is green on this head. - **A check-only `update` policy is legal and enforced.** The schema accepts it (`rls.zod.ts`, the at-least-one rule). The row gate derives the write scope from `select` when no write-class `using` applies (`security-plugin.ts:7018`), and the post-image check judges the written rows. Pinned by `check-only-write-scope.test.ts`, which is green. The showcase's `invoice_owner_immutable` is one such policy. - **A non-blank `check` on `select` / `delete` is refused** (`rls.zod.ts`, the `superRefine` at the `check` path, from PR #20167). - **OR-combination.** On reads, and on the rows an update or delete may target, the applicable policies' `using` clauses OR-combine (`compileFilter`). On the written rows, the check is chosen first and then OR-combined. So "most permissive wins" is true on reads and false as a statement about writes. - **Post-image scope: every insert and update shape.** The seam is installed for every insert and for every update (`security-plugin.ts:3191`, `:3261`, `:3508`). The engine runs it: - on each live row of an insert, after `beforeInsert` (`packages/objectql/src/engine.ts:12470`); - on the by-id row merged with the final payload, after `beforeUpdate` (`:14085`, PR #20012); - on each matched row of a `multi: true` update (`:14345`, PR #19988). The by-id update is also judged in the middleware on the change set as sent (`security-plugin.ts:3116`). Pinned by `rls-check-multi-row-writes.test.ts`, `rls-check-by-id-update-post-hook.test.ts` and `insert-check-post-image.test.ts`, all green on this head. ## Sites, old text, new text, and the code that makes the new text true | Site | Old text | New text | True because | |---|---|---|---| | `rls.zod.ts` `check` describe | "matched against the new row of a single-record INSERT or a by-id UPDATE ...; an array insert and a `multi: true` update are not post-image checked." | "judged on every row an insert or an update writes ...: 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." | `engine.ts:12470`, `:14085`, `:14345`; `security-plugin.ts:3116` | | `rls.zod.ts` `check` TSDoc | "Validation of the new row of a single-record INSERT or a by-id UPDATE. An array insert and a `multi: true` update are not post-image checked." | Every row an insert or update writes, with the per-shape image. It also names the engine-filled values that are not on an insert's judged image (autonumber, a `secret` field's stored reference, an absent tenant column). | the same lines; the engine's closed list after the insert seam (`engine.ts`, the comment above `:12470`) | | `rls.zod.ts` `check` TSDoc, floor | "takes part only where the by-id pre-image gate kept it" | "only where the write's own row gate kept it" | `computeWriteCheckFilter`: a `multi: true` update has no by-id gate and keeps the floor unless the OWD yields (`platformFloorYieldsToObjectWriteModel`) | | `rls.zod.ts` `using` describe | "Filter condition for SELECT/UPDATE/DELETE ... Optional for INSERT-only policies." | "Row predicate ...: 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 ...; on an `insert` policy that is its only effect. ... Needed on a `select` or `delete` policy (a `check` there is refused); optional on an `insert`, `update` or `all` policy that declares `check`." | `writeCheckPolicies:896`; `getApplicablePolicies:846`; the `check` refusal | | `rls.zod.ts` `using` TSDoc | "For INSERT-only policies, USING is not required (only CHECK is needed). For SELECT/UPDATE/DELETE operations, USING is required." | A per-operation list. `update` does not require `using`: a check-only `update` policy is accepted and enforced, and its target rows come from the other `update` / `all` `using` or from `select`. An `insert` policy's `using` filters nothing, but it is the insert check when none is declared. | `security-plugin.ts:7018`; `check-only-write-scope.test.ts` | | `rls.zod.ts` `superRefine` message | "... For SELECT/UPDATE/DELETE operations, provide "using". For INSERT operations, provide "check"." | The same head. Then: `select` / `delete` take "using"; `insert` takes "check", or a "using" alone as that check when no applicable insert policy declares one; `update` / `all` take either or both. | the schema accepts each prescription (pinned below) | | `rls.zod.ts` schema TSDoc | "combined with OR logic (union of results)" | OR on reads; on writes, the per-operation choice (see `check`) | `compileFilter`; `writeCheckPolicies` | | `rls.zod.ts` `priority` TSDoc | "Applicable policies OR-combine (... the doc above `RLSCompiler.compileFilter` and this schema's own former describe both say most-permissive-wins)" | No outcome depends on an order: OR on reads; on writes, the check is chosen per operation and then OR-combined | the same | | `rls.zod.ts` overview, "Default Deny" | "If no policy matches, access is denied" | Default deny among the policies that apply. When no policy applies, the policies restrict nothing (the tenant wall still applies). | `compileFilter:656` (`applicable === 0` returns `null`, no filter); `content/docs/permissions/rls.mdx` callout | | `migrations/registry.ts` `--from 16` prose (and regenerated `docs/protocol-upgrade-guide.md`) | "applicable policies OR-combine (most permissive wins)" | "no outcome depends on an order: applicable policies OR-combine on reads, and a write's check is chosen once per operation across the applicable policies, then OR-combined" | the same | | `liveness/permission.json` `check` / `using` evidence | `compileFilter` "`(policy as { check?: string }).check ?? policy.using`" (the expression is gone) | `security-plugin.ts#writeCheckPolicies` plus `rls-compiler.ts#compileFilter`, with the live expression | `check:liveness` resolves both anchors (green) | | `security-plugin.ts` `writeCheckPolicies` docblock | "The published contract is `RowLevelSecurityPolicySchema.check`: "defaults to USING clause if not specified"." | It points at `RowLevelSecurityPolicySchema.check` without quoting it, so it cannot go stale. PostgreSQL's per-policy rule is named as the starting point. | the describe itself | | `rls-check-defaults-to-using.test.ts` header | "A policy that declares no `check` holds the write post-image to its `using`, which is what `RowLevelSecurityPolicySchema.check` publishes: "defaults to USING clause if not specified"." | When no applicable policy declares `check`, each `using` stands in. The choice is per operation, not per policy. | `writeCheckPolicies` | | `content/docs/permissions/authorization.mdx:108-109` | "Multiple row policies for the same object/operation OR-combine" | They OR-combine their `using` on reads and on the rows a write may target. The written-row `check` is chosen per operation first. Links the fail-closed contract. | the same | ### In-place fixes beyond the claim's file surface (same defect class: the same stale sentences) The dispatch asked for a grep for other copies of the old sentences. These hits are not governed and not pending changesets, so they are fixed here. **File-surface supplement for the claim:** `content/docs/permissions/rls.mdx`, `content/docs/protocol/objectql/security.mdx` and `packages/spec/src/conversions/registry.ts` (TSDoc only). The two changesets below are also outside it. - `content/docs/permissions/rls.mdx:63`: "after a single-record insert or a by-id update; an array insert and a `multi: true` update are not checked" now says every row an insert or update writes. - `content/docs/protocol/objectql/security.mdx:144`: the same parenthetical. `using` is no longer "for SELECT/UPDATE/DELETE" only. - `packages/spec/src/conversions/registry.ts`: the `permission-rls-priority-removed` TSDoc said "most permissive wins". Its `summary` (which feeds `spec-changes.json`) is unchanged and not false. - `packages/spec/src/security/rls.test.ts`: the `priority` test comment said "(most permissive wins)". ## Pin sweep for the `superRefine` message ① The repo-wide grep for `At least one of` and `provide "using"` finds pins only in `packages/spec/src/security/rls.test.ts`: `toContain('At least one of')` and its negation. Both still hold, because the head is unchanged. No other package, doc or translation carries the message. ② New load-bearing pins, in the `RowLevelSecurityPolicySchema — the "at least one" refusal` block of `rls.test.ts`. For each of the five operations they assert the refusal's `code` (`custom`), `path` (`[]`) and head, that every operation is named, and that the false sentence is absent. They then prove each prescription against the schema itself: `select` / `delete` + `using`, `insert` + `check`, `insert` + `using` alone, and `update` / `all` with check only, using only, and both. All parse. ## Pending release notes corrected (DELIBERATE CORRECTION: `check-empty-changeset` is red by design) - `.changeset/19953-rls-check-default-composition-text.md` (from PR #19962). It said: "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". That would ship false. #19988 and #20012 land in the same release, and this PR changes the texts it says "now say so". The rewrite dates the old scope to when that change was written, and states the release's scope. - `.changeset/rls-check-defaults-to-using.md` (from PR #19952). It said: "`RowLevelSecurityPolicySchema.check` reads "defaults to USING clause if not specified"". The describe no longer reads that in the release this note ships in (PR #19962). Changed to "read ... (it now states the default per operation across the applicable policies, #19953)". The triage on #19967 raised this note for the dev to judge. The gate's remedy is to say so here and get it confirmed. Restoring either note from base would put the false sentence back. ## Reported only, not edited - **Governed (Tier H):** - `docs/adr/0066-unified-authorization-model.md:93`: "Multiple row policies for the same object/operation are OR-combined". This is the ADR sentence that `authorization.mdx` paraphrases, and it has the same false-for-writes reading. - `skills/objectstack-data/rules/security.md:72-75`: calls `using` the "read filter" and `check` the "write filter", and does not state the stand-in check. - `docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md:79-81`: about the read filter, and not false. - **Release-owned (never edited in a code PR):** `packages/spec/CHANGELOG.md:46934`, `:73373` and `packages/plugins/plugin-security/CHANGELOG.md:6610`, `:10225`. These say "(the schema's own describe says most-permissive-wins)". That was true of the describe when they shipped. - `packages/spec/spec-changes.json:79`, `:971`: the generated `summary` "policies OR-combine". It is shorthand and not false. It is regenerated from the conversion registry, which this PR leaves as is. ## Published surface - **`@objectstack/spec`**: `patch`. It ships `src/**/*.zod.ts`, `dist`, `liveness` and the `--from 16` prose. - **`@objectstack/plugin-security`**: no changeset. The two edits are a comment in a non-exported function and a test header. Measured: tsup strips in-body comments. A positive control in `packages/objectql/dist/index.js` shows the code line `postHookWriteImageCheck.honoured = true` present (1 hit) and the comment above it, "INSERT POST-IMAGE seam", absent (0 hits). `writeCheckPolicies` is not exported, so no `.d.ts` carries its docblock. ## Verification Every reading below was taken on head `e2bd8f3680`, the final commit, with a clean tree. - **Gate union.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived the list. `--ran` reconciles it: "116 derived famil(ies) accounted for — 112 run, 4 NOT-MEASURED". The 116 includes 2 families the derivation added after the regeneration commit (`pnpm --filter @objectstack/spec run check:generated` and `pnpm check:quick-reference-counts`). Both were run: exit 0. - **111 green.** - **1 red by design:** `node scripts/check-empty-changeset.mjs --base origin/main` exits 1 with the DELIBERATE CORRECTION class, for the two notes named above. - **NOT MEASURED, each one a PREREQUISITE NOT MET (exit 3):** - `check:skill-examples` needs a built `@objectstack/client-react` (a 36-package closure); - `check:dual-build-cjs-loads` needs a full `pnpm build`; - `check:i18n` needs the built CLI closure; - `check:type-check-debt` needs a whole-workspace build. - **`@objectstack/spec` build:** exit 0, DTS included. `check:generated`: 2 of 15 stale (`docs/protocol-upgrade-guide.md`, `content/docs/references/**`). Regenerated with exactly `gen:upgrade-guide && gen:docs`, then "All 15 generated artifacts are up to date". - **`@objectstack/spec` tests:** `vitest run --project local --maxWorkers=2`: "Test Files 547 passed (547) · Tests 16086 passed | 2 todo". - **`@objectstack/plugin-security` tests:** `vitest run --maxWorkers=2`: "Test Files 139 passed (139) · Tests 2839 passed (2839)". This includes the semantics pins cited above: `rls-check-defaults-to-using` (22 cases), `check-only-write-scope` (21), `rls-check-multi-row-writes` (32), `rls-check-by-id-update-post-hook` (24) and `insert-check-post-image` (25), all green. - **`@objectstack/plugin-security` typecheck:** exit 0, test layer included ("check:test-typecheck: OK"). - **`@objectstack/spec` typecheck: declared narrowing.** The full `pnpm --filter @objectstack/spec typecheck` never got a turn on the shared verify lock in about 40 minutes of queueing: 8 attempts, each exit 99. Its three legs are covered as follows: - the src program: the build's DTS pass compiles the entry closure, which contains `rls.zod.ts`, `migrations/registry.ts` and `conversions/registry.ts`; - the test program: `check:test-typecheck` is green inside `check:generated` on this head, and `tsconfig.test.json` includes `src/**/*.test.ts`; - `check:scripts-typecheck`: no spec script is touched. CI's `TypeScript Type Check` runs the full command. - **eslint, a proven narrowing:** - ① Population: `eslint.config.mjs` lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus build output, which contains all 6 changed `.ts` files. - ② `pnpm exec eslint --no-inline-config --format json` over those 6 files reports 6 files, 0 errors, 0 warnings, exit 0. - ③ No config object sets `parserOptions.project`, so no rule is type-aware, and this diff cannot move a verdict on an untouched file. - **Control bytes:** `check:nul-bytes` is green. The self-scan `grep -naP` for C0 control characters and DEL over the changed files exits 1 with no match. ## Acceptance notes - **Whitespace-only `check`: the schema and the runtime disagree** (observation, not filed; no public-door measurement, no named producer). The at-least-one rule tests `!data.check`, so `check: ' '` satisfies it. The runtime and the `select` / `delete` refusal read a blank clause as absent (`policyDeclaresClause`). So a policy with only a whitespace `check` parses and is inert. The `using` describe's "needed on a `select` or `delete` policy" is worded as the runtime reads it. - **`@objectstack/lint` `rls-predicate-*` messages understate the scope** (`packages/lint/src/validate-rls-predicate-enforceability.ts:272`, `:318`, `:943`, `:969`). They say "the single-record INSERT check" and "every single-record insert and by-id update ... fails". Since PR #19988 and PR #20012 this is true but narrow: array inserts and `multi: true` updates are refused too. These are not false, and they are outside this card's file surface. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47849e7 commit 3f86dc5

16 files changed

Lines changed: 207 additions & 54 deletions

File tree

‎.changeset/19953-rls-check-default-composition-text.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Text only. No schema shape, accepted value or runtime behaviour changes.
1616

1717
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.
1818

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.
19+
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).
2020

2121
- **`@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.
2222
- **`@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.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
9+
Text only. No schema shape, accepted value or runtime behaviour changes.
10+
11+
- **`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.
12+
- **`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.
13+
- **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.
14+
- **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.
15+
- **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.
16+
- The generated reference pages (`references/security/rls`, `references/security/permission`) and `docs/protocol-upgrade-guide.md` are regenerated from these sources.

‎.changeset/rls-check-defaults-to-using.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Clause-②: no (narrowing)
1010

1111
**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).
1212

13-
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.
13+
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.
1414

1515
**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.
1616

‎content/docs/permissions/authorization.mdx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,14 @@ implementation detail:
107107
Keys never collide across packages because object api names are
108108
package-namespaced.
109109
3. **RLS: OR within an object, AND with tenant-global.** Multiple row policies
110-
for the same object/operation OR-combine; the tenant wall (Layer 0, ADR-0095
111-
D1) is a separate always-first AND conjunct, not an OR-mergeable policy.
110+
for the same object/operation OR-combine their `using` on reads and on the
111+
rows a write may target. The `check` on the rows an insert or update writes
112+
is chosen per operation first: when any applicable policy declares `check`,
113+
only the declared checks decide (OR-combined) and a USING-only policy adds
114+
nothing; otherwise each `using` stands in (OR-combined) — see
115+
[the fail-closed contract](/docs/permissions/rls#the-fail-closed-contract).
116+
The tenant wall (Layer 0, ADR-0095 D1) is a separate always-first AND
117+
conjunct, not an OR-mergeable policy.
112118
`viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated)
113119
short-circuit the object's *business* RLS. Crossing the **tenant wall**,
114120
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1). That posture

‎content/docs/permissions/rls.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export const ContributorAccess = definePermissionSet({
6161
| `object` | `string` | Target object — or `'*'` to apply to every object |
6262
| `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 |
6363
| `using` | `string` | Predicate for rows the user may **see / act on** (compiled into the query filter) |
64-
| `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+
| `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) |
6565
| `positions` | `string[]` | Which positions the policy applies to. Omit = everyone |
6666
| `enabled` | `boolean` | Default `true`. `false` switches the policy off — a disabled policy is not evaluated |
6767

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

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

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,8 @@ const result = AdminScopeSchema.parse(data);
167167
| **description** | `string` | optional | Policy description and business justification |
168168
| **object** | `string` | ✅ | Target object name |
169169
| **operation** | `Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>` | ✅ | Database operation this policy applies to |
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 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. |
170+
| **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`. |
171+
| **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. |
172172
| **positions** | `string[]` | optional | Positions this policy applies to (omit for all) |
173173
| **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active |
174174
| **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

Comments
 (0)