Skip to content

Commit df893f6

Browse files
refactor(plugin-security): move the authorization middleware out of security-plugin.ts (#25223 cut 12) (#25545)
<!-- 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 ⛔ **Tier H on both counts, so this is a human-governed landing.** (1) Size: 4,369 changed lines (+2,284 / −2,085), over the 3,000-line line. (2) Path: one path in one anchor of `docs/adr/0055-master-detail-controlled-by-parent.md` (ruling 6353073966 item 5, the `start()`-local re-anchor), in its own commit. A governed approver (os-zhuang) approved this PR and enabled auto-merge at 00:20Z on its first head. That head no longer merged once #25541 landed, so the cut was re-derived (below). The seat re-measures this head before it lands. Cut 12 of the `security-plugin.ts` split is the second part of the `start()` row. The claim is 6375757728. The seat agreed the order on the plan (R2 in cut 11, then this cut claimed afresh on the merged `main`) and cleared it after cut 11 merged as `c15fd9a72b`. This PR moves one statement of `start()`, the authorization middleware's registration, into one new module, unedited: - `packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts`: `registerAuthzMiddleware`, the `ql.registerMiddleware(…)` call whose handler every engine operation passes through, with the two module-level declarations only the handler reads. The change is move-only, and the card stays open for R4. The branch is `claude/project-thread-3fzya4`, the harness-pinned branch for this session. The branch name names no issue, so the claim comment is the identity record. ## Re-derived after #25541, and what happened to its hunk The first head (`1b2378c299`, on `0fab7baf51`) yielded to #25541, as its claim said, because #25541 inserted lines into the moved statement. #25541 merged at 23:59Z as `45f996231e`. The cut was then re-derived on that `main` by the same scripted move, and every measurement below is of the re-derivation. - **#25541's hunk now lives in the module.** Its 16 lines are the EXTERNAL rung's grant stamp in step 2.6 (`if (isExternalAudienceContext(sc)) { … stampExternalGrant(…) … }`). They sit inside the moved statement, so they moved with it, unedited, into `registerAuthzMiddleware`, where they follow the access-depth stash exactly as they did in `start()`. - They read `this.collectRLSPolicies`, so the host gains `collectRLSPolicies`: a late-bound closure, like every other method. - They import three names that nothing else in `security-plugin.ts` reads: `isExternalAudienceContext` (`@objectstack/core`), and `grantRowPolicySpeaks` and `stampExternalGrant` (`./external-owd-grant-stamp.js`). Those move to the module from the same specifiers. The second import moves whole. - #25541's other edits to `security-plugin.ts`, the `humanBaselineFor` import and the edits inside other methods, are outside the statement and stay where #25541 put them. - Two negative controls are on these lines: a token changed inside the stamp, and `collectRLSPolicies` left out of the stub. Each fails the proof. - **Why the history has a merge, not a rewrite.** AGENTS §3 forbids a force-push here: the open PR carries reviewers and an approval (④), and the branch is not `claude/issue-*` (①). So the branch keeps its two first commits, and one merge of `origin/main` (`9c43e19f65`) resolves the conflict by taking the re-derivation's tree. - The merge's tree is byte-identical to the scripted re-derivation merged onto that `main`: tree `8704c395d0`. - The re-derivation itself is `6feaeb7274` (the move and readers) and `7e3f354475` (the ADR re-anchor alone) on `45f996231e`. Both are kept as local refs and were measured there. - The diff of this head against `origin/main` is the same as that re-derivation's diff (sha256 of both diffs: `78b9e39a…`). - The seven commits `main` gained after `45f996231e` touch no file this cut touches. Commits: 1. `b7b408e502` moves the statement and the two declarations on the first base, and re-points the system-context page rows and the census ledger row. 2. `1b2378c299` is the ADR-0055 re-anchor alone: one path in one anchor, `docs/adr/0055-master-detail-controlled-by-parent.md:24`. 3. `fa9e98b036` merges `origin/main` and carries #25541's hunk with the statement, as above. ## How the statement moves The same form cut 11 used for one statement of `start()`. Base line numbers here and below are at `45f996231e`. - The statement is `:2251`-`:4207` (1,957 lines). It closes over three of `start()`'s locals and nothing else: `ctx`, `ql` and `metadata`. It uses no `arguments`, `super`, `new.target` or bare `this`. It has no `return`, `await`, `break` or `continue` at its top level that would act on `start()`; the handler's own `return`s and `await`s are inside the arrow and move with it. All of that was measured on the AST. - So the function takes the three locals as parameters, typed as the statement saw them there: `PluginContext`, `IObjectQLEngine` and `IMetadataService`. Its body is the statement, re-indented one level and otherwise unedited. It returns `void`. - `start()` keeps the statement's position, the comment above it (`// Register security middleware`, `:2250`) and the log line after it (`:4209`). In its place is one BLOCK: the getter reads, one host literal annotated with the `Pick` of `AuthzMiddlewareHost`, and `registerAuthzMiddleware.call(host, ctx, ql, metadata);`. - Registration is still synchronous and in the same place in `start()`'s order: after the `security` service registration (cut 11's stub), before the log line and the position-catalog middleware. `start()`'s three other middlewares (`:4219`, `:4538`, `:4561`) stay. ## What moved | code | base lines | host members | |---|---|---| | the authorization middleware registration (one statement of `start()`) | `:2251`-`:4207` | 25 methods: `addressedByIdWriteId`, `addressedPredicateWrite`, `assertControlledByParentWrite`, `callerCannotReadAddressedRow`, `collectRLSPolicies`, `composeWriteTenantWall`, `computeControlledByParentFilter`, `computeForbiddenFieldWrites`, `computeLayeredRlsFilter`, `computePartialMaskRules`, `computeQueryGuardFieldPerms`, `computeRlsFilter`, `computeWriteCheckFilter`, `delegatorOwnWriteFloorOptions`, `extractSingleId`, `getCallerPreImage`, `getObjectFieldNames`, `maskOperationResult`, `raiseWriteWallRefusal`, `readableRowsScopeForPredicateWrite`, `resolveCallerPosture`, `resolveOperationPrincipals`, `resolvePermissionSetsForContext`, `resolvePreImageFloorDrop`, `writeCheckFieldOptions`; by value: `engineOperationScope`; getters: `delegatedAdminGate`, `epoch`, `fieldMasker`, `logger`, `permissionEvaluator`, `ql` | | `WriteImageCheckSeam` (module interface, with its docblock) | `:407`-`:442` | none | | `userFacingDenialMessage` (module function, with its docblock) | `:1144`-`:1197` | none | - `WriteImageCheckSeam` gains `export` and nothing else. `security-plugin.ts` imports it back type-only, because the staying `WriteWallRowVerdict` docblock links to it (`{@link WriteImageCheckSeam}`, `:491`). Cut 6's type-only `principalLessDenial` import is the precedent. `userFacingDenialMessage` moves unchanged. - Import names that only the moved code reads leave `security-plugin.ts`, 23 in all. The module imports each from the specifier `security-plugin.ts` imports it from (the proof checks every pair): - `recordNotFoundError` and `isExternalAudienceContext` (`@objectstack/core`), `PUBLIC_FORM_SERVER_MANAGED_FIELDS` (`@objectstack/spec/security`), `markFilterSubtreeProvenance` (`@objectstack/spec/data`), `renderOperationMessage` (`@objectstack/spec/system`), `resolveEngineUpdateDispatch` and the type `EngineUpdateDispatchData` (`@objectstack/metadata-core`), `crossFieldClassRefusalCarriedBy` and `findCrossFieldClassRefusal` (`@objectstack/formula`); - `assertFlowSystemIdentityWriteAllowed`, `intersectDelegatedScope`, `jsonColumnCheckRefusalCarriedBy`, `storedFormCheckJudge`, `compiledPolicyNameOf`, `intersectTenantLayer0Verdicts`, `rlsOperationForVerb`, `unresolvedPostureDenialMessage`, `unresolvedPostureLogLine`, `assertReadableQueryFields`, `MaskedValueWriteError`, `assertEngineOwnedWriteAllowed`, `grantRowPolicySpeaks` and `stampExternalGrant`, each from its `./` module. - Seven comment lines describe three of those imports, and they move with them, verbatim: `[#7414]` above `renderOperationMessage`, `[#19989]` above the `@objectstack/metadata-core` import, and the `[#8220]` paragraph above the `@objectstack/spec/data` import, which names `markFilterSubtreeProvenance` alone. The `[#21829]` paragraph under it describes `MAX_BULK_PER_ROW_HOOK_ROWS`, which stays, and it stays. - Two exported functions stay, and the module imports them by value: `isGuestEnvelope`, which the principal resolution reads too, and `requiredCapsForOperation`, which `packages/spec/liveness/object.json` anchors by symbol in that file. Each is read only inside the handler, at call time, never while either module evaluates. - `RlsFilterOptions` keeps its one declaration in `security-plugin.ts`. The module names it through the class's own signature, `computeLayeredRlsFilter`'s fifth parameter, as cuts 9 and 10 did. - Staying prose that names a moved import is in backticks (`assertEngineOwnedWriteAllowed`, `intersectDelegatedScope`, `resolveEngineUpdateDispatch`, each in a staying docblock), not a link, so nothing dangles. - The module header lists the handler's steps. Its step 2.6 entry names the grant stamp too ("the access depth and an external principal's grant stamp (2.6)"). ## The shape (#23050 shape B, as cuts 1 to 11 applied it) - `AuthzMiddlewareHost` is new. It types every member as the class member itself (`SecurityPlugin['m']`). The function types its `this` as the `Pick` of the host over exactly the members its code reads, and the stub annotates its literal with the same `Pick`. There is no cast, and no member becomes public or protected. - Methods go in as late-bound closures that call `this.m(...a)` on the plugin at call time. So every step still reaches the plugin's method on the plugin, and a spy or test double set on the plugin is still the one the handler reaches. - Fields follow the rule cut 1 set, judged on each field's own declaration: - `engineOperationScope` is declared `readonly`, so it goes by value: the same `AsyncLocalStorage`. - `delegatedAdminGate`, `epoch`, `fieldMasker`, `logger`, `permissionEvaluator` and `ql` are not, so they go as getters. Each read happens when the handler makes it, on every operation, as before: a gate, epoch source or logger assigned after `start()` is the one the next operation sees. - None is function-valued: each is an object whose own methods the handler calls, or a value it reads. No receiver changes. - The host declares every field `readonly`, so moved code cannot assign a member through it. The typecheck passes, so none does. - Nothing re-exports the module, so the package entry does not move. ## Three readers key on the moved lines, and all three are re-pointed in this PR Each was run on the re-derivation with its edit reverted, and each fails without it. - `content/docs/permissions/system-context.mdx`, rows 1, 2 and 30. R3 holds `start()`'s last `isSystem` read, the row-1 short-circuit (`:2307` at base). `check-system-context-census` keys each read by file and enclosing symbol, so each row's `security-plugin.ts#start` becomes `security-plugin-authz-middleware.ts#registerAuthzMiddleware`. The row text and every other anchor stay. - Reverted: `[file-without-a-row]` for the new module and `[anchor-is-not-a-read-site]` for `#start`. - `scripts/check-system-context-census.mjs`, the `NON_READ_ANCHORS` row that declares `collapsesOntoRead` for row 2's `owner_id` guard. Its `file`, its `symbol` and the symbol name in its `why` change; `collapsesOntoRead: true` and its `rowSeams` stay. The ledger's docblock names the same pair as its worked example of `collapsesOntoRead`, so that one path changes too. - Reverted: `[ledger-stale-collapse]`, because the census no longer finds a read in `security-plugin.ts#start`. - `docs/adr/0055-master-detail-controlled-by-parent.md:24`, in its own commit (`1b2378c299`; `7e3f354475` in the re-derivation). The `rlsFilter` local is R3's (`:4064` at base), so the anchor `security-plugin.ts#rlsFilter` becomes `security-plugin-authz-middleware.ts#rlsFilter`. One path changes; the sentence, the symbol and every other anchor in the record stay. - Reverted: `check-adr-symbol-anchors` reports `[unresolved-symbol]` for that anchor. - Not readers here, measured: the #25107 pin (R3 resolves no delegator: 0 asks, 0 resolutions in the module), the core `canReadObject` caller pin (R3 has no call), and the position-name fold census (its method stays). #25541's lines add none of those. ## No new anchor entry, and why `scripts/adr-anchors/` is not touched. The `security-plugin.ts` entry is unchanged and stays true. Paragraph by paragraph: - ADR-0055 §1 is the relation reader (`security-plugin-cbp-relation.ts`). - ADR-0106 D7 is the readable-field asymmetry, in cut 9's field-readers module. - ADR-0138 D1 names the seed claim, in cut 11's module. R3's guest-owner branch cites ADR-0138 D1, but the paragraph is about the claim's re-owning arm. - ADR-0123 D2 names "step 3.7's no-active-organization refusal" and its order ahead of the forge guard. The refusal, its message and its pre-test live in `security-plugin-write-tenant-wall.ts`, which carries that paragraph in its own entry since cut 5. - Disclosed: R3's step 3.7 asks that module, takes `wall.preTest` before `wall.judgePayload(…)`, and runs only for `insert` and `update`. That call-site order now sits in a module with no entry. An ADR-0123 entry for it would need an ADR-0123 citation the moved code does not carry, and this cut plants none. A follow-up can add one if the seat wants it. ## The published `.d.ts` does not change Measured under the series bar (ruling 6348891160 item 1), on base `45f996231e` and on the re-derivation. Each was a full `turbo run build` in its own worktree (both exit 0, 77 tasks). - Raw sha256 of `dist/index.d.ts` and `dist/index.d.mts` is the same for all four files, base and head: `7e6a99f7d59debbac137ff8509e7d92fd5d000c655b412ab0f042a27ad35b380` (14,722 lines). The raw diff is 0 lines, so no union order moved either. - Normalised sha256 is the same for all four: `e22a714da70e9efc2849122d87f64bc2f364aa9a5a3fe73965118b5fd272f5a3`. - The normaliser, run from `packages/plugins/plugin-security`, 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 control, a real package build rather than an edit of the emitted file. With `private` removed from the `epoch` field in the head source, `pnpm build` in the package emits `epoch` as a public member, and the `.d.ts` pulls in `AuthzInvalidationReason`. That gives raw `96fda037ff639ae8acc5dd136334fe517dbace0789a4a0abae075cc73b014b94` and normalised `89c71232ae367d47666acc8f443ac22528b32a5d272ea91574eb2f4677e294c9`, both different from the real hashes. So the comparison does see a visibility change. With the source restored, a rebuild gives `7e6a99f7…` again. - `security-plugin.ts` gains no export. `registerAuthzMiddleware`, `AuthzMiddlewareHost` and `WriteImageCheckSeam` are in no published file, because nothing re-exports the module. - Export tables: the ESM and CJS bundles each export the same 66 names at base and at head. The lists were loaded at runtime, with `require` and `import()` of the built bundles, not parsed. The sorted name list hashes to `83576430f83cf60cecbcc5c0e80214f16c626a8e66f700294fc6a44361423116` for all four, as at cuts 6 to 11. - The bundle JS bytes differ, because code moved within them: `index.js` goes from `e29c0e06` to `77bed321`, and `index.mjs` from `d9401b7c` to `29e9cece`. The set of external specifiers each bundle loads is the same at both ends: 17 distinct `require(…)` specifiers in `index.js` and 24 in `index.mjs`. - Bundle order, disclosed. `security-plugin.ts` no longer imports twelve modules ahead of its first chunk, so they are now emitted later in `index.mjs`: `flow-system-identity-write-refusal.ts`, `explain-engine.ts`, `lifecycle-verb-rls-operation.ts`, `rls-compiler.ts`, `object-default-row-scope.ts`, `object-membership-keys.ts`, `tenant-layer.ts`, `declared-comparison-columns.ts`, `rls-check-stored-form.ts`, `external-baseline.ts`, `objects/default-permission-sets.ts` and `predicate-guard.ts`. - None of the twelve imports `security-plugin.ts`. - Their top level only declares: constants, sets, `Map`s, `WeakMap`s, a `WeakSet`, symbols, and `objects/default-permission-sets.ts`'s default sets, which it builds from its own constants. The imported values their top level reads all come from `@objectstack/spec` and `@objectstack/platform-objects`, outside the bundle. - Both built bundles load at head (`import()` and `require`), so no top-level read meets an uninitialised binding. No changeset: `skip-changeset`, per ruling 6348891160 item 4. ## Proof of move-only Measured on the re-derivation against `45f996231e`. The head tree differs from it only by `main`'s later commits, none of which touches these files. - **The statement, by AST token.** `registerAuthzMiddleware`'s body is one statement, with the same TypeScript token sequence as the base statement: 5,471 tokens. Each of its 1,957 lines is the base line with two leading spaces removed. Multi-line JSDoc text tokens inside it are re-indented one level, like every other line, so the comparison dedents those tokens one level, and only those. - The base statement is the only one in `start()` whose handler names `chainNext`; the other three `registerMiddleware` calls stay, and none is left at head that matches it. - **`start()` outside the statement.** The head text of `start()` equals the base text exactly, outside the one statement, which is now a 44-line stub block ending in the `.call`. Its signature, docblock and every other statement are byte-identical: R1, cut 11's stub, the comment above, the log line after, and R4. `start()` goes from 2,789 lines to 876. - **The stub's host literal.** It has 32 members, exactly the `Pick`, in the same order. Each is in the form its own declaration calls for: 25 methods as `this.m(...a)` closures, `engineOperationScope` by value (declared `readonly`), and six getters, each over a `read…` local that reads `this.m`. - **The host interface.** 32 members, each typed `SecurityPlugin['m']`, fields `readonly`, and its set equals the `Pick`. - **The two declarations.** `WriteImageCheckSeam`'s text in the module, docblock included, is the base text with `export` added, and nothing else. `userFacingDenialMessage`'s is the base text. Neither is left in `security-plugin.ts`. - **Class side.** Every other class member is byte-identical to base: 125 named members plus the constructor. The class has 127 members at base and at head. - **Top level of `security-plugin.ts`.** Of the 112 top-level statements at base other than the class, 93 are byte-identical at head. The other 19 are: - eight imports that each lose the names listed above (one of them also loses the `[#8220]` comment paragraph above it); - nine imports that are gone, two of them with the comment lines above them (`[#7414]`, `[#19989]`); - the two declarations, which are gone from the file. The one new statement is the module's import, with its comment. The check asserts the exact list of 23 dropped names and 3 added names (`registerAuthzMiddleware`, the type `AuthzMiddlewareHost`, the type `WriteImageCheckSeam`), and that no dropped name is still referenced in the file. - **The module's imports.** 31 names, each imported from the specifier `security-plugin.ts` imports it from, with no type import turned into a value import. `./security-plugin.js` gives only the type `SecurityPlugin` and the two staying exports. The seven comment lines that left `security-plugin.ts` above imports are exactly the module's import comments, in order. - **Negative controls.** Eleven mutations of one copy each, and every one fails the check it targets: - in the moved statement, one denial code changed (`record_change_not_allowed` to `record_access_denied`); - in the moved statement, inside #25541's grant stamp, the read stamp's `'read'` changed to `'write'`; - in `start()`, the log line after the stub changed; - in the stub, the `epoch` getter turned into a by-value member; - in the stub, `collectRLSPolicies` left out; - in the host interface, `readonly` dropped from `epoch`; - in `userFacingDenialMessage`, one space added to its signature; - in `security-plugin.ts`, `recordNotFoundError` left in the core import; - in the module, `assertReadableQueryFields` imported from `./field-masker.js`; - in the module, the `[#19989]` import comment edited; - in `security-plugin.ts`, one space added to `init()`'s signature. - **git's own view.** This is `git diff --color-moved=plain --color-moved-ws=allow-indentation-change 45f9962` over the two source files. - Of the 2,077 deleted lines in `security-plugin.ts`, git sees 2,068 as moved. The other 9 are the four one-line imports that lose names (core, `spec/security`, `spec/data`, `rls-compiler`), four specifier lines of multi-line imports, and the `interface WriteImageCheckSeam {` line, which gains `export`. - Of the 2,218 lines of the new module, 2,092 show as moved. The other 126 are among the module's own lines: its header, the regrouped imports, the `RlsFilterOptions` alias, the host interface, the function's docline and signature, and the `export interface WriteImageCheckSeam {` line. - **Size.** 4,369 changed lines (+2,284 / −2,085) over 5 files: `security-plugin.ts` (+58 / −2,077, from 8,183 to 6,164 lines), the new module (+2,218), the page (+3 / −3), the census script (+4 / −4) and ADR-0055 (+1 / −1). ## ADR anchors (ruling 6353073966 item 2) - `node scripts/check-adr-symbol-anchors.mjs` exits 0 at base `45f996231e`, on the re-derivation, on today's `main` `9c43e19f65` and on this head: 2,384 anchors over 142 records, 466 symbol anchors, at each. - `pnpm check:adr-anchors` exits 0 at each. Anchored files stay at 84. - **Decision letters on the anchored surface.** On the merge-base of the measured re-derivation, `45f996231e`: 1946. On the re-derivation: 1895. On today's `main` `9c43e19f65`: 1947. On this head `fa9e98b036`: 1896. The same 51 leave either way. That gate counts `ADR-NNNN Dk` citations in each anchored file and in each entry's own invariant text. All −51 are R3's letters leaving `security-plugin.ts`, which holds 236 at base and 185 at head: - ADR-0090 D10 ×20, ADR-0138 D1 ×6, ADR-0096 D5 ×4, ADR-0066 D2 ×3, ADR-0058 D4 ×2, ADR-0066 D3 ×2, ADR-0090 D12 ×2, ADR-0105 D5 ×2; - ×1 each: ADR-0029 D8, ADR-0056 D2, ADR-0057 D1, ADR-0058 D6, ADR-0095 D1, ADR-0111 D3, ADR-0131 D8, ADR-0139 D1, ADR-0139 D5.1, ADR-0139 D6. - Three letters leave `security-plugin.ts` entirely: ADR-0058 D6, ADR-0105 D5 and ADR-0131 D8. - #25541's lines cite a ruling of record, not a decision letter, so they move no letter. The stub and the new import comment cite none. No letter is planted and none is invented. - Every moved citation is still in the tree, in the module its code moved to. Citations that resolve tree-wide go from 52,280 across 6,688 files to 52,282 across 6,689 (on `45f996231e`), and from 52,285 across 6,691 to 52,287 across 6,692 (on `9c43e19f65`). The new file is the module. The two added citations are in its header, which names ADR-0056 and ADR-0090 D12 once each, describing two of the handler's steps. - The anchor map covers every ADR symbol anchor that names `security-plugin.ts` or the new module, at both ends: 27 citing lines over 18 symbols, measured with `extractAnchors` and `symbolSegmentResolution` from `scripts/symbol-anchors.mjs` (measurement, not a new gate). One anchor changes file: `#rlsFilter`, by the re-anchor above. No anchor changes class. - Several anchored members are named inside the stub's host literal and its `Pick` (`#computeControlledByParentFilter`, `#assertControlledByParentWrite`, `#computeRlsFilter`, `#composeWriteTenantWall`, and now `#collectRLSPolicies`). Each still resolves as `declaration` on its method. | ADR line | anchor | base | head | |---|---|---|---| | ADR-0049:49 | `#SecurityPlugin` | security-plugin.ts · declaration · `:1312` (class) | security-plugin.ts · declaration · `:1206` (class) | | ADR-0055:123 | `#computeControlledByParentFilter` | security-plugin.ts · declaration · `:7086` (`computeControlledByParentFilter`) | security-plugin.ts · declaration · `:5067` (`computeControlledByParentFilter`) | | ADR-0055:125 | `#assertControlledByParentWrite` | security-plugin.ts · declaration · `:7440` (`assertControlledByParentWrite`) | security-plugin.ts · declaration · `:5421` (`assertControlledByParentWrite`) | | ADR-0055:125 | `#assertMasterRowEditable` | security-plugin.ts · declaration · `:7566` (`assertMasterRowEditable`) | security-plugin.ts · declaration · `:5547` (`assertMasterRowEditable`) | | ADR-0055:135 | `#CBP_MAX_CHAIN_DEPTH` | security-plugin.ts · declaration · `:751` (top-level) | security-plugin.ts · declaration · `:700` (top-level) | | ADR-0055:24 | `#computeRlsFilter` | security-plugin.ts · declaration · `:6460` (`computeRlsFilter`) | security-plugin.ts · declaration · `:4441` (`computeRlsFilter`) | | ADR-0055:24 | `#rlsFilter` | security-plugin.ts · declaration · `:4064` (`start`) | authz-middleware.ts · declaration · `:2074` (`registerAuthzMiddleware`) | | ADR-0055:5 | `#computeControlledByParentFilter` | security-plugin.ts · declaration · `:7086` (`computeControlledByParentFilter`) | security-plugin.ts · declaration · `:5067` (`computeControlledByParentFilter`) | | ADR-0055:58 | `#assertControlledByParentWrite` | security-plugin.ts · declaration · `:7440` (`assertControlledByParentWrite`) | security-plugin.ts · declaration · `:5421` (`assertControlledByParentWrite`) | | ADR-0066:112 | `#writeCheckPolicies` | security-plugin.ts · declaration · `:1248` (`writeCheckPolicies`) | security-plugin.ts · declaration · `:1142` (`writeCheckPolicies`) | | ADR-0096:181 | `#getReadFilter` | security-plugin.ts · declaration · `:5195` (`getReadFilter`) | security-plugin.ts · declaration · `:3176` (`getReadFilter`) | | ADR-0096:182 | `#getReadFilter` | security-plugin.ts · declaration · `:5195` (`getReadFilter`) | security-plugin.ts · declaration · `:3176` (`getReadFilter`) | | ADR-0096:183 | `#getReadFilter` | security-plugin.ts · declaration · `:5195` (`getReadFilter`) | security-plugin.ts · declaration · `:3176` (`getReadFilter`) | | ADR-0096:199 | `#getReadFilter` | security-plugin.ts · declaration · `:5195` (`getReadFilter`) | security-plugin.ts · declaration · `:3176` (`getReadFilter`) | | ADR-0099:317 | `#hasPlatformAdminPosture` | security-plugin.ts · declaration · `:6487` (`hasPlatformAdminPosture`) | security-plugin.ts · declaration · `:4468` (`hasPlatformAdminPosture`) | | ADR-0099:68 | `#manage_metadata` | security-plugin.ts · literal · `:377` (top-level) | security-plugin.ts · literal · `:363` (top-level) | | ADR-0105:93 | `#collectRLSPolicies` | security-plugin.ts · declaration · `:7620` (`collectRLSPolicies`) | security-plugin.ts · declaration · `:5601` (`collectRLSPolicies`) | | ADR-0123:4 | `#composeWriteTenantWall` | security-plugin.ts · declaration · `:6778` (`composeWriteTenantWall`) | security-plugin.ts · declaration · `:4759` (`composeWriteTenantWall`) | | ADR-0126:406 | `#nav_permission_sets` | security-plugin.ts · literal · `:1740` (`init`) | security-plugin.ts · literal · `:1634` (`init`) | | ADR-0138:152 | `#isPrincipalLessContext` | security-plugin.ts · declaration · `:624` (`isPrincipalLessContext`) | security-plugin.ts · declaration · `:573` (`isPrincipalLessContext`) | | ADR-0138:153 | `#SecurityPlugin.getMetadataReadableFields` | security-plugin.ts · declaration · `:5330` (`getMetadataReadableFields`) | security-plugin.ts · declaration · `:3311` (`getMetadataReadableFields`) | | ADR-0138:248 | `#SecurityPlugin` | security-plugin.ts · declaration · `:1312` (class) | security-plugin.ts · declaration · `:1206` (class) | | ADR-0138:265 | `#SecurityPlugin` | security-plugin.ts · declaration · `:1312` (class) | security-plugin.ts · declaration · `:1206` (class) | | ADR-0138:427 | `#SecurityPlugin.resolvePermissionSetsForContextUnmemoized` | security-plugin.ts · declaration · `:5924` (`resolvePermissionSetsForContextUnmemoized`) | security-plugin.ts · declaration · `:3905` (`resolvePermissionSetsForContextUnmemoized`) | | ADR-0139:222 | `#getReadFilter` | security-plugin.ts · declaration · `:5195` (`getReadFilter`) | security-plugin.ts · declaration · `:3176` (`getReadFilter`) | | ADR-0139:223 | `#computeDelegatedReadFilter` | security-plugin.ts · declaration · `:5249` (`computeDelegatedReadFilter`) | security-plugin.ts · declaration · `:3230` (`computeDelegatedReadFilter`) | | ADR-0139:246 | `#computeControlledByParentFilter` | security-plugin.ts · declaration · `:7086` (`computeControlledByParentFilter`) | security-plugin.ts · declaration · `:5067` (`computeControlledByParentFilter`) | ## Censuses, and the `collapsesOntoRead` reading Each census was run at base `45f996231e` and on the re-derivation, and compared site by site, with the module path mapped back to `security-plugin.ts` and `registerAuthzMiddleware` mapped back to `start`. - `isystem-census`: 149 sites at both ends, 0 differences. The one moved read is the row-1 short-circuit, `if (opCtx.context?.isSystem)`: `security-plugin.ts:2307` (`start`) at base, `security-plugin-authz-middleware.ts:317` (`registerAuthzMiddleware`) at head. Scanned files go from 406 to 407, the module. - `check-system-context-census` exits 0 at both ends with the same summary: 149 sites in 22 packages across 77 files, 129 symbols, 142 cited against 142 required, 164 anchors, 21 declared non-read, 20 row references. It exits 0 on today's `main` and on this head too. - `collapsesOntoRead`, at both ends. The gate holds the ledger row's declaration to the census in both directions, and both ends agree: - base: the row names `security-plugin.ts#start` and declares the overlap; the census finds exactly 1 elevation read in `security-plugin.ts#start`; - head: the row names `security-plugin-authz-middleware.ts#registerAuthzMiddleware` and declares the overlap; the census finds exactly 1 read there, and 0 in `security-plugin.ts#start`. - Without the ledger edit, the head reports `[ledger-stale-collapse]` (above). - `tenant-audit-census`: 238 sites at both ends, 0 differences, 176 non-engine calls, 87 engine types, 117 declared objects. The moved handler holds no site, #25541's lines included. - `pnpm check:query-options-erasure` exits 0 at both ends; its baseline key set is unchanged ("no files added"). ## Gates All gates ran against base `45f996231e` and on the re-derivation (`7e3f354475`), each in its own worktree after a full `turbo run build` there (both exit 0, 77 tasks). - `pnpm --filter @objectstack/plugin-security test` (`vitest run`): Test Files 222 passed (222); Tests 4628 passed, 45 skipped (4673). Base `45f996231e` gives the same counts. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 at head and at base. It runs `tsc --noEmit`, the scripts tsconfig, and `check:test-typecheck`. - `pnpm --filter @objectstack/plugin-security build`: exit 0. It produced the `.d.ts` and the bundles measured above. - `row-serving-door-exposure.pin.test.ts` and `operation-private-keys.pin.test.ts` (`@objectstack/core`): 8 of 8 pass at head and at base. - `dispatch-gates --commands`: 110 commands, the same set as on the first head. The `--ran` reconciliation shows 110 derived, 110 run and 0 NOT-MEASURED. The zero is derived: all 110 recorded an exit code, and none is 3. - Hand-run, because the series reads them every cut: `check-adr-symbol-anchors`, `pnpm check:adr-anchors`, `check-platform-checklist` (script and `pnpm check:platform-checklist`), `check-scripts-symbol-anchors` and its `--self-test`, `pnpm check:query-options-erasure`, the two census `--json` reads, `check-system-context-census` and its `--self-test`, and `pnpm --filter @objectstack/spec check:liveness`. - Artifact rosters: ten families keep their roster in a directory this change touches, so `dispatch-gates` marks their silence as no evidence. All ten were run: `check-published-list-mirrors` and its `--self-test`, `check:authz-resolver`, `check:console-injection`, `check:engine-double-contract`, `check:error-code-casing`, `check:filter-alias-parity`, `check:i18n-stale-fill`, `check:published-readme-exports` and `check-dts-references --self-test`. The package typecheck was run with them. - **Base against head, all 128 commands.** All 128 exit 0 at head and at base. 102 give identical output at both, once the worktree path and timings are masked. The other 26 are the same 26 families as on the first head, each read line by line: - These move as described above: `check:adr-anchors` (1946 to 1895 letters) and the two census `--json` reads (the module path and symbol of the one moved read). - The plugin-security suite's console output is not stable between runs: log lines from parallel workers, generated ids and timestamps. Its counts are the same at both ends. - `check-issue-citations` judges a change set, so it has nothing to read at base. At head it judged 134 citations across 2 files: 129 resolve, 4 resolve as pull requests and 1 is cross-repo. - `check:merge-driver` differs only by the name of the temporary repository it builds. - The other 20 move by exactly the one new file, and by nothing else: `check-closing-keyword-parity`, `check:gitlink-declared` and `check:nul-bytes` (tracked files), `check-comment-mask-corpus`, `check-plugin-teardown-shape`, `check-position-name-fold-loaders` (the same 8 references), `check:duration-unit-keys`, `check:closed-ceremony-text`, `check:error-status-conformance` (the same 511 producer sites), `check:logger-receiver-detach`, `check:org-identifier`, `check:page-declaration-shape`, `check:pm-dispatch-gates`, `check:refd-timer-probe`, `check:vendor-version-stamps`, `check:engine-double-contract` and `check:error-code-casing`, plus three that also count what the files hold: - `check-undeclared-dep-imports`: 3,341 to 3,342 files, and 2,914 to 2,919 `@objectstack/*` specifiers (the module imports from seven package specifiers; `security-plugin.ts` drops two); - `check:doc-authoring`: 1,456 to 1,457 parsed sources, and 105,684 to 105,793 strings read. The 109 new strings are the module's import specifiers net of the imports `security-plugin.ts` drops (+11), the new import's specifier (+1), the host's indexed-access keys (+32), the alias's (+1), and the `Pick` key lists in the function signature and the stub (+32 each); - `check:skill-identifier-liveness`: 140,292 to 140,294 implementation word tokens (`registerAuthzMiddleware`, `AuthzMiddlewareHost`), with the same 457 citations checked. - On this head's tree, which adds `main`'s seven later commits to the re-derivation: `check-adr-symbol-anchors`, `check:adr-anchors`, `check-system-context-census` exit 0 (above). CI runs the rest on this head. ## Acceptance notes - Nothing outside this cut is touched: no other section of the class or of `start()`, no behaviour, no anchor entry and no `packages/spec/**`. Outside the package, three files change: three anchor paths on the system-context page, the census ledger row and its docblock example, and the one ADR-0055 anchor path. - Serial check: #25541 has landed and its hunk moved with the statement (above). #25528 landed before it and touches nothing this cut moves. - After this cut, `start()` holds R1, cut 11's stub, this cut's stub and R4. What is left of the row is R4, the hooks `start()` registers after the middleware. R4 assigns `claimTargetAdminUserId`, so it moves as cut 13 with that field as a get/set host (the #25508 precedent), or it is recorded in the final report. --- _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 e1e9022 commit df893f6

5 files changed

Lines changed: 2284 additions & 2085 deletions

File tree

‎content/docs/permissions/system-context.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ that silently does not happen.
9696

9797
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
9898
|:--|:---|:---|:---|:---|
99-
| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: every gate under it in this lane at once, down to and including the write-bypass row that widens the effective write scope to `org` (row 7) — this is the single largest behaviour on the page | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
99+
| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: every gate under it in this lane at once, down to and including the write-bypass row that widens the effective write scope to `org` (row 7) — this is the single largest behaviour on the page | `packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts#registerAuthzMiddleware` |
100100
| 1b | **A flow run's system context is REFUSED governed writes to the identity-assignment objects** — the one check ahead of the middleware's short-circuit | plugin-security | Get: nothing — an insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from a context carrying `isSystem` and a `flowRunId` (a `runAs: 'system'` flow's data nodes, at any depth of their writes) is refused `PERMISSION_DENIED` before the short-circuit skips the ADR-0090 D12 gate and the identity write guard. Lose: nothing a platform writer held — a system context without a `flowRunId` (the seeders, better-auth and its reconciliation, invitation placement) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` run carries no `isSystem`, so the D12 gate judges it as the user it acts for | `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#assertFlowSystemIdentityWriteAllowed` |
101-
| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | the step 3.5 guard block and the short-circuit that skips it are both inside `packages/plugins/plugin-security/src/security-plugin.ts#start` |
101+
| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | the step 3.5 guard block and the short-circuit that skips it are both inside `packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts#registerAuthzMiddleware` |
102102
| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `packages/plugins/plugin-security/src/security-plugin-read-filter.ts#getReadFilter` |
103103
| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable (`getReadableFields`) and writable (`getWritableFields`). Lose: field masking | `packages/plugins/plugin-security/src/security-plugin-field-readers.ts#resolveProjectionFieldMask` |
104104
| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin-object-admission.ts#canExport` |
@@ -141,7 +141,7 @@ that silently does not happen.
141141
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `packages/objectql/src/engine.ts#recordReferenceCheckElevation` |
142142
| 29b | A traversing validation rule's related read skips the **caller's bounds** | objectql | Get: no row bound from the caller's own read on a related object no organization wall scopes, no read door on a header the rule reads through a `master_detail` field, and a related read even with no organization under a walled posture (a `tenantId` on the context still scopes it). Every caller that is not system is held to all three: a header its read door does not return is unavailable to the rule exactly as a header that exists nowhere is, whatever it holds, and a header it can read is judged on its stored values. A plain `lookup` to an object an organization wall scopes asks no read door, system caller or not | `packages/objectql/src/engine.ts#resolveOneHopRelated` |
143143
| 29c | A detail write's master-detail header read asks **no read door** | objectql | Get: the write binds the header a parent-scoped `requiredWhen` / `readonlyWhen` reads as `parent` from the header's stored row whoever can read it, as before. Every caller that is not system meets its own read door first: a header that read does not return answers as a header that exists nowhere — an unbound `parent`, and `reference_not_found` on the reference check (which a system caller's write skips altogether) | `packages/objectql/src/engine.ts#masterDetailHeadersCallerReads` |
144-
| 30 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
144+
| 30 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts#registerAuthzMiddleware` |
145145
| 30b | **The import doors admit a control opt-out** — `runAutomations: false` and `treatAsHistorical: true` on `POST /data/:object/import` and `/import/jobs` | rest | Get: an import whose rows are written with `skipAutomations` (no metadata-bound hook, no record-change trigger: an app's approvals and record freezes among them) or with `skipStateMachine` and `preserveAudit`. Every other caller, every signed-in user and administrator included, is refused `403 PERMISSION_DENIED` before the body is parsed, so nothing is written and no job is created. Inbound HTTP never carries the flag, so this is an in-process caller's door. Note: the flag still suppresses none of those controls by itself (see below); it only admits the opt-outs that do | `packages/rest/src/import-prepare.ts#importControlOptOutRefusal` |
146146
| 30c | **The import undo keeps the control skips** — `POST /data/import/jobs/:jobId/undo` | rest | Get: an undo whose deletes and restores are written with `skipAutomations` and `skipStateMachine`, so no metadata-bound hook, record-change trigger or `state_machine` rule refuses a reversal. Every other caller, every signed-in user and administrator included, undoes under those controls: a reversal one of them refuses is reported per row in the undo answer's `failures` and kept in the job's undo log, and the job is stamped reverted only once every row is. A historical job's `preserveAudit` belongs to the job, not the caller, and rides every undo. Inbound HTTP never carries the flag, so this is an in-process caller's door | `packages/rest/src/data-action-routes.ts#importUndoWriteContext` |
147147

‎docs/adr/0055-master-detail-controlled-by-parent.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ A **master-detail detail** record today is access-controlled entirely on its own
2121

2222
## Context — the mechanisms this builds on (verified)
2323

24-
1. **Read-path RLS injection.** `packages/plugins/plugin-security/src/security-plugin.ts#rlsFilter` AND-s an RLS filter into the query AST (`opCtx.ast.where = { $and: [where, rlsFilter] }`) before the driver runs. The builder `computeRlsFilter` (`packages/plugins/plugin-security/src/security-plugin.ts#computeRlsFilter`) is **async** and shared by the engine find-path and the analytics raw-SQL path (`getReadFilter`).
24+
1. **Read-path RLS injection.** `packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts#rlsFilter` AND-s an RLS filter into the query AST (`opCtx.ast.where = { $and: [where, rlsFilter] }`) before the driver runs. The builder `computeRlsFilter` (`packages/plugins/plugin-security/src/security-plugin.ts#computeRlsFilter`) is **async** and shared by the engine find-path and the analytics raw-SQL path (`getReadFilter`).
2525
2. **Pre-resolved membership (§7.3.1) already exists.** The RLS compiler recognizes `field IN (current_user.<key>)` and resolves `<key>` against `ExecutionContext.rlsMembership` — "the runtime resolves set-membership that would otherwise need a subquery … and stages each set here under a stable key" (`packages/spec/src/kernel/execution-context.zod.ts#rlsMembership`; merge at `packages/plugins/plugin-security/src/rls-compiler.ts`). **This is the seam controlled_by_parent plugs into — no new compiler form.**
2626
3. **By-id write pre-image check (#1994).** `packages/plugins/plugin-security/src/security-plugin.ts` already re-reads the target row under the write-op RLS filter before an update/delete and denies if invisible. This is the exact hook to extend with a master-access check.
2727
4. **`sharingModel` enforcement seam.** `packages/plugins/plugin-sharing/src/sharing-service.ts#buildReadFilter` reads `object.sharingModel`; `buildReadFilter` gates on it (`effectiveSharingModel(schema) !== 'private'`). A `controlled_by_parent` baseline plugs in here / in the security middleware.

0 commit comments

Comments
 (0)