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:
Gleb Peregud
2026-05-15 01:18:09 +02:00
committed by GitHub
parent 955049186f
commit 24b2e77aae
3 changed files with 93 additions and 0 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"76a3e58d-d4b6-4002-bdb6-160ea48c702b","pid":1528501,"procStart":"16318804","acquiredAt":1778795671252}

View File

@@ -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,

View File

@@ -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";