Skip to content

Commit dfb3ba2

Browse files
fix(plugin-security): a controlled_by_parent middle master is judged at the chain hop by its own direct-write composition (#23093) (#23114)
Fixes #23093 Clause-②: no — a runtime authorization behaviour under the maintainer's ruling A; no published accept set or public surface moves. ## What changes `@objectstack/plugin-security`, `assertMasterRowEditable` (the master-edit legs the ADR-0055 chain walk runs on every hop). The write-RLS leg now passes one more option to `computeRlsFilter`: ```ts masterGateCoversThisWrite: !delegatedWrite && this.declaresControlledByParent(rel.master), ``` So when the master on a hop is itself `controlled_by_parent` and the write is not delegated, that hop composes the master's write filter the way the master's own by-id write does (step 2.7, commit 6feac91): the **platform** ownership floor (`owner_only_writes`, positions `['org_member']`) yields to the master gate. Nothing else yields. An app-authored update policy on that master and the #7665 derive-from-select floor still run, and a delegated write keeps both principals' floors. The docblock bullet that read "A master that is itself a detail keeps its floor exactly as it does today" now states the ruling. The cycle guard, the depth bound and the walk itself are untouched. **Why the vouch holds.** The flag is set on the predicate (`declaresControlledByParent(rel.master)`) that makes the walk in `assertControlledByParentWrite` continue to that master's own master. That master's own master gate therefore runs next, for the same principal, on the same pass, unless the walk refuses (cycle, depth bound, broken chain). This is the same structural argument step 2.7 makes for the by-id write. ## The ruling The maintainer chose **A** on the decision analysis (card comment 6133315184). It is recorded twice: - 6133922339, the maintainer's word: 「A 延伸裁决 (推荐)」. The option text the maintainer chose: > 把 2026-08-15 裁决延伸到中间层:能改报价单的人也能改其明细(Salesforce 主从语义)。应用自定义的 update RLS 仍生效,代理写入仍保留两道底线。这是一次权限放宽,由 services 车道改一行加测试;PR #23091 的锁定断言随之翻成 proven。 - 6134050293, the director binding of the maintainer's 「同意」. It also sets the fork clause: for one principal, a line write under a middle master is admitted **iff** that principal's direct by-id update of the middle master is admitted. **No fork was found.** Every case below, at both layers, satisfies that invariant. ## Pins — `controlled-by-parent-chain-middle-master.test.ts` (new) The rig is a real `ObjectQL` with the SQL driver (better-sqlite3, in memory), the real `SecurityPlugin`, the real `SharingService` middleware, and `member_default` carrying the floor. Principals hold `org_member`. A delegator resolves from stored `sys_user` / `sys_member` / `sys_user_position` rows. The chain is `cbc_line → cbc_quote → cbc_account` (`controlled_by_parent`, `controlled_by_parent`, `private`). Every case asserts the quote's own by-id update beside the line writes for the same principal. | pin | case | quote's own update | line update / insert / delete | |:--|:--|:--|:--| | 1 middle editable | a member who did not create the quote | admitted, row changed | admitted; row changed / row added / row gone | | 2 root not editable | account readable, no edit grant | refused | refused `403 PERMISSION_DENIED`, naming master `cbc_account` | | 2 root not readable | account another's, never shared | refused | insert refused `403`, naming `cbc_account`; update and delete refused | | 3 middle not editable | no update grant on the quote | refused | refused `403`, naming `cbc_quote` | | 3 middle not editable (H2) | app-authored update policy `stage == 'open'`, closed quote | refused (row-level security) | refused `403`, naming `cbc_quote` (row-level security) | | 3 (H2, creator) | same policy, the quote created by the caller | refused | refused (this was **admitted** before: see below) | | 3 control | same policy, open quote | admitted | admitted, row changed | | 4 delegated, agent's floor | floor-bound member acting for an unbound delegator | refused | refused `403`, naming `cbc_quote`; the same member acting for itself is admitted | | 4 delegated, delegator's floor | unbound agent acting for a floor-bound member | refused | refused `403`, naming `cbc_quote`; the same agent acting for itself is admitted | Every refusal asserts the ADR-0112 envelope (`code`, `status`), plus the first sentence naming the caller's own object and verb: `[Security] Access denied: VERB on 'cbc_line' requires edit access to its master record`. Every refusal also asserts that no row moved. **Pre-fix baseline** (the same file on `main` 8464380, before the fix): 5 failed / 4 passed. Pin 1's line update was refused `(master 'cbc_quote' not editable by this user (row-level security))`. Both pin-2 refusals sat at the quote hop instead of the root. The creator case of pin 3 was admitted. The non-delegated control of pin 4 was refused. **A narrowing the ruling's composition implies.** Before this change, the hop composed the platform floor and an app-authored update policy as alternatives (row-level policies OR together). So the creator of a middle master could write its details while that master's own update refused them, the two-answer shape in the other direction. Under one composition the line write is refused as the quote's update is. The changeset states it for upgraders. ## Ablations (each from the committed fix c6a48bd, through `scripts/ablation-replace.mjs`, restore proven by blob == HEAD 12a9565 and an empty `git diff HEAD`) The test imports `./security-plugin.js` from source, so no `dist/` is involved. | ablation | mutation | result | |:--|:--|:--| | A0, the fix | `masterGateCoversThisWrite: false` (the old leg) | 5 failed / 4 passed. Pin 1: the line update `expected { kind: 'refused' … } to deeply equal { kind: 'admitted' }`, refused `(master 'cbc_quote' not editable by this user (row-level security))` | | A2, root not editable | the walk stops after the first hop | 2 failed (both pin-2 cases). Root readable: the line write was admitted. Root unreadable: the insert passed the master gate and was refused only later by `VALIDATION_FAILED` | | A3a, middle grant | the hop skips the object-permission leg for a `controlled_by_parent` master | 1 failed (pin 3, no update grant): the line write was admitted | | A3b, middle app RLS (bare widening) | the hop skips the whole write-RLS leg for a `controlled_by_parent` master | 4 failed (pin 3 H2 and its creator case, both pin-4 cases): admitted | | A4, delegation | `!delegatedWrite &&` removed from the flag | 2 failed (both pin-4 cases): the delegated line write was admitted while the delegated quote update was refused | ## Full stack, hotcrm-shaped (scratch, not committed) `@objectstack/verify` `bootStack` was driven through `hooks.run` as signed-up personas (the previous dev's matrix plus an H2 persona). Five personas ran, each with and without `org_member`, over chains another user owns, the persona owns, and the persona created itself, with 3 line verbs each. Measured on this fix over base 8464380: **0 cases where a line answer differs from the same persona's quote update**. Results: - the manager: 15/15 line writes admitted, both variants; - root read-only: every write refused at the root; - middle read-only: refused naming `rp_quote`; - the app-locked manager: 30/30 line writes refused naming `rp_quote` (row-level security), its own door chain included; - the plain member: its own chains admitted; another's chain refused or 404. ## Whole-repo sweep The refusal texts `requires edit access to its master` and `not editable by this user` appear in 11 files. None pins the two-level refusal under the floor; the fixtures that arm `org_member` are single-level. The full `plugin-security` suite is green after the change. PR #23091 has merged, so its pinned expectation is flipped here, expectation only (declared in 6132790096): - `packages/verify/src/parent-write-reach.test.ts`: `pwr_quote_line` is now `proven`. The master-all-records reaches are `ALL_ALLOWED` with every answer `written`, and `detail-only` stays `ALL_REFUSED`. The summary is `proven: 3, contradicted: 0, held: 135, contradictions: 0, hardFailures: 0`. - `packages/cli/test/verify-parent-write-reach.test.ts`: nothing to flip. Its fixture is single-level (`vpw_contract → vpw_account`), so none of its expectations reads the two-level answer. ## Local verification (at c6a48bd, before the merge of `main`; the os-dev-report on the card carries the re-run on the final head) - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: 193 files passed, 3981 tests passed, 45 skipped. `VERDICT command-exit 0`. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. The new file is in the `tsconfig.test.json` program (`--listFiles` lists it once). - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran …`: 66 derived, 66 run, 0 NOT-MEASURED, every recorded exit 0. `check:i18n` first answered `PREREQUISITE NOT MET` (exit 3) and was re-run after the CLI closure build: exit 0. - eslint, narrowed (`--no-inline-config --format json`): the 2 touched TS files, 0 errors, 0 warnings, neither ignored. This repo's `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`), so the diff cannot move a verdict on an untouched file. The full `pnpm lint` is CI's. ## Acceptance notes - **Docs that this change makes stale (outside this card's declared surface, not edited):** - `packages/verify/README.md`, about line 136: "Today's engine refuses every write to a detail two levels below its root master, even where the caller edits the middle master, so on an app that declares such a chain the family reports a contradiction and fails the run." - `content/docs/deployment/cli.mdx`, about line 2014: the same sentence. After this change, both describe the old engine. Named in the report for the seat to route. - The `RlsFilterOptions.masterGateCoversThisWrite` docblock lists the call sites that vouch. The chain hop is now one of them and is documented at the call site, not in that list. - `security/explain` and the `sys_attachment` / `sys_comment` parent gates ask this same check (`checkControlledByParentWrite`), so they give the same answers; no separate edit. ### Patch round (15058d6), appended by the `domain:services` seat 2 PM The dev writes this body once, so the seat appends the dev's report text here (report `6136047254` on #23093). Four texts that this change made false or incomplete are corrected. These are prose and comment edits only, per the claim amendment `6135431019`: - `.changeset/15953-verify-parent-write-reach-family.md`, line 19: the pending parent-write-reach release note. Its sentence "Today's engine refuses every write to a detail two levels below its root master ..." now states the ruled behaviour: the write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root. It is refused otherwise, so the family reports such a chain `proven`. `check-empty-changeset` is red on this file BY DESIGN, a DELIBERATE CORRECTION of a pending release note this PR made false. It is not restored, and no changeset is added for `@objectstack/verify`. - `packages/verify/README.md` and `content/docs/deployment/cli.mdx`: the same sentence, rewritten the same way. - `security-plugin.ts`, the `masterGateCoversThisWrite` option docblock: its list of the call sites that vouch for coverage now includes the chain hop in `assertMasterRowEditable`, pointing at that call site's bullet instead of restating the reason. Local verification at 15058d6: - `dispatch-gates --ran`: 96 derived, 96 run, 0 NOT-MEASURED. Every exit is 0 except `check-empty-changeset`, which exits 1 as above. - `pnpm --filter @objectstack/plugin-security typecheck`: OK. - The pin file and `controlled-by-parent-chain.test.ts`: 29 passed. - `packages/verify` `parent-write-reach.test.ts`: 10 passed. At 343e21e, the code head, which this round changes only in comments: - `plugin-security`: 191 files, 3966 tests passed, 45 skipped. - 66 of 66 gates exit 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bcebd84 commit dfb3ba2

7 files changed

Lines changed: 501 additions & 34 deletions

File tree

‎.changeset/15953-verify-parent-write-reach-family.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The promise is ADR-0055 §3 and its chain amendment: editing, deleting or creati
1616

1717
The writes are made by three verifier personas whose permission sets the family authors from the app's own objects — the root master by the all-records grant (`modifyAllRecords`) with every verb on the detail; the root master readable but not editable with every verb on the detail; the all-records grant with no delete on the detail — and by one persona per position the app declares, whose capability is what the app binds to the position. Each writes under a master chain another principal owns (so the all-records grant, not ownership, is exercised) and one it owns, on a detail another principal owns and one it owns. A refusal holds only as the platform's `403 PERMISSION_DENIED` naming the detail and the operation; any other refusal (a rule, a hook's own code, a refusal on another object) is `unattributed`. An object is `proven`, `contradicted` (a write met the other answer), `failed` (a door faulted), `inconclusive`, `excluded` (no one master: none, two, an undeclared one, a cycle, or a chain at the write gate's depth bound) or `not-derivable`. `contradicted + failed` objects, plus personas that could not be provisioned, are the report's `hardFailures`.
1818

19-
Today's engine refuses every write to a detail two levels below its root master, even where the caller edits the middle master, so on an app that declares such a chain the family reports a contradiction and the run fails.
19+
A detail two levels below its root master (a line under a quote under an account) is answered the same way: its write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root, and refused otherwise, so the family reports such a chain `proven`.
2020

2121
**`@objectstack/verify` — new exports.**
2222

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
A `controlled_by_parent` master that is itself a detail decides who may write its details the way its own record update is decided: whoever can edit a quote can edit its lines
6+
7+
Clause-②: no
8+
9+
In a two-level master-detail chain (a quote line under a quote under an account, every level below the account `controlled_by_parent`), one person got two answers about the same quote. An organization member who could edit a quote they had not created was allowed to update the quote itself, but every insert, update and delete of its lines was refused `403 PERMISSION_DENIED` ("requires edit access to its master record … not editable by this user (row-level security)"). Only the quote's creator could change its lines. The quote's own update hands the platform's creator-only floor (`owner_only_writes`, positions `['org_member']`) to the master-detail write check, by the 2026-08-15 ruling; the check that a line write runs on the quote kept that floor.
10+
11+
Now the line write asks the same question the quote's own update asks. By the maintainer's ruling (letter A), a line write under a middle master is admitted exactly when the same person may update that middle master directly:
12+
13+
- **Admitted now:** an organization member who can update the quote (and the account above it) may insert, update and delete its lines, whoever created the quote.
14+
- **Still refused:** the account above is not editable or not readable; the quote is not editable through its object permission; or an app-authored update rule on the quote refuses the person.
15+
- **Also refused now:** the quote's creator, writing the lines of a quote that an app-authored update rule refuses them. The quote's own update already refused them. The line write had admitted them, because the floor and the app's rule each admitted a row on its own.
16+
- **Unchanged:** a write made on behalf of another user keeps both principals' floors at every level. A master that governs its own rows, a single-level detail, the cycle guard and the depth bound are untouched.
17+
18+
`security/explain` and the parent gates of `sys_attachment` and `sys_comment` ask the same check, so they give the same answers.

‎content/docs/deployment/cli.mdx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2011,10 +2011,11 @@ os verify --parent-write-reach # Also prove each controlled_by_p
20112011
naming the detail and the operation (a rule, a hook's own code, a refusal
20122012
on another object) is reported inconclusive, never proven. An object
20132013
with two `master_detail` fields, none, or a chain at the write gate's depth
2014-
bound is reported `excluded`. Today's engine refuses every write to a detail
2015-
two levels below its root master (a line under a quote under an account),
2016-
even where the caller edits the middle master, so an app that declares such
2017-
a chain fails the run.
2014+
bound is reported `excluded`. A detail two levels below its root master (a
2015+
line under a quote under an account) is answered the same way: its write is
2016+
admitted exactly when the caller holds the verb on the detail and may update
2017+
the middle master directly, which also needs edit access to the root, and
2018+
refused otherwise, so such a chain is reported proven.
20182019
`--multi-tenant` boots org-scoped so tenant-isolation RLS policies
20192020
apply; a walled tenancy posture (`OS_TENANCY_POSTURE` set to `isolated` or
20202021
`group`) asks for the same boot.

0 commit comments

Comments
 (0)