fix(mcp): derive the tenancy posture for the stdio API-key door - #15474
Conversation
`resolveStdioExecutionContext` built its own header map and called `resolveAuthzContext` with no `tenancyPosture`. Both posture-conditional API-key refusals are gated on a caller-supplied posture (`organization_required` in `api-key.ts`, `organization_membership_ended` in `resolve-authz-context.ts`), so supplying none skipped both: the key's `sys_api_key.active_organization_id` — the caller's own stored claim, never vetted against current membership — was admitted verbatim as the request's tenant. Every caller on this transport is an API key by construction, so that admission is the whole of this door's authorization. The posture is now derived in `start()`, where the plugin context is in scope, and threaded into the resolver as a REQUIRED argument. The derivation carries decision 1 option A's classification (#13906): a `tenancy` service that was never registered is branded and resolves quietly to "no posture"; one that was registered and FAILED to build raises `AuthzStoreUnavailableError`. It is read per call rather than hoisted, because `TenancyService.posture` is a live getter and a value frozen inside this plugin's `start()` window would freeze "no wall" for the life of a long-lived transport. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
A real `ObjectKernel` holds the services, so the classification under test is the registry's own branded / unbranded rejection rather than a stub error thrown at the seam. `@objectstack/core` is not mocked: the real verify then authorize chain runs, Layer 0 is modelled as the hard `organization_id = context.tenantId` equality it is, and every write is read back from the fixture's table rather than from a response body. Eighteen arms: controls in both directions, the ex-member and the organization-less key, the `single` and `group` narrowness rows, the registered-and-broken 503 pin with its ADR-0112 code AND status, the never-registered contrast that doubles as the permanent ablation, and the per-call arms a hoisted posture would redden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…dicates `check:engine-double-contract` reads the new fixture as an engine double and requires its scanned write verbs to route through the producer-side predicates. The matrix reads and creates and never calls update / delete / findOne, but a double looser than the real engine is what the ratchet exists to keep out, so all three are pinned rather than left open. The RETAINED ledger is regenerated with `--write`: three rows added, none lost — new pinned coverage, not a weakened baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 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 — 12 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 20f0212e48ba54267fc1480287cabc9ccd81c902 && git checkout 20f0212e48ba54267fc1480287cabc9ccd81c902
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e8c7956c46c0e8abda9d259ce07ba5f64e072c47 d2a8d4fa703cee7351051a9853eb1095f8d137af && git checkout -B drift-repro e8c7956c46c0e8abda9d259ce07ba5f64e072c47 && git merge --no-ff d2a8d4fa703cee7351051a9853eb1095f8d137af
node scripts/docs-audit/affected-docs.mjs --json e8c7956c46c0e8abda9d259ce07ba5f64e072c47 |
Fixes #15348
The defect
packages/mcp/src/plugin.ts'sresolveStdioExecutionContextbuilt its own header map and calledresolveAuthzContextwith notenancyPosture. Both posture-conditional API-key refusals are gated on the caller supplying one —organization_required(core/src/security/api-key.ts) andorganization_membership_ended(core/src/security/resolve-authz-context.ts) — so a door that supplies none runs neither, and the key'ssys_api_key.active_organization_id, never re-checked against current membership, became the request's tenant.This transport has no session path at all: every caller on it is an API key by construction, so that admission is not one branch of the door's authorization, it is the whole of it.
The change
The posture is derived in
start(), where the plugin context is in scope, and threaded intoresolveStdioExecutionContextas a required fourth argument.undefinedremains a legitimate value (notenancyservice registered means no wall exists); making it required is what stops a future call site from omitting it and silently un-reaching both refusals again.The derivation carries decision 1 option A's classification (#13906), which is why this was not a one-liner:
tenancyserviceisServiceNotRegisteredError)undefined— the supported no-tenancy compositionAuthzStoreUnavailableError(SERVICE_UNAVAILABLE/ 503)Only the async accessor carries that brand, so the async leg is preferred; the sync leg is reached only on a host whose
getKernel()yields nogetServiceAsync, which instantiates no service factories at all and so can report nothing but absence.Read per call, not hoisted. The card's shape was "thread a posture from
start()", and threading it from there is what this does — but the read happens per call.TenancyService.postureis a live getter that probesorg-scopingon each read and reports a wall it cannot yet enforce assingle(ADR-0093 D4/D5); this plugin'sstart()runs strictly before every other plugin'sstart()and before the firstkernel:ready. A posture frozen inside that window would freeze "no wall" for the life of a long-lived transport and never self-correct — the #11580 defect this same file already paid for, pointed at a security control instead of a locale. The read costs two registry lookups and no I/O, and ADR-0101 D1 already promises this door a per-call re-resolution of the identity beside it. §5 of the test is what a hoist would redden.The measurement
packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts— 18 arms, the #15163 / #15365 shape carried to this door. A realObjectKernelholds the services, so the two rejection classes are the registry's own rather than stub errors thrown at the seam under measurement;@objectstack/coreis not mocked anywhere, so the real verify then authorize chain runs. Layer 0 is modelled as the hardorganization_id = context.tenantIdequalitytenant-layer.tscomputes underisolated, a second organization is seeded so a wall that stopped applying reddens, RBAC is opened symmetrically through one shared permission set, and every write is read back from the fixture's table rather than from a response body.This door's fail-closed answer is not REST's 401: under ADR-0101 a key that does not resolve to an identity throws out of
start()rather than attaching a transport, so a posture refusal takes that same exit.isolatedorg_alpha/u_memberorg_alphaNarrowness rows are asserted in both directions rather than assumed: under
singlethe ex-member's key is admitted (no wall to be walled out of), and undergroupthe organization-less key is admitted (organization_requiredis theisolatedrefusal only) while the ex-member's is still refused. Both refusals are checked to log one server-side line naming key, principal, organization and reason, and never the raw key or its at-rest hash.Ablations
Both legs ran on a tree at HEAD, proved on disk before the run (removed-text hits 0, injected marker hits at the expected count, blob hash moved), and restored with
git checkout HEAD -- ABSOLUTE_PATHproved by an emptygit diff HEADplus blob-hash equality against the HEAD blob and zero marker residue. Direction was predicted before each run and both matched exactly.catch { return undefined; }→ 2 failed / 16 passed. Exactly the two §4 arms, and nothing else moved. That is the leg that shows the classification is enforced rather than merely written: the shape the issue names as the wrong fix reddens on its own.A third, permanent ablation is held in the test itself — §4's never-registered contrast. With no posture in play the ex-member reads and writes
org_alphaagain, which is both correct there (a kernel with notenancyservice enforces no wall) and what makes every refusal above attributable to the posture and nothing else.Census, re-derived
The issue's numbers were re-derived rather than quoted. Method:
git grep -n "resolveAuthzContext({"overpackages/**/*.ts, excluding*.test.ts,*.spec.ts,__tests__/and*.testkit.ts. That yields 9 call expressions, one of which (packages/qa/dogfood/test/armed.ts) is a test helper — so 8 non-test callers, of which exactly 2 supply a posture (rest/src/rest-server.ts:2497,runtime/src/security/resolve-execution-context.ts:179). The call sites ofresolveStdioExecutionContextwere verified complete rather than trusted: repo-wide, the declaration plus two calls, all three in this file.Verification
At
d2a8d4fa70:pnpm --filter @objectstack/mcp test— 26 files, 289 tests, all pass.pnpm --filter @objectstack/mcp typecheck— exit 0. This package excludes its test files from its tsconfig (a state already carried incheck-type-check-coverage.mjs's TEST_DEBT ledger), so that green says nothing about the new test file; measured with--listFiles, it reads 0 of them. The new file was type-checked separately through a throwaway config that drops only the exclusion: 0 errors in it, with--listFilesconfirming it was in the program. The other test files in the package carry pre-existing errors, which is why the exclusion is there.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandson a clean tree at final HEAD, plus every roster family the derivation flagged as holding its baseline under a directory these paths are in: 60 commands, all exit 0. Two returned exit 3PREREQUISITE NOT METon the first pass (check:dual-build-cjs-loads,check:type-check-debt); the prerequisite was satisfied with a fullturbo run buildand both then passed rather than being reported as green.pnpm lint(repo-wideeslint . --no-inline-config) — exit 0.check:engine-double-contractfirst went red on the new fixture: its scanned write verbs are now pinned toassertEngineUpdateDispatch/assertEngineDeleteDispatch/assertEngineFindOnePredicate, and the RETAINED ledger regenerated with--write(3 rows added, 0 lost — new pinned coverage, not a weakened baseline).Scope
One door. The other five census sites (#15349–#15353) are their own cards and nothing here touches them;
packages/core/src/security/**is unchanged, because the resolver's contract is correct and the callers were wrong.Generated by Claude Code