287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
/**
|
|
* T-011 — DB-stored authz bypass policy + hot-reload.
|
|
*
|
|
* Covers spec AC-3 through AC-8:
|
|
* - AC-3 kill-switch flip → bypass disabled → /api/mcp/ from non-loopback → 403
|
|
* - AC-4 PATCH missing currentPassword → 400 PASSWORD_REQUIRED
|
|
* - AC-5 wrong currentPassword → 401 PASSWORD_MISMATCH
|
|
* - AC-6 toggle list reflects DB after PATCH
|
|
* - AC-7 add a new prefix → persists + applyRuntimeSettings fires + snapshot reflects
|
|
* - AC-8 add /api/cli-tools/runtime/ → 400 BYPASS_PREFIX_NOT_ALLOWED, snapshot unchanged
|
|
*
|
|
* Goes through the production `updateSettings → applyRuntimeSettings` and
|
|
* the real PATCH route handler — no direct `getAuthzBypassSnapshot` mocks.
|
|
*/
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { setupSettingsFixture, mockSettings } from "../_mocks/settings.ts";
|
|
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
|
|
|
// Allocate fixture FIRST so DATA_DIR is set before any DB import resolves.
|
|
const fixture = setupSettingsFixture("authz-bypass");
|
|
// API-key auth check uses a Redis-backed cache otherwise — disable so
|
|
// isValidApiKey() does not stall on ETIMEDOUT in the local test loop.
|
|
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
|
|
|
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
|
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
|
|
|
const core = await import("../../../src/lib/db/core.ts");
|
|
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
|
const runtime = await import("../../../src/lib/config/runtimeSettings.ts");
|
|
const routeGuard = await import("../../../src/server/authz/routeGuard.ts");
|
|
const settingsRoute = await import("../../../src/app/api/settings/route.ts");
|
|
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
|
|
|
test.beforeEach(async () => {
|
|
await fixture.resetStorage();
|
|
apiKeysDb.resetApiKeyState();
|
|
// Force the route guard to start each test from cold-boot default
|
|
// (enabled=true, prefixes=["/api/mcp/"]).
|
|
runtime.resetRuntimeSettingsStateForTests();
|
|
});
|
|
|
|
test.after(() => {
|
|
core.resetDbInstance();
|
|
fixture.cleanup();
|
|
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
|
|
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
|
if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET;
|
|
else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
|
});
|
|
|
|
function nonLoopbackCtx(headers: Headers, path = "/api/mcp/stream") {
|
|
return {
|
|
request: {
|
|
method: "GET",
|
|
headers,
|
|
url: `https://dashboard.example${path}`,
|
|
nextUrl: { pathname: path },
|
|
},
|
|
classification: {
|
|
routeClass: "MANAGEMENT" as const,
|
|
normalizedPath: path,
|
|
reason: "management_api" as const,
|
|
},
|
|
requestId: "req_authz_bypass_test",
|
|
};
|
|
}
|
|
|
|
// ─── AC-3 — kill-switch flips bypass off → request 403 ───────────────────
|
|
|
|
test("AC-3: kill-switch off → /api/mcp/* with manage-scope Bearer from non-loopback → 403 LOCAL_ONLY", async () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
|
|
process.env.INITIAL_PASSWORD = "initial-pass";
|
|
// Seed DB with default snapshot first (kill-switch ON) and confirm the
|
|
// bypass works.
|
|
await mockSettings({ requireLogin: true });
|
|
const managePolicy = await import("../../../src/server/authz/policies/management.ts");
|
|
const created = await apiKeysDb.createApiKey("ac3-mgmt", "machine-ac3", ["manage"]);
|
|
|
|
const headers = new Headers({ authorization: `Bearer ${created.key}` });
|
|
|
|
// Sanity: bypass ENABLED → 200/allow.
|
|
const before = await managePolicy.managementPolicy.evaluate(nonLoopbackCtx(headers));
|
|
assert.equal(before.allow, true, "default kill-switch ON should allow manage-scope bypass");
|
|
|
|
// Flip the kill-switch off via the production pipeline.
|
|
await mockSettings({ localOnlyManageScopeBypassEnabled: false });
|
|
assert.equal(routeGuard.isLocalOnlyBypassableByManageScope("/api/mcp/stream"), false);
|
|
|
|
// After the hot-reload, the policy must reject.
|
|
const after = await managePolicy.managementPolicy.evaluate(nonLoopbackCtx(headers));
|
|
assert.equal(after.allow, false);
|
|
if (!after.allow) {
|
|
assert.equal(after.status, 403);
|
|
assert.equal(after.code, "LOCAL_ONLY");
|
|
}
|
|
});
|
|
|
|
// ─── AC-4 — missing currentPassword → 400 PASSWORD_REQUIRED ──────────────
|
|
|
|
test("AC-4: PATCH missing currentPassword for security-impacting key → 400 PASSWORD_REQUIRED", async () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
|
|
process.env.INITIAL_PASSWORD = "initial-pass-ac4";
|
|
// Bootstrap a password so the cold-boot exception does NOT fire.
|
|
await settingsDb.updateSettings({ requireLogin: true });
|
|
const { ensurePersistentManagementPasswordHash } =
|
|
await import("../../../src/lib/auth/managementPassword.ts");
|
|
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
|
|
|
const response = await settingsRoute.PATCH(
|
|
await makeManagementSessionRequest("http://localhost/api/settings", {
|
|
method: "PATCH",
|
|
body: { localOnlyManageScopeBypassEnabled: false },
|
|
})
|
|
);
|
|
|
|
assert.equal(response.status, 400);
|
|
const body = (await response.json()) as { error: { code: string; keys: string[] } };
|
|
assert.equal(body.error.code, "PASSWORD_REQUIRED");
|
|
assert.ok(body.error.keys.includes("localOnlyManageScopeBypassEnabled"));
|
|
|
|
// Persisted state unchanged — kill-switch still on by default.
|
|
const settings = await settingsDb.getSettings();
|
|
assert.equal(settings.localOnlyManageScopeBypassEnabled, true);
|
|
});
|
|
|
|
// ─── AC-5 — wrong currentPassword → 401 PASSWORD_MISMATCH ────────────────
|
|
|
|
test("AC-5: PATCH wrong currentPassword for security-impacting key → 401 PASSWORD_MISMATCH", async () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
|
|
process.env.INITIAL_PASSWORD = "initial-pass-ac5";
|
|
await settingsDb.updateSettings({ requireLogin: true });
|
|
const { ensurePersistentManagementPasswordHash } =
|
|
await import("../../../src/lib/auth/managementPassword.ts");
|
|
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
|
|
|
const response = await settingsRoute.PATCH(
|
|
await makeManagementSessionRequest("http://localhost/api/settings", {
|
|
method: "PATCH",
|
|
body: {
|
|
localOnlyManageScopeBypassEnabled: false,
|
|
currentPassword: "definitely-wrong",
|
|
},
|
|
})
|
|
);
|
|
|
|
assert.equal(response.status, 401);
|
|
const body = (await response.json()) as { error: { code: string } };
|
|
assert.equal(body.error.code, "PASSWORD_MISMATCH");
|
|
|
|
const settings = await settingsDb.getSettings();
|
|
assert.equal(settings.localOnlyManageScopeBypassEnabled, true);
|
|
});
|
|
|
|
// ─── AC-6 — bypass list reflects DB after PATCH ──────────────────────────
|
|
|
|
test("AC-6: PATCH with correct currentPassword + new prefix list → persists to DB", async () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
|
|
process.env.INITIAL_PASSWORD = "initial-pass-ac6";
|
|
await settingsDb.updateSettings({ requireLogin: true });
|
|
const { ensurePersistentManagementPasswordHash } =
|
|
await import("../../../src/lib/auth/managementPassword.ts");
|
|
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
|
|
|
const response = await settingsRoute.PATCH(
|
|
await makeManagementSessionRequest("http://localhost/api/settings", {
|
|
method: "PATCH",
|
|
body: {
|
|
localOnlyManageScopeBypassPrefixes: ["/api/mcp/"],
|
|
currentPassword: "initial-pass-ac6",
|
|
},
|
|
})
|
|
);
|
|
|
|
assert.equal(response.status, 200);
|
|
const settings = await settingsDb.getSettings();
|
|
assert.deepEqual(settings.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]);
|
|
});
|
|
|
|
// ─── AC-7 — add prefix → applyRuntimeSettings fires + snapshot reflects ──
|
|
|
|
test("AC-7: PATCH adds prefix → applyRuntimeSettings fires + getAuthzBypassSnapshot reflects (hot-reload <50ms)", async () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
|
|
process.env.INITIAL_PASSWORD = "initial-pass-ac7";
|
|
await settingsDb.updateSettings({ requireLogin: true });
|
|
const { ensurePersistentManagementPasswordHash } =
|
|
await import("../../../src/lib/auth/managementPassword.ts");
|
|
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
|
// Prime the snapshot from the persisted defaults so we measure a real diff.
|
|
const seeded = await settingsDb.getSettings();
|
|
await runtime.applyRuntimeSettings(seeded);
|
|
|
|
const before = routeGuard.LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES;
|
|
assert.deepEqual([...before], ["/api/mcp/"]);
|
|
|
|
// Measure the snapshot-read latency (spec SLA: <50 ms).
|
|
const t0 = process.hrtime.bigint();
|
|
const response = await settingsRoute.PATCH(
|
|
await makeManagementSessionRequest("http://localhost/api/settings", {
|
|
method: "PATCH",
|
|
body: {
|
|
localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/mcp/v2/"],
|
|
currentPassword: "initial-pass-ac7",
|
|
},
|
|
})
|
|
);
|
|
const snapshotAfterPatch = runtime.getAuthzBypassSnapshot();
|
|
const elapsedNs = process.hrtime.bigint() - t0;
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.deepEqual(snapshotAfterPatch.prefixes, ["/api/mcp/", "/api/mcp/v2/"]);
|
|
assert.equal(snapshotAfterPatch.enabled, true);
|
|
// The hot-path accessor itself is O(1) — total PATCH→snapshot covers
|
|
// bcrypt + SQLite write, so we only assert the in-memory accessor is fast.
|
|
// Microbench: dedicated snapshot read.
|
|
const tSnap0 = process.hrtime.bigint();
|
|
for (let i = 0; i < 10_000; i++) runtime.getAuthzBypassSnapshot();
|
|
const snapElapsedNs = process.hrtime.bigint() - tSnap0;
|
|
assert.ok(
|
|
snapElapsedNs < 50_000_000n,
|
|
`getAuthzBypassSnapshot x10k must complete in <50 ms (got ${Number(snapElapsedNs) / 1e6} ms)`
|
|
);
|
|
// Whole PATCH < 5 s sanity bound (bcrypt-bounded).
|
|
assert.ok(elapsedNs < 5_000_000_000n);
|
|
|
|
// Live route-guard predicate reflects the new prefix.
|
|
assert.equal(routeGuard.isLocalOnlyBypassableByManageScope("/api/mcp/v2/foo"), true);
|
|
});
|
|
|
|
// ─── AC-8 — spawn-capable prefix → 400 BYPASS_PREFIX_NOT_ALLOWED ─────────
|
|
|
|
test("AC-8: PATCH with /api/cli-tools/runtime/ in bypass list → 400 BYPASS_PREFIX_NOT_ALLOWED + snapshot unchanged", async () => {
|
|
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
|
|
process.env.INITIAL_PASSWORD = "initial-pass-ac8";
|
|
await settingsDb.updateSettings({ requireLogin: true });
|
|
const { ensurePersistentManagementPasswordHash } =
|
|
await import("../../../src/lib/auth/managementPassword.ts");
|
|
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
|
// Prime snapshot from default DB state.
|
|
const seeded = await settingsDb.getSettings();
|
|
await runtime.applyRuntimeSettings(seeded);
|
|
const snapshotBefore = runtime.getAuthzBypassSnapshot();
|
|
|
|
const response = await settingsRoute.PATCH(
|
|
await makeManagementSessionRequest("http://localhost/api/settings", {
|
|
method: "PATCH",
|
|
body: {
|
|
localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/cli-tools/runtime/"],
|
|
currentPassword: "initial-pass-ac8",
|
|
},
|
|
})
|
|
);
|
|
|
|
assert.equal(response.status, 400);
|
|
const body = (await response.json()) as {
|
|
error: { details?: Array<{ field: string; message: string }> };
|
|
};
|
|
// zod path is the array path; the message embeds the BYPASS_PREFIX_NOT_ALLOWED code.
|
|
const offending = body.error.details?.find((d) =>
|
|
d.message.includes("BYPASS_PREFIX_NOT_ALLOWED")
|
|
);
|
|
assert.ok(offending, `expected BYPASS_PREFIX_NOT_ALLOWED in details: ${JSON.stringify(body)}`);
|
|
|
|
// Persisted state untouched.
|
|
const settings = await settingsDb.getSettings();
|
|
assert.deepEqual(settings.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]);
|
|
// Runtime snapshot untouched.
|
|
const snapshotAfter = runtime.getAuthzBypassSnapshot();
|
|
assert.deepEqual(snapshotAfter.prefixes, snapshotBefore.prefixes);
|
|
assert.equal(snapshotAfter.enabled, snapshotBefore.enabled);
|
|
});
|
|
|
|
// ─── Defence-in-depth: snapshot mutation alone cannot grant spawn bypass ─
|
|
|
|
test("Defence-in-depth: even if a malformed snapshot lists /api/cli-tools/runtime/, the runtime predicate rejects it", async () => {
|
|
// applyRuntimeSettings wires the snapshot through normalizeAuthzBypass,
|
|
// which does not filter spawn-capable entries (zod is the gate). The
|
|
// routeGuard predicate must still refuse them at runtime.
|
|
await runtime.applyRuntimeSettings({
|
|
localOnlyManageScopeBypassEnabled: true,
|
|
localOnlyManageScopeBypassPrefixes: ["/api/cli-tools/runtime/"],
|
|
});
|
|
|
|
assert.equal(routeGuard.isLocalOnlyBypassableByManageScope("/api/cli-tools/runtime/foo"), false);
|
|
});
|