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)
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
// #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");
|
|
}
|
|
});
|