mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* feat: narrow mcp:connect scope + per-key HTTP tool-scope binding (#7895) Adds MCP_CONNECT_SCOPE ("mcp:connect"), a narrow additive API-key scope (kept out of MANAGEMENT_API_KEY_SCOPES, same precedent as SELF_USAGE_SCOPE) that authorizes ONLY the /api/mcp/ LOCAL_ONLY route-guard carve-out -- remote MCP-only callers no longer need broad manage/admin scope just to reach the transport routes. Scoped strictly to /api/mcp/; every other LOCAL_ONLY bypass prefix still requires hasManageScope() unchanged. Also resolves the caller's real api_keys.scopes over HTTP/SSE (httpAuthContext.ts::resolveMcpCallerAuthInfo) and passes it to the MCP SDK's transport.handleRequest(req, { authInfo }), so extra.authInfo.scopes reaching tool calls reflects the Bearer key's own scopes instead of the OMNIROUTE_MCP_SCOPES env fallback -- scopeEnforcement.ts already prioritized authInfo, it was simply unfed over HTTP. Does not flip the OMNIROUTE_MCP_ENFORCE_SCOPES default; stdio is unaffected (no per-caller identity, stays on the meta/env fallback chain). Closes #7895 * test(mcp): register mcp-connect-scope test in stryker tap.testFiles (#7895)
This commit is contained in:
committed by
GitHub
parent
ff320cbfd5
commit
6602af7478
1
changelog.d/features/7895-mcp-connect-scope-binding.md
Normal file
1
changelog.d/features/7895-mcp-connect-scope-binding.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(mcp): add a narrow `mcp:connect` API-key scope for the `/api/mcp/` LOCAL_ONLY carve-out (separate from `manage`/`admin`) and populate `authInfo.scopes` from the caller's real per-key scopes over HTTP/SSE so `scopeEnforcement.ts` prefers per-key scopes over the `OMNIROUTE_MCP_SCOPES` env fallback when enforcement is enabled (#7895).
|
||||
@@ -308,6 +308,34 @@ MCP tools are authenticated through API key scopes. Scope enforcement is central
|
||||
|
||||
Wildcard scopes are supported: `read:*` grants all read-scopes, `*` grants full access.
|
||||
|
||||
### `mcp:connect` — narrow route capability (#7895)
|
||||
|
||||
Reaching the HTTP/SSE MCP transport (`/api/mcp/*`) from non-loopback requires the
|
||||
`/api/mcp/` LOCAL_ONLY carve-out (see `docs/security/ROUTE_GUARD_TIERS.md`). Historically
|
||||
that carve-out only accepted a full `manage`/`admin`-scope API key — too broad for a
|
||||
caller that only needs to talk MCP. `src/shared/constants/managementScopes.ts` now
|
||||
exports `MCP_CONNECT_SCOPE = "mcp:connect"`: an additive, narrow scope (same precedent as
|
||||
`SELF_USAGE_SCOPE`) that authorizes ONLY the `/api/mcp/` bypass in
|
||||
`src/server/authz/policies/management.ts` — it grants no other management-route access
|
||||
and is deliberately kept OUT of `MANAGEMENT_API_KEY_SCOPES`. A key holding `manage`/`admin`
|
||||
still passes the carve-out unchanged; `mcp:connect` is a lower-privilege alternative for
|
||||
remote MCP-only callers, checked via `hasMcpConnectOrManageScope()`.
|
||||
|
||||
### Per-key HTTP scope binding (#7895)
|
||||
|
||||
Over HTTP/SSE, `open-sse/mcp-server/httpTransport.ts` now resolves the caller's real
|
||||
`api_keys.scopes` via `resolveMcpCallerAuthInfo()` (`open-sse/mcp-server/httpAuthContext.ts`)
|
||||
and passes it to the MCP SDK's `transport.handleRequest(req, { authInfo })`, so
|
||||
`extra.authInfo.scopes` reaching each tool call reflects the Bearer key's own scopes.
|
||||
`scopeEnforcement.ts`'s `resolveCallerScopeContext()` already prioritized `authInfo` over
|
||||
the `_meta` and `OMNIROUTE_MCP_SCOPES` env fallback — this only populates that first,
|
||||
highest-priority source, which was previously unfed over HTTP. When no API key resolves
|
||||
(no header, invalid key), `authInfo` stays `undefined` and resolution falls through to the
|
||||
existing `meta`/env chain unchanged. This does NOT flip `OMNIROUTE_MCP_ENFORCE_SCOPES`'s
|
||||
default — enforcement still has to be explicitly enabled; this change only makes the
|
||||
per-key path take precedence once it is. stdio has no per-caller identity (see
|
||||
`mcpCallerIdentity.ts`) and is unaffected — it stays on the `_meta`/env fallback chain.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -73,13 +73,26 @@ Today the only bypassable prefix is `/api/mcp/`. `/api/cli-tools/runtime/` and
|
||||
subprocesses (`npm install`, `node`), which is the exact CVE class the
|
||||
LOCAL_ONLY tier exists to prevent.
|
||||
|
||||
| Request | Path | Result |
|
||||
| ------------------------------------------- | -------------------------- | ------------------- |
|
||||
| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY |
|
||||
| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow |
|
||||
| Non-loopback, Bearer without `manage` scope | `/api/mcp/*` | 403 LOCAL_ONLY |
|
||||
| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY |
|
||||
| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) |
|
||||
**#7895 — `mcp:connect` narrow scope:** the `/api/mcp/` carve-out ALSO accepts
|
||||
a Bearer key holding the narrow `mcp:connect` scope
|
||||
(`src/shared/constants/managementScopes.ts::MCP_CONNECT_SCOPE`), checked via
|
||||
`hasMcpConnectOrManageScope()` in `src/server/authz/policies/management.ts`.
|
||||
This is scoped to `/api/mcp/` ONLY — `mcp:connect` grants nothing on any other
|
||||
management route (including every other LOCAL_ONLY bypass prefix, should one
|
||||
ever be added), and it is deliberately excluded from
|
||||
`MANAGEMENT_API_KEY_SCOPES`. A key holding `manage`/`admin` still passes the
|
||||
carve-out exactly as before; `mcp:connect` is a lower-privilege alternative
|
||||
for remote MCP-only callers who should not need broad management access.
|
||||
|
||||
| Request | Path | Result |
|
||||
| ------------------------------------------------- | -------------------------- | ------------------- |
|
||||
| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY |
|
||||
| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow |
|
||||
| Non-loopback, Bearer with `mcp:connect` scope | `/api/mcp/*` | Allow |
|
||||
| Non-loopback, Bearer without `manage`/`mcp:connect` | `/api/mcp/*` | 403 LOCAL_ONLY |
|
||||
| Non-loopback, Bearer with `mcp:connect` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY |
|
||||
| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY |
|
||||
| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) |
|
||||
|
||||
#### Operator guidance & auditing
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { extractApiKey, isValidApiKey } from "../../src/sse/services/auth.ts";
|
||||
import { getApiKeyMetadata } from "../../src/lib/db/apiKeys.ts";
|
||||
|
||||
type McpHttpAuthContext = {
|
||||
authorization?: string;
|
||||
@@ -7,6 +9,19 @@ type McpHttpAuthContext = {
|
||||
anthropicVersion?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal shape of the MCP SDK's `AuthInfo` (server/auth/types.ts) that
|
||||
* `httpTransport.ts` passes into `transport.handleRequest(req, { authInfo })`
|
||||
* so per-tool-call `extra.authInfo` — and therefore
|
||||
* `scopeEnforcement.ts::resolveCallerScopeContext` — sees the caller's real
|
||||
* per-key scopes instead of falling back to the `OMNIROUTE_MCP_SCOPES` env var.
|
||||
*/
|
||||
export type McpCallerAuthInfo = {
|
||||
token: string;
|
||||
clientId: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
const mcpHttpAuthContext = new AsyncLocalStorage<McpHttpAuthContext>();
|
||||
|
||||
function headerValue(request: Request, name: string): string | undefined {
|
||||
@@ -26,6 +41,34 @@ export function getMcpHttpAuthHeadersForInternalFetch(): Record<string, string>
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the caller's real per-key `api_keys.scopes` for one HTTP/SSE MCP
|
||||
* request, for #7895's per-key scope binding. Returns `undefined` when the
|
||||
* request carries no resolvable API key (no header, invalid key, or the
|
||||
* DB/auth backend throws) — callers MUST treat `undefined` as "no per-key
|
||||
* authInfo available", NOT as "zero scopes", so `scopeEnforcement.ts` falls
|
||||
* through to its existing `meta` → env fallback chain unchanged. Only the
|
||||
* HTTP/SSE transports call this; stdio has no per-caller identity and stays
|
||||
* on the env fallback (see `docs/frameworks/MCP-SERVER.md`).
|
||||
*/
|
||||
export async function resolveMcpCallerAuthInfo(
|
||||
request: Request
|
||||
): Promise<McpCallerAuthInfo | undefined> {
|
||||
const rawKey = extractApiKey(request, { allowUrl: false });
|
||||
if (!rawKey) return undefined;
|
||||
|
||||
try {
|
||||
if (!(await isValidApiKey(rawKey))) return undefined;
|
||||
const meta = await getApiKeyMetadata(rawKey);
|
||||
if (!meta || !meta.id) return undefined;
|
||||
return { token: rawKey, clientId: String(meta.id), scopes: meta.scopes ?? [] };
|
||||
} catch {
|
||||
// Fail closed: an unresolved caller falls through to the meta/env scope
|
||||
// chain rather than ever synthesizing a false per-key scope grant.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function withMcpHttpAuthContext<T>(
|
||||
request: Request,
|
||||
callback: () => Promise<T>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createMcpServer } from "./server.ts";
|
||||
import { withMcpHttpAuthContext } from "./httpAuthContext.ts";
|
||||
import { resolveMcpCallerAuthInfo, withMcpHttpAuthContext } from "./httpAuthContext.ts";
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
|
||||
@@ -135,6 +135,23 @@ async function isInitializeRequest(request: Request): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the caller's per-key scopes (#7895) and hand the request to the
|
||||
* transport with `authInfo` populated, so `extra.authInfo.scopes` reaching
|
||||
* tool handlers reflects the real `api_keys.scopes` row instead of the
|
||||
* `OMNIROUTE_MCP_SCOPES` env fallback. When no per-key auth can be resolved
|
||||
* (no key, invalid key, stdio has no `Request` at all), `authInfo` stays
|
||||
* `undefined` and `scopeEnforcement.ts` falls through to its existing
|
||||
* meta/env chain unchanged.
|
||||
*/
|
||||
async function handleRequestWithAuthInfo(
|
||||
transport: WebStandardStreamableHTTPServerTransport,
|
||||
request: Request
|
||||
): Promise<Response> {
|
||||
const authInfo = await resolveMcpCallerAuthInfo(request);
|
||||
return transport.handleRequest(request, { authInfo });
|
||||
}
|
||||
|
||||
function errorResponse(message: string, code: number, status = 400): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -182,7 +199,7 @@ async function handleStreamableRequest(request: Request): Promise<Response> {
|
||||
const newSession = createStreamableSession();
|
||||
try {
|
||||
const response = await withMcpHttpAuthContext(request, () =>
|
||||
newSession.transport.handleRequest(request)
|
||||
handleRequestWithAuthInfo(newSession.transport, request)
|
||||
);
|
||||
return withSessionHeader(response, newSession.sessionId);
|
||||
} catch (err) {
|
||||
@@ -200,7 +217,7 @@ async function handleStreamableRequest(request: Request): Promise<Response> {
|
||||
try {
|
||||
session.lastActivityAt = Date.now();
|
||||
const response = await withMcpHttpAuthContext(request, () =>
|
||||
session.transport.handleRequest(request)
|
||||
handleRequestWithAuthInfo(session.transport, request)
|
||||
);
|
||||
if (request.method === "DELETE") {
|
||||
closeStreamableSession(sessionId);
|
||||
@@ -226,7 +243,7 @@ async function handleStreamableRequest(request: Request): Promise<Response> {
|
||||
|
||||
try {
|
||||
const response = await withMcpHttpAuthContext(request, () =>
|
||||
session.transport.handleRequest(request)
|
||||
handleRequestWithAuthInfo(session.transport, request)
|
||||
);
|
||||
return withSessionHeader(response, session.sessionId);
|
||||
} catch (err) {
|
||||
@@ -256,7 +273,7 @@ export async function handleMcpSSE(request: Request): Promise<Response> {
|
||||
const { transport } = ensureSseServer();
|
||||
|
||||
try {
|
||||
return await withMcpHttpAuthContext(request, () => transport.handleRequest(request));
|
||||
return await withMcpHttpAuthContext(request, () => handleRequestWithAuthInfo(transport, request));
|
||||
} catch (err) {
|
||||
console.error("[MCP] SSE error:", err);
|
||||
return new Response(JSON.stringify({ error: "MCP SSE transport error" }), {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { allow, reject } from "../context";
|
||||
import { extractApiKey, isValidApiKey } from "../../../sse/services/auth";
|
||||
import { getApiKeyMetadata } from "../../../lib/db/apiKeys";
|
||||
import { hasManageScope } from "../../../lib/api/requireManagementAuth";
|
||||
import { hasMcpConnectOrManageScope, MCP_CONNECT_SCOPE } from "../../../shared/constants/managementScopes";
|
||||
import { evaluateAccessTokenAuth } from "../accessTokenAuth";
|
||||
import { CLI_TOKEN_HEADER, PEER_IP_HEADER, VIA_PROXY_HEADER } from "../headers";
|
||||
import { resolveStampedPeer, resolveStampedViaProxy } from "../peerStamp";
|
||||
@@ -153,10 +154,26 @@ export const managementPolicy: RoutePolicy = {
|
||||
try {
|
||||
if (await isValidApiKey(apiKey)) {
|
||||
const meta = await getApiKeyMetadata(apiKey);
|
||||
if (meta && hasManageScope(meta.scopes)) {
|
||||
// Distinguish admin vs manage in the audit label so log review
|
||||
// can tell which privilege actually granted the bypass.
|
||||
const grantedBy = meta.scopes.includes("admin") ? "admin" : "manage";
|
||||
// #7895: the `/api/mcp/` carve-out ALSO accepts the narrow
|
||||
// `mcp:connect` scope, so remote MCP-only callers don't need
|
||||
// broad `manage`/`admin` just to reach the transport routes.
|
||||
// Scoped to `/api/mcp/` ONLY — every other LOCAL_ONLY bypass
|
||||
// prefix still requires full `hasManageScope` (below).
|
||||
const scopeGranted =
|
||||
path.startsWith("/api/mcp/") && meta
|
||||
? hasMcpConnectOrManageScope(meta.scopes)
|
||||
: Boolean(meta && hasManageScope(meta.scopes));
|
||||
if (meta && scopeGranted) {
|
||||
// Distinguish admin vs manage vs the narrow mcp:connect scope in
|
||||
// the audit label so log review can tell which privilege
|
||||
// actually granted the bypass.
|
||||
const grantedBy = meta.scopes.includes("admin")
|
||||
? "admin"
|
||||
: meta.scopes.includes("manage")
|
||||
? "manage"
|
||||
: meta.scopes.includes(MCP_CONNECT_SCOPE)
|
||||
? "mcp-connect"
|
||||
: "manage";
|
||||
return allow({
|
||||
kind: "management_key",
|
||||
id: meta.id,
|
||||
|
||||
@@ -20,6 +20,29 @@ export const MANAGE_SCOPE = "manage";
|
||||
*/
|
||||
export const MANAGEMENT_API_KEY_SCOPES = new Set<string>(["manage", "admin"]);
|
||||
|
||||
/**
|
||||
* Narrow, additive scope (#7895) that grants a non-loopback caller ONLY the
|
||||
* `/api/mcp/` LOCAL_ONLY carve-out (see `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES`
|
||||
* in `src/server/authz/routeGuard.ts`) — it does NOT grant broader management
|
||||
* API access. Deliberately kept OUT of `MANAGEMENT_API_KEY_SCOPES`, mirroring the
|
||||
* existing narrow-additive-scope precedent (`SELF_USAGE_SCOPE`,
|
||||
* `API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE`). A key holding `manage`/`admin` still
|
||||
* passes the carve-out unchanged; `mcp:connect` is an alternative, lower-privilege
|
||||
* path for remote MCP-only callers.
|
||||
*/
|
||||
export const MCP_CONNECT_SCOPE = "mcp:connect";
|
||||
|
||||
/**
|
||||
* Check whether any of the given scopes authorizes the `/api/mcp/` LOCAL_ONLY
|
||||
* carve-out specifically — i.e. either a full management scope (`manage`/`admin`)
|
||||
* or the narrow `mcp:connect` scope. Use this ONLY for the `/api/mcp/` bypass
|
||||
* check; every other management route must keep using `hasManageScope`.
|
||||
*/
|
||||
export function hasMcpConnectOrManageScope(scopes: readonly string[] = []): boolean {
|
||||
if (hasManageScope(scopes)) return true;
|
||||
return scopes.includes(MCP_CONNECT_SCOPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether any of the given scopes authorizes management API access.
|
||||
*/
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"tests/unit/management-auth-hardening.test.ts",
|
||||
"tests/unit/mark-account-unavailable-numeric-epoch-guard.test.ts",
|
||||
"tests/unit/masked-200-exhaustion-fallback-6427.test.ts",
|
||||
"tests/unit/mcp-connect-scope.test.ts",
|
||||
"tests/unit/memory-embedding-remote.test.ts",
|
||||
"tests/unit/memory-embedding-transformers.test.ts",
|
||||
"tests/unit/microsoft-designer-web-6672.test.ts",
|
||||
|
||||
270
tests/unit/mcp-connect-scope.test.ts
Normal file
270
tests/unit/mcp-connect-scope.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
// #7895 — narrow `mcp:connect` scope + per-key HTTP tool-scope binding for
|
||||
// remote MCP. Security-critical (touches the LOCAL_ONLY manage-scope bypass,
|
||||
// Hard Rules #15/#17) — dedicated regression tests, mirroring the harness in
|
||||
// tests/unit/authz/management-policy.test.ts.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-mcp-connect-scope-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-secret";
|
||||
// API-key validation falls through to a Redis-backed cache otherwise — disable
|
||||
// it for the local test loop so isValidApiKey() does not stall on ETIMEDOUT.
|
||||
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const { managementPolicy } = await import("../../src/server/authz/policies/management.ts");
|
||||
const {
|
||||
isLocalOnlyPath,
|
||||
isLocalOnlyBypassableByManageScope,
|
||||
} = await import("../../src/server/authz/routeGuard.ts");
|
||||
const { MCP_CONNECT_SCOPE, hasMcpConnectOrManageScope } = await import(
|
||||
"../../src/shared/constants/managementScopes.ts"
|
||||
);
|
||||
const { resolveMcpCallerAuthInfo } = await import("../../open-sse/mcp-server/httpAuthContext.ts");
|
||||
const { resolveCallerScopeContext, evaluateToolScopes } = await import(
|
||||
"../../open-sse/mcp-server/scopeEnforcement.ts"
|
||||
);
|
||||
|
||||
const ORIGINAL_JWT = process.env.JWT_SECRET;
|
||||
const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD;
|
||||
|
||||
function reset() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.JWT_SECRET;
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_JWT === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = ORIGINAL_JWT;
|
||||
if (ORIGINAL_INITIAL === undefined) delete process.env.INITIAL_PASSWORD;
|
||||
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL;
|
||||
});
|
||||
|
||||
function mgmtCtx(headers: Headers, method = "GET", pathname = "/api/keys") {
|
||||
return {
|
||||
request: {
|
||||
method,
|
||||
headers,
|
||||
url: `http://localhost${pathname}`,
|
||||
nextUrl: { pathname },
|
||||
},
|
||||
classification: {
|
||||
routeClass: "MANAGEMENT" as const,
|
||||
reason: "management_api" as const,
|
||||
normalizedPath: pathname,
|
||||
},
|
||||
requestId: "req_mcp_connect_test",
|
||||
};
|
||||
}
|
||||
|
||||
async function seedAuthRequired() {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mcp-connect-scope";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
}
|
||||
|
||||
// ─── 1. mcp:connect-only key passes the /api/mcp/ carve-out ────────────────
|
||||
|
||||
test("mcp:connect-only key passes the /api/mcp/ LOCAL_ONLY carve-out from non-loopback", async () => {
|
||||
await seedAuthRequired();
|
||||
const created = await apiKeysDb.createApiKey("mcp-connect-only", "machine-mcp-connect", [
|
||||
MCP_CONNECT_SCOPE,
|
||||
]);
|
||||
|
||||
const out = await managementPolicy.evaluate(
|
||||
mgmtCtx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/api/mcp/stream")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "management_key");
|
||||
assert.equal(out.subject.id, created.id);
|
||||
assert.ok(
|
||||
(out.subject.label ?? "").includes("mcp-connect"),
|
||||
`expected label to credit mcp-connect scope, got ${out.subject.label}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 2. no manage/admin/mcp:connect → still rejected ────────────────────────
|
||||
|
||||
test("key with neither manage/admin nor mcp:connect is rejected for /api/mcp/ (403 LOCAL_ONLY)", async () => {
|
||||
await seedAuthRequired();
|
||||
const created = await apiKeysDb.createApiKey("chat-only-mcp", "machine-chat-only-mcp", ["chat"]);
|
||||
|
||||
const out = await managementPolicy.evaluate(
|
||||
mgmtCtx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/api/mcp/stream")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "LOCAL_ONLY");
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 3. mcp:connect does NOT open any other management route ───────────────
|
||||
|
||||
test("mcp:connect does NOT authorize a non-mcp management route (still needs manage/admin)", async () => {
|
||||
await seedAuthRequired();
|
||||
const created = await apiKeysDb.createApiKey("mcp-connect-scope-only", "machine-mcp-only-key", [
|
||||
MCP_CONNECT_SCOPE,
|
||||
]);
|
||||
|
||||
// /api/keys is NOT LOCAL_ONLY — it falls through to the generic
|
||||
// bearer-token management-auth branch at the bottom of evaluate(), which
|
||||
// must keep using plain hasManageScope() and reject mcp:connect.
|
||||
const out = await managementPolicy.evaluate(
|
||||
mgmtCtx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/api/keys")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "AUTH_001");
|
||||
}
|
||||
});
|
||||
|
||||
test("hasMcpConnectOrManageScope: mcp:connect alone authorizes; manage/admin still do too", () => {
|
||||
assert.equal(hasMcpConnectOrManageScope([MCP_CONNECT_SCOPE]), true);
|
||||
assert.equal(hasMcpConnectOrManageScope(["manage"]), true);
|
||||
assert.equal(hasMcpConnectOrManageScope(["admin"]), true);
|
||||
assert.equal(hasMcpConnectOrManageScope(["chat"]), false);
|
||||
assert.equal(hasMcpConnectOrManageScope([]), false);
|
||||
});
|
||||
|
||||
// ─── 4. authInfo.scopes populated per-key over HTTP; enforcement uses them ──
|
||||
|
||||
test("resolveMcpCallerAuthInfo resolves the caller's real per-key scopes from a Bearer header", async () => {
|
||||
const created = await apiKeysDb.createApiKey("scoped-http-caller", "machine-scoped-http", [
|
||||
"read:health",
|
||||
"read:combos",
|
||||
]);
|
||||
|
||||
const request = new Request("http://localhost/api/mcp/stream", {
|
||||
headers: { authorization: `Bearer ${created.key}` },
|
||||
});
|
||||
|
||||
const authInfo = await resolveMcpCallerAuthInfo(request);
|
||||
assert.ok(authInfo, "expected authInfo to resolve for a valid API key");
|
||||
assert.equal(authInfo?.clientId, created.id);
|
||||
assert.equal(authInfo?.token, created.key);
|
||||
assert.deepEqual([...(authInfo?.scopes ?? [])].sort(), ["read:combos", "read:health"]);
|
||||
});
|
||||
|
||||
test("resolveMcpCallerAuthInfo returns undefined without a resolvable API key (no false grant)", async () => {
|
||||
const request = new Request("http://localhost/api/mcp/stream");
|
||||
const authInfo = await resolveMcpCallerAuthInfo(request);
|
||||
assert.equal(authInfo, undefined);
|
||||
});
|
||||
|
||||
test("per-key authInfo.scopes takes precedence over the env fallback once resolved", async () => {
|
||||
const created = await apiKeysDb.createApiKey("scoped-enforce-caller", "machine-scoped-enforce", [
|
||||
"read:health",
|
||||
]);
|
||||
const request = new Request("http://localhost/api/mcp/stream", {
|
||||
headers: { authorization: `Bearer ${created.key}` },
|
||||
});
|
||||
|
||||
const authInfo = await resolveMcpCallerAuthInfo(request);
|
||||
assert.ok(authInfo);
|
||||
|
||||
// Mirror what httpTransport.ts hands the MCP SDK: extra.authInfo populated
|
||||
// from the per-key lookup. scopeEnforcement.ts must prefer it over any env
|
||||
// fallback scopes, even when the env fallback would have granted access.
|
||||
const scopeContext = resolveCallerScopeContext({ authInfo }, ["write:combos"]);
|
||||
assert.equal(scopeContext.source, "authInfo");
|
||||
assert.deepEqual(scopeContext.scopes, ["read:health"]);
|
||||
|
||||
const allowedCheck = evaluateToolScopes(
|
||||
"irrelevant-tool-name",
|
||||
scopeContext.scopes,
|
||||
true,
|
||||
["read:health"]
|
||||
);
|
||||
assert.equal(allowedCheck.allowed, true);
|
||||
|
||||
const deniedCheck = evaluateToolScopes(
|
||||
"irrelevant-tool-name",
|
||||
scopeContext.scopes,
|
||||
true,
|
||||
["write:combos"]
|
||||
);
|
||||
assert.equal(deniedCheck.allowed, false, "per-key scopes must gate, not the wider env fallback");
|
||||
});
|
||||
|
||||
// ─── 5. stdio path unaffected — still env fallback ──────────────────────────
|
||||
|
||||
test("stdio path is unaffected: with no authInfo/meta, scope resolution still falls back to env scopes", () => {
|
||||
// stdio tool handlers never populate extra.authInfo (no per-caller identity
|
||||
// over stdio — see mcpCallerIdentity.ts) and never call
|
||||
// resolveMcpCallerAuthInfo (HTTP/SSE-only, see httpTransport.ts). The only
|
||||
// scope source left for them is the OMNIROUTE_MCP_SCOPES env fallback.
|
||||
const scopeContext = resolveCallerScopeContext({ sessionId: "stdio-session" }, ["read:health"]);
|
||||
assert.equal(scopeContext.source, "env");
|
||||
assert.deepEqual(scopeContext.scopes, ["read:health"]);
|
||||
});
|
||||
|
||||
test("stdio entrypoint (server.ts) does not import the HTTP-only authInfo resolver", () => {
|
||||
const serverSource = fs.readFileSync(
|
||||
new URL("../../open-sse/mcp-server/server.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
assert.ok(
|
||||
serverSource.includes("StdioServerTransport"),
|
||||
"sanity check: server.ts still wires the stdio transport"
|
||||
);
|
||||
assert.ok(
|
||||
!serverSource.includes("resolveMcpCallerAuthInfo"),
|
||||
"resolveMcpCallerAuthInfo is HTTP/SSE-only (httpTransport.ts) and must not leak into the shared server.ts used by stdio"
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 6. isLocalOnlyPath()/bypass regression guard (Hard Rule #15/#17) ───────
|
||||
|
||||
test("isLocalOnlyPath/isLocalOnlyBypassableByManageScope classification is unchanged by #7895", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/mcp/sse"), true);
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/mcp/sse"), true);
|
||||
|
||||
// /api/cli-tools/runtime/* stays LOCAL_ONLY and NON-bypassable — mcp:connect
|
||||
// must not leak the carve-out to other spawn-capable prefixes.
|
||||
assert.equal(isLocalOnlyPath("/api/cli-tools/runtime/foo"), true);
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/cli-tools/runtime/foo"), false);
|
||||
});
|
||||
|
||||
test("mcp:connect key is still rejected for the non-bypassable /api/cli-tools/runtime/* prefix", async () => {
|
||||
await seedAuthRequired();
|
||||
const created = await apiKeysDb.createApiKey("mcp-connect-cli-runtime", "machine-mcp-cli", [
|
||||
MCP_CONNECT_SCOPE,
|
||||
]);
|
||||
|
||||
const out = await managementPolicy.evaluate(
|
||||
mgmtCtx(
|
||||
new Headers({ authorization: `Bearer ${created.key}` }),
|
||||
"GET",
|
||||
"/api/cli-tools/runtime/foo"
|
||||
)
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "LOCAL_ONLY");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user