Skip to content

Commit 77a77e4

Browse files
refactor(plugin-security): move the rest of the tenant write wall and the context prologue out of security-plugin.ts (#25223 cut 6) (#25396)
<!-- 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 6 of the `security-plugin.ts` split: the rest of step 3.7's Layer 0 tenant write wall, and the engine middleware's context prologue. The claim is 6358893564, amended by 6359105931 before the push (below). The bodies of four async methods move unedited out of the class: - to `packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts`, beside cut 5's `composeWriteTenantWall`: - `organizationWallRefusal`, the ADR-0123 D2 scope pre-test. The wall and `canWriteObject` both ask it. - `writeWallVerdict`, the wall's per-row verdict for the write preview (#25062), which the engine asks through `registerWriteWallVerdict`. - to the new module `packages/plugins/plugin-security/src/security-plugin-operation-principals.ts`: - `resolveOperationPrincipals`, the context prologue. It refuses a context the operation may not run under (ADR-0096 D5 strict mode; on a walled posture, an on-behalf-of context whose delegator is not a member of the live organization, ADR-0090 D10 and #25063). It resolves every other context to the principal's permission sets, and the delegator's on a delegated context. - `refuseGuestWithoutOrganization`, the prologue's ADR-0138 D4 refusal of a guest acting in no organization. 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 `bdf762c9bd`, which carries cut 5's merge `be318db654`. It names no issue, so the claim comment is the identity record. **What stays in the class.** Each moved method keeps its name, signature, docblock and position, less `async`. Three thin adapters of the wall stay whole: `computeWriteTenantCheckFilter`, `computeWriteTenantWall` and `raiseWriteWallRefusal`. Each body is shorter than its stub would be. The middleware, `canWriteObject` and every other caller still call the methods. **Module-level declarations.** - Two move to the wall module, as they stood, docblocks included: `callerHasOrganizationScope` and `carriesActiveOrganization`. Only the moved bodies read them: the pre-test reads the first, and the first and cut 5's body read the second. `carriesActiveOrganization` loses the `export` cut 5 gave it, so the wall module no longer imports anything back from `security-plugin.ts` for it. - Two move to the prologue module: `principalLessDenial`, the ADR-0096 D5 refusal only the prologue throws, docblock included, and the `@objectstack/core` import of `GuestOrganizationUnresolvedError` and `resolveGuestOrganization`, comment included, because only the guest refusal reads it. `principalLessDenial` gains `export` there. - Three stay and gain `export`. Each module imports what it reads by value, as cuts 3 and 5 import theirs. - `isPrincipalLessContext`: ADR-0138:152 anchors it in `security-plugin.ts`, and the probes and the row scope read it there. - `isGuestEnvelope`: the middleware's owner-write step and the set resolution read it. - `isTenantWallDenial`: `tenant-layer.ts` names it as living in `security-plugin.ts`. - `isPrincipalLessContext`'s docblock names the refusal with `{@link principalLessDenial}`. `security-plugin.ts` imports that name type-only from the prologue module, so the link still resolves. A type-only import emits nothing and has no runtime effect; typecheck and lint pass with it. - **Why the refusal moves rather than staying exported (the claim amendment).** The claim had `principalLessDenial` staying in `security-plugin.ts` with `export`. That was measured on the first head build: its return type makes the file's declarations import `errors.ts`, and the published `dist/index.d.ts` then declares `PermissionDeniedError` and `isPermissionDeniedError` at the top instead of after the class. That is a raw diff of 102 lines, one 51-line block moved with its text unchanged, and ruling 6348891160 admits union-order swaps only. With the refusal moved, the head `.d.ts` is byte-identical to base. - Each value import is read only inside a function body, at call time, never while either module evaluates. `index.ts` still exports only `SecurityPlugin`, so the published surface does not move (measured below). - `WriteTenantWall`, `WriteWallRefusal` and `WriteWallRowVerdict` keep their one declaration. The method signatures, `raiseWriteWallRefusal` and `start()` read them. The wall module names all three through the class's own signatures, as cuts 2 to 5 name theirs. - The `postureUsesUnionScope` name leaves the `@objectstack/spec/security` import of `security-plugin.ts`, because no code left there reads it. The wall module already imports it. **Two readers outside the moved code follow the bodies.** - `content/docs/permissions/system-context.mdx`, row 6c. Its anchor is `security-plugin.ts#writeWallVerdict`, the census site of the body's `context.isSystem` read. `check-system-context-census` keys each elevation read by file and enclosing symbol, so with the body moved and the anchor unchanged the census finds a file with no row. The anchor now names `security-plugin-write-tenant-wall.ts#writeWallVerdict`. Only the path changed. The census lists the same sites at base and head, with the one site's file mapped back (below). - `packages/plugins/plugin-security/src/delegated-organization-membership.test.ts`, the #25107 enumeration pin. It counts, per source file, the delegator resolutions and the organization-refusal asks, and lists the files that resolve one. The prologue's one resolution and its one ask now sit in the new module. The expected list gains `security-plugin-operation-principals.ts`, and the title says three files instead of two. The rule it pins, asks equal resolutions in every file, is unchanged, and it holds at head: the new module counts 1 and 1, `security-plugin.ts` 7 and 7. ## The published `.d.ts` does not change Under the series bar (ruling 6348891160 item 1), measured on base `bdf762c9bd` 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: `af5dee1bebafa0376a876d1e7b247c878ea42ac35c7336a6d3c12a7953143156`. The raw diff is 0 lines, so no union order moved either. - Normalised sha256, all four the same: `431e7f5097ac1c220bc840188aef02105bc0b16e845e976dd562d02cc79be8bd`. - The hashes differ from cut 5's report because `origin/main` moved between cut 5's base and this one. Base and head here are both built against `bdf762c9bd`'s dependencies, rebuilt on that commit first. - The first head build of this cut did not meet the bar, and the claim amendment 6359105931 records why. With `principalLessDenial` kept in `security-plugin.ts` and exported, the head `.d.ts` declared `PermissionDeniedError` and `isPermissionDeniedError` at the top instead of after the class: a raw diff of 102 lines, one 51-line block moved with its text unchanged. The refusal moved to the prologue module instead, and the head above is the tree after that change. - 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 1813 (`private organizationWallRefusal;`): - Made public (`organizationWallRefusal;`): raw `648873d54d4f3adf15cdbdd199ae7fa25bc46a30230249cebfe5e5bb812c29d1`, normalised `847afa0caa5b4756a02a9e7833a0a878783a41187ac45e2e6aea5600a2b35fcf`. - Renamed (`private organizationWallRefusalRenamed;`): raw `7a1e24b3874a47fb0a2a54f368917c6db961f2fcde858a11145e55cf7c9e423d`, normalised `f76ae5f05b7ca709d90f94a15277906f2862240f3b923c61707dcd703a632bec`. - Both differ from the real hashes, so the comparison does see a visibility change and a rename. The first head build above is a third, unplanned control: the comparison saw one declaration move. - `writeWallVerdict` is public, so the `.d.ts` carries its full signature. It is the same line at base and head (line 1874 of both): the four parameters `object`, `operation`, `context` and `rows`, and the promise of one verdict per row or `null`. - Export tables: the ESM and CJS bundles each export the same 66 names at base and head. The lists were loaded at runtime (`require` and `import()` of the built bundles), not parsed. The sorted name list hashes `83576430f83cf60cecbcc5c0e80214f16c626a8e66f700294fc6a44361423116` on all four. The count differs from cut 5's 63 because `origin/main` added exports between the two bases; it is the same at this base and head. The new exports of `security-plugin.ts` (`isGuestEnvelope`, `isPrincipalLessContext`, `isTenantWallDenial`) do not reach the entry. - The bundle JS bytes differ (`index.js` `fe2eebf9` to `77ef1113`, `index.mjs` `254fc57b` to `f604cc07`), because code moved within them. No changeset: `skip-changeset`, per ruling 6348891160 item 4. ## What moved The base line numbers are each method's signature and closing brace at `bdf762c9bd`. The last column lists the members the body reads, which is exactly what its host carries. | method | to | base lines | host members | |---|---|---|---| | `organizationWallRefusal` (private, async) | wall | `:8471`-`:8493` | `computeWriteTenantCheckFilter`, `orgScopingEnabled`, `tenancyPosture` | | `writeWallVerdict` (public, async) | wall | `:8580`-`:8612` | `composeWriteTenantWall`, `resolveOperationPrincipals` | | `resolveOperationPrincipals` (private, async) | prologue | `:8953`-`:9012` | `logger`, `ql`, `refuseGuestWithoutOrganization`, `resolvePermissionSetsForContext`, `tenancyPosture` | | `refuseGuestWithoutOrganization` (private, async) | prologue | `:9038`-`:9062` | `guestOrganizationRefusalWarned`, `logger`, `ql`, `tenancyPosture` | Each stub drops `async` and returns the moved function's promise, as cuts 1, 3 and 5 did for their async methods. The `.d.ts` emits no `async`, so this does not reach it. That includes `writeWallVerdict`, the one public method here, whose full signature the `.d.ts` carries. ## The shape (#23050 shape B, as cuts 1 to 5 applied it) - The class keeps each method with its name, signature, docblock and position, because tsup emits members with their JSDoc into the `.d.ts`. Each body is now one host literal plus `fn.call(host, …)`, marked `[#25223 cut 6]`. - `WriteTenantWallHost` gains the members the two new wall bodies read (`composeWriteTenantWall`, `resolveOperationPrincipals`), and `OperationPrincipalsHost` is new. Each types every member as the class member itself (`SecurityPlugin['m']`). Each moved function types its `this` as the `Pick` of its host over exactly the members its body 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. A spy or test double set on the plugin is still the one the body reaches: - the pre-test still asks the read side's Layer 0 through `computeWriteTenantCheckFilter`; - the preview still runs the prologue and the wall through the plugin; - the prologue still asks the plugin's `refuseGuestWithoutOrganization` and `resolvePermissionSetsForContext`. No moved body calls a function-valued field, so no receiver changes. - Fields follow the rule cut 1 set, on each field's own declaration. - `guestOrganizationRefusalWarned` is declared `readonly` and goes by value, so the guest refusal records each reason it warned for in the plugin's own set, once per reason as before. - `ql`, `logger` and `tenancyPosture` are not `readonly` (the plugin assigns each after construction), and `orgScopingEnabled` is the class's own getter. All four go as getters, so each read happens when the body makes it. - The host declares every field `readonly`, so a moved body cannot assign a member through it; the typecheck passes, so none does. - Nothing re-exports either module, so the package entry does not move. ## Proof of move-only - **Bodies, by AST token**: each moved function's body has the same TypeScript token sequence as the base method body, and each of its 117 lines is the base line with two leading spaces removed. Parameters, the return type and `async` match. Two negative controls fail the check: - in the prologue module, the delegation branch's guard on the resolved set count changed from greater than zero to at least zero; - in the wall module, `context.isSystem || !context.userId` changed to `context.isSystem && !context.userId` in the preview's first guard. - **Cut 5's function**: `composeWriteTenantWall` in the wall module is byte-identical to base, its pointer docblock included. - **Class side**: each moved method keeps its docblock and signature, less `async`. Every other class member, 122 named members plus the constructor, is byte-identical to base. That includes the three adapters, `canWriteObject`, `checkControlledByParentWrite`, `resolvePermissionSetsForContext` and `start()`. The class has 127 members at base and at head. - **Top level of `security-plugin.ts`**: 96 of the 105 base top-level statements other than the class are byte-identical to base, `WriteTenantWall`, `WriteWallRefusal` and `WriteWallRowVerdict` included. The nine that differ: - three are the base text with `export ` before `function`: `isGuestEnvelope`, `isPrincipalLessContext`, `isTenantWallDenial`; - four moved, with their text checked in the module: `callerHasOrganizationScope` (identical), `carriesActiveOrganization` (identical less the `export ` cut 5 added), `principalLessDenial` (identical plus `export `) and the `@objectstack/core` import (identical, comment included); - the `@objectstack/spec/security` import, which loses `postureUsesUnionScope`; - cut 5's import of the wall module, which now names cut 6's two functions too. The one new statement is the import of the prologue module, with the type-only `principalLessDenial`. - **git's own view** (`git diff --color-moved=plain --color-moved-ws=allow-indentation-change bdf762c HEAD` over the three source files): 183 of 240 deleted lines and 211 of 448 added lines are detected as moved. - Deleted, not moved (57). In `security-plugin.ts` (14): cut 5's import of the wall module with its comment (4 lines), the `postureUsesUnionScope,` import line, the five declaration lines that come back changed (`isGuestEnvelope`, `isPrincipalLessContext` and `isTenantWallDenial` with `export`; `principalLessDenial` with `export`, in the prologue module; `carriesActiveOrganization` without `export`, in the wall module) and the four method signature lines, which come back without `async`. In the wall module (43): cut 5's module docblock and its import of `carriesActiveOrganization`, rewritten for two cuts, and two lines of the host docblock, which now speak of several functions. - Added, not moved: those lines' replacements, the four stubs and the two import blocks with their comments. In the wall module: the new docblock and imports, the third type alias and the two new host members. In the prologue module: the license header, the docblock, the imports, `OperationPrincipalsHost`, the pointer docblocks and the rewritten signature heads. The rest are lines git does not pair with a moved block, such as blanks and closing braces. - **Size**: 694 changed lines (+451 / -243, 5 files), under the 3000-line human-merge threshold. `security-plugin.ts` goes from 9,992 to 9,863 lines, and the wall module from 437 to 567. The prologue module is 207 lines. ## ADR anchors (ruling 6353073966 item 2) - `node scripts/check-adr-symbol-anchors.mjs`: exit 0 at base `bdf762c9bd` 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 same counts as cut 5. 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 at base and at head, 79 anchored files at both (no entry added or changed). One count moves, and it is disclosed here rather than answered with a new anchor entry. The decision-letter citations on the anchored surface go from 2014 to 2010. Seven `ADR-NNNN Dk` citations move with the prologue's text into the prologue module, which has no anchor entry: `ADR-0090 D10` four times in the delegation branch, `ADR-0138 D4` twice (the moved import comment and the guest refusal's warning) and `ADR-0096 D5` once (`principalLessDenial`'s docblock). The two new import comments in `security-plugin.ts` add three. Every citation still resolves as a citation: the total goes from 52091 to 52100, across 6619 to 6620 files. Writing an invariant paragraph for the prologue module would be new prose about the prologue, outside a move-only cut. #25224 cut 5 disclosed the same kind of shift the same way. The wall module is anchored, so the six citations that move into it (the pre-test's comment and the two lifted docblocks) stay on the anchored surface. - `scripts/adr-anchors/` is unchanged. The wall module's entry (cut 5) carries ADR-0123 and the ADR-0123 D2 paragraph, which describes the no-active-organization pre-test; `organizationWallRefusal` is that pre-test, and it now lives in the module the entry names. No paragraph of the `security-plugin.ts` entry (ADR-0055, ADR-0106, ADR-0123 D2, ADR-0138 D1) describes the prologue or the guest refusal, so the prologue module gets no entry, as cut 1's module has none. 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. It was measured with `extractAnchors` and `symbolSegmentResolution` from `scripts/symbol-anchors.mjs`, which is measurement, not a new gate. It was read on `bdf762c9bd` BEFORE the cut, with the four 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 · `:663` (top-level) | declaration · `:658` | | `#SecurityPlugin` | ADR-0049:49, ADR-0138:248, ADR-0138:265 | declaration · `:1340` (class) | declaration · `:1290` | | `#SecurityPlugin.getMetadataReadableFields` | ADR-0138:153 | declaration · `:2516` (`start`), `:6135` (method) | declaration · `:2466`, `:6085` | | `#SecurityPlugin.resolvePermissionSetsForContextUnmemoized` | ADR-0138:427 | declaration · `:7046` (method) | declaration · `:6996` | | `#assertControlledByParentWrite` | ADR-0055:58, ADR-0055:125 | declaration · `:9249` (method shell) | declaration · `:9120` | | `#assertMasterRowEditable` | ADR-0055:125 | declaration · `:9375` (method shell) | declaration · `:9246` | | `#collectRLSPolicies` | ADR-0105:93 | declaration · `:9429` (method) | declaration · `:9300` | | `#composeWriteTenantWall` | ADR-0123:4 | declaration · `:8513` (method shell) | declaration · `:8458` (method shell), and `:8533` (see below) | | `#computeControlledByParentFilter` | ADR-0055:5, ADR-0055:123, ADR-0139:246 | declaration · `:8841` (method shell) | declaration · `:8766` | | `#computeDelegatedReadFilter` | ADR-0139:223 | declaration · `:6013` (method) | declaration · `:5963` | | `#computeRlsFilter` | ADR-0055:24 | declaration · `:5265` (`explainAccessForCaller`), `:7740` (method) | declaration · `:5215`, `:7690` | | `#getReadFilter` | ADR-0096:181, :182, :183, :199, ADR-0139:222 | declaration · `:2275` (`start`), `:5891` (method) | declaration · `:2225`, `:5841` | | `#hasPlatformAdminPosture` | ADR-0099:317 | declaration · `:7767` (method) | declaration · `:7717` | | `#isPrincipalLessContext` | ADR-0138:152 | declaration · `:517` (top-level) | declaration · `:531` (top-level, now `export function`) | | `#manage_metadata` | ADR-0099:68 | literal · `:266` (`PLATFORM_ADMIN_ONLY_CAPABILITIES`) | literal · `:280` | | `#nav_permission_sets` | ADR-0126:363 | literal · `:1768` (`init`) | literal · `:1718` | | `#rlsFilter` | ADR-0055:24 | declaration · `:4342` (local in `start`) | declaration · `:4292` | | `#writeCheckPolicies` | ADR-0066:112 | declaration · `:1235` (top-level) | declaration · `:1185` | No resolving site moves out of the file. One anchor gains a site, and its class does not change. ADR-0123:4 `#composeWriteTenantWall` still resolves on the method declaration, which stays as a shell. At head the name also starts a line in `writeWallVerdict`'s stub, as its host-literal key, the closure that calls the method on the plugin (`:8533`), and appears quoted in that stub's `Pick` (`:8532`). Cut 3's stub added the same kind of site for `#assertControlledByParentWrite` (`checkControlledByParentWrite`'s stub, `:8980` here). The anchor is judged by its class, which is `declaration` at base and at head. ADR-0138:152 `#isPrincipalLessContext` resolves on the declaration, which stays and gains `export`. ## Readers Two readers of the moved lines exist outside the moved code, the census row and the enumeration pin above, and both are re-pointed in this PR. No other test, script, gate, baseline or docs anchor reads the moved lines by path. - **The system-context census.** `check-system-context-census` exits 0 at base and at head. Both print `148 elevation read sites in 22 packages … the page cites 140 symbol(s) against 140 required, over 163 anchors`. The only difference is the file count, 70 to 71, because the one read site of `writeWallVerdict` now sits in the wall module. `node scripts/isystem-census.mjs --json` gives the same 148 sites at base and head, compared without line numbers and with the two new modules mapped back to `security-plugin.ts`: 0 differences. The role counts are the same (`read` 153, `declaration` 38, `key` 438, `other` 9), and only the corpus and scanned file counts move, by one. - **The tenant-audit census.** The moved bodies hold no engine write: the prologue reads through `resolveDelegatorContext` and `resolveGuestOrganization`, which take `ql` as an argument. `node scripts/tenant-audit-census.mjs --json` gives the same 237 write call sites at base and head, compared the same way: 0 differences, the same totals, 0 unresolved, 0 unledgered. Only `scannedSources` moves, 715 to 716, plus the measured-at ref. `check-tenant-audit-census` prints the same line at both. - **The #25107 enumeration pin** is described above. It is part of the plugin-security suite, which passes at head with the same counts as base. - **`packages/core/src/security/row-serving-door-exposure.pin.test.ts`** classifies every caller of the read admission. The moved bodies call no admission, so the new module needs no row. Run on its own: 3 of 3 pass. - **The platform checklist.** `check-platform-checklist` exits 0 at base and head with the same output. No checklist citation names a moved symbol or a local inside a moved body. - **Readers by name, not by path.** These were read and none needs a change: - `packages/core/src/security/guest-organization.ts` names `SecurityPlugin.resolveOperationPrincipals`, and `explain-engine.ts` and `delegated-organization-membership.test.ts` name `resolveOperationPrincipals`. The method still exists under that name. - `tenant-layer.ts` names `isTenantWallDenial` in `security-plugin.ts`, and `explain-engine.ts` names `isTenantWallDenial`. It stays there. - `write-wall-verdict-equivalence.test.ts` calls `plugin.writeWallVerdict(…)`, and `packages/objectql` names it in comments. The public method still exists with the same signature. - Docblocks in `security-plugin.ts` name `{@link resolveOperationPrincipals}`, `{@link composeWriteTenantWall}` and `{@link canWriteObject}`. The methods still exist. - **Line citations**: no `security-plugin.ts:N` citation in the repo points into the moved ranges (base `:8471`-`:8493`, `:8580`-`:8612`, `:8953`-`:9012`, `:9038`-`:9062`) or at the moved module-level declarations. The citations found are the list cuts 2 to 5 read (`docs/launch-readiness.md` `:309`, `packages/objectql/src/engine.ts` `:1614`, `:1690`, `:1736`, spec liveness `:3281` and the rest). 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 bdf762c` (advisory): 1 page, `content/docs/permissions/system-context.mdx`, through `writeWallVerdict`. That is the census row this PR re-points. - **Census gates, run on base `bdf762c9bd` and head and compared:** - Identical output: `check-adr-symbol-anchors`, `check-platform-checklist`, `check-platform-object-tenancy-census`, `check-registry-log-declared`, `check-rest-log-spy-declared`, `check-tenant-audit-census`, `check:cross-package-test-inputs`, `check:driver-memory-census` and `check:query-options-erasure`. The last reports the same counts at both, and `security-plugin.ts` is not a baseline key. - Only scanned counts move: - `check-system-context-census`: 70 to 71 files, as above. - `check-undeclared-dep-imports`: 3301 to 3302 files, and 2843 to 2844 specifiers. The prologue module imports `@objectstack/spec/security` and `@objectstack/core`, and `security-plugin.ts` drops the one `@objectstack/core` import that moved. Both are declared dependencies. - `check:org-identifier`: 3643 to 3644 files. - `check:doc-authoring`: 1420 to 1421 parsed sources. Its clean-file count goes from 424 to 427 because the head worktree holds three gitignored generated pages that the base worktree does not (`content/docs/protocol-upgrade/17.md`, `18.md` and `index.md`). CI's checkout does not have them. - `check:logger-receiver-detach`: 3617 to 3619 files: the new module, and one local build output, as at cuts 2 to 5. - `check-position-name-fold-loaders`: 12331 to 12335 files, with the same 8 references (6 test, 2 declared instrument, 0 loader). It walks the working tree: the new module plus local build outputs that exist only in the head worktree. - `check-issue-citations` judges a change set, so it has nothing to read at base. At head it judged 13 citations across 3 files, and all resolve. ## Acceptance notes - Nothing outside this cut is touched: no other section of the class, no behaviour, no `docs/adr/**`, no `scripts/adr-anchors/` entry. The one file outside the package is the census page, and only its row 6c path changes. - Serial check, also in the claim: of the 25 open PRs at claim time, none touched `security-plugin.ts`, the wall module, `system-context.mdx`, the delegated-organization test or the plugin-security anchor entries. - Since the local runs, `origin/main` gained 15 commits (to `b22ab8d6fb`). None touches this cut's files. On a scratch merge of this head onto that main, `check-adr-symbol-anchors`, `check:adr-anchors`, `check-platform-checklist` and `check-system-context-census` all exit 0. `check:adr-anchors` there counts 80 anchored files and 2013 decision-letter citations, against 80 and 2017 on that main alone (main added an anchored file), so the shift this cut makes is the same four. - What is left of the tenant write wall in `security-plugin.ts` after this cut: the three adapters (`computeWriteTenantCheckFilter`, `computeWriteTenantWall`, `raiseWriteWallRefusal`), the four method shells, the three type declarations and the middleware's step 3.7. ## Gates All gates ran at the head, the tree of `0177b21a24`, on base `bdf762c9bd`. - `pnpm --filter @objectstack/plugin-security test` (`vitest run --maxWorkers=3`, under the verify lock): Test Files 214 passed (214), Tests 4465 passed, 45 skipped (4510). Base `bdf762c9bd` gives the same counts. VERDICT command-exit 0 on both. The first head commit (before the claim amendment) gave the same counts too. - `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 three touched source files and the touched test: exit 0. - `pnpm --filter @objectstack/plugin-security build`: exit 0. It produced the `.d.ts` and bundles measured above. The package's dependencies were rebuilt on `bdf762c9bd` first. - `packages/core/src/security/row-serving-door-exposure.pin.test.ts`: 3 of 3 pass. - `node scripts/check-adr-symbol-anchors.mjs`, `pnpm check:adr-anchors`, `pnpm check:platform-checklist` (and its script), `check-scripts-symbol-anchors` (and `--self-test`), and the two census `--json` reads. None of these is derived for this change set; each was run by hand, and each exit is listed below. - `dispatch-gates --commands`: 88 commands. The census page adds the docs families, as it did at cut 3. The `--ran` reconciliation shows 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN. The zero is derived: all 88 recorded an exit code and none is 3. Per command (exit code), the 88 derived first and the 8 hand-run after: - `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-doc-frontmatter.mjs` exit 0 - `node scripts/check-doc-frontmatter.mjs --self-test` exit 0 - `node scripts/check-doc-route-spelling.mjs --advisory` exit 0 - `node scripts/check-doc-route-spelling.mjs --self-test` exit 0 - `node scripts/check-docs-section-name.mjs` exit 0 - `node scripts/check-docs-section-name.mjs --self-test` 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-section-landing-index.mjs` exit 0 - `node scripts/check-section-landing-index.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/lint run check:doc-formula-expressions` exit 0 - `pnpm --filter @objectstack/lint run check:doc-security-posture` exit 0 - `pnpm --filter @objectstack/spec run check:docs` exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` exit 0 - `pnpm --filter @objectstack/spec run check:empty-state` exit 0 - `pnpm --filter @objectstack/spec run check:liveness` exit 0 - `pnpm --filter @objectstack/spec run check:skill-examples` exit 0 - `pnpm --filter @objectstack/spec run check:strictness-ledger` exit 0 - `pnpm --filter @objectstack/spec run check:variant-docs` exit 0 - `pnpm --filter @objectstack/spec run check:yaml-examples` exit 0 - `pnpm check:closed-ceremony-text` exit 0 - `pnpm check:corpus-claim-drift` exit 0 - `pnpm check:cross-package-test-inputs` exit 0 - `pnpm check:doc-anchors` exit 0 - `pnpm check:doc-authoring` exit 0 - `pnpm check:docs-audit-scope` exit 0 - `pnpm check:docs-redirects` exit 0 - `pnpm check:docs-single-h1` exit 0 - `pnpm check:docs-spec-enumerations` exit 0 - `pnpm check:docs-transcript-drift` 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:engine-double-contract` 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:merge-driver` exit 0 - `pnpm check:nul-bytes` exit 0 - `pnpm check:objectql-double-limit` exit 0 - `pnpm check:org-identifier` exit 0 - `pnpm check:page-declaration-shape` exit 0 - `pnpm check:published-files` exit 0 - `pnpm check:published-readme-links` exit 0 - `pnpm check:query-options-erasure` exit 0 - `pnpm check:react-page-adapter-contract` exit 0 - `pnpm check:refd-timer-probe` exit 0 - `pnpm check:role-word` exit 0 - `pnpm check:skill-identifier-liveness` 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:type-check-coverage` exit 0 - `pnpm check:type-check-debt` exit 0 - `pnpm check:vendor-version-stamps` exit 0 - `pnpm check:watch-hint-literal` exit 0 - `pnpm check:where-matcher` exit 0 - `node scripts/check-adr-symbol-anchors.mjs` exit 0 - `node scripts/check-platform-checklist.mjs` exit 0 - `node scripts/check-scripts-symbol-anchors.mjs` exit 0 - `node scripts/check-scripts-symbol-anchors.mjs --self-test` exit 0 - `node scripts/isystem-census.mjs --json` exit 0 - `node scripts/tenant-audit-census.mjs --json` exit 0 - `pnpm check:adr-anchors` exit 0 - `pnpm check:platform-checklist` 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 3306cad commit 77a77e4

5 files changed

Lines changed: 451 additions & 243 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ that silently does not happen.
103103
| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` |
104104
| 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` |
105105
| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture, the D10 dangling delegator and, on a walled posture, the D10 delegator who is not a member of the organization the request runs in), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` |
106-
| 6c | The tenant wall's per-row verdict for the write PREVIEW answers **"not asked"** | plugin-security | Get: `writeWallVerdict` returns `null`, so `ObjectQL.validate()` treats the rows exactly as it does with no verdict registered: no row is refused by the Layer 0 tenant wall, and the elevated preview's `readonly` strips stay skipped as they always were. That is what the elevated write does: the whole security middleware short-circuits before step 3.7, so the wall never judges an elevated row's organization. Lose: nothing the write enforces. The verdict exists so a preview answers a row's organization as its write answers it (#25062), and an elevated write meets no tenant wall to answer for | `packages/plugins/plugin-security/src/security-plugin.ts#writeWallVerdict` |
106+
| 6c | The tenant wall's per-row verdict for the write PREVIEW answers **"not asked"** | plugin-security | Get: `writeWallVerdict` returns `null`, so `ObjectQL.validate()` treats the rows exactly as it does with no verdict registered: no row is refused by the Layer 0 tenant wall, and the elevated preview's `readonly` strips stay skipped as they always were. That is what the elevated write does: the whole security middleware short-circuits before step 3.7, so the wall never judges an elevated row's organization. Lose: nothing the write enforces. The verdict exists so a preview answers a row's organization as its write answers it (#25062), and an elevated write meets no tenant wall to answer for | `packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts#writeWallVerdict` |
107107
| 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
108108
| 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` |
109109
| 8b | Authoring-trail reads admitted | rest, runtime | Get: a metadata item's version history, audit rows and stored-version diff (`GET /meta/:type/:name/history`, `/audit`, `/diff`) without `studio.access` or `manage_metadata`, and a package's commit timeline (`GET /packages/:id/commits`) without `studio.access`. Note: narrower than the schema-mask exemption the draft switches ask (`isObjectSchemaMaskExempt`): neither door admits `setup.access`, which organization admins hold | `packages/rest/src/rest-server.ts#mayReadAuthoringTrail`, `packages/runtime/src/domains/packages.ts#requireCommitTrailReadCapability` |

‎packages/plugins/plugin-security/src/delegated-organization-membership.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@ describe('[#25107] every reader in this package that resolves a delegator asks t
685685
const calls = (text: string, name: string) =>
686686
(text.match(new RegExp(`\\b${name}\\(`, 'g')) ?? []).length - (text.match(new RegExp(`function ${name}\\(`, 'g')) ?? []).length;
687687

688-
it('per source file, the asks equal the resolutions — and the two files that resolve one are both counted', () => {
688+
it('per source file, the asks equal the resolutions — and the three files that resolve one are all counted', () => {
689689
const counted: Record<string, [number, number]> = {};
690690
for (const file of readdirSync(SRC, { recursive: true }) as string[]) {
691691
if (!file.endsWith('.ts') || file.endsWith('.test.ts') || file.includes('.testkit.')) continue;
@@ -694,7 +694,7 @@ describe('[#25107] every reader in this package that resolves a delegator asks t
694694
if (resolutions === 0) continue;
695695
counted[file] = [resolutions, calls(text, 'delegatedOrganizationRefusal')];
696696
}
697-
expect(Object.keys(counted).sort()).toEqual(['explain-engine.ts', 'security-plugin.ts']);
697+
expect(Object.keys(counted).sort()).toEqual(['explain-engine.ts', 'security-plugin-operation-principals.ts', 'security-plugin.ts']);
698698
for (const [file, [resolutions, asks]] of Object.entries(counted)) expect(asks, file).toBe(resolutions);
699699
});
700700
});
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25223 cut 6] The CONTEXT prologue of `SecurityPlugin`'s engine
5+
* middleware, moved out of `security-plugin.ts`:
6+
*
7+
* - `resolveOperationPrincipals`, the prologue itself. A context the operation
8+
* may not run under is refused before any gate: the ADR-0096 D5 strict mode,
9+
* and on a walled posture an on-behalf-of context whose delegator's own
10+
* membership does not back the organization the operation runs in
11+
* (ADR-0090 D10, #25063). Every other context is resolved to the permission
12+
* sets every gate composes with: the principal's, and on a delegated context
13+
* the delegator's too.
14+
* - `refuseGuestWithoutOrganization`, the located refusal the prologue raises
15+
* for a guest acting in no organization, ahead of any set resolution
16+
* (ADR-0138 D4).
17+
*
18+
* A MOVE, not a redesign. Each function below is the body of the
19+
* `SecurityPlugin` method of the same name as it stood, re-indented one level
20+
* and otherwise unedited: the same refusals with the same codes and spellings,
21+
* in the same order, the same fail-closed resolution, the same warning once per
22+
* reason. What only these bodies read moved with them, also unedited: the
23+
* ADR-0096 D5 refusal `principalLessDenial`, docblock included, and the
24+
* `@objectstack/core` import the guest refusal reads, comment included. The
25+
* refusal now carries `export`; see below.
26+
*
27+
* ## How the moved bodies reach the class
28+
*
29+
* The shape cuts 1 to 5 used (`security-plugin-object-inputs.ts`, whose header
30+
* carries the reasoning). The class keeps both private methods — the published
31+
* `dist/*.d.ts` lists each one's name, docblock and position — and each body is
32+
* one literal of exactly the {@link OperationPrincipalsHost} members that
33+
* method reads, plus a `.call`. A method is handed over as a closure that calls
34+
* it on the plugin at call time, so a test double or spy set on the plugin is
35+
* still the one the body reaches: the prologue still asks the plugin's
36+
* `refuseGuestWithoutOrganization` and `resolvePermissionSetsForContext`, and
37+
* the middleware, `checkControlledByParentWrite` and the write preview still
38+
* reach the prologue through the plugin. Both methods were async and both
39+
* functions still are; each method now returns its function's promise.
40+
*
41+
* Each field was classified on its own declaration, by the rule cut 1 set.
42+
* `guestOrganizationRefusalWarned` is declared `readonly` and goes by value, so
43+
* the refusal records each reason it warned for in the plugin's own set. `ql`,
44+
* `logger` and `tenancyPosture` are not declared `readonly` (the plugin assigns
45+
* each after construction), so they go as GETTERS, and each read happens when
46+
* the body makes it.
47+
*
48+
* ## What stays in `security-plugin.ts`, and how it is read
49+
*
50+
* `isPrincipalLessContext`, the ADR-0096 D5 test, stays there: the
51+
* object-admission probes and the row scope read it too, and ADR-0138 anchors
52+
* it in that file. `isGuestEnvelope` stays too, because the middleware's
53+
* owner-write step and the set resolution read it. Both are exported and
54+
* imported here by value. Each is read only inside a function body, at call
55+
* time, never while either module evaluates, so the two-way import is safe
56+
* under ESM, and the published bundle holds one module. The test's docblock
57+
* there names the refusal (`principalLessDenial`), so `security-plugin.ts`
58+
* imports it from here type-only, which keeps that link resolving and emits
59+
* nothing. The refusal is not exported from `security-plugin.ts` instead,
60+
* because its return type then moves `PermissionDeniedError`'s declaration to
61+
* the top of the published `.d.ts` (measured on this cut). The delegator's
62+
* resolution and its organization check are imported from `explain-engine.js`,
63+
* and `PermissionDeniedError` from `errors.js`, as `security-plugin.ts` imports
64+
* them.
65+
*
66+
* Nothing re-exports this module, so the published surface does not move.
67+
*/
68+
69+
import type { SecurityPlugin } from './security-plugin.js';
70+
import { isGuestEnvelope, isPrincipalLessContext } from './security-plugin.js';
71+
import type { PermissionSet } from '@objectstack/spec/security';
72+
import { delegatedOrganizationRefusal, resolveDelegatorContext } from './explain-engine.js';
73+
import { PermissionDeniedError } from './errors.js';
74+
// [ADR-0138 D4] The guest entry's organization step, and the located refusal
75+
// the CRUD gate raises where it refuses.
76+
import { GuestOrganizationUnresolvedError, resolveGuestOrganization } from '@objectstack/core';
77+
78+
/**
79+
* [#21908] ADR-0096 D5 strict mode — the refusal the engine middleware throws
80+
* for a principal-less, non-system context ({@link isPrincipalLessContext}):
81+
* `PermissionDeniedError`, `403 PERMISSION_DENIED`, the engine's one deny
82+
* vocabulary. ⛔ Not the doors' anonymous pair (`401 UNAUTHENTICATED`): every
83+
* HTTP door answers that one first, so what reaches this line is in-process
84+
* plumbing, for which "authenticate" is a false remedy. The text names the two
85+
* that are true.
86+
*/
87+
export function principalLessDenial(operation: string, object: string): PermissionDeniedError {
88+
return new PermissionDeniedError(
89+
`[Security] Access denied: operation '${operation}' on object '${object}' was called with a ` +
90+
`context that carries no principal (no user, no position, no permission set) and is not a ` +
91+
`system context. Pass the caller's execution context, or, for platform plumbing whose own ` +
92+
`door already authorized the caller, the explicit system opt-in (isSystem: true).`,
93+
{ operation, object },
94+
);
95+
}
96+
97+
/**
98+
* The `SecurityPlugin` members the functions below read — every one of them,
99+
* and no other; each function's `this` is the `Pick` of the members IT reads.
100+
* Each is typed AS the class member (an indexed access on the class), so a
101+
* change to a member's signature on the class is a change here too, and naming
102+
* a member the class does not have is a compile error. See the module header
103+
* for which members are getters.
104+
*/
105+
export interface OperationPrincipalsHost {
106+
readonly guestOrganizationRefusalWarned: SecurityPlugin['guestOrganizationRefusalWarned'];
107+
readonly logger: SecurityPlugin['logger'];
108+
readonly ql: SecurityPlugin['ql'];
109+
readonly tenancyPosture: SecurityPlugin['tenancyPosture'];
110+
refuseGuestWithoutOrganization: SecurityPlugin['refuseGuestWithoutOrganization'];
111+
resolvePermissionSetsForContext: SecurityPlugin['resolvePermissionSetsForContext'];
112+
}
113+
114+
/** The body of `SecurityPlugin#resolveOperationPrincipals`; the method's docblock there is its contract. */
115+
export async function resolveOperationPrincipals(
116+
this: Pick<OperationPrincipalsHost, 'logger' | 'ql' | 'refuseGuestWithoutOrganization' | 'resolvePermissionSetsForContext' | 'tenancyPosture'>,
117+
operation: string,
118+
object: string,
119+
context: any,
120+
): Promise<{ permissionSets: PermissionSet[]; delegatorSets: PermissionSet[] | null; delegatorContext: any }> {
121+
if (isPrincipalLessContext(context)) {
122+
throw principalLessDenial(operation, object);
123+
}
124+
await this.refuseGuestWithoutOrganization(context, operation, object);
125+
126+
let permissionSets: PermissionSet[] = [];
127+
try {
128+
permissionSets = await this.resolvePermissionSetsForContext(context);
129+
} catch (e) {
130+
this.logger.error?.(
131+
`[security] permission resolution failed for operation '${operation}' on ` +
132+
`object '${object}' (user ${context?.userId ?? 'unknown'}) — ` +
133+
`denying request (fail-closed)`,
134+
e instanceof Error ? e : new Error(String(e)),
135+
);
136+
throw new PermissionDeniedError(
137+
`[Security] Access denied: permission subsystem unavailable for ` +
138+
`operation '${operation}' on object '${object}'`,
139+
);
140+
}
141+
142+
let delegatorSets: PermissionSet[] | null = null;
143+
let delegatorContext: any = null;
144+
if (permissionSets.length > 0 && context?.onBehalfOf?.userId) {
145+
const del = await resolveDelegatorContext(this.ql, context);
146+
if (del.kind === 'missing') {
147+
throw new PermissionDeniedError(
148+
`[Security] Access denied: on-behalf-of principal names delegator ` +
149+
`'${del.userId}', who does not exist — refusing to act (ADR-0090 D10 fail-closed)`,
150+
{ operation, object },
151+
);
152+
}
153+
if (del.kind === 'resolved') {
154+
const liveOrganization = delegatedOrganizationRefusal(context, del.context, this.tenancyPosture);
155+
if (liveOrganization !== undefined) {
156+
this.logger.warn?.(
157+
`[Security] on-behalf-of ${operation} '${object}' REFUSED — the delegator is not a member of the ` +
158+
`organization the request runs in (ADR-0090 D10, fail-closed)`,
159+
);
160+
throw new PermissionDeniedError(
161+
`[Security] Access denied: on-behalf-of principal acts in organization '${liveOrganization}', which ` +
162+
`delegator '${String(context.onBehalfOf.userId)}' is not a member of — refusing to act (ADR-0090 D10 fail-closed)`,
163+
{ operation, object },
164+
`[ADR-0090 D10] tenancy posture '${this.tenancyPosture}' walls organizations, and a delegated operation is ` +
165+
`bounded by the delegator's scope: it runs in the live principal's active organization, which the ` +
166+
`delegator's own membership must back. Build the on-behalf-of context in an organization the delegator ` +
167+
`is a member of. System contexts and the single posture are unaffected.`,
168+
);
169+
}
170+
delegatorContext = del.context;
171+
delegatorSets = await this.resolvePermissionSetsForContext(delegatorContext);
172+
}
173+
}
174+
return { permissionSets, delegatorSets, delegatorContext };
175+
}
176+
177+
/** The body of `SecurityPlugin#refuseGuestWithoutOrganization`; the method's docblock there is its contract. */
178+
export async function refuseGuestWithoutOrganization(
179+
this: Pick<OperationPrincipalsHost, 'guestOrganizationRefusalWarned' | 'logger' | 'ql' | 'tenancyPosture'>,
180+
context: any,
181+
operation: string,
182+
object: string,
183+
): Promise<void> {
184+
// A system context never reaches here: both callers of
185+
// {@link resolveOperationPrincipals} return before it for one (the
186+
// middleware's system short-circuit, and `checkControlledByParentWrite`'s
187+
// first exit), so this rule reads no elevation of its own.
188+
if (!isGuestEnvelope(context)) return;
189+
const tenantId = context?.tenantId;
190+
if (typeof tenantId === 'string' && tenantId !== '') return;
191+
const decision = await resolveGuestOrganization(this.ql, this.tenancyPosture);
192+
if (decision.kind !== 'refuse') return;
193+
if (!this.guestOrganizationRefusalWarned.has(decision.reason)) {
194+
this.guestOrganizationRefusalWarned.add(decision.reason);
195+
this.logger.warn?.(
196+
`[security] an anonymous request reached '${operation}' on '${object}' and was refused: this deployment ` +
197+
`resolves no organization for the guest (${decision.reason}, tenancy posture '${this.tenancyPosture}'` +
198+
`${decision.organizationCount !== undefined ? `, ${decision.organizationCount} organization(s)` : ''}). ` +
199+
`ADR-0138 D4 derives the guest's organization only on a single-organization deployment, and no site ` +
200+
`binding (D5) exists yet to name one per request, so every anonymous data operation here answers ` +
201+
`501 NOT_IMPLEMENTED. Serve the endpoint to signed-in callers (authRequired: true), or run it on a ` +
202+
`single-organization deployment. Reported once per reason.`,
203+
{ operation, object, reason: decision.reason },
204+
);
205+
}
206+
throw new GuestOrganizationUnresolvedError(decision.reason);
207+
}

0 commit comments

Comments
 (0)