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
16 changes: 16 additions & 0 deletions content/docs/permissions/permissions-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
</Callout>

<Callout type="warn">
Expand Down
28 changes: 27 additions & 1 deletion content/docs/permissions/rls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="warn">
**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).
</Callout>

## The expression grammar

RLS predicates are **canonical CEL**, lowered into a query filter by the shared
Expand Down
38 changes: 38 additions & 0 deletions content/docs/permissions/sharing-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading