Skip to content

docs(permissions): a write widener reaches only rows the caller can read - #19880

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-7401-read-to-write-docs
Sep 23, 2026
Merged

huangyiirene merged 1 commit into
mainfrom
claude/issue-7401-read-to-write-docs

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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

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

The by-id write pre-image gate re-reads the target under the caller's own
read scope, and that is the ruled, intended behaviour ("you may not modify
what you cannot see"). The RLS and sharing pages said an authored update
policy "widens exactly as written", which on a private-OWD object sends
authors straight into a 403.

- rls.mdx: state the read floor for write targets, and add the known
  limitation that app-authored wideners do nothing on a private object
  without a matching read grant.
- sharing-rules.mdx: the supervisor recipe now says it needs a read grant on
  private objects and shows the paired spelling (viewAllRecords / readScope
  depth, or a read-level sharing rule); a select policy cannot supply it.
- permissions-matrix.mdx: the bypass is also gated by deployment posture;
  under the default single posture the org-admin auto-grant is
  organization_admin_no_bypass (ADR-0105 D4).

Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation labels Sep 23, 2026
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 23, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3fc2c9acc94c9de1e7c5c5b9a0385fb2fc363e0f

An isolated reviewer ran at the served tier. It was given only the card #7401, the rulings 5791225941 and 5791407913, and this PR. It did not see the dispatch order or this seat's conclusions. The domain:services seat adopts its verdict below. Source was read at the PR head, which equals base because the diff is docs-only.

① Derived judgments

15 behavioural claims were checked against source. All 15 are RIGHT. Key evidence:

  • The pre-image re-read runs under the caller's own read scope (security-plugin.ts:2427, :2506-2516, :2542). It is pinned by authored-row-write-scope.dogfood.test.ts:403-433 (403, PERMISSION_DENIED).
  • The private read scope is: own records at __readScope depth, OR sys_record_share, and it is null at 'org' (sharing-service.ts:422-489). public_read returns null (:426).
  • ⭐ The deviation from the ruling's example list is correct. An extra select policy is NOT a read grant on private, because buildReadFilter is ANDed unconditionally on every read (sharing-plugin.ts:1204, :1241-1242). Nothing in the read branch consults RLS; that deferral exists only in the write branch (:1275-1303).
  • In the paired recipe, viewAllRecords makes __readScope = 'org'. The write then goes through canEdit → probeAuthoredRowWrite → checkAuthoredRowWrite → admit (security-plugin.ts:4637-4700). A read-level criteria rule or share also works (sharing-rule-service.ts:233, :620-646).
  • The example code is valid under the strict schemas: permission.zod.ts:366/368/484/833/879, rls.zod.ts:113/392/431.
  • Posture: the default is single (security-plugin.ts:868). In that posture orgAdminSetNameForPosture gives organization_admin_no_bypass (auto-org-admin-grant.ts:81-88), and deriveWallLessOrgAdmin strips exactly viewAllRecords and modifyAllRecords (default-permission-sets.ts:1170-1188). The bypass derives only from held sets (computeLayeredRlsFilter:6021-6025, :6070).
  • Anchors: the repo slugger sweep reports checked: 360, broken: [], unresolved: [].

Advisory only, not blocking:

  • 「in the same permission set」 is stricter than the runtime, because read depth merges widest-wins across sets.
  • public_read 「every row」 leaves out the Layer 0 wall under group/isolated. The page states the wall elsewhere.
  • The re-read is skipped when there is no write filter. The widener case always has one.

② Semver level

None. The diff is 3 files under content/docs/permissions/, with zero package source and zero packages/spec. skip-changeset and Clause-②: no are consistent with AGENTS.md:1081-1084.

③ Boundary flags

Inside the ruling. Exactly the three named pages are touched, and content/docs/releases/** is untouched. The known-limitations note appears only in rls.mdx and sharing-rules.mdx. #6736 and #7497 are untouched. The one deviation (the select example) is required by source and is flagged in the PR body for the maintainer's confirmation.

Implemented-by: claude/issue-7401-read-to-write-docs
Reviewed-by: session_01AhQASwqJr2Z7XfGWUdvnbF

VERDICT: PASS


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 23, 2026 14:55
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit dabf8d7 Sep 23, 2026
38 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7401-read-to-write-docs branch September 23, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants