mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 18:02:17 +03:00
feat(authz): managementPolicy accepts API keys with manage scope (#2265)
feat(authz): managementPolicy accepts API keys with manage scope (#2265 — thanks @gleber)
This commit is contained in:
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
||||
{"sessionId":"76a3e58d-d4b6-4002-bdb6-160ea48c702b","pid":1528501,"procStart":"16318804","acquiredAt":1778795671252}
|
||||
@@ -2,6 +2,9 @@ import { isModelSyncInternalRequest } from "../../../shared/services/modelSyncSc
|
||||
import { isAuthRequired, isDashboardSessionAuthenticated } from "../../../shared/utils/apiAuth";
|
||||
import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context";
|
||||
import { allow, reject } from "../context";
|
||||
import { extractApiKey, isValidApiKey } from "../../../sse/services/auth";
|
||||
import { getApiKeyMetadata } from "../../../lib/db/apiKeys";
|
||||
import { hasManageScope } from "../../../lib/api/requireManagementAuth";
|
||||
|
||||
const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/;
|
||||
|
||||
@@ -30,6 +33,37 @@ export const managementPolicy: RoutePolicy = {
|
||||
return allow({ kind: "dashboard_session", id: "dashboard" });
|
||||
}
|
||||
|
||||
// Allow API keys with the `manage` scope — enables headless / programmatic
|
||||
// management (e.g. provisioning providers, setting rate limits) without
|
||||
// a browser session. The pieces below already exist and are used by
|
||||
// `requireManagementAuth` on individual routes; wiring them here closes
|
||||
// the gap so management auth is consistent across the policy layer.
|
||||
//
|
||||
// Error handling mirrors `requireManagementAuth.ts`: a thrown
|
||||
// isValidApiKey / getApiKeyMetadata indicates the auth backend is
|
||||
// unhealthy, which is a 503, not a 403 — masking it as an auth failure
|
||||
// would tell callers their credentials are wrong when the real problem
|
||||
// is that the server cannot validate any credential right now.
|
||||
const apiKey = extractApiKey(ctx.request as unknown as Request);
|
||||
if (apiKey) {
|
||||
try {
|
||||
if (await isValidApiKey(apiKey)) {
|
||||
const meta = await getApiKeyMetadata(apiKey);
|
||||
// getApiKeyMetadata returns null whenever the row has no id,
|
||||
// so when `meta` is truthy `meta.id` is guaranteed non-empty.
|
||||
if (meta && hasManageScope(meta.scopes)) {
|
||||
return allow({
|
||||
kind: "management_key",
|
||||
id: meta.id,
|
||||
label: "api-key-manage-scope",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return reject(503, "AUTH_BACKEND_UNAVAILABLE", "Service temporarily unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
const bearerPresent = hasBearerToken(ctx.request.headers);
|
||||
return reject(
|
||||
bearerPresent ? 403 : 401,
|
||||
|
||||
@@ -7,6 +7,9 @@ import path from "node:path";
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-mgmt-policy-"));
|
||||
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");
|
||||
@@ -131,6 +134,61 @@ test("managementPolicy: rejects client API keys for dashboard access", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: allows API keys with manage scope", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const created = await apiKeysDb.createApiKey("mgmt-key", "machine-mgmt-allow", ["manage"]);
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: `Bearer ${created.key}` }), "POST", "/api/keys")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "management_key");
|
||||
assert.equal(out.subject.label, "api-key-manage-scope");
|
||||
assert.equal(out.subject.id, created.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: rejects valid API keys that lack manage scope", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const created = await apiKeysDb.createApiKey("no-scope-key", "machine-no-scope", []);
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: `Bearer ${created.key}` }), "POST", "/api/keys")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
// A valid bearer is present but its scope is insufficient → 403.
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "AUTH_001");
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: rejects invalid API keys with 403 when bearer is present", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: "Bearer not-a-real-key" }), "POST", "/api/keys")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "AUTH_001");
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: allows internal model sync only on the dedicated provider routes", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
|
||||
Reference in New Issue
Block a user