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/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; }; 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..1977d21031 --- /dev/null +++ b/packages/mcp/src/stdio-tenancy-posture-api-key-matrix.test.ts @@ -0,0 +1,566 @@ +// 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'; +// 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'; + +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: any, opts?: any) => Promise; + delete: (object: string, opts?: any) => Promise; + findOne: (object: string, query?: EngineFindOneQueryInput) => 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(_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; }, + }; + + 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/); + }); +}); 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",