From 24b2e77aaeb572decfb3830f6c854e5c64146993 Mon Sep 17 00:00:00 2001 From: Gleb Peregud Date: Fri, 15 May 2026 01:18:09 +0200 Subject: [PATCH] feat(authz): managementPolicy accepts API keys with `manage` scope (#2265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(authz): managementPolicy accepts API keys with manage scope (#2265 — thanks @gleber) --- .claude/scheduled_tasks.lock | 1 + src/server/authz/policies/management.ts | 34 +++++++++++++ tests/unit/authz/management-policy.test.ts | 58 ++++++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000000..8e6eea5db3 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"76a3e58d-d4b6-4002-bdb6-160ea48c702b","pid":1528501,"procStart":"16318804","acquiredAt":1778795671252} \ No newline at end of file diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index e876d0a9ab..c0510d779b 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -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, diff --git a/tests/unit/authz/management-policy.test.ts b/tests/unit/authz/management-policy.test.ts index 6d07f7aae2..b75be34acf 100644 --- a/tests/unit/authz/management-policy.test.ts +++ b/tests/unit/authz/management-policy.test.ts @@ -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";