Skip to content

Commit dabf8d7

Browse files
docs(permissions): a write widener reaches only rows the caller can read (#19880)
Closes #7401 Clause-②: no Docs-only. Zero code changes, zero `packages/spec` edits, nothing under `content/docs/releases/**`. ## What changed The maintainer ruled reading 1 on this card: a caller may not write a row they cannot read, even when an app-authored write policy admits it by predicate. The `plugin-security` by-id write pre-image gate keeps re-reading the target under the caller's own read scope. The docs said the opposite ("widens exactly as written"), so authors following them hit a 403 on `private` objects. This PR brings the four named spots onto the ruled behaviour. 1. `content/docs/permissions/rls.mdx`: the "widens exactly as written" sentence now says it decides the update *filter* alone. A new paragraph states the read floor: a single-record `update` / `delete` re-reads its target under the caller's own read scope. On `private` OWD, an `update` widener therefore reaches only rows the caller can already read. A `warn` callout carries the known limitation and names the read grants that actually work. 2. `content/docs/permissions/sharing-rules.mdx`: the recipe "owners edit their own records, supervisors edit all" now says it needs a matching read grant on `private` objects. It shows the paired spelling (object permission with `viewAllRecords` plus the `update` RLS policy in the same set), and it names a read-level sharing rule or record share as the alternative read half. 3. The owed known-limitations note ("app-authored wideners do not function on `private` OWD without a matching read grant") is written into both pages above. It is not in release notes. 4. `content/docs/permissions/permissions-matrix.mdx`: the bypass-posture paragraph now covers deployment posture too. Under the default wall-less `single` posture, `auto-org-admin-grant` gives org owners and admins `organization_admin_no_bypass` (ADR-0105 D4), not `organization_admin`. So on `single` an org admin is not short-circuited, even on a better-auth-managed object. ## Verified against source (at base `b940f32a56`) - The pre-image gate is in `packages/plugins/plugin-security/src/security-plugin.ts`, step 2.7. It calls `this.ql.findOne(object, { where: { $and: [{ id }, ...writeParts] }, context: opCtx.context })` under the caller's context, and a null result throws `PermissionDeniedError`. The dogfood pin `packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts` case `[E2E private]` asserts that 403. - The read scope on `private` comes from `packages/plugins/plugin-sharing/src/sharing-service.ts` `buildReadFilter`. It is owner-match at the caller's `__readScope` depth, OR record shares whose recipient is that user. It returns null when the read depth is `org`, and `permission-evaluator.ts` `getEffectiveScope('read')` answers `org` for `viewAllRecords` / `modifyAllRecords`. Sharing rules expand a position recipient to users (`sharing-rule-service.ts` `expandRecipient`). - Item 4 comes from `packages/plugins/plugin-security/src/objects/default-permission-sets.ts` `deriveWallLessOrgAdmin`, which strips both bits from the wildcard, and from `auto-org-admin-grant.ts` `orgAdminSetNameForPosture`, which returns the no-bypass set unless the posture enforces a wall. The posture defaults to `single` when no org-scoping service is registered (`security-plugin.ts`, the `tenancyPosture` field). ## One deviation from the ruling's wording (please confirm) The ruling lists three read grants as its examples: a `select` policy, `viewAllRecords`, or sharing. On a `private` object a `select` policy is **not** a read grant. The sharing read filter and the RLS read filter both AND into the query (`sharing-plugin.ts` read branch, `composeAnd`), and nothing defers the sharing read filter to an authored `select` policy. RLS only narrows, as `rls.mdx` already says in "RLS narrows what the earlier layers already allowed; it never widens". Documenting `select` as the fix would send authors into the same 403. So the pages name `viewAllRecords` or a `readScope` depth, or a read-level sharing rule or record share, and they say explicitly that an extra `select` policy does not work. The ruling's substance is unchanged: widen read to match. ## Acceptance notes - Out of scope, per the ruling: #6736 (the batch-write half) remains open and on hold. #7497 is not addressed here. The new text describes single-record writes only and makes no claim about bulk writes. - Noted, not fixed (class a, a doc error reachable today): the same `permissions-matrix.mdx` callout still says "The built-in `admin_full_access` / `organization_admin` sets therefore carry `allowExport: true`". `default-permission-sets.ts` says neither set grants export any more (the `allowExport` comments on the admin wildcard and the org-admin set). It is left for a separate card so this PR stays within the ruled four items. ## Tests Local gates were derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at head `3fc2c9acc9`. There were 44 commands, all run, and all exit 0. `check:docs-transcript-drift` and `check:skill-examples` first refused with exit 3 (PREREQUISITE NOT MET) and were re-run green after building `@objectstack/lint...` and `@objectstack/client-react...` under the verify lock. `--ran` reconciliation: 44 derived, 44 run, 0 unrun. The page-relevant gates are `check:doc-anchors`, `check:doc-authoring`, `check:docs`, `check:doc-security-posture`, `check:docs-single-h1`, `check:nul-bytes` and `check:doc-frontmatter`, all green. Build Docs and the typecheck lanes are left to CI. No changeset. `content/docs/**` ships only in the private `apps/docs` package, so this PR carries `skip-changeset`. --- _Generated by [Claude Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3bd221d commit dabf8d7

3 files changed

Lines changed: 81 additions & 1 deletion

File tree

‎content/docs/permissions/permissions-matrix.mdx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,22 @@ owner-scoped write policies keep `org_member` holders owner-scoped on
4747
Layer 0 tenant wall always ANDs on top. See
4848
[Sharing & OWD](/docs/permissions/sharing-rules#how-viewallrecords--modifyallrecords-interact-with-the-owd)
4949
and `plugin-security/src/security-plugin.ts` (`computeLayeredRlsFilter`).
50+
51+
The bypass is **also gated by the deployment's tenancy posture** (ADR-0105
52+
D4), because a principal is short-circuited only if some set it holds carries
53+
the bits at all. Under the default wall-less `single` posture there is no
54+
organization wall to bound them, so the membership-driven auto-grant
55+
(`auto-org-admin-grant`) gives org owners and admins
56+
`organization_admin_no_bypass` — `organization_admin` without
57+
`viewAllRecords` / `modifyAllRecords` — instead of `organization_admin`.
58+
On a `single` deployment an org admin is therefore **not** short-circuited,
59+
not even on a posture-permitting object (a better-auth-managed one included):
60+
the object's RLS policies apply to them as to any other holder. The walled
61+
postures (`group` / `isolated`) grant the full `organization_admin` unless the
62+
deployment suppresses it. A deployment-wide data superuser needs
63+
`admin_full_access` or an explicit set carrying the bits. See
64+
`plugin-security/src/objects/default-permission-sets.ts`
65+
(`deriveWallLessOrgAdmin`).
5066
</Callout>
5167

5268
<Callout type="warn">

‎content/docs/permissions/rls.mdx‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,37 @@ to a caller on an object, the `update` / `delete` scope is derived from that
7777
caller's `select` policies — a record they cannot read is one they cannot
7878
modify, by id or in bulk. Authoring a write-class policy switches the
7979
derivation off for that class: an authored `update` predicate then decides
80-
`update` alone and widens exactly as written. Nothing is derived for `insert`
80+
the `update` *filter* alone, widening past the platform's owner-scoped write
81+
floor exactly as written. Nothing is derived for `insert`
8182
(there is no pre-existing row to be visible), and a caller holding the
8283
read-side superuser bypass (`viewAllRecords` on a private or platform-global
8384
object) is not narrowed, because their readable set is already unbounded.
8485

86+
**A write widener never reaches a row the caller cannot read.** "You may not
87+
modify what you cannot see" is a floor, not a default the write filter
88+
overrides: before a single-record `update` / `delete` runs, the target row is
89+
re-read under the caller's **own read scope** together with the write filter,
90+
and a row that read cannot see is refused with `403 PERMISSION_DENIED`. On a
91+
`public_read` object that read scope is every row (minus any `select`
92+
narrowing you authored), so an `update` widener works as written. On a
93+
**`private`** object — the default OWD — the read scope is the caller's own
94+
records, their access depth and the records shared with them, so an `update`
95+
widener reaches **only rows the caller can already read**; everything else it
96+
admits by predicate is still refused.
97+
98+
<Callout type="warn">
99+
**Known limitation — an app-authored write widener does nothing on a
100+
`private` object without a matching read grant.** To let a position edit
101+
*other people's* records on a `private` object, grant read reach to the same
102+
rows as well: `viewAllRecords` (or a wider `readScope` depth) on the
103+
object in the same permission set, or a
104+
[sharing rule or record share](/docs/permissions/sharing-rules) that shares
105+
those rows with the position. An additional `select` policy is **not** a read
106+
grant — RLS only narrows, so it cannot make a `private` row visible. The
107+
paired spelling is shown in the
108+
[supervisor recipe](/docs/permissions/sharing-rules#how-viewallrecords--modifyallrecords-interact-with-the-owd).
109+
</Callout>
110+
85111
## The expression grammar
86112

87113
RLS predicates are **canonical CEL**, lowered into a query filter by the shared

‎content/docs/permissions/sharing-rules.mdx‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,44 @@ supervisors widen to the org) — or model the object with `access: {
8383
default: 'private' }` + explicit grants, where `modifyAllRecords` bypasses
8484
RLS by design.
8585

86+
**On a `private` OWD object this recipe needs a matching read grant.** A
87+
caller may not write a row they cannot read: a single-record `update` first
88+
re-reads its target under the caller's **own read scope**, and on a `private`
89+
object that scope is the supervisor's own records, their depth and their
90+
shares — so the `update` policy above would admit a colleague's record by
91+
predicate and the write would still be refused (`403 PERMISSION_DENIED`).
92+
Pair the widener with read reach over the same rows, in the same permission
93+
set:
94+
95+
```typescript
96+
objects: {
97+
// `viewAllRecords` (or a `readScope` depth) makes the rows readable;
98+
// the RLS policy below decides which of them the supervisor may edit.
99+
project: { allowRead: true, allowEdit: true, viewAllRecords: true },
100+
},
101+
rowLevelSecurity: [
102+
{
103+
name: 'supervisor_edit_org',
104+
object: 'project',
105+
operation: 'update',
106+
using: 'organization_id == current_user.organization_id',
107+
positions: ['supervisor'],
108+
},
109+
],
110+
```
111+
112+
A criteria sharing rule that shares those records with the supervisor
113+
position, or a record share to the supervisor, works as the read half too
114+
(`accessLevel: 'read'` is enough — the policy supplies the edit). An extra `select` policy does **not**: RLS
115+
only narrows, so it cannot make a `private` row visible. On `public_read` and
116+
`public_read_write` objects every row is already readable (unless an authored
117+
`select` policy hides it), and the recipe works as written.
118+
119+
> **Known limitation.** App-authored write wideners (`update` / `delete`
120+
> `rowLevelSecurity` policies) do not function on a `private` OWD object without
121+
> a matching read grant — the rows they admit stay unwritable until the caller
122+
> can also read them. See [RLS — write targets](/docs/permissions/rls#anatomy-of-a-policy).
123+
86124
## The external dial — `externalSharingModel` (ADR-0090 D11)
87125

88126
Portal/partner scenarios get a second, independent dial with the same enum:

0 commit comments

Comments
 (0)