Skip to content

fix(mcp): derive the tenancy posture for the stdio API-key door - #15474

Merged
os-litant merged 3 commits into
mainfrom
claude/issue-15348-mcp-stdio-tenancy-posture
Sep 4, 2026
Merged

fix(mcp): derive the tenancy posture for the stdio API-key door#15474
os-litant merged 3 commits into
mainfrom
claude/issue-15348-mcp-stdio-tenancy-posture

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #15348

The defect

packages/mcp/src/plugin.ts's resolveStdioExecutionContext built its own header map and called resolveAuthzContext with no tenancyPosture. Both posture-conditional API-key refusals are gated on the caller supplying one — organization_required (core/src/security/api-key.ts) and organization_membership_ended (core/src/security/resolve-authz-context.ts) — so a door that supplies none runs neither, and the key's sys_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 into resolveStdioExecutionContext as a required fourth argument. undefined remains a legitimate value (no tenancy service 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:

the tenancy service rejection this seam answers
never registered branded (isServiceNotRegisteredError) quiet undefined — the supported no-tenancy composition
registered, FAILED to build unbranded AuthzStoreUnavailableError (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 no getServiceAsync, 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.posture is a live getter that probes org-scoping on each read and reports a wall it cannot yet enforce as single (ADR-0093 D4/D5); this plugin's start() runs strictly before every other plugin's start() and before the first kernel: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 real ObjectKernel holds the services, so the two rejection classes are the registry's own rather than stub errors thrown at the seam under measurement; @objectstack/core is not mocked anywhere, so the real verify then authorize chain runs. Layer 0 is modelled as the hard organization_id = context.tenantId equality tenant-layer.ts computes under isolated, 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.

credential, isolated before after
current member (control) reads its 2 rows, create lands stamped org_alpha / u_member unchanged
ex-member, key stamped org_alpha transport starts, reads and writes that organization refuses to start
organization-less key transport starts, silent empty read refuses to start
unknown key (control) refuses to start unchanged

Narrowness rows are asserted in both directions rather than assumed: under single the ex-member's key is admitted (no wall to be walled out of), and under group the organization-less key is admitted (organization_required is the isolated refusal 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_PATH proved by an empty git diff HEAD plus blob-hash equality against the HEAD blob and zero marker residue. Direction was predicted before each run and both matched exactly.

  • (a) drop the posture argument at both call sites → 9 failed / 9 passed. Exactly the nine posture-conditional arms; the controls, both narrowness rows, the registry-classification arms and the never-registered contrast were untouched.
  • (b) replace the two-branch classification with a naive 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_alpha again, which is both correct there (a kernel with no tenancy service 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({" over packages/**/*.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 of resolveStdioExecutionContext were 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 in check-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 --listFiles confirming it was in the program. The other test files in the package carry pre-existing errors, which is why the exclusion is there.
  • Gate union derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on 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 3 PREREQUISITE NOT MET on the first pass (check:dual-build-cjs-loads, check:type-check-debt); the prerequisite was satisfied with a full turbo run build and both then passed rather than being reported as green.
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0.
  • check:engine-double-contract first went red on the new fixture: its scanned write verbs are now pinned to assertEngineUpdateDispatch / 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

`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
@github-actions github-actions Bot added the size/l label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e8c7956c46c0e8abda9d259ce07ba5f64e072c47packageMentionDocs.

Which tree this was computed on

This run read content/docs from 20f0212e48ba54267fc1480287cabc9ccd81c902 — the merge of head d2a8d4fa703cee7351051a9853eb1095f8d137af into base e8c7956c46c0e8abda9d259ce07ba5f64e072c47, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp: the stdio door resolves an API key with no tenancyPosture — an ex-member's org-stamped key is admitted with its own unvetted claim

2 participants