Skip to content

Commit 4d7e2b1

Browse files
refactor(plugin-security): move SecurityPlugin's controlled-by-parent read leaf into security-plugin-cbp-read-filter.ts (#25223 cut 4) (#25360)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuMYdF2uchcinMphEKW4sYxd)_ Part of #25223 Clause-②: no Metadata-protocol: none Ruling-ref: 6353073966 Cut 4 of the `security-plugin.ts` split: the rest of cut 2. The claim is 6355875966. The bodies of two synchronous methods move unedited to the new module `packages/plugins/plugin-security/src/security-plugin-cbp-read-filter.ts`: - `computeControlledByParentFilter`, the ADR-0055 §2 read derivation. It answers a `controlled_by_parent` object's read with the engine's `ReadableIds` leaf over its master (ADR-0139 D6). - `controlledByParentChainRefusal`, the chain refusal that derivation runs over the registry before it writes the leaf. The one module-level declaration only those bodies read moves with them: `CBP_LEAF_READ_VERBS`. The `ReadableIds` name leaves the `@objectstack/spec/data` import of `security-plugin.ts`, because no code left there reads it. It is move-only, and the card stays open for the later cuts. Branch: `claude/project-thread-3fzya4`, the harness-pinned branch for this session, restarted from `origin/main` at `3e514912ad` (cut 3's merge). It names no issue, so the claim comment is the identity record. **Why these two now.** Ruling 6353073966 item 4 held them until the Tier H docs PR for #25314 landed. That PR, #25316, merged as `97967a9a06` before cut 3's merge, and #25314 is closed as completed. On this base, ADR-0055:26 is a file-level anchor and ADR-0055:58 anchors the `assertControlledByParentWrite` method shell, so no ADR sentence resolves on a token inside these two bodies any more. `composeWriteTenantWall`, which item 4 also held, belongs to a different section (the tenant wall) and is not in this cut. **What stays in `security-plugin.ts`.** `CBP_MAX_CHAIN_DEPTH` stays: ADR-0055:135 anchors it by symbol in that file (ruling item 5). It has been exported since cut 3, and the new module imports it by value, as cut 3's module does, so `security-plugin.ts` gains no new export. It is read only inside a function body, at call time, never while either module evaluates. `CbpRelation` keeps its one declaration. Both files import `RLS_DENY_FILTER` from `rls-compiler.js`. ## The published `.d.ts` does not change Under the series bar (ruling 6348891160 item 1), measured on base `3e514912ad` and on the head (the tree of this PR's commit): - Raw sha256 of `dist/index.d.ts` and `dist/index.d.mts`, base and head, all four the same: `803f5bfb7da0b00f8cd8d8f25172463d8dfb07d93fbdd8a9c6f4d4b7435e429a`. The raw diff is 0 lines, so no union order moved either. - Normalised sha256, all four the same: `b6b5c1b7fa6fa188f6e2f7e4cca3816a246b570f20d708643d116012aa86fa77`. - The normaliser, run from `packages/plugins/plugin-security` (it sorts the members of each string-literal union and changes nothing else): ```sh node -e 'const s=require("fs").readFileSync(process.argv[1],"utf8");process.stdout.write(s.replace(/"[^"\n]*"(?:\s*\|\s*"[^"\n]*")+/g,r=>r.split(/\s*\|\s*/).sort().join(" | ")))' dist/index.d.ts | sha256sum ``` - Positive controls on a copy of the head `dist/index.d.ts`, line 2027 (`private computeControlledByParentFilter;`): - Made public (`computeControlledByParentFilter;`): raw `133464a499c14936dfef45acfbfc4dc06f474a637d04a41af9c96cd69467e57b`, normalised `18feab96bc21c749ed55f60542c9564a6f80de841a2e650325229a8167ced6ff`. - Renamed (`private computeControlledByParentFilterRenamed;`): raw `702460748f17c01330fd55483961d149a1972622317ba54c89f03a99c2a045f7`, normalised `a7ef310b2e289b480123f13b49aa60391b0e0ee5951cb650296a5f2d757a5759`. - Both differ from the real hashes, so the comparison does see a visibility change and a rename. - Export tables: the ESM and CJS bundles each export the same 63 names at base and head. The lists were loaded at runtime, not parsed. The sorted name list hashes `f607fd765d6b277e08b3b46ccb16d1e2d133ec55658ffe40155aef25e1bf54f0` on all four, the same as at cuts 2 and 3. - The bundle JS bytes differ (`index.js` `4f2b1fcb` to `7179686c`, `index.mjs` `8a9277ae` to `474e62d2`), because code moved within them. No changeset: `skip-changeset`, per ruling 6348891160 item 4. ## What moved Base line numbers are each member's signature at `3e514912ad`. The last column lists the members the body reads, which is exactly what its host carries. | method | base lines | host members | |---|---|---| | `computeControlledByParentFilter` | `:9058`-`:9099` | `controlledByParentChainRefusal`, `logger`, `ql`, `resolveCbpRelation` | | `controlledByParentChainRefusal` | `:9123`-`:9140` | `ql`, `resolveCbpRelation` | Both methods are synchronous, so no stub changes `async`. `CBP_LEAF_READ_VERBS` (base `:661`, 11 lines with its docblock) moves verbatim. No code left in `security-plugin.ts` reads it. ## The shape (#23050 shape B, as cuts 1 to 3 applied it) - The class keeps each private method with its name, signature, docblock and position, because tsup emits private members with their JSDoc into the `.d.ts`. Each body is now one host literal plus `fn.call(host, …)`, marked `[#25223 cut 4]`. - `CbpReadFilterHost` types every member as the class member itself (`SecurityPlugin['m']`). Each moved function types its `this` as the `Pick` of the host over exactly the members its body reads, and each stub annotates its literal with the same `Pick`. There is no cast, and no member becomes public or protected. - The module imports `SecurityPlugin` type-only, and names `CbpRelation` through the class's own signature, as cuts 2 and 3 do (an alias over the non-null return type of `SecurityPlugin['resolveCbpRelation']`). - Methods go in as late-bound closures that call `this.m(...a)` on the plugin at call time. A spy or test double set on the plugin is still the one the leaf reaches. The leaf still reaches its chain refusal through the plugin, and both still reach `resolveCbpRelation` through it. No moved body calls a function-valued field, so no receiver changes. - Fields: `ql` and `logger` go as getters, because neither is declared `readonly`. `start()` assigns both after construction. The host declares both `readonly`, so a moved body cannot assign a member through it; the typecheck passes, so none does. - Nothing re-exports the module, so the package entry does not move. ## Proof of move-only - **Bodies, by AST token**: for both methods, the moved function's body has the same TypeScript token sequence as the base method body, and each of its 48 lines is the base line with two leading spaces removed. Parameters, the parameter docblock, return types and `async` match. A negative control, `!context?.userId` changed to `context?.userId` in `computeControlledByParentFilter`, fails the check. - **Class side**: each method keeps its docblock and signature. `CBP_LEAF_READ_VERBS` is verbatim in the module and absent from `security-plugin.ts`. `CBP_MAX_CHAIN_DEPTH` and `CbpRelation` are byte-identical to base. Every other class member, 123 named members plus the constructor, is byte-identical to base, `composeWriteTenantWall` included. - **git's own view** (`git diff --color-moved=plain --color-moved-ws=allow-indentation-change 3e51491 HEAD` over the two source files): 60 of 61 deleted lines and 75 of 179 added lines are detected as moved. - Deleted, not moved (1): the `@objectstack/spec/data` import line, which loses `ReadableIds`. The module imports `ReadableIds` from the same package. - Added, not moved: that import line's replacement, the stub bodies, and the import of the new module with its comment. In the module: the license header, the module docblock, the imports, the `CbpRelation` alias, `CbpReadFilterHost`, one pointer docblock per function and the rewritten signature heads. The rest are lines git does not pair with a moved block, such as blanks and closing braces. - **Size**: 247 changed lines (+186 / -61, 3 files), under the 3000-line human-merge threshold. `security-plugin.ts` goes from 10,240 to 10,205 lines. ## ADR anchors (ruling 6353073966 item 2) - `node scripts/check-adr-symbol-anchors.mjs`: exit 0 at base `3e514912ad` and exit 0 at head, both with `2383 anchors across 142 records resolve — 466 symbol (440 declaration, 26 literal), 1889 file-level, 28 cross-repo, 6 exempt, 18 continuation`. The counts differ from cut 3's report because #25316 made one symbol anchor file-level. CI runs this gate (`lint.yml`), but `dispatch-gates --commands` does not derive it for this change set (#21697), so it is run by hand here. - `pnpm check:adr-anchors`: exit 0, 78 anchored files (77 at base, plus the new entry). The new entry is `scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin-cbp-read-filter.ts.json`. It carries ADR-0055 and the §1 invariant paragraph of the `security-plugin.ts` entry, verbatim, as the entries of cuts 2 and 3 do. Its "reads answer RLS_DENY_FILTER" half is this leaf. The moved comments also cite ADR-0139. Like the `security-plugin.ts` entry, the new entry does not list every ADR its file cites, and the gate passes. The `security-plugin.ts` entry is unchanged. `docs/adr/**` is not touched. - The anchor map covers every ADR symbol anchor naming `security-plugin.ts`: 27 anchors, 18 symbols, 10 ADRs on this base. It was measured with `extractAnchors` and `symbolSegmentResolution` from `scripts/symbol-anchors.mjs`, which is measurement, not a new gate. It was read on `3e514912ad` BEFORE the cut, with the two bodies blanked: no anchor changed class. Every anchor resolves at head in `security-plugin.ts` with its base class. The table gives each anchor's class at base and head, the line it resolves on, and the member that line sits in. Every anchor stays in `security-plugin.ts`. | anchor | cited at | base class · line (member) | head class · line | |---|---|---|---| | `#CBP_MAX_CHAIN_DEPTH` | ADR-0055:135 | declaration · `:649` (top-level) | declaration · `:657` | | `#SecurityPlugin` | ADR-0049:49, ADR-0138:248, ADR-0138:265 | declaration · `:1338` (class) | declaration · `:1334` | | `#SecurityPlugin.getMetadataReadableFields` | ADR-0138:153 | declaration · `:2487` (`start`), `:6106` (method) | declaration · `:2483`, `:6102` | | `#SecurityPlugin.resolvePermissionSetsForContextUnmemoized` | ADR-0138:427 | declaration · `:7017` (method) | declaration · `:7013` | | `#assertControlledByParentWrite` | ADR-0055:58, ADR-0055:125 | declaration · `:9497` (method shell) | declaration · `:9462` | | `#assertMasterRowEditable` | ADR-0055:125 | declaration · `:9623` (method shell) | declaration · `:9588` | | `#collectRLSPolicies` | ADR-0105:93 | declaration · `:9677` (method) | declaration · `:9642` | | `#composeWriteTenantWall` | ADR-0123:4 | declaration · `:8412` (method) | declaration · `:8408` | | `#computeControlledByParentFilter` | ADR-0055:5, ADR-0055:123, ADR-0139:246 | declaration · `:9058` (method) | declaration · `:9054` (method shell) | | `#computeDelegatedReadFilter` | ADR-0139:223 | declaration · `:5984` (method) | declaration · `:5980` | | `#computeRlsFilter` | ADR-0055:24 | declaration · `:5236` (`explainAccessForCaller`), `:7639` (method) | declaration · `:5232`, `:7635` | | `#getReadFilter` | ADR-0096:181, :182, :183, :199, ADR-0139:222 | declaration · `:2250` (`start`), `:5862` (method) | declaration · `:2246`, `:5858` | | `#hasPlatformAdminPosture` | ADR-0099:317 | declaration · `:7666` (method) | declaration · `:7662` | | `#isPrincipalLessContext` | ADR-0138:152 | declaration · `:503` (top-level) | declaration · `:511` | | `#manage_metadata` | ADR-0099:68 | literal · `:252` (`PLATFORM_ADMIN_ONLY_CAPABILITIES`) | literal · `:260` | | `#nav_permission_sets` | ADR-0126:363 | literal · `:1743` (`init`) | literal · `:1739` | | `#rlsFilter` | ADR-0055:24 | declaration · `:4313` (local in `start`) | declaration · `:4309` | | `#writeCheckPolicies` | ADR-0066:112 | declaration · `:1233` (top-level) | declaration · `:1229` | No resolving site moves. The three anchors that name `computeControlledByParentFilter` resolve on its declaration, which stays as a shell. `assertControlledByParentWrite`, `assertMasterRowEditable`, `collectRLSPolicies` and `computeRlsFilter` also appear as host-literal keys inside cut 3's stubs, as cut 3's report noted; this cut adds no such line for an anchored name. ## Readers No test, script, gate, baseline or docs anchor reads the moved lines by path, so nothing else is re-pointed. - **The census pages.** The two bodies hold no `isSystem` read, so `check-system-context-census` has no row to move. Unlike cut 3, `content/docs/permissions/system-context.mdx` is not touched. The gate exits 0 at base and head with the same summary except the file count. The bodies hold no engine write, so `check-tenant-audit-census` sees the same 237 write call sites. - The other symbol-anchor gates all exit 0: `check-scripts-symbol-anchors`, `check-spec-docblock-symbol-anchors`, `pnpm --filter @objectstack/spec check:liveness`, and `check-platform-checklist --changed-from origin/main`. - Readers by name, not by path. These were read and none needs a change: - Two test docblocks name `computeControlledByParentFilter` as the mechanism (`controlled-by-parent-master-object-read.test.ts`, `readable-ids-nested-compose.test.ts`), and so do two comments in `packages/lint/src/validate-security-posture.ts` and its test. The method still exists under that name. - `packages/qa/dogfood/test/authz-conformance.matrix.ts` describes the enforcement as `plugin-security/security-plugin.ts computeControlledByParentFilter + assertControlledByParentWrite`. Both are still methods of that class in that file, now as shells, as cut 3 already left `assertControlledByParentWrite`. - Line citations: no `security-plugin.ts:N` citation in the repo points into a moved range. The moved ranges at `3e514912ad` are `:651`-`:662` and the two method bodies, `:9064`-`:9098` and `:9127`-`:9139`. - Head line numbers shift by +8 for base `:138`-`:650` (the new import), by -4 for base `:663`-`:9063`, by -29 for base `:9099`-`:9126` and by -35 after `:9139`. Lines `:1`-`:137` do not move. - The citations `:132`, `:303`, `:309`, `:1614`, `:1690`, `:1736`, `:3215`, `:3281`, `:3519` and `:4344` are the same list cuts 2 and 3 read. Each lands in an unmoved or shifted range, and each already pointed at an unrelated line before cut 2, so this cut does not make any of them wrong. - Docs Drift Check, re-derived with `node scripts/docs-audit/affected-docs.mjs --json 3e51491` (advisory): 37 pages, 9 of them release-owned. Every one is reached only through a string literal inside the moved code: `'controlled_by_parent'` in the two bodies, or `'findOne'` in `CBP_LEAF_READ_VERBS`. The literals moved unchanged and no behaviour changed, so there is nothing to edit on those pages. - Census gates, run on base `3e514912ad` and head and compared: - Identical output: `check-platform-object-tenancy-census`, `check-position-name-fold-loaders`, `check-registry-log-declared`, `check-rest-log-spy-declared`, `check-system-context-census` (148 elevation read sites in 70 files at both), `check-tenant-audit-census` (237 write call sites at both), `check:cross-package-test-inputs`, `check:driver-memory-census` and `check:query-options-erasure`. The last of these reports the same counts at both, and `security-plugin.ts` is not a baseline key. - Only the scanned counts move, by the one new file: - `check-undeclared-dep-imports`: 3291 to 3292 files, and 2835 to 2836 specifiers. The one is the new module's `@objectstack/spec/data` import, a declared dependency. - `check:doc-authoring`: 1413 to 1414 sources. - `check:org-identifier`: 3633 to 3634 files. - `check:logger-receiver-detach`: 3607 to 3609 files. The second extra file is `apps/docs/.next/types/validator.ts`, a gitignored local build output of the docs app, as at cuts 2 and 3. It is not in the tree CI checks out. - `check-issue-citations` judges a change set, so it has nothing to read at base. At head it judged 5 citations across 2 files, and all resolve. ## Acceptance notes - Nothing outside this cut is touched: no other section of the class, no behaviour, no other lane's file, and no `docs/adr/**`. - Serial check, also in the claim: of the open PRs, only #25355 touches `security-plugin.ts`, with one import after `:47` and one block at `:1765`. This cut has no hunk near either. - What is left of the controlled-by-parent section in `security-plugin.ts` after this cut: the method shells, `CBP_MAX_CHAIN_DEPTH`, `CbpRelation` and `cbpRelCache`. `composeWriteTenantWall` gets its own cut. ## Gates All gates ran at the head, the tree of `d2b23e286b`, on base `3e514912ad`. - `pnpm --filter @objectstack/plugin-security test` (`vitest run --maxWorkers=3`, under the verify lock): Test Files 212 passed (212), Tests 4442 passed, 45 skipped (4487). Base `3e514912ad` gives the same counts. VERDICT command-exit 0 on both. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. It runs `tsc --noEmit`, the scripts tsconfig, and `check:test-typecheck`. - `eslint --no-inline-config` over the two touched source files: exit 0. - `pnpm --filter @objectstack/plugin-security build`: exit 0. It produced the `.d.ts` and bundles measured above. - `node scripts/check-adr-symbol-anchors.mjs` exit 0, `pnpm check:query-options-erasure` exit 0, plus the three symbol-anchor gates under Readers. None of these is derived for this change set; each was run by hand. - `dispatch-gates --commands`: 62 commands, the same list as cut 3's cut commit. The `--ran` reconciliation shows 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. The zero is derived: all 62 recorded an exit code and none is 3. Per command (exit code): - `node scripts/check-ci-filter-parity.mjs` exit 0 - `node scripts/check-closing-keyword-parity.mjs` exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` exit 0 - `node scripts/check-comment-mask-adoption.mjs` exit 0 - `node scripts/check-comment-mask-adoption.mjs --self-test` exit 0 - `node scripts/check-comment-mask-corpus.mjs` exit 0 - `node scripts/check-dts-emitted.mjs --self-test` exit 0 - `node scripts/check-issue-citations.mjs` exit 0 - `node scripts/check-keyed-text-bounds.mjs` exit 0 - `node scripts/check-keyed-text-bounds.mjs --self-test` exit 0 - `node scripts/check-platform-object-tenancy-census.mjs` exit 0 - `node scripts/check-platform-object-tenancy-census.mjs --self-test` exit 0 - `node scripts/check-plugin-teardown-shape.mjs` exit 0 - `node scripts/check-plugin-teardown-shape.mjs --self-test` exit 0 - `node scripts/check-position-name-fold-loaders.mjs` exit 0 - `node scripts/check-position-name-fold-loaders.mjs --self-test` exit 0 - `node scripts/check-registry-log-declared.mjs` exit 0 - `node scripts/check-registry-log-declared.mjs --self-test` exit 0 - `node scripts/check-rest-log-spy-declared.mjs` exit 0 - `node scripts/check-rest-log-spy-declared.mjs --self-test` exit 0 - `node scripts/check-scripts-symbol-anchors.mjs` exit 0 - `node scripts/check-scripts-symbol-anchors.mjs --self-test` exit 0 - `node scripts/check-system-context-census.mjs` exit 0 - `node scripts/check-system-context-census.mjs --self-test` exit 0 - `node scripts/check-tenant-audit-census.mjs` exit 0 - `node scripts/check-tenant-audit-census.mjs --self-test` exit 0 - `node scripts/check-undeclared-dep-imports.mjs` exit 0 - `node scripts/check-undeclared-dep-imports.mjs --self-test` exit 0 - `node scripts/docs-audit/check-affected-docs.mjs` exit 0 - `node scripts/docs-audit/check-drift-comment.mjs` exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` exit 0 - `pnpm check:adr-anchors` exit 0 - `pnpm check:agent-test-spelling` exit 0 - `pnpm check:bash32-floor` exit 0 - `pnpm check:cli-command-ids` exit 0 - `pnpm check:closed-ceremony-text` exit 0 - `pnpm check:cross-package-test-inputs` exit 0 - `pnpm check:doc-authoring` exit 0 - `pnpm check:driver-memory-census` exit 0 - `pnpm check:dts-closure` exit 0 - `pnpm check:dual-build-cjs-loads` exit 0 - `pnpm check:entry-guard` exit 0 - `pnpm check:error-status-conformance` exit 0 - `pnpm check:gitlink-declared` exit 0 - `pnpm check:i18n` exit 0 - `pnpm check:i18n-stale-fill` exit 0 - `pnpm check:issue-citations` exit 0 - `pnpm check:lean-entry-closure` exit 0 - `pnpm check:logger-receiver-detach` exit 0 - `pnpm check:nul-bytes` exit 0 - `pnpm check:org-identifier` exit 0 - `pnpm check:page-declaration-shape` exit 0 - `pnpm check:parse-guard` exit 0 - `pnpm check:pnpm-filter-targets` exit 0 - `pnpm check:pnpm-flag-spellings` exit 0 - `pnpm check:published-files` exit 0 - `pnpm check:refd-timer-probe` exit 0 - `pnpm check:slot-lookup` exit 0 - `pnpm check:sourcemap-no-sources-content` exit 0 - `pnpm check:test-source-alias` exit 0 - `pnpm check:tier-file-adoption` exit 0 - `pnpm check:watch-hint-literal` exit 0 - `pnpm check:query-options-erasure` exit 0 - `node scripts/check-adr-symbol-anchors.mjs` exit 0 - `node scripts/check-spec-docblock-symbol-anchors.mjs` exit 0 - `pnpm --filter @objectstack/spec check:liveness` exit 0 - `node scripts/check-platform-checklist.mjs --changed-from origin/main` exit 0 --- _Generated by [Claude Code](https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa Co-authored-by: Claude <noreply@anthropic.com>
1 parent b964ea1 commit 4d7e2b1

