Skip to content

Commit e73a121

Browse files
committed
test(mcp): drive the stdio door's posture matrix, and a changeset
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
1 parent a319a32 commit e73a121

2 files changed

Lines changed: 565 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/mcp": patch
3+
---
4+
5+
The MCP stdio transport now vets an API key's organization against the deployment's tenancy posture, instead of trusting the key's own stored claim.
6+
7+
`resolveStdioExecutionContext` — the whole of this transport's authorization, since every caller on it is an API key by construction and there is no session path — 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` at admission, `organization_membership_ended` after grants), so a door that supplied none ran neither: the key's `sys_api_key.active_organization_id`, never re-checked against current membership, became the request's tenant. Under a wall-enforcing posture a key stamped with an organization its owner had left read and wrote that organization's rows through this door.
8+
9+
The posture is now derived in the plugin's `start()`, where the kernel is reachable, and threaded into the resolver. What changes for a deployment:
10+
11+
- Under `isolated` or `group`, a stdio transport configured with a key whose owner is no longer a member of the organization the key names refuses to start, and a key already live is refused on its next call. Under `isolated`, an organization-less key is refused the same way. Both refusals are logged server-side naming the key, principal, organization and reason; nothing about them reaches the caller.
12+
- A kernel that registers no `tenancy` service is unaffected: no organization wall exists there, so no posture-conditional refusal is made. That is the supported composition, not a degraded one.
13+
- A `tenancy` service that is registered and **fails to build** now raises `SERVICE_UNAVAILABLE` (503) rather than reading as "no posture". A posture that could not be read is not a posture that is absent, and admitting on one is the permissive-on-failure shape this repair exists to avoid.
14+
15+
The posture is re-read per call, on the same schedule as the identity beside it (ADR-0101 D1), so a wall that comes up or a membership that ends mid-session takes effect on the next call rather than at the next restart.

0 commit comments

Comments
 (0)