From a319a326b6625b0457ec0fac83df123a0a2ed14a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 16:37:01 +0000 Subject: [PATCH 1/3] fix(mcp): derive the tenancy posture for the stdio API-key door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- packages/mcp/src/plugin.ts | 127 ++++++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/plugin.ts b/packages/mcp/src/plugin.ts index 538e94ac66..87bab78e79 100644 --- a/packages/mcp/src/plugin.ts +++ b/packages/mcp/src/plugin.ts @@ -5,10 +5,19 @@ import { assembleExecutionContext, resolveAuthzContext, resolveLocalizationContext, + // [#15348] The three symbols this door's tenancy-posture read is built from: + // the posture reader itself, plus the two halves of the classification + // decision 1 option A requires (#13906) — the registry's "never registered" + // brand, and the loud outage every other rejection has to become. + effectiveTenancyPosture, + isServiceNotRegisteredError, + AuthzStoreUnavailableError, type EntryLocalization, + type TenancyPostureSource, } from '@objectstack/core'; import { readEnvWithDeprecation, isMcpServerEnabled, resolveMcpStdioAutoStart } from '@objectstack/types'; import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { TenancyPosture } from '@objectstack/spec/security'; import type { IAIService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; import { MCPServerRuntime } from './mcp-server-runtime.js'; import type { MCPServerRuntimeConfig, McpMergedMetadataRead } from './mcp-server-runtime.js'; @@ -17,6 +26,84 @@ import { createStdioDataBridge, enforceApiExposure, GATED_ACTIONS } from './stdi import type { McpDataBridge } from './mcp-http-tools.js'; import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js'; +/** + * [#15348] Resolve the deployment's EFFECTIVE tenancy posture for the stdio + * door — the argument BOTH posture-conditional API-key refusals are gated on + * (`organization_required`, in `@objectstack/core`'s `api-key.ts`, and + * `organization_membership_ended`, in `resolve-authz-context.ts`). + * + * Supplying none does not weaken those guards, it SKIPS them, and the resolver + * then admits the key carrying `sys_api_key.active_organization_id` VERBATIM — + * the caller's own stored claim, never vetted against current membership. So + * under a wall-enforcing posture a key stamped with an organization its owner + * has LEFT was admitted with that organization as its tenant. + * + * ## Why this door and not only the ones already wired + * + * Every caller on this transport is an API key by construction: the header map + * is built from `OS_MCP_STDIO_API_KEY` a few lines below and there is no + * session path at all. The API-key admission is therefore not one branch of + * this door's authorization — it IS this door's authorization, and the + * `tenantId` it resolves is what `assembleExecutionContext` hands the engine as + * the request's tenant. + * + * ## The classification, and the one shape that must not be written here + * + * [#13906 decision 1 option A] Two facts a `try { … } catch { undefined }` + * would collapse into one: + * + * - **never registered** → branded (`isServiceNotRegisteredError`) → a quiet + * `undefined`. The supported no-tenancy composition: a kernel assembled + * without `plugin-auth` registers no `tenancy` service and enforces no + * organization wall, so there is nothing for a key to be walled out of. + * - **registered and FAILED to build** → unbranded → `AuthzStoreUnavailableError` + * (503). A posture that could not be READ is not a posture that is ABSENT; + * admitting on it is exactly the permissive-on-failure defect #13906 exists + * to repair, and the reason this seam is not a one-liner. + * + * Only the ASYNC accessor carries that discriminator — the branded rejection is + * raised by `PluginLoader.getService`, which the sync accessor never reaches. + * The sync leg below is taken only on a host whose `getKernel()` yields no + * `getServiceAsync` (a `KernelBase`-shaped host, and the duck-typed contexts + * this package's own tests build). Such a host instantiates no service + * factories at all, so "nothing is registered under that name" is the only + * fault its accessor can report, and absorbing it is the SAME classification + * rather than a second collapse of it. + * + * ## ⚠️ Read PER CALL — deliberately not hoisted into `start()` + * + * A posture resolved once in `start()` and held would be the #11580 defect this + * file already paid for, pointed at a security control instead of a locale. + * `start()` bodies run strictly before every other plugin's `start()` and + * before the first `kernel:ready`; `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). Freezing a read taken inside that + * window would freeze "no wall" for the life of a long-lived transport, and it + * would never self-correct. + * + * The localization hoist below is memoized because its resolution costs + * settings reads. This one costs two registry lookups and no I/O, so there is + * nothing to buy — and a live read is what ADR-0101 D1 already promises this + * door for the identity beside it: re-resolved per call, so a change takes + * effect on the next one. + */ +async function resolveStdioTenancyPosture(ctx: PluginContext): Promise { + const kernel = typeof ctx.getKernel === 'function' ? ctx.getKernel() : undefined; + if (kernel && typeof kernel.getServiceAsync === 'function') { + try { + return effectiveTenancyPosture(await kernel.getServiceAsync('tenancy')); + } catch (err) { + if (!isServiceNotRegisteredError(err)) throw new AuthzStoreUnavailableError('tenancy', err); + return undefined; + } + } + try { + return effectiveTenancyPosture(ctx.getService('tenancy')); + } catch { + return undefined; + } +} + /** * Resolve `OS_MCP_STDIO_API_KEY` into an {@link ExecutionContext} through the * SAME `@objectstack/core` verify + authorization chain the HTTP and REST @@ -44,13 +131,25 @@ import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js'; * window CLOSES: a call that races the boot is answered from a fresh * resolution that is deliberately not kept, so a pre-bind answer can never * become the memoized one. + * + * @param tenancyPosture [#15348] The deployment's effective posture, from + * {@link resolveStdioTenancyPosture}. REQUIRED rather than optional, and + * threaded rather than resolved here, for two separate reasons. Threaded, + * because this function holds no kernel handle and the posture must come from + * the ONE place that does (`start()`); required, because an optional parameter + * is how the argument came to be missing in the first place — a new call site + * that omits it would compile, and its two refusals would silently stop being + * reachable. `undefined` is a legitimate VALUE here (no `tenancy` service is + * registered ⇒ no wall exists ⇒ no posture-conditional refusal), and it has to + * be passed on purpose. */ async function resolveStdioExecutionContext( ql: { find: (object: string, opts: unknown) => Promise }, apiKey: string, localization: EntryLocalization | undefined, + tenancyPosture: TenancyPosture | undefined, ): Promise { - const authz = await resolveAuthzContext({ ql, headers: { 'x-api-key': apiKey } }); + const authz = await resolveAuthzContext({ ql, headers: { 'x-api-key': apiKey }, tenancyPosture }); return assembleExecutionContext({ authz, // OAuth access tokens are honoured on the `/mcp` HTTP door alone @@ -286,7 +385,19 @@ export class MCPServerPlugin implements Plugin { // data call, so it must not pay for settings reads whose result it would // discard. The localization hoist below needs its `userId`/`tenantId`, // which is why the probe comes first. - const initial = await resolveStdioExecutionContext(ql, apiKey, undefined); + // [#15348] The posture is read HERE too, not only per call: an ex-member's + // or organization-less key that this deployment's wall refuses is not a + // credential this transport can run under, so it takes the same + // fail-closed refusal-to-start the unknown/revoked/expired key takes + // below. A `tenancy` service that is REGISTERED AND BROKEN raises + // `AuthzStoreUnavailableError` out of this line — loud, and it stops the + // boot rather than starting a door whose admission was never decided. + const initial = await resolveStdioExecutionContext( + ql, + apiKey, + undefined, + await resolveStdioTenancyPosture(ctx), + ); if (!initial) { throw new Error( '[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 { await localizationForRead(); }); // Re-resolve per call so a revoked/expired key stops working on the next read. + // [#15348] The tenancy posture is re-read on the same schedule and for the + // same reason: it is an input to that admission, and a membership or a + // wall that changed mid-session must take effect on the next call rather + // than at the next process restart. See `resolveStdioTenancyPosture` for + // why this is not hoisted next to the localization memo. const resolvePrincipal = async (): Promise => { - const ec = await resolveStdioExecutionContext(scopedQl, apiKey, await localizationForRead()); + const ec = await resolveStdioExecutionContext( + scopedQl, + apiKey, + await localizationForRead(), + await resolveStdioTenancyPosture(ctx), + ); if (!ec) throw new Error('MCP stdio identity is no longer valid (key revoked or expired)'); return ec; }; From e73a1213654b4635b85e7e675d292251f33d91be Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 16:43:29 +0000 Subject: [PATCH 2/3] 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 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .changeset/mcp-stdio-tenancy-posture.md | 15 + ...dio-tenancy-posture-api-key-matrix.test.ts | 550 ++++++++++++++++++ 2 files changed, 565 insertions(+) create mode 100644 .changeset/mcp-stdio-tenancy-posture.md create mode 100644 packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts diff --git a/.changeset/mcp-stdio-tenancy-posture.md b/.changeset/mcp-stdio-tenancy-posture.md new file mode 100644 index 0000000000..8f4288c510 --- /dev/null +++ b/.changeset/mcp-stdio-tenancy-posture.md @@ -0,0 +1,15 @@ +--- +"@objectstack/mcp": patch +--- + +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. + +`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. + +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: + +- 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. +- 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. +- 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. + +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. diff --git a/packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts b/packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts new file mode 100644 index 0000000000..42620d1d39 --- /dev/null +++ b/packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts @@ -0,0 +1,550 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15348] The #15163 matrix, driven through the MCP **stdio** door. + * + * ## The defect this measures + * + * `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` in `api-key.ts`, `organization_membership_ended` in + * `resolve-authz-context.ts`), so a door that supplies none runs NEITHER, and + * the key's `sys_api_key.active_organization_id` — the caller's own stored + * claim, never vetted against current membership — became the request's tenant. + * + * This transport is the sharpest of the census: it has no session path at all, + * so the API-key admission is not one branch of its authorization, it IS its + * authorization. + * + * ## What this door answers, and why it is not REST's 401 + * + * The stdio face is FAIL-CLOSED BY REFUSING TO START (ADR-0101): a key that + * does not resolve to an identity throws out of `start()` rather than + * attaching a transport. A posture refusal resolves to no principal, so it + * takes that same exit — there is no wire on which to answer 401. §2 and §3 + * therefore assert a refused BOOT, and §5 asserts the per-call half, where the + * transport is already live and the next call is what has to refuse. + * + * ## Why the fixture is shaped the way it is + * + * Carried from the REST reading (`single-kernel-isolated-api-key-matrix.test.ts`): + * + * 1. **Data must be shown to REACH.** Every arm has a current member's key on + * the same door requiring rows back, so a green cannot mean "nothing works". + * 2. **The write is read back FROM THE STORE**, never from the tool's response + * body. `store()` is the fixture's table and the assertions count rows in it. + * 3. **Layer 0 is modelled as the hard equality it is** — `organization_id = + * context.tenantId` (`tenant-layer.ts`'s `isolated` branch), which is + * exactly what admits an ex-member whose key names the organization. A + * second organization is seeded so a wall that stopped applying reddens. + * 4. **A REAL `ObjectKernel` holds the services.** The classification under + * test is the registry's own — branded "never registered" versus the + * unbranded rejection of a factory that threw — so a hand-built stub error + * at the seam would be the fixture asserting itself. `@objectstack/core` is + * not mocked anywhere in this file: the real verify → authorize chain runs. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + ObjectKernel, + hashApiKey, + isServiceNotRegisteredError, + AUTHZ_STORE_UNAVAILABLE_CODE, + AUTHZ_STORE_UNAVAILABLE_STATUS, +} from '@objectstack/core'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { MCPServerPlugin } from './plugin.js'; +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import type { McpDataBridge } from './mcp-http-tools.js'; + +const OBJECT = 'crm_unit'; + +const RAW_MEMBER_KEY = 'osk_15348_member'; +const RAW_EXMEMBER_KEY = 'osk_15348_exmember'; +const RAW_ORGLESS_KEY = 'osk_15348_orgless'; + +// --------------------------------------------------------------------------- +// The store — the fixture's table, read directly by the write assertions +// --------------------------------------------------------------------------- + +interface UnitRow { + id: string; + organization_id: string | undefined; + created_by: string | undefined; + name: string; +} + +const SEED: readonly UnitRow[] = [ + { id: 'u_a1', organization_id: 'org_alpha', created_by: undefined, name: 'alpha unit 1' }, + { id: 'u_a2', organization_id: 'org_alpha', created_by: undefined, name: 'alpha unit 2' }, + // The other organization, seeded so "the wall is live" is a control rather + // than an assumption: a member of org_alpha must never see these two. + { id: 'u_b1', organization_id: 'org_beta', created_by: undefined, name: 'beta unit 1' }, + { id: 'u_b2', organization_id: 'org_beta', created_by: undefined, name: 'beta unit 2' }, +]; + +/** + * The fixture's ONE hand-written where-matcher: equality plus `$in` — the two + * shapes the shared resolver actually issues — refusing every other shape + * loudly, so a combinator it does not implement can never read as a field that + * happened not to match. + */ +function matchesWhere(row: Record, where: unknown): boolean { + for (const [field, cond] of Object.entries((where ?? {}) as Record)) { + if (field.startsWith('$')) { + throw new Error(`fixture where-matcher: unsupported combinator '${field}'`); + } + if (cond !== null && typeof cond === 'object') { + const ops = Object.keys(cond as object); + if (ops.length !== 1 || ops[0] !== '$in' || !Array.isArray((cond as { $in?: unknown }).$in)) { + throw new Error(`fixture where-matcher: unsupported operator shape on '${field}'`); + } + if (!((cond as { $in: unknown[] }).$in).includes(row[field])) return false; + continue; + } + if (row[field] !== cond) return false; + } + return true; +} + +interface Engine { + find: (object: string, query?: unknown, opts?: unknown) => Promise; + insert: (object: string, data: unknown, opts?: unknown) => Promise; + update: (object: string, data: unknown, opts?: unknown) => Promise; + delete: (object: string, opts?: unknown) => Promise; + findOne: (object: string, query?: unknown, opts?: unknown) => Promise; + count: () => Promise; +} + +interface Fixture { + engine: Engine; + store: () => UnitRow[]; + /** Drop `u_exmember`'s remaining membership row — used by §5's live arm. */ + endMembership: (userId: string) => void; +} + +/** + * The permission store, in the SHIPPED aggregation shapes, plus the one data + * object the door reads and writes. + * + * `u_exmember`'s key is stamped `org_alpha` while its only current `sys_member` + * row is for `org_beta` — the credential outlived the membership that backed + * it, which is the whole scenario. RBAC is opened SYMMETRICALLY through one + * shared permission set, so only the organization wall can separate the arms. + */ +function makeFixture(): Fixture { + const rows: UnitRow[] = SEED.map((r) => ({ ...r })); + let seq = 0; + const tables: Record>> = { + sys_api_key: [ + { id: 'key_member', key: hashApiKey(RAW_MEMBER_KEY), user_id: 'u_member', active_organization_id: 'org_alpha', revoked: false }, + { id: 'key_exmember', key: hashApiKey(RAW_EXMEMBER_KEY), user_id: 'u_exmember', active_organization_id: 'org_alpha', revoked: false }, + { id: 'key_orgless', key: hashApiKey(RAW_ORGLESS_KEY), user_id: 'u_orgless', revoked: false }, + ], + sys_member: [ + { user_id: 'u_member', organization_id: 'org_alpha' }, + { user_id: 'u_exmember', organization_id: 'org_beta' }, + ], + sys_user: [ + { id: 'u_member', email: 'u_member@example.com' }, + { id: 'u_exmember', email: 'u_exmember@example.com' }, + { id: 'u_orgless', email: 'u_orgless@example.com' }, + ], + sys_user_permission_set: [ + { user_id: 'u_member', permission_set_id: 'ps_shared' }, + { user_id: 'u_exmember', permission_set_id: 'ps_shared' }, + { user_id: 'u_orgless', permission_set_id: 'ps_shared' }, + ], + sys_permission_set: [ + { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, + ], + }; + + /** The context reaches `find` in the options bag on one call shape and in a + * third argument on the other — both are live on this door (the ADR-0101 + * record reader uses the first, the data bridge the second). */ + const contextOf = (query: unknown, opts: unknown): ExecutionContext | undefined => + ((opts as { context?: ExecutionContext } | undefined)?.context + ?? (query as { context?: ExecutionContext } | undefined)?.context); + + const engine: Engine = { + async find(object, query: any = {}, opts?: unknown) { + if (object !== OBJECT) { + const matched = (tables[object] ?? []).filter((row) => matchesWhere(row, query?.where)); + return typeof query?.limit === 'number' ? matched.slice(0, query.limit) : matched; + } + // ADR-0105 Layer 0 under `isolated`, as `tenant-layer.ts` computes it: a + // HARD EQUALITY against the caller's active organization. It never reads + // `accessible_org_ids` — that is the `group` union branch — which is + // precisely why a key naming an organization its owner LEFT passes it. + const tenantId = contextOf(query, opts)?.tenantId; + const visible = rows.filter( + (row) => row.organization_id === tenantId && matchesWhere(row as never, query?.where), + ); + return { value: visible, total: visible.length }; + }, + async insert(object, data: any, opts?: unknown) { + if (object !== OBJECT) throw new Error(`fixture: no write table for '${object}'`); + const ctx = contextOf(undefined, opts); + const row: UnitRow = { + id: `w${++seq}`, + organization_id: ctx?.tenantId, + created_by: ctx?.userId, + name: String(data?.name ?? ''), + }; + rows.push(row); + return { ...row }; + }, + async update() { throw new Error('fixture: update not exercised'); }, + async delete() { throw new Error('fixture: delete not exercised'); }, + async findOne() { return null; }, + async count() { return 0; }, + }; + + return { + engine, + store: () => rows.map((r) => ({ ...r })), + endMembership: (userId) => { + tables.sys_member = tables.sys_member.filter((m) => m.user_id !== userId); + }, + }; +} + +// --------------------------------------------------------------------------- +// The host — a REAL kernel behind a plugin context, so the registry's own +// branded / unbranded rejections are what the seam classifies. +// --------------------------------------------------------------------------- + +/** A `tenancy` service whose posture the test can move while the door is live. */ +interface LiveTenancy { posture: string } + +type TenancyWiring = + | { kind: 'posture'; posture: string } + | { kind: 'live'; service: LiveTenancy } + | { kind: 'unregistered' } + | { kind: 'factory-throws' }; + +function makeKernel(engine: Engine, tenancy: TenancyWiring): ObjectKernel { + // `gracefulShutdown: false` — a fixture kernel must not hook the test + // runner's process signals (the default registers SIGTERM/SIGINT handlers). + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as never); + kernel.registerService('objectql', engine); + kernel.registerService('metadata', { + listObjects: vi.fn(async () => []), + // No declaration to read ⇒ the ADR-0049 exposure gate falls open, which is + // the state a bare kernel is in. That gate is #8266's subject, not this + // file's: what is measured here is the tenant the read runs under. + getObject: vi.fn(async () => null), + get: vi.fn(async () => null), + list: vi.fn(async () => []), + exists: vi.fn(async () => false), + getRegisteredTypes: vi.fn(async () => ['object']), + register: vi.fn(), + unregister: vi.fn(), + }); + if (tenancy.kind === 'posture') { + kernel.registerService('tenancy', { posture: tenancy.posture }); + } else if (tenancy.kind === 'live') { + kernel.registerService('tenancy', tenancy.service); + } else if (tenancy.kind === 'factory-throws') { + // The REAL failure class (#13905 "registered and FAILED to construct"): + // the registry's own UNBRANDED rejection, not a stub error thrown at the + // seam under measurement. + kernel.registerServiceFactory('tenancy', () => { + throw new Error('tenancy backend unavailable'); + }); + } + // 'unregistered' → nothing registered: the branded not-registered rejection. + return kernel; +} + +/** The plugin context, delegating every registry question to the real kernel. */ +function makeCtx(kernel: ObjectKernel) { + return { + registerService: vi.fn((name: string, service: unknown) => { kernel.registerService(name, service); }), + getService: vi.fn((name: string): T => kernel.getService(name)), + replaceService: vi.fn(), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(async () => {}), + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(() => kernel), + }; +} + +/** + * `McpDataBridge.query` declares `Promise` (its shape is the tool + * layer's business, not the bridge contract's), so every read in this file goes + * through one narrowing point rather than a cast per assertion. + */ +interface QueryAnswer { + records: Array>; + total: number; +} + +async function readAll(bridge: McpDataBridge): Promise { + return (await bridge.query(OBJECT, {})) as QueryAnswer; +} + +interface Started { + bridge: McpDataBridge; + getRecord: (object: string, id: string) => Promise | null>; +} + +/** + * Boot the plugin on the stdio path and hand back the two principal-bound + * surfaces it registered. The transport itself is stubbed: a real `start()` + * would claim this process's stdin/stdout. + */ +async function startStdio(ctx: unknown): Promise { + let bridge: McpDataBridge | undefined; + let getRecord: Started['getRecord'] | undefined; + const spies = [ + vi.spyOn(MCPServerRuntime.prototype, 'bridgeResources').mockImplementation( + (_meta, reader) => { getRecord = reader as Started['getRecord']; }, + ), + vi.spyOn(MCPServerRuntime.prototype, 'bridgePrompts').mockImplementation(async () => {}), + vi.spyOn(MCPServerRuntime.prototype, 'bridgeDataTools').mockImplementation( + (b) => { bridge = b as McpDataBridge; return []; }, + ), + vi.spyOn(MCPServerRuntime.prototype, 'start').mockImplementation(async () => {}), + ]; + try { + const plugin = new MCPServerPlugin({ autoStart: true }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + } finally { + for (const s of spies) s.mockRestore(); + } + if (!bridge || !getRecord) throw new Error('stdio start registered no principal-bound surface'); + return { bridge, getRecord }; +} + +/** Boot with this raw key, and hand back whatever `start()` did. */ +async function boot(rawKey: string, tenancy: TenancyWiring) { + process.env.OS_MCP_STDIO_API_KEY = rawKey; + const fixture = makeFixture(); + const ctx = makeCtx(makeKernel(fixture.engine, tenancy)); + return { fixture, ctx, start: () => startStdio(ctx) }; +} + +const ORIGINAL_ENV = { ...process.env }; +beforeEach(() => { process.env = { ...ORIGINAL_ENV }; }); +afterEach(() => { process.env = { ...ORIGINAL_ENV }; vi.restoreAllMocks(); }); + +// --------------------------------------------------------------------------- +// §0 — Instrument controls. Both directions, before any subject arm is read. +// --------------------------------------------------------------------------- + +describe('[#15348] §0 — the door can serve, and the door can refuse', () => { + it('CONTROL · data REACHES: a CURRENT member reads its own organization and only that one', async () => { + const h = await boot(RAW_MEMBER_KEY, { kind: 'posture', posture: 'isolated' }); + const { bridge } = await h.start(); + const res = await readAll(bridge); + expect(res.total).toBe(2); + expect(res.records.map((r) => r.id)).toEqual(['u_a1', 'u_a2']); + // The wall IS live: org_beta's two rows exist in the store and are not served. + expect(h.fixture.store().filter((r) => r.organization_id === 'org_beta')).toHaveLength(2); + expect(res.records.every((r) => r.organization_id === 'org_alpha')).toBe(true); + }); + + it('CONTROL · writes REACH: a CURRENT member\'s create lands, read back FROM THE STORE', async () => { + const h = await boot(RAW_MEMBER_KEY, { kind: 'posture', posture: 'isolated' }); + const { bridge } = await h.start(); + await bridge.create(OBJECT, { name: 'w-member' }); + const landed = h.fixture.store().filter((r) => r.name === 'w-member'); + expect(landed).toHaveLength(1); + expect(landed[0]).toMatchObject({ organization_id: 'org_alpha', created_by: 'u_member' }); + }); + + it('CONTROL · the ADR-0101 record reader is bound to the same identity', async () => { + const h = await boot(RAW_MEMBER_KEY, { kind: 'posture', posture: 'isolated' }); + const { getRecord } = await h.start(); + expect(await getRecord(OBJECT, 'u_a1')).toMatchObject({ id: 'u_a1', organization_id: 'org_alpha' }); + // The other organization's row is in the store and is NOT readable. + expect(await getRecord(OBJECT, 'u_b1')).toBeNull(); + }); + + it('CONTROL · the door refuses: an unknown key never starts a transport', async () => { + const h = await boot('osk_not_a_real_key', { kind: 'posture', posture: 'isolated' }); + await expect(h.start()).rejects.toThrow(/did not resolve to a valid identity/); + }); +}); + +// --------------------------------------------------------------------------- +// §1 — the seam actually reads a posture at all +// --------------------------------------------------------------------------- + +describe('[#15348] §1 — the fixture\'s two rejection classes are the registry\'s own', () => { + it('an unregistered `tenancy` rejects BRANDED — the fact the quiet branch keys on', async () => { + const kernel = makeKernel(makeFixture().engine, { kind: 'unregistered' }); + const err = await kernel.getServiceAsync('tenancy').then(() => undefined, (e) => e); + expect(isServiceNotRegisteredError(err)).toBe(true); + }); + + it('a `tenancy` factory that THROWS rejects UNBRANDED — the fact the loud branch keys on', async () => { + const kernel = makeKernel(makeFixture().engine, { kind: 'factory-throws' }); + const err = await kernel.getServiceAsync('tenancy').then(() => undefined, (e) => e); + expect(err).toBeInstanceOf(Error); + expect(isServiceNotRegisteredError(err)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — THE SUBJECT ROW. An ex-member's org-stamped key under `isolated`. +// --------------------------------------------------------------------------- + +describe('[#15348] §2 — an ex-member\'s org-stamped key on the stdio door under `isolated`', () => { + it('REPAIRED: the transport REFUSES TO START — it used to attach and serve the other organization', async () => { + const h = await boot(RAW_EXMEMBER_KEY, { kind: 'posture', posture: 'isolated' }); + await expect(h.start()).rejects.toThrow(/did not resolve to a valid identity/); + // Nothing ran, so nothing was written. + expect(h.fixture.store()).toHaveLength(SEED.length); + }); + + it('[2A] the refusal is said OUT LOUD on the server side, naming key / principal / organization / reason', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = await boot(RAW_EXMEMBER_KEY, { kind: 'posture', posture: 'isolated' }); + await h.start().catch(() => {}); + const lines = warn.mock.calls.map((c) => c.map(String).join(' ')).filter((l) => l.includes('API key refused')); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('organization_membership_ended'); + expect(lines[0]).toContain('key=key_exmember'); + expect(lines[0]).toContain('principal=u_exmember'); + expect(lines[0]).toContain('organization=org_alpha'); + // ⛔ NEVER the credential — neither the raw key nor its at-rest hash. + expect(lines[0]).not.toContain(RAW_EXMEMBER_KEY); + expect(lines[0]).not.toContain(hashApiKey(RAW_EXMEMBER_KEY)); + }); + + it('`group` refuses it too — the union scope is not a licence for a key naming an org its owner left', async () => { + const h = await boot(RAW_EXMEMBER_KEY, { kind: 'posture', posture: 'group' }); + await expect(h.start()).rejects.toThrow(/did not resolve to a valid identity/); + }); + + it('NARROWNESS · `single` admits it — there is no wall to be walled out of', async () => { + const h = await boot(RAW_EXMEMBER_KEY, { kind: 'posture', posture: 'single' }); + const { bridge } = await h.start(); + // Admitted, and the read is what an unwalled deployment answers. + expect((await readAll(bridge)).total).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — The organization-less key: the silent-empty row of the same matrix. +// --------------------------------------------------------------------------- + +describe('[#15348] §3 — an organization-less key', () => { + it('REPAIRED under `isolated`: refused at start — it used to attach and answer a silent empty set', async () => { + const h = await boot(RAW_ORGLESS_KEY, { kind: 'posture', posture: 'isolated' }); + await expect(h.start()).rejects.toThrow(/did not resolve to a valid identity/); + }); + + it('[2A] its refusal is its own line, with its own reason', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const h = await boot(RAW_ORGLESS_KEY, { kind: 'posture', posture: 'isolated' }); + await h.start().catch(() => {}); + const lines = warn.mock.calls.map((c) => c.map(String).join(' ')).filter((l) => l.includes('API key refused')); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('organization_required'); + expect(lines[0]).toContain('key=key_orgless'); + expect(lines[0]).toContain('organization='); + }); + + it('NARROWNESS · `group` admits it — `organization_required` is the `isolated` refusal only', async () => { + // `postureUsesUnionScope('group')` is true, so an org-less key still reads + // through the membership union. Asserted so the two refusals cannot be + // conflated into one broader rule than either declares. + const h = await boot(RAW_ORGLESS_KEY, { kind: 'posture', posture: 'group' }); + const { bridge } = await h.start(); + expect((await readAll(bridge)).total).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// §4 — THE CLASSIFICATION. #13906 decision 1 option A, both halves. +// +// This is the pin 1.2 of the dispatch is about, and the one a naive +// `try { … } catch { undefined }` at this seam would turn green in the wrong +// direction: it would make the BROKEN service admit exactly like the absent one. +// --------------------------------------------------------------------------- + +describe('[#15348] §4 — a `tenancy` service that is registered and FAILS TO BUILD', () => { + it('raises the ADR-0112 outage envelope — code AND status — instead of admitting quietly', async () => { + const h = await boot(RAW_EXMEMBER_KEY, { kind: 'factory-throws' }); + const err = await h.start().then(() => undefined, (e) => e); + expect(err).toBeInstanceOf(Error); + expect((err as { code?: unknown }).code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); + expect((err as { status?: unknown }).status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS); + expect((err as { object?: unknown }).object).toBe('tenancy'); + // ⛔ NOT the fail-closed identity refusal: an outage must not wear the + // costume of "this key is not valid", which is what a `catch { undefined }` + // would have produced here (a quiet admit, or a 401-shaped refusal). + expect(String((err as Error).message)).not.toMatch(/did not resolve to a valid identity/); + }); + + it('and the CURRENT member is refused too — an undecidable posture is not a per-key verdict', async () => { + const h = await boot(RAW_MEMBER_KEY, { kind: 'factory-throws' }); + const err = await h.start().then(() => undefined, (e) => e); + expect((err as { code?: unknown }).code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); + }); + + it('CONTRAST · a `tenancy` that was NEVER REGISTERED stays quiet — the supported no-tenancy composition', async () => { + // The other half of decision 1A, and simultaneously this file's permanent + // ABLATION: with no posture in play the ex-member's key is admitted again + // and reads `org_alpha`'s rows, which is the measured defect returning the + // moment the argument stops being supplied. It is CORRECT here — a kernel + // with no `tenancy` service enforces no organization wall — and it is what + // makes every refusal above attributable to the posture and nothing else. + const h = await boot(RAW_EXMEMBER_KEY, { kind: 'unregistered' }); + const { bridge } = await h.start(); + const res = await readAll(bridge); + expect(res.total).toBe(2); + expect(res.records.map((r) => r.id)).toEqual(['u_a1', 'u_a2']); + await bridge.create(OBJECT, { name: 'w-nowall' }); + expect(h.fixture.store().filter((r) => r.name === 'w-nowall')[0]).toMatchObject({ + organization_id: 'org_alpha', created_by: 'u_exmember', + }); + }); +}); + +// --------------------------------------------------------------------------- +// §5 — READ PER CALL, not frozen at `start()`. +// +// `TenancyService.posture` is a live getter that reports a wall it cannot yet +// enforce as `single` (ADR-0093 D4/D5), and this plugin's `start()` runs before +// every other plugin's. A posture captured there and held would freeze +// "no wall" for the life of a long-lived transport — #11580's defect pointed at +// a security control. These two arms are what a hoist would redden. +// --------------------------------------------------------------------------- + +describe('[#15348] §5 — the posture and the membership are both re-read per call', () => { + it('a wall that comes up AFTER the transport attaches refuses the next call', async () => { + const service: LiveTenancy = { posture: 'single' }; + const h = await boot(RAW_EXMEMBER_KEY, { kind: 'live', service }); + // Boots: at start there is no wall, so the key is legitimately admitted. + const { bridge } = await h.start(); + expect((await readAll(bridge)).total).toBe(2); + + // The enterprise multi-org runtime registers and the wall goes live. + service.posture = 'isolated'; + + await expect(bridge.query(OBJECT, {})).rejects.toThrow(/no longer valid/); + await expect(bridge.create(OBJECT, { name: 'w-after-wall' })).rejects.toThrow(/no longer valid/); + expect(h.fixture.store().filter((r) => r.name === 'w-after-wall')).toHaveLength(0); + }); + + it('a membership that ENDS mid-session refuses the next call (ADR-0101 D1)', async () => { + // `u_member` starts as a current member of the organization its key names. + const h = await boot(RAW_MEMBER_KEY, { kind: 'posture', posture: 'isolated' }); + const { bridge, getRecord } = await h.start(); + expect((await readAll(bridge)).total).toBe(2); + + h.fixture.endMembership('u_member'); + + await expect(bridge.query(OBJECT, {})).rejects.toThrow(/no longer valid/); + // The ADR-0101 record reader is on the same schedule. + await expect(getRecord(OBJECT, 'u_a1')).rejects.toThrow(/no longer valid/); + }); +}); From d2a8d4fa703cee7351051a9853eb1095f8d137af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 16:57:31 +0000 Subject: [PATCH 3/3] test(mcp): pin the matrix fixture's engine double to the dispatch predicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- ...dio-tenancy-posture-api-key-matrix.test.ts | 28 +++++++++++++++---- scripts/engine-double-contract.pinned.json | 15 ++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts b/packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts index 42620d1d39..1977d21031 100644 --- a/packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts +++ b/packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts @@ -54,6 +54,16 @@ import { AUTHZ_STORE_UNAVAILABLE_STATUS, } from '@objectstack/core'; import type { ExecutionContext } from '@objectstack/spec/kernel'; +// The producer-side dispatch predicates every engine double in this repo is +// pinned to (`check:engine-double-contract`). The three verbs below are not +// exercised by this matrix — it reads and creates — but a double looser than +// the real engine is exactly what the ratchet exists to keep out of the tree. +import { + assertEngineDeleteDispatch, + assertEngineUpdateDispatch, + assertEngineFindOnePredicate, + type EngineFindOneQueryInput, +} from '@objectstack/metadata-core'; import { MCPServerPlugin } from './plugin.js'; import { MCPServerRuntime } from './mcp-server-runtime.js'; import type { McpDataBridge } from './mcp-http-tools.js'; @@ -111,9 +121,9 @@ function matchesWhere(row: Record, where: unknown): boolean { interface Engine { find: (object: string, query?: unknown, opts?: unknown) => Promise; insert: (object: string, data: unknown, opts?: unknown) => Promise; - update: (object: string, data: unknown, opts?: unknown) => Promise; - delete: (object: string, opts?: unknown) => Promise; - findOne: (object: string, query?: unknown, opts?: unknown) => Promise; + update: (object: string, data: any, opts?: any) => Promise; + delete: (object: string, opts?: any) => Promise; + findOne: (object: string, query?: EngineFindOneQueryInput) => Promise; count: () => Promise; } @@ -196,9 +206,15 @@ function makeFixture(): Fixture { rows.push(row); return { ...row }; }, - async update() { throw new Error('fixture: update not exercised'); }, - async delete() { throw new Error('fixture: delete not exercised'); }, - async findOne() { return null; }, + async update(_object, data, opts) { + assertEngineUpdateDispatch(data, opts); + throw new Error('fixture: update not exercised by this matrix'); + }, + async delete(_object, opts) { + assertEngineDeleteDispatch(opts); + throw new Error('fixture: delete not exercised by this matrix'); + }, + async findOne(object, query) { assertEngineFindOnePredicate(object, query); return null; }, async count() { return 0; }, }; diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index ba0ef11772..a68bb7ccfe 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -91,6 +91,21 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/migrations/recorded-by-sentinel.test.ts", "verb": "delete",