3 files changed

Lines changed: 186 additions & 61 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25223 cut 4] The controlled-by-parent read leaf of `SecurityPlugin`, moved
5+
* out of `security-plugin.ts` whole: the ADR-0055 §2 read derivation, which
6+
* answers a `controlled_by_parent` object's read with the engine's
7+
* `ReadableIds` leaf over its master (ADR-0139 D6), and the chain refusal it
8+
* runs over the registry before it writes that leaf.
9+
*
10+
* A MOVE, not a redesign. Each function below is the body of the
11+
* `SecurityPlugin` method of the same name as it stood, re-indented one level
12+
* and otherwise unedited: the same leaf, the same deny sentinel, the same
13+
* chain guards, failing closed exactly where they did. The one module-level
14+
* declaration only these bodies read moved with them, also unedited:
15+
* `CBP_LEAF_READ_VERBS`, with the `ReadableIds` import that
16+
* `security-plugin.ts` no longer needs. The relation the leaf is derived from
17+
* stays the class's `resolveCbpRelation` (`security-plugin-cbp-relation.ts`),
18+
* read through the plugin as before.
19+
*
20+
* ## How the moved bodies reach the class
21+
*
22+
* The shape cuts 1 to 3 used (`security-plugin-object-inputs.ts`, whose header
23+
* carries the reasoning). The class keeps every method — the published
24+
* `dist/*.d.ts` lists each one's name, docblock and position — and each body is
25+
* one literal of exactly the {@link CbpReadFilterHost} members that method
26+
* reads, plus a `.call`. A method is handed over as a closure that calls it on
27+
* the plugin at call time, so a test double or spy set on the plugin is still
28+
* the one the body reaches, and the leaf reaches its chain refusal through the
29+
* plugin, as before. Both functions are synchronous.
30+
*
31+
* The two fields the bodies read go as GETTERS, by the rule cut 1 set: neither
32+
* is declared `readonly`. `start()` assigns `ql` and `logger` after
33+
* construction. Each read happens when the body makes it, as before.
34+
*
35+
* ## What stays in `security-plugin.ts`, and how it is read
36+
*
37+
* `CBP_MAX_CHAIN_DEPTH` stays there, exported, and is imported here by value:
38+
* ADR-0055 anchors it by symbol in that file, and the master-write gate's
39+
* chain walk (`security-plugin-cbp-write-gate.ts`) reads the same bound. It is
40+
* read only inside a function body, at call time, never while either module
41+
* evaluates, so the two-way import is safe under ESM, and the published bundle
42+
* holds one module. `RLS_DENY_FILTER` is imported from `rls-compiler.js`, as
43+
* `security-plugin.ts` imports it. `CbpRelation` keeps its one declaration in
44+
* `security-plugin.ts`; this module names it through the class's own signature
45+
* (below).
46+
*
47+
* Nothing re-exports this module, so the published surface does not move.
48+
*/
49+
50+
import type { SecurityPlugin } from './security-plugin.js';
51+
import { CBP_MAX_CHAIN_DEPTH } from './security-plugin.js';
52+
import { ReadableIds } from '@objectstack/spec/data';
53+
import { RLS_DENY_FILTER } from './rls-compiler.js';
54+
55+
/**
56+
* [ADR-0139 D1] The engine verbs whose `where` may carry a `ReadableIds`
57+
* leaf — the engine's own `READABLE_IDS_READ_VERBS`. The read derivation
58+
* answers its leaf for these and nothing for a write verb's predicate: a
59+
* predicate write is narrowed to the rows the read door returns for the
60+
* caller's own predicate ({@link SecurityPlugin.readableRowsScopeForPredicateWrite},
61+
* where the leaf composes) and judged per master by the 2.8 gate
62+
* ({@link SecurityPlugin.assertControlledByParentWrite}, a verdict, never a
63+
* scope), so no master set is composed into a write's predicate.
64+
*/
65+
const CBP_LEAF_READ_VERBS: ReadonlySet<string> = new Set(['find', 'findOne', 'count', 'aggregate']);
66+
67+
/**
68+
* A `controlled_by_parent` object's resolved master relation, as the class
69+
* declares it: the `CbpRelation` interface stays in `security-plugin.ts`
70+
* beside `cbpRelCache`, and the moved bodies name it exactly as they did.
71+
*/
72+
type CbpRelation = NonNullable<ReturnType<SecurityPlugin['resolveCbpRelation']>>;
73+
74+
/**
75+
* The `SecurityPlugin` members the functions below read — every one of them,
76+
* and no other; each function's `this` is the `Pick` of the members IT reads.
77+
* Each is typed AS the class member (an indexed access on the class), so a
78+
* change to a member's signature on the class is a change here too, and naming
79+
* a member the class does not have is a compile error. Every field here is a
80+
* getter on the host; see the module header.
81+
*/
82+
export interface CbpReadFilterHost {
83+
readonly logger: SecurityPlugin['logger'];
84+
readonly ql: SecurityPlugin['ql'];
85+
controlledByParentChainRefusal: SecurityPlugin['controlledByParentChainRefusal'];
86+
resolveCbpRelation: SecurityPlugin['resolveCbpRelation'];
87+
}
88+
89+
/** The body of `SecurityPlugin#computeControlledByParentFilter`; the method's docblock there is its contract. */
90+
export function computeControlledByParentFilter(
91+
this: Pick<CbpReadFilterHost, 'controlledByParentChainRefusal' | 'logger' | 'ql' | 'resolveCbpRelation'>,
92+
object: string,
93+
/** The engine verb the answer is composed onto; a write verb answers `null` (ADR-0139 D1). */
94+
operation: string,
95+
context: any,
96+
): Record<string, unknown> | null {
97+
if (!this.ql || !context?.userId) return null;
98+
const schema = typeof this.ql.getSchema === 'function' ? this.ql.getSchema(object) : null;
99+
const sharingModel = schema?.sharingModel ?? schema?.security?.sharingModel;
100+
if (sharingModel !== 'controlled_by_parent') return null;
101+
if (!CBP_LEAF_READ_VERBS.has(operation)) return null;
102+
103+
const rel = this.resolveCbpRelation(object);
104+
// No resolvable master: the deny sentinel, by reference (ADR-0139 D2 step 2).
105+
if (!rel) return RLS_DENY_FILTER;
106+
107+
// [ADR-0055 amendment] Chain guards, over the registry, BEFORE the leaf is
108+
// written. Both answer with the empty master set — the same shape the
109+
// #5386 sharing-resolution failure answered with, and the same posture: a
110+
// chain this derivation cannot resolve denies, because the alternative
111+
// ("no restriction") is the defect being fixed, and the other alternative
112+
// (the engine's refusal of the same chain) is a 500 for a declared shape.
113+
const refusal = this.controlledByParentChainRefusal(object, rel);
114+
if (refusal?.kind === 'cycle') {
115+
this.logger.error?.(
116+
`[security] controlled_by_parent derivation found a CYCLE resolving '${object}' ` +
117+
`(chain: ${refusal.chain.join(' -> ')}) — denying (fail-closed: a chain the derivation ` +
118+
`cannot resolve admits no child rather than leaving it unrestricted)`,
119+
);
120+
return { [rel.fk]: { $in: [] } };
121+
}
122+
if (refusal?.kind === 'depth') {
123+
this.logger.error?.(
124+
`[security] controlled_by_parent derivation exceeded the chain depth bound ` +
125+
`(${CBP_MAX_CHAIN_DEPTH}) resolving '${object}' ` +
126+
`(chain: ${refusal.chain.join(' -> ')}) — denying (fail-closed: a chain the derivation ` +
127+
`cannot resolve admits no child rather than leaving it unrestricted)`,
128+
);
129+
return { [rel.fk]: { $in: [] } };
130+
}
131+
return { [rel.fk]: { $in: ReadableIds.of(rel.master) } };
132+
}
133+
134+
/** The body of `SecurityPlugin#controlledByParentChainRefusal`; the method's docblock there is its contract. */
135+
export function controlledByParentChainRefusal(
136+
this: Pick<CbpReadFilterHost, 'ql' | 'resolveCbpRelation'>,
137+
object: string,
138+
rel: CbpRelation,
139+
): { kind: 'cycle' | 'depth'; chain: string[] } | null {
140+
const chain: string[] = [object];
141+
let master = rel.master;
142+
for (;;) {
143+
if (chain.includes(master)) return { kind: 'cycle', chain: [...chain, master] };
144+
const schema = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(master) : null;
145+
const model = schema?.sharingModel ?? schema?.security?.sharingModel;
146+
if (model !== 'controlled_by_parent') return null;
147+
if (chain.length >= CBP_MAX_CHAIN_DEPTH) return { kind: 'depth', chain: [...chain, master] };
148+
chain.push(master);
149+
const next = this.resolveCbpRelation(master);
150+
if (!next) return null;
151+
master = next.master;
152+
}
153+
}

