@@ -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' ;
1018import { readEnvWithDeprecation , isMcpServerEnabled , resolveMcpStdioAutoStart } from '@objectstack/types' ;
1119import type { ExecutionContext } from '@objectstack/spec/kernel' ;
20+ import type { TenancyPosture } from '@objectstack/spec/security' ;
1221import type { IAIService , IDataEngine , IMetadataService } from '@objectstack/spec/contracts' ;
1322import { MCPServerRuntime } from './mcp-server-runtime.js' ;
1423import type { MCPServerRuntimeConfig , McpMergedMetadataRead } from './mcp-server-runtime.js' ;
@@ -17,6 +26,84 @@ import { createStdioDataBridge, enforceApiExposure, GATED_ACTIONS } from './stdi
1726import type { McpDataBridge } from './mcp-http-tools.js' ;
1827import { 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 */
48146async 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