Repository navigation
Commit dfb3ba2
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
- content/docs/deployment
- packages
- plugins/plugin-security/src
- verify
- src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
19 | | - | |
| 19 | + | |
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2011 | 2011 | | |
2012 | 2012 | | |
2013 | 2013 | | |
2014 | | - | |
2015 | | - | |
2016 | | - | |
2017 | | - | |
| 2014 | + | |
| 2015 | + | |
| 2016 | + | |
| 2017 | + | |
| 2018 | + | |
2018 | 2019 | | |
2019 | 2020 | | |
2020 | 2021 | | |
| |||
0 commit comments