‎packages/plugins/plugin-security/src/security-plugin.ts‎

Lines changed: 26 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai';
1212
// [#21829] `MAX_BULK_PER_ROW_HOOK_ROWS`: the platform's row ceiling for one
1313
// predicate write, which also bounds the id list a predicate write's read
1414
// question composes (see PREDICATE_WRITE_READABLE_ID_CAP).
15-
import { markFilterSubtreeProvenance, FieldMaskingRuleSchema, MAX_BULK_PER_ROW_HOOK_ROWS, ReadableIds, type FieldMaskingRule } from '@objectstack/spec/data';
15+
import { markFilterSubtreeProvenance, FieldMaskingRuleSchema, MAX_BULK_PER_ROW_HOOK_ROWS, type FieldMaskingRule } from '@objectstack/spec/data';
1616
import { StandardErrorCode } from '@objectstack/spec/api';
1717
import type { NumberComparandDoorFieldMeta } from '@objectstack/spec/data';
1818
// [#7414] The SHARED operation-message catalog #7307 built for the data path's
@@ -135,6 +135,14 @@ import {
135135
controlledByParentPredicateWriteMasters,
136136
type CbpWriteGateHost,
137137
} from './security-plugin-cbp-write-gate.js';
138+
// [#25223 cut 4] The controlled-by-parent read leaf (ADR-0055 §2, ADR-0139 D6)
139+
// and its chain refusal; the methods of the same names below hand each body
140+
// the members it reads (`CbpReadFilterHost`).
141+
import {
142+
computeControlledByParentFilter,
143+
controlledByParentChainRefusal,
144+
type CbpReadFilterHost,
145+
} from './security-plugin-cbp-read-filter.js';
138146
import {
139147
isPlatformOwnershipFloorPolicy,
140148
masterGateCoversOperation,
@@ -648,18 +656,6 @@ interface RlsFilterOptions {
648656
*/
649657
export const CBP_MAX_CHAIN_DEPTH = 8;
650658

651-
/**
652-
* [ADR-0139 D1] The engine verbs whose `where` may carry a `ReadableIds`
653-
* leaf — the engine's own `READABLE_IDS_READ_VERBS`. The read derivation
654-
* answers its leaf for these and nothing for a write verb's predicate: a
655-
* predicate write is narrowed to the rows the read door returns for the
656-
* caller's own predicate ({@link SecurityPlugin.readableRowsScopeForPredicateWrite},
657-
* where the leaf composes) and judged per master by the 2.8 gate
658-
* ({@link SecurityPlugin.assertControlledByParentWrite}, a verdict, never a
659-
* scope), so no master set is composed into a write's predicate.
660-
*/
661-
const CBP_LEAF_READ_VERBS: ReadonlySet<string> = new Set(['find', 'findOne', 'count', 'aggregate']);
662-
663659
interface CbpRelation {
664660
/** The detail's master reference field key. */
665661
fk: string;
@@ -9061,41 +9057,16 @@ export class SecurityPlugin implements Plugin {
90619057
operation: string,
90629058
context: any,
90639059
): Record<string, unknown> | null {
9064-
if (!this.ql || !context?.userId) return null;
9065-
const schema = typeof this.ql.getSchema === 'function' ? this.ql.getSchema(object) : null;
9066-
const sharingModel = schema?.sharingModel ?? schema?.security?.sharingModel;
9067-
if (sharingModel !== 'controlled_by_parent') return null;
9068-
if (!CBP_LEAF_READ_VERBS.has(operation)) return null;
9069-
9070-
const rel = this.resolveCbpRelation(object);
9071-
// No resolvable master: the deny sentinel, by reference (ADR-0139 D2 step 2).
9072-
if (!rel) return RLS_DENY_FILTER;
9073-
9074-
// [ADR-0055 amendment] Chain guards, over the registry, BEFORE the leaf is
9075-
// written. Both answer with the empty master set — the same shape the
9076-
// #5386 sharing-resolution failure answered with, and the same posture: a
9077-
// chain this derivation cannot resolve denies, because the alternative
9078-
// ("no restriction") is the defect being fixed, and the other alternative
9079-
// (the engine's refusal of the same chain) is a 500 for a declared shape.
9080-
const refusal = this.controlledByParentChainRefusal(object, rel);
9081-
if (refusal?.kind === 'cycle') {
9082-
this.logger.error?.(
9083-
`[security] controlled_by_parent derivation found a CYCLE resolving '${object}' ` +
9084-
`(chain: ${refusal.chain.join(' -> ')}) — denying (fail-closed: a chain the derivation ` +
9085-
`cannot resolve admits no child rather than leaving it unrestricted)`,
9086-
);
9087-
return { [rel.fk]: { $in: [] } };
9088-
}
9089-
if (refusal?.kind === 'depth') {
9090-
this.logger.error?.(
9091-
`[security] controlled_by_parent derivation exceeded the chain depth bound ` +
9092-
`(${CBP_MAX_CHAIN_DEPTH}) resolving '${object}' ` +
9093-
`(chain: ${refusal.chain.join(' -> ')}) — denying (fail-closed: a chain the derivation ` +
9094-
`cannot resolve admits no child rather than leaving it unrestricted)`,
9095-
);
9096-
return { [rel.fk]: { $in: [] } };
9097-
}
9098-
return { [rel.fk]: { $in: ReadableIds.of(rel.master) } };
9060+
// [#25223 cut 4] The body moved to `security-plugin-cbp-read-filter.ts` (see `CbpReadFilterHost`).
9061+
const readLogger = () => this.logger;
9062+
const readQl = () => this.ql;
9063+
const host: Pick<CbpReadFilterHost, 'controlledByParentChainRefusal' | 'logger' | 'ql' | 'resolveCbpRelation'> = {
9064+
controlledByParentChainRefusal: (...a) => this.controlledByParentChainRefusal(...a),
9065+
get logger() { return readLogger(); },
9066+
get ql() { return readQl(); },
9067+
resolveCbpRelation: (...a) => this.resolveCbpRelation(...a),
9068+
};
9069+
return computeControlledByParentFilter.call(host, object, operation, context);
90999070
}
91009071

91019072
/**
@@ -9124,19 +9095,13 @@ export class SecurityPlugin implements Plugin {
91249095
object: string,
91259096
rel: CbpRelation,
91269097
): { kind: 'cycle' | 'depth'; chain: string[] } | null {
9127-
const chain: string[] = [object];
9128-
let master = rel.master;
9129-
for (;;) {
9130-
if (chain.includes(master)) return { kind: 'cycle', chain: [...chain, master] };
9131-
const schema = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(master) : null;
9132-
const model = schema?.sharingModel ?? schema?.security?.sharingModel;
9133-
if (model !== 'controlled_by_parent') return null;
9134-
if (chain.length >= CBP_MAX_CHAIN_DEPTH) return { kind: 'depth', chain: [...chain, master] };
9135-
chain.push(master);
9136-
const next = this.resolveCbpRelation(master);
9137-
if (!next) return null;
9138-
master = next.master;
9139-
}
9098+
// [#25223 cut 4] The body moved to `security-plugin-cbp-read-filter.ts` (see `CbpReadFilterHost`).
9099+
const readQl = () => this.ql;
9100+
const host: Pick<CbpReadFilterHost, 'ql' | 'resolveCbpRelation'> = {
9101+
get ql() { return readQl(); },
9102+
resolveCbpRelation: (...a) => this.resolveCbpRelation(...a),
9103+
};
9104+
return controlledByParentChainRefusal.call(host, object, rel);
91409105
}
91419106

91429107
/**
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"file": "packages/plugins/plugin-security/src/security-plugin-cbp-read-filter.ts",
3+
"adrs": [
4+
"ADR-0055"
5+
],
6+
"invariant": "ADR-0055 §1 — `resolveCbpRelation` derives a `controlled_by_parent` object's master from exactly one `master_detail` field declared `required: true` that names a reference, and from nothing else: zero such fields or two or more resolve `null`, which both halves treat as fail-closed (reads answer RLS_DENY_FILTER, writes throw MasterDetailRelationMissingError, 422 INVALID_METADATA), reported once per object per cache generation. Do NOT restore the retired fallback tiers (any `master_detail`, then a required `lookup`) or a first-by-field-order pick among two: they were never part of ADR-0055 §1, `ObjectSchema` refuses those shapes at parse, and a gate that derives access from a relation the parse refuses is the disagreement the step-18 semantic migration `cbp-master-relation-tiers-retired` closed. The reference-SPELLING tolerance in the same reader is a different, deliberate decision and stays."
7+
}

0 commit comments

Comments
 (0)