Skip to content

Commit a319a32

Browse files
committed
fix(mcp): derive the tenancy posture for the stdio API-key door
`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
1 parent 6ed4b81 commit a319a32

1 file changed

Lines changed: 124 additions & 3 deletions

File tree

packages/mcp/src/plugin.ts

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@ import {
55
assembleExecutionContext,
66
resolveAuthzContext,
77
resolveLocalizationContext,
8+
// [#15348] The three symbols this door's tenancy-posture read is built from:
9+
// the posture reader itself, plus the two halves of the classification
10+
// decision 1 option A requires (#13906) — the registry's "never registered"
11+
// brand, and the loud outage every other rejection has to become.
12+
effectiveTenancyPosture,
13+
isServiceNotRegisteredError,
14+
AuthzStoreUnavailableError,
815
type EntryLocalization,
16+
type TenancyPostureSource,
917
} from '@objectstack/core';
1018
import { readEnvWithDeprecation, isMcpServerEnabled, resolveMcpStdioAutoStart } from '@objectstack/types';
1119
import type { ExecutionContext } from '@objectstack/spec/kernel';
20+
import type { TenancyPosture } from '@objectstack/spec/security';
1221
import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
1322
import { MCPServerRuntime } from './mcp-server-runtime.js';
1423
import type { MCPServerRuntimeConfig, McpMergedMetadataRead } from './mcp-server-runtime.js';
@@ -17,6 +26,84 @@ import { createStdioDataBridge, enforceApiExposure, GATED_ACTIONS } from './stdi
1726
import type { McpDataBridge } from './mcp-http-tools.js';
1827
import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js';
1928

29+
/**
30+
* [#15348] Resolve the deployment's EFFECTIVE tenancy posture for the stdio
31+
* door — the argument BOTH posture-conditional API-key refusals are gated on
32+
* (`organization_required`, in `@objectstack/core`'s `api-key.ts`, and
33+
* `organization_membership_ended`, in `resolve-authz-context.ts`).
34+
*
35+
* Supplying none does not weaken those guards, it SKIPS them, and the resolver
36+
* then admits the key carrying `sys_api_key.active_organization_id` VERBATIM —
37+
* the caller's own stored claim, never vetted against current membership. So
38+
* under a wall-enforcing posture a key stamped with an organization its owner
39+
* has LEFT was admitted with that organization as its tenant.
40+
*
41+
* ## Why this door and not only the ones already wired
42+
*
43+
* Every caller on this transport is an API key by construction: the header map
44+
* is built from `OS_MCP_STDIO_API_KEY` a few lines below and there is no
45+
* session path at all. The API-key admission is therefore not one branch of
46+
* this door's authorization — it IS this door's authorization, and the
47+
* `tenantId` it resolves is what `assembleExecutionContext` hands the engine as
48+
* the request's tenant.
49+
*
50+
* ## The classification, and the one shape that must not be written here
51+
*
52+
* [#13906 decision 1 option A] Two facts a `try { … } catch { undefined }`
53+
* would collapse into one:
54+
*
55+
* - **never registered** → branded (`isServiceNotRegisteredError`) → a quiet
56+
* `undefined`. The supported no-tenancy composition: a kernel assembled
57+
* without `plugin-auth` registers no `tenancy` service and enforces no
58+
* organization wall, so there is nothing for a key to be walled out of.
59+
* - **registered and FAILED to build** → unbranded → `AuthzStoreUnavailableError`
60+
* (503). A posture that could not be READ is not a posture that is ABSENT;
61+
* admitting on it is exactly the permissive-on-failure defect #13906 exists
62+
* to repair, and the reason this seam is not a one-liner.
63+
*
64+
* Only the ASYNC accessor carries that discriminator — the branded rejection is
65+
* raised by `PluginLoader.getService`, which the sync accessor never reaches.
66+
* The sync leg below is taken only on a host whose `getKernel()` yields no
67+
* `getServiceAsync` (a `KernelBase`-shaped host, and the duck-typed contexts
68+
* this package's own tests build). Such a host instantiates no service
69+
* factories at all, so "nothing is registered under that name" is the only
70+
* fault its accessor can report, and absorbing it is the SAME classification
71+
* rather than a second collapse of it.
72+
*
73+
* ## ⚠️ Read PER CALL — deliberately not hoisted into `start()`
74+
*
75+
* A posture resolved once in `start()` and held would be the #11580 defect this
76+
* file already paid for, pointed at a security control instead of a locale.
77+
* `start()` bodies run strictly before every other plugin's `start()` and
78+
* before the first `kernel:ready`; `TenancyService.posture` is a LIVE getter
79+
* that probes `org-scoping` on each read and reports a wall it cannot yet
80+
* enforce as `single` (ADR-0093 D4/D5). Freezing a read taken inside that
81+
* window would freeze "no wall" for the life of a long-lived transport, and it
82+
* would never self-correct.
83+
*
84+
* The localization hoist below is memoized because its resolution costs
85+
* settings reads. This one costs two registry lookups and no I/O, so there is
86+
* nothing to buy — and a live read is what ADR-0101 D1 already promises this
87+
* door for the identity beside it: re-resolved per call, so a change takes
88+
* effect on the next one.
89+
*/
90+
async function resolveStdioTenancyPosture(ctx: PluginContext): Promise<TenancyPosture | undefined> {
91+
const kernel = typeof ctx.getKernel === 'function' ? ctx.getKernel() : undefined;
92+
if (kernel && typeof kernel.getServiceAsync === 'function') {
93+
try {
94+
return effectiveTenancyPosture(await kernel.getServiceAsync<TenancyPostureSource>('tenancy'));
95+
} catch (err) {
96+
if (!isServiceNotRegisteredError(err)) throw new AuthzStoreUnavailableError('tenancy', err);
97+
return undefined;
98+
}
99+
}
100+
try {
101+
return effectiveTenancyPosture(ctx.getService<TenancyPostureSource>('tenancy'));
102+
} catch {
103+
return undefined;
104+
}
105+
}
106+
20107
/**
21108
* Resolve `OS_MCP_STDIO_API_KEY` into an {@link ExecutionContext} through the
22109
* SAME `@objectstack/core` verify + authorization chain the HTTP and REST
@@ -44,13 +131,25 @@ import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js';
44131
* window CLOSES: a call that races the boot is answered from a fresh
45132
* resolution that is deliberately not kept, so a pre-bind answer can never
46133
* become the memoized one.
134+
*
135+
* @param tenancyPosture [#15348] The deployment's effective posture, from
136+
* {@link resolveStdioTenancyPosture}. REQUIRED rather than optional, and
137+
* threaded rather than resolved here, for two separate reasons. Threaded,
138+
* because this function holds no kernel handle and the posture must come from
139+
* the ONE place that does (`start()`); required, because an optional parameter
140+
* is how the argument came to be missing in the first place — a new call site
141+
* that omits it would compile, and its two refusals would silently stop being
142+
* reachable. `undefined` is a legitimate VALUE here (no `tenancy` service is
143+
* registered ⇒ no wall exists ⇒ no posture-conditional refusal), and it has to
144+
* be passed on purpose.
47145
*/
48146
async function resolveStdioExecutionContext(
49147
ql: { find: (object: string, opts: unknown) => Promise<unknown> },
50148
apiKey: string,
51149
localization: EntryLocalization | undefined,
150+
tenancyPosture: TenancyPosture | undefined,
52151
): Promise<ExecutionContext | undefined> {
53-
const authz = await resolveAuthzContext({ ql, headers: { 'x-api-key': apiKey } });
152+
const authz = await resolveAuthzContext({ ql, headers: { 'x-api-key': apiKey }, tenancyPosture });
54153
return assembleExecutionContext({
55154
authz,
56155
// OAuth access tokens are honoured on the `/mcp` HTTP door alone
@@ -286,7 +385,19 @@ export class MCPServerPlugin implements Plugin {
286385
// data call, so it must not pay for settings reads whose result it would
287386
// discard. The localization hoist below needs its `userId`/`tenantId`,
288387
// which is why the probe comes first.
289-
const initial = await resolveStdioExecutionContext(ql, apiKey, undefined);
388+
// [#15348] The posture is read HERE too, not only per call: an ex-member's
389+
// or organization-less key that this deployment's wall refuses is not a
390+
// credential this transport can run under, so it takes the same
391+
// fail-closed refusal-to-start the unknown/revoked/expired key takes
392+
// below. A `tenancy` service that is REGISTERED AND BROKEN raises
393+
// `AuthzStoreUnavailableError` out of this line — loud, and it stops the
394+
// boot rather than starting a door whose admission was never decided.
395+
const initial = await resolveStdioExecutionContext(
396+
ql,
397+
apiKey,
398+
undefined,
399+
await resolveStdioTenancyPosture(ctx),
400+
);
290401
if (!initial) {
291402
throw new Error(
292403
'[MCP] OS_MCP_STDIO_API_KEY did not resolve to a valid identity (unknown / revoked / expired / owner-less). ' +
@@ -411,8 +522,18 @@ export class MCPServerPlugin implements Plugin {
411522
await localizationForRead();
412523
});
413524
// Re-resolve per call so a revoked/expired key stops working on the next read.
525+
// [#15348] The tenancy posture is re-read on the same schedule and for the
526+
// same reason: it is an input to that admission, and a membership or a
527+
// wall that changed mid-session must take effect on the next call rather
528+
// than at the next process restart. See `resolveStdioTenancyPosture` for
529+
// why this is not hoisted next to the localization memo.
414530
const resolvePrincipal = async (): Promise<ExecutionContext> => {
415-
const ec = await resolveStdioExecutionContext(scopedQl, apiKey, await localizationForRead());
531+
const ec = await resolveStdioExecutionContext(
532+
scopedQl,
533+
apiKey,
534+
await localizationForRead(),
535+
await resolveStdioTenancyPosture(ctx),
536+
);
416537
if (!ec) throw new Error('MCP stdio identity is no longer valid (key revoked or expired)');
417538
return ec;
418539
};

0 commit comments

Comments
 (0)