mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +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 { isAuthRequired, isDashboardSessionAuthenticated } from "../../../shared/utils/apiAuth";
|
||||||
import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context";
|
import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context";
|
||||||
import { allow, reject } 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)$/;
|
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" });
|
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);
|
const bearerPresent = hasBearerToken(ctx.request.headers);
|
||||||
return reject(
|
return reject(
|
||||||
bearerPresent ? 403 : 401,
|
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-"));
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-mgmt-policy-"));
|
||||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||||
process.env.API_KEY_SECRET = "test-secret";
|
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 core = await import("../../../src/lib/db/core.ts");
|
||||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.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 () => {
|
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.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||||
|
|||||||
Reference in New Issue
Block a user