fix(service-storage): derive the tenancy posture at the file-read admission seam - #16017
Conversation
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 180e295309b401aca37c7d864412e084680f8aff && git checkout 180e295309b401aca37c7d864412e084680f8aff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f50c394da55846da8d38f1e1efadbc24faa26ce2 337bc54ec684ce009a1708db7cefcaba3c946cde && git checkout -B drift-repro f50c394da55846da8d38f1e1efadbc24faa26ce2 && git merge --no-ff 337bc54ec684ce009a1708db7cefcaba3c946cde
node scripts/docs-audit/affected-docs.mjs --json f50c394da55846da8d38f1e1efadbc24faa26ce2 |
…orage-tenancy-posture
|
Standing down on the red
Why it is not this PR's: the self-test's fixture wrote a stub Severity bound: false-RED only, never false-green — a poisoned store makes every artifact look Fix landed: #16002 merged as ⛔ This PR's one permitted re-run is NOT being spent, deliberately. A re-run replays the original merge ref against the old base, so it would re-measure the same poisoned condition — and 5 of 5 re-runs on the other PRs reproduced. With the fix on ⛔ No test skipped, disabled or quarantined; ⛔ no empty commit; ⛔ no registry widened. Generated by Claude Code |
Fixes #15352
buildFileReadAuthorizer(packages/services/service-storage/src/storage-service-plugin.ts) resolved every gated download withresolveAuthzContext({ ql: engine, headers, getSession })and supplied notenancyPosture. Both posture-conditional API-key refusals are gated on the CALLER supplying one —organization_required(core/security/api-key.ts,if (!tenantId && tenancyPosture)) andorganization_membership_ended(core/security/resolve-authz-context.ts,if (keyPrincipal?.tenantId && input.tenancyPosture)) — so at this door neither ran. Its headers are the real request's (toWebHeaders), sox-api-keyis accepted, and an API key's tenant issys_api_key.active_organization_idcopied verbatim: the caller's own stored claim, never vetted against current membership. Under a wall-enforcing posture a key stamped with an organization its owner had LEFT resolved auserIdhere and was then judged by the ownership and record-reachability checks — checks evaluated for a principal the wall should have refused at the door.The posture is now read off the kernel's
tenancyservice, per download, and classified rather than swallowed (#13906 decision 1 option A): never registered ⇒ branded ⇒ quietundefined; registered and unable to answer ⇒AuthzStoreUnavailableError. Raised inside the authorizer's owntry, so it takes the#13279relay already there rather than adding a second net.Measured, on this tree
Driven through the real routes (
GET /api/v1/storage/files/:fileId/urland its302siblingGET /api/v1/storage/files/:fileId), booted from a realStorageServicePlugin, with a realObjectKernelcarrying the wiring fact. This door performs no write, so the second fact — the analogue of the sibling cards' "read the write back from the store" — is read off the storage ADAPTER: every arm asserts whethergetPresignedDownloadminted a download capability at all. A door that answered 401 after minting a URL would pass a status-only suite and still have issued the capability.isolatedFILE_DOWNLOAD_DENIEDgroupsingletenancynever registeredgetKernelThe card's body says two things that are false on the current tree, both re-measured here rather than repeated: the census of
resolveAuthzContextcallers supplying a posture is 4 of 8 onorigin/main(rest-server.ts,runtime/security/resolve-execution-context.ts,mcp/src/plugin.ts,cloud-connection/src/marketplace-install-local-plugin.ts), not 2; andgroupis not unaffected — the ex-member refusal fires there exactly as underisolated, becauseorganization_membership_endedkeys onpostureEnforcesWall, whichgroupsatisfies. The organization-less row deliberately differs:organization_requiredadditionally requires NOTpostureUsesUnionScope, whichgroupfails. Both directions are pinned.The ablation is held permanently
Its handle is the wiring this package shipped before this card: a
PluginContextwith nogetKernelat all, which is byte-identically what every other suite here still mounts (storage-service-plugin.test.ts'smakeCtx). It removes exactly the one input this change added, and with it removed the ex-member downloads again — status and a freshly minted signed URL. A pin that cannot go red has measured nothing.The outage arm, MEASURED rather than assumed — and it is NOT 503
tenancyservice that was REGISTERED and FAILED to build renders403 FILE_DOWNLOAD_DENIED, not the503 SERVICE_UNAVAILABLEtheAuthzStoreUnavailableErrorbrand declares, and not the bare500measured next door onservice-datasource. Measured on this branch, all three principals, both download routes, 0 capabilities minted.The mechanism is one frame ABOVE the authorizer and is pre-existing. The authorizer does re-raise the brand (
isAuthzStoreUnavailableError(err) ⇒ throw,#13279), butregisterStorageRoutes'authorizeDownloadwraps the whole call incatch { verdict = 'deny' }, so the re-raise is absorbed and rendered as the gate's own refusal. Thatcatchhas swallowed the#13279permission-store outage at this door since that card landed; this change adds a second producer of the identical brand at the identical seam, it does not create the path. ⛔ Not repaired here.This is the datum #15999 explicitly asks for: that card names
storage-service-plugin.ts:921in its census of six re-raise sites and records the other five as UNMEASURED. The measurement is posted there. It also bears on that card's "two directions" question: for this door neither repair direction is sufficient on its own, because the throw never escapes the handler in the first place.Fail-closed either way, which is why the pin asserts the outage CLASS (
[403, 500, 503], never 200, never 302, never a minted capability) rather than the digits — a later status repair must not have to redden a security test.Clause-2: no — re-derived, not inherited
The claim comment's
nowas explicitly non-binding. Re-derived with the instrument this family settled on, becauseapi-surface/artifacts exist only forpackages/specand prove nothing about a service package: build the package, swap in the base commit's copy of the one changed source file, rebuild, compare the builtdist/index.d.ts.The other limb is unchanged too: the diff touches no
packages/spec/src/**, no Zod schema and no error-code ledger. No accept set widens; a declared guard returns to enforced. Clause-2: no.Verification
Everything below was run at final commit
bf004da92.Derived gate family via
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(which reads its own change set from the merge base; 2 paths, 263 families discovered). Run locally, all exit 0:The ratchet-bearing subset was re-run at
bf004da92after the final commit, not before it. The rest of the farm is CI's.Lint & Repo Gatesmay red oncheck:merge-driverwith✗ self-test failed -- 1 failure(s) (cases and floor). That is #15990 (a Corepack store holding an unpinned pnpm), not this change; fix open as PR #16009, hardening as #16012.Deliberately not done
⛔ No shared helper extracted.
resolveAdmissionTenancyPostureis a local copy, asmcpandcloud-connectioneach wrote one, because sibling cards (#15349, #15350, #15351) are live on this same seam in other packages and an extraction by one collides with the rest. The docblock's justification for keeping the async accessor's ABSENCE quiet is written for THIS door — this gate already returnsundefined(downloads ungated) when theauthservice or the engine is missing, so degrading through an incompletely composed host is its declared contract — rather than copied from a sibling whose reason is not true here. Family-wideKernelBase/LiteKernelbehaviour is ruled and filed as #15997.🤖 Generated with Claude Code
Generated by Claude Code