diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx index 62698d3d32a..9d1cc8b6d7a 100644 --- a/content/docs/permissions/permissions-matrix.mdx +++ b/content/docs/permissions/permissions-matrix.mdx @@ -47,6 +47,22 @@ owner-scoped write policies keep `org_member` holders owner-scoped on Layer 0 tenant wall always ANDs on top. See [Sharing & OWD](/docs/permissions/sharing-rules#how-viewallrecords--modifyallrecords-interact-with-the-owd) and `plugin-security/src/security-plugin.ts` (`computeLayeredRlsFilter`). + +The bypass is **also gated by the deployment's tenancy posture** (ADR-0105 +D4), because a principal is short-circuited only if some set it holds carries +the bits at all. Under the default wall-less `single` posture there is no +organization wall to bound them, so the membership-driven auto-grant +(`auto-org-admin-grant`) gives org owners and admins +`organization_admin_no_bypass` — `organization_admin` without +`viewAllRecords` / `modifyAllRecords` — instead of `organization_admin`. +On a `single` deployment an org admin is therefore **not** short-circuited, +not even on a posture-permitting object (a better-auth-managed one included): +the object's RLS policies apply to them as to any other holder. The walled +postures (`group` / `isolated`) grant the full `organization_admin` unless the +deployment suppresses it. A deployment-wide data superuser needs +`admin_full_access` or an explicit set carrying the bits. See +`plugin-security/src/objects/default-permission-sets.ts` +(`deriveWallLessOrgAdmin`). diff --git a/content/docs/permissions/rls.mdx b/content/docs/permissions/rls.mdx index 0bad5c55730..e52c8b911f0 100644 --- a/content/docs/permissions/rls.mdx +++ b/content/docs/permissions/rls.mdx @@ -77,11 +77,37 @@ to a caller on an object, the `update` / `delete` scope is derived from that caller's `select` policies — a record they cannot read is one they cannot modify, by id or in bulk. Authoring a write-class policy switches the derivation off for that class: an authored `update` predicate then decides -`update` alone and widens exactly as written. Nothing is derived for `insert` +the `update` *filter* alone, widening past the platform's owner-scoped write +floor exactly as written. Nothing is derived for `insert` (there is no pre-existing row to be visible), and a caller holding the read-side superuser bypass (`viewAllRecords` on a private or platform-global object) is not narrowed, because their readable set is already unbounded. +**A write widener never reaches a row the caller cannot read.** "You may not +modify what you cannot see" is a floor, not a default the write filter +overrides: before a single-record `update` / `delete` runs, the target row is +re-read under the caller's **own read scope** together with the write filter, +and a row that read cannot see is refused with `403 PERMISSION_DENIED`. On a +`public_read` object that read scope is every row (minus any `select` +narrowing you authored), so an `update` widener works as written. On a +**`private`** object — the default OWD — the read scope is the caller's own +records, their access depth and the records shared with them, so an `update` +widener reaches **only rows the caller can already read**; everything else it +admits by predicate is still refused. + + +**Known limitation — an app-authored write widener does nothing on a +`private` object without a matching read grant.** To let a position edit +*other people's* records on a `private` object, grant read reach to the same +rows as well: `viewAllRecords` (or a wider `readScope` depth) on the +object in the same permission set, or a +[sharing rule or record share](/docs/permissions/sharing-rules) that shares +those rows with the position. An additional `select` policy is **not** a read +grant — RLS only narrows, so it cannot make a `private` row visible. The +paired spelling is shown in the +[supervisor recipe](/docs/permissions/sharing-rules#how-viewallrecords--modifyallrecords-interact-with-the-owd). + + ## The expression grammar RLS predicates are **canonical CEL**, lowered into a query filter by the shared diff --git a/content/docs/permissions/sharing-rules.mdx b/content/docs/permissions/sharing-rules.mdx index a46a1d4adc9..b4efa1f89ac 100644 --- a/content/docs/permissions/sharing-rules.mdx +++ b/content/docs/permissions/sharing-rules.mdx @@ -83,6 +83,44 @@ supervisors widen to the org) — or model the object with `access: { default: 'private' }` + explicit grants, where `modifyAllRecords` bypasses RLS by design. +**On a `private` OWD object this recipe needs a matching read grant.** A +caller may not write a row they cannot read: a single-record `update` first +re-reads its target under the caller's **own read scope**, and on a `private` +object that scope is the supervisor's own records, their depth and their +shares — so the `update` policy above would admit a colleague's record by +predicate and the write would still be refused (`403 PERMISSION_DENIED`). +Pair the widener with read reach over the same rows, in the same permission +set: + +```typescript +objects: { + // `viewAllRecords` (or a `readScope` depth) makes the rows readable; + // the RLS policy below decides which of them the supervisor may edit. + project: { allowRead: true, allowEdit: true, viewAllRecords: true }, +}, +rowLevelSecurity: [ + { + name: 'supervisor_edit_org', + object: 'project', + operation: 'update', + using: 'organization_id == current_user.organization_id', + positions: ['supervisor'], + }, +], +``` + +A criteria sharing rule that shares those records with the supervisor +position, or a record share to the supervisor, works as the read half too +(`accessLevel: 'read'` is enough — the policy supplies the edit). An extra `select` policy does **not**: RLS +only narrows, so it cannot make a `private` row visible. On `public_read` and +`public_read_write` objects every row is already readable (unless an authored +`select` policy hides it), and the recipe works as written. + +> **Known limitation.** App-authored write wideners (`update` / `delete` +> `rowLevelSecurity` policies) do not function on a `private` OWD object without +> a matching read grant — the rows they admit stay unwritable until the caller +> can also read them. See [RLS — write targets](/docs/permissions/rls#anatomy-of-a-policy). + ## The external dial — `externalSharingModel` (ADR-0090 D11) Portal/partner scenarios get a second, independent dial with the same enum: