From d0b6bc396923fc07e4d6b9a93f7c07db0b3ec64a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 09:16:33 +0000 Subject: [PATCH] test(authz): register MCP execution-surface identity admission in the ADR-0096 matrix (#3167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authz conformance matrix (ADR-0056 D10 / ADR-0096 D4) pins every execution surface's identity posture with a CI ratchet, but the MCP server — a live tool- execution surface — had no row of its own (only an incidental mention in the readonly-strip row). This adds two rows + three probe pins, one per transport, after auditing the actual code: - mcp-http-identity (ENFORCED): /api/v1/mcp already admits the caller — handleMcp denies anonymous (401), OAuth-scope-gates the tool families (#2698), and buildMcpBridge(context) threads the caller ExecutionContext so handleHttpRequest's per-request McpServer runs every tool under RLS/FLS/tenant exactly like REST /data. Pinned by probes on (UNCLASSIFIED tripwire for a new sibling MCP data handler) and (STALE if the threading is dropped). This CORRECTS #3167's premise that the HTTP admission was missing — it is wired; it was just unpinned. Its e2e dogfood proof is deferred to the dev-wiring follow-up (PR-B), so it is not marked high-risk yet — noted on the row. - mcp-stdio-authority (EXPERIMENTAL): the opt-in stdio transport (autoStart / OS_MCP_SERVER_ENABLED=true) bridges the RAW metadata service + data engine onto the long-lived server with NO per-request principal, so a stdio client reads unscoped (no RLS/FLS/tenant). Safe only as a single- operator local tool (the operator already owns the process + dev DB), and deliberately not default. This is the REAL admission gap. The row states the requirement before stdio is ever promoted to default-on / multi-user: thread a principal into the long-lived bridge. Pinned by a probe on the raw-service bridging (STALE forces re-classification when it changes). Bite tests (f)(g) prove both new pins fire. Baseline stays sound: the three new probe keys are covered exactly by the two new rows. Test-only change in the private @objectstack/dogfood gate — no changeset. Refs #3167, ADR-0096, ADR-0056 D10, #2698. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pbmw3pMqNJfPbkvwh9Fkjs --- .../dogfood/test/authz-conformance.matrix.ts | 13 +++++ .../qa/dogfood/test/authz-conformance.test.ts | 49 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 31ba832c9a..882526643d 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -89,6 +89,19 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ { id: 'realtime-delivery-authz', summary: 'realtime delivery fan-out has NO per-recipient authorization — trusted server-internal subscribers only (#2992 surface 2)', state: 'experimental', covers: ['realtime:in-memory-realtime-adapter.ts:publish(trusted-fan-out)'], note: 'Surface posture: system (trusted-implicit), pre-wiring — no end-user transport exists (handleUpgrade unimplemented, no REST subscribe route, client RealtimeAPI is a placeholder); the only subscribers are server-internal plugins (webhook auto-enqueuer, knowledge sync). Structural defect: Subscription carries no principal, matchesSubscription filters only by object+eventTypes (RealtimeSubscriptionOptions.filter is declared but never read), and the engine publishes the FULL after-row — so any future external subscriber would receive record bodies cross-tenant that its own find would hide. ADMISSION REQUIREMENT before any WebSocket/SSE/subscribe transport ships: per-recipient RLS/FLS/tenant re-check on delivery (subscription carries the subscriber ExecutionContext) OR id-only payload + client re-fetch. The transport tripwire probes in authz-conformance.test.ts turn a wired transport into an UNCLASSIFIED surface → red CI until this row is upgraded with the enforcement site.' }, + + // ── ADR-0096 — MCP execution-surface identity admission (#3167). The MCP + // server exposes ObjectStack tool execution over two transports with DIFFERENT + // identity postures; both are pinned here so a refactor can't silently change + // either. (Corrects #3167's premise that the HTTP admission was missing — it + // is wired; the real gap is the opt-in stdio transport.) + { id: 'mcp-http-identity', summary: 'MCP HTTP surface (/api/v1/mcp) admits the caller identity — anonymous denied, OAuth scope-gated, caller ExecutionContext threaded to every tool\'s data op', state: 'enforced', + enforcement: 'runtime/http-dispatcher.ts handleMcp — requires ec.userId||ec.isSystem (401 else, RFC 9728 WWW-Authenticate advertised when the OAuth track is live); OAuth-token provenance narrows the exposed tool families to the granted MCP scopes (403 on none, #2698); buildMcpBridge(context) threads the caller ExecutionContext into every bridge op (callData(..., ec)), and mcp-server-runtime.ts handleHttpRequest builds a fresh per-request McpServer from that principal-bound bridge (registerObjectTools/registerActionTools) — so RLS / FLS / tenant apply exactly as on REST /data', + covers: ['mcp:http-dispatcher.ts:handleMcp', 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)'], + note: 'The per-request principal-bound tool server is isolated from the long-lived UNSCOPED stdio server (see mcp-stdio-authority). NOT marked high-risk here only because its end-to-end dogfood proof (anonymous → 401; principal → RLS/FLS-scoped tool results) lands with the dev-default-wiring follow-up (#3167 PR-B), not because the surface is latent — it is a live route. Dropping the buildMcpBridge(context) threading (or building an unscoped/system bridge for HTTP) makes the context-threaded key STALE → red CI; a new sibling MCP data handler appears as an UNCLASSIFIED surface until a row covers it.' }, + { id: 'mcp-stdio-authority', summary: 'MCP stdio transport runs UNSCOPED — the long-lived server bridges the raw metadata service + data engine with no per-request principal (opt-in: autoStart / OS_MCP_SERVER_ENABLED=true)', state: 'experimental', + covers: ['mcp:plugin.ts:bridgeResources(unscoped-stdio)'], + note: 'Surface posture: process-authority, opt-in. Unlike the HTTP path (mcp-http-identity), MCPServerPlugin.start() bridges resources/tools onto the long-lived this.mcpServer from the RAW metadata service + data engine (bridgeResources(metadataService, dataEngine)) — there is no ExecutionContext, so a stdio-attached client reads metadata + records with full, unscoped authority (no RLS/FLS/tenant). This is safe ONLY as a single-operator LOCAL tool: the operator who can attach stdio already owns the process and its dev database, so the transport grants nothing they lack. It is deliberately NOT default (the shouldStart gate in plugin.ts keeps stdio opt-in, stricter than the default-on HTTP surface). ADMISSION REQUIREMENT before stdio is ever promoted to default-on OR served in a multi-user / hosted context: thread a principal (a configured service identity, or a per-session ExecutionContext) into the long-lived bridge, mirroring the HTTP path\'s buildMcpBridge. Changing the raw-service bridging makes the unscoped-stdio key STALE → forces re-classification in CI.' }, { id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced', enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' }, { id: 'readonly-static-write', summary: 'static `readonly: true` stripped from non-system UPDATE (#2948 / #3003) AND INSERT (#3043) payloads — neither a direct PATCH nor a direct POST can forge approval/status/amount columns the UI never renders', state: 'enforced', diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index a90a27ee3f..225e5c7ae8 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -114,6 +114,32 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray re: /['"`][^'"`]*\/realtime[^'"`]*['"`]/g, key: () => 'realtime:rest-server.ts:route(TRANSPORT-WIRED)', }, + + // ── ADR-0096 / #3167 — MCP execution-surface identity pins ───────────── + // (1) The HTTP `/mcp` handler must stay classified (a new sibling MCP data + // handler → UNCLASSIFIED). (2) Its caller-identity threading: handleMcp must + // build the tool bridge FROM the request context (carrying the caller EC). + // Drop the threading (or build a system/unscoped bridge for HTTP) → the + // context-threaded key vanishes → the mcp-http-identity row goes STALE → red CI. + { + file: 'packages/runtime/src/http-dispatcher.ts', + re: /async\s+handleMcp\s*\(/g, + key: () => 'mcp:http-dispatcher.ts:handleMcp', + }, + { + file: 'packages/runtime/src/http-dispatcher.ts', + re: /this\.buildMcpBridge\(context\)/g, + key: () => 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)', + }, + // (3) The stdio transport's UNSCOPED data bridge: the long-lived server is fed + // the raw metadata service + data engine with no principal. Wrapping these in + // a principal-bound bridge (the admission fix) changes this line → the + // unscoped-stdio key goes STALE → forces re-classifying mcp-stdio-authority. + { + file: 'packages/mcp/src/plugin.ts', + re: /bridgeResources\(metadataService, dataEngine\)/g, + key: () => 'mcp:plugin.ts:bridgeResources(unscoped-stdio)', + }, ]; /** Statically enumerate the anonymous-deny HTTP entry points from source. */ @@ -211,4 +237,27 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => { ); expect(problems.some((p) => /STALE covers/.test(p) && p.includes(threaded))).toBe(true); }); + + // ── ADR-0096 / #3167 — the MCP identity pins bite too ────────────────── + it('(f) dropping the MCP HTTP context-thread → STALE covers failure (#3167)', () => { + const threaded = 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)'; + // Baseline sanity: the HTTP `/mcp` handler threads the caller EC today. + expect(discoverAnonymousDenySurfaces().has(threaded)).toBe(true); + const problems = checkLedger( + AUTHZ_CONFORMANCE, + opts(() => new Set([...discoverAnonymousDenySurfaces()].filter((k) => k !== threaded))), + ); + expect(problems.some((p) => /STALE covers/.test(p) && p.includes(threaded))).toBe(true); + }); + + it('(g) the stdio unscoped-bridge posture is pinned; changing it goes STALE (#3167)', () => { + const stdio = 'mcp:plugin.ts:bridgeResources(unscoped-stdio)'; + // Baseline sanity: the long-lived server bridges the raw services today. + expect(discoverAnonymousDenySurfaces().has(stdio)).toBe(true); + const problems = checkLedger( + AUTHZ_CONFORMANCE, + opts(() => new Set([...discoverAnonymousDenySurfaces()].filter((k) => k !== stdio))), + ); + expect(problems.some((p) => /STALE covers/.test(p) && p.includes(stdio))).toBe(true); + }); });