Skip to content

Commit f46ef4d

Browse files
fix(plugin-security)!: an update of a controlled_by_parent detail also asks for edit access on the master its body names (ADR-0055 §3) (#25066)
Fixes #25044 Clause-②: no (narrowing) ## What changed ADR-0055 §3 requires edit access to the master for every write to a `controlled_by_parent` detail, and it reads a create's master from the master id in the incoming body. A write can involve more than one master. For one shape of write, the master-detail write gate (`assertControlledByParentWrite`, step 2.8) asked the master-edit question of only one of the masters the write involves. It now asks every one of them. - `packages/plugins/plugin-security/src/security-plugin.ts`, inside `assertControlledByParentWrite` only. The gate first asks about the master or masters it already asked about, unchanged. Then it runs the same chain walk on the master id the write's body names, unless that master was already asked. The walk is `assertControlledByParentChainEditable`, the one walk PR #25042 extracted; no second copy exists. - A refusal uses the by-id leg's own envelope: `403 PERMISSION_DENIED`, naming the caller's detail and operation, with its leg recorded. It is thrown before the write runs, so nothing is written. - An insert's master already is its body's, so it is asked once. - A write that selects no row involves no master and is not refused. - No master scope is composed into any write. `computeControlledByParentFilter`, `packages/objectql/**` and `packages/spec/**` are untouched. - `packages/plugins/plugin-security/src/controlled-by-parent-every-master-edit.test.ts` (new): 17 cases on a real stack. That is `ObjectQL` over better-sqlite3, the real `SecurityPlugin`, the real sharing service and middleware, and contexts built by `resolveAuthzContext` and `assembleExecutionContext`. - `.changeset/25044-cbp-every-master-edit.md`: `@objectstack/plugin-security` `minor`. It is BREAKING under the launch-window convention for an accept-set narrowing. It carries the `Clause-②` line and the ADR-0087 disposition `not-required (no-migration-prescription)`. ## Reach Reach is stated abstractly here, because the disclosure ruling recorded on #9387 applies. The reading was re-measured on a real `ObjectQL` + `SqlDriver` + `SecurityPlugin`, for a member and for an organization admin. The write landed on `origin/main` `eddffc3390` (before PR #25042) and again on PR #25042's head `033fc75a09` (after it). The probes were throwaway and are not committed. The detail is held privately. ## Verification Every run below is on this branch's head `a447bfdb8f`, after the final commit, unless it is marked otherwise. Each one ran under the shared verify lock. - **Build.** The dependency closure was built with `pnpm --filter '@objectstack/dogfood^...' build` (a superset of `@objectstack/plugin-security^...`, plugin-security itself included). It exited 0. - **The new pins.** `controlled-by-parent-every-master-edit.test.ts` passes 17/17, beside PR #25042's `controlled-by-parent-write-shape-master-edit.test.ts` (32/32 for the two files together). - **The package.** `pnpm --filter @objectstack/plugin-security exec vitest run`: Test Files 202 passed (202), Tests 4156 passed and 45 skipped (4201). `pnpm --filter @objectstack/plugin-security typecheck` exited 0. That covers `tsc` on both programs and `check:test-typecheck`, and the new test file is in the `tsconfig.test.json` program (`--listFiles`, 1 hit). - **Dogfood battery** (`pnpm --filter @objectstack/dogfood test`, no shard and no path, owed by the `!` title). The lock's verdict was command-exit 0. Test Files: 285 passed, 3 skipped (288). Tests: 2375 passed, 18 skipped (2393). - **Derived gate families.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 69 families. All 69 ran, and each exit code was captured before any pipe. `--ran` reports: 69 run, 0 NOT-MEASURED, 0 UNRUN. - **Ablation**, one leg per pinned behaviour. The fix was committed first. Each leg is a mutation made through `scripts/ablation-replace.mjs`, where the anchor must hit and the blob must change. Each restore is `git checkout HEAD --`, proven by blob equality with `HEAD` and an empty `git diff HEAD`. The subject is imported as `./security-plugin.js` from source, so no `dist/` sits on the path. The result: | leg | mutation | went red | |---|---|---| | L1 | drop the new question after the by-id leg | the refusal pins (7 cases) and the count pin | | L2 | drop the new question after the predicate leg | the refusal pins (7) and the count pin | | L3 | ask the body's master instead of the stored one | the stored-master pin and the count pin | | L4 | a blanket refusal in place of the walk | the lands-as-it-did pins (7), and the refusal and stored-master pins on the envelope | | L5 | drop the no-row guard | the no-row pin | | L6 | drop the already-asked skip | the count pin (an insert's master asked twice) | The first L4 attempt was refused by the tool: its replacement contained its anchor, so the anchor count did not move. It measured nothing and was rerun with a non-overlapping anchor. The unmutated control passed 17/17. The final tree is byte-identical to `HEAD`. - **Base.** The branch is `main` at `b2890909aa` (PR #25042 landed) plus this change, merged with `origin/main` `72518d20f5` before the runs above. Since then `main` has gained one commit (`4bf079c693`, plugin-auth), which is disjoint from this diff. ## Acceptance notes - Two adjacent readings in the same gate family turned up while measuring. Neither is changed here. Both go to the seat privately, for filing under the same ruling. - The question is asked of what the body names before the engine's own field strips run. On a reference the engine would strip from the write, a body naming a master the caller may not edit is therefore refused, where before it was dropped. That fails closed. Mirroring the engine's strip rules in the gate would make a second copy of them. - The new walk runs on the master id the body names, as the insert's walk already does. --- **Seat's append** (`domain:services` seat 1, from the dev's patch-round report `6310152347` on #25044; the dev writes its PR body once): - Patch round 1 (`4b4cfed7af`): only the changeset headline changes, narrowed to the update shape this diff covers, per contract review `6310100720` ③.1. No code or test line moved. - `git diff --stat a447bfd 4b4cfed` touches only `.changeset/25044-cbp-every-master-edit.md` (+1/−1). Code is byte-identical, so the dogfood battery and the package and gate runs at `a447bfdb8f` stand. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c67f244 commit f46ef4d

3 files changed

Lines changed: 510 additions & 4 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
fix(plugin-security)!: an update of a `controlled_by_parent` detail also asks for edit access on the master its body names (ADR-0055 §3)
6+
7+
ADR-0055 §3 says editing, deleting or creating a detail requires edit access to its master, and it reads a create's master from the master id in the incoming body. A write to a `controlled_by_parent` detail can involve more than one master, and for one shape of write the master-detail write gate asked the master-edit question of one of them only. It now asks it of every master the write involves, with the same check and in the same order as before for the one it already asked. One master the caller may not edit refuses the whole write, and nothing is written. On a write made on a user's behalf, both principals are asked, as on every other detail write.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the security plugin's master-detail write gate gives for one shape of write to a `controlled_by_parent` detail: it now asks the master-edit question of every master that write involves, and refuses where that question refuses for any of them. The remedy is a grant on the master or a narrower write, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
**BREAKING** for a caller that relied on that shape of write landing while one of the masters it involves is a master the caller may not edit. Shipped as `minor` under the launch-window convention for accept-set narrowings.
14+
15+
- **Before.** That shape of write was decided on one of the masters it involves, and landed when the caller could edit that one, whatever the other.
16+
- **Now.** It answers the refusal a write under the other master answers: `PERMISSION_DENIED` (403), naming the detail and the master-edit reason. A write whose masters the caller may all edit lands as before and writes the same rows. A write that reaches no row involves no master and is not refused.
17+
- **Unchanged.** Inserts and deletes; every write that involves only the master its rows already sit under; system contexts; the detail's read scope; the platform ownership floor; and every object that does not declare `controlled_by_parent`.
18+
19+
**What to do.** Grant the caller edit access on every master the write involves, or keep the write within masters the caller may edit.

0 commit comments

Comments
 (0)