From 71e0d3497500f55fec0c74dbaadaf41172bf6515 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 12:25:04 +0000 Subject: [PATCH] docs(permissions): say that an empty RLS policy set is unfiltered, not deny-all (#7811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layer-5 row of the evaluation-order table read "If **no** applicable policy compiles, the result is a deny-all sentinel (fail-closed)", which a reader can take as "an object with no RLS policies is invisible". The implementation's contract is narrower: `RLSCompiler.compileFilter` returns `null` (no filter) when no policy is applicable to the clause, and only returns the `RLS_DENY_FILTER` sentinel when applicable policies exist and every one of them fails to compile. The imprecision is not academic — it misled a reviewer into treating the sentence as evidence that a write path was already fail-closed. - `authorization.mdx`: state the narrow condition and name the empty case explicitly, so the wrong reading is closed rather than merely unstated. - `rls.mdx`: the fail-closed contract section and its callout were already exact; the summary bullet at the top of the page was not, and it is the first statement of the rule a reader meets. Docs-only; no behaviour change. --- content/docs/permissions/authorization.mdx | 2 +- content/docs/permissions/rls.mdx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 008c920309..8b4e49c301 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -52,7 +52,7 @@ site — the file you read when behavior surprises you. | 2 | **Public-form grant** | An anonymous form submission carries a declaration-derived `publicFormGrant` authorizing ONLY create + read-back on the form's declared target object — never anything else (ADR-0056 Option A). No guest-portal configuration needed (anonymous principals hold the `guest` position). | `packages/plugins/plugin-security/src/security-plugin.ts` (ObjectQL middleware) | scope-limited allow | | 3 | **Object CRUD** | `allowRead/Create/Edit/Delete` (+ the destructive lifecycle class `allowTransfer/Restore/Purge`, gated ahead of the M2 operations — #1883) resolved across the caller's permission sets. | `packages/plugins/plugin-security/src/permission-evaluator.ts` `checkObjectPermission` | fail-closed 403 | | 4 | **OWD / sharing** | Org-wide default (`private` / `public_read` / `public_read_write` / `controlled_by_parent`; **unset or unknown ⇒ `private`, fail-closed** — ADR-0090 D1) plus the external dial (`externalSharingModel`, ADR-0090 D11), manual record shares, criteria sharing rules (owner-type rules were removed from the authoring surface in v17 rather than left declared-but-skipped — [Sharing Rules](/docs/permissions/sharing-rules#recipient-types)), business-unit hierarchy widening (ADR-0057 D5: scope-depth hierarchy lives on `sys_business_unit`, not positions). | `packages/plugins/plugin-sharing/src/sharing-service.ts` + `sharing-rule-service.ts` | fail-closed to owner-only | -| 5 | **Row-level security** | CEL predicates (`using` read filter, `check` write post-image) compiled into the query. If **no** applicable policy compiles, the result is a deny-all sentinel (fail-closed); an uncompilable policy alongside compilable ones is excluded from the OR-union **with a logged warning** — exclusion can only narrow access, never widen it. Tenant isolation is a wildcard RLS rule AND-ed on top. | `packages/plugins/plugin-security/src/rls-compiler.ts` + `security-plugin.ts` | fail-closed | +| 5 | **Row-level security** | CEL predicates (`using` read filter, `check` write post-image) compiled into the query. If applicable policies exist but **none of them compiles**, the result is a deny-all sentinel (fail-closed); an object with **no** applicable policy is simply unfiltered at this layer — RLS narrows what the earlier gates already allowed, it never denies on its own. An uncompilable policy alongside compilable ones is excluded from the OR-union **with a logged warning** — exclusion can only narrow access, never widen it. Tenant isolation is a wildcard RLS rule AND-ed on top. | `packages/plugins/plugin-security/src/rls-compiler.ts` + `security-plugin.ts` | fail-closed | | 6 | **Field-level security** | Read mask (strip non-readable fields) + write deny per `fields` rules. Caller queries that **filter, sort, group, or aggregate by** a non-readable field are rejected outright (HTTP 403, `field_predicate_denied`) — masking only the output would leave row presence as a value oracle. RLS-injected predicates are exempt (they run after the guard and may reference hidden fields like `owner_id`). | `packages/plugins/plugin-security/src/field-masker.ts` + `predicate-guard.ts` | fail-closed on predicates; see posture note below | Two orthogonal identity-layer gates run before all of this: the ADR-0069 diff --git a/content/docs/permissions/rls.mdx b/content/docs/permissions/rls.mdx index 0dfdc09999..37cf4dc1d0 100644 --- a/content/docs/permissions/rls.mdx +++ b/content/docs/permissions/rls.mdx @@ -15,8 +15,10 @@ Two properties define how it behaves: - **It's a filter, not a row hook.** Policies compile to a data filter that is pushed down into the query, so a restricted user's list is *narrower* — they don't get denied, they get fewer rows. -- **It's fail-closed.** A policy that can't compile denies everything rather - than admitting anything. See [the contract](#the-fail-closed-contract). +- **It's fail-closed — where it applies.** If policies apply and *every* one of + them fails to compile, the query denies everything rather than admitting + anything; an object with no applicable policy is left unfiltered, not hidden. + See [the contract](#the-fail-closed-contract). RLS is the expert escape hatch. Reach for it when