Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions docs/architecture/AUTHZ_GUIDE.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
---
title: "Authorization Guide"
version: 3.8.2
lastUpdated: 2026-06-05
version: 3.8.40
lastUpdated: 2026-06-28
---

# Authorization Guide

> **Source of truth:** `src/server/authz/`, `src/shared/constants/publicApiRoutes.ts`, `src/lib/api/requireManagementAuth.ts`, `src/shared/utils/apiAuth.ts`
> **Last updated:** 2026-06-05 — v3.8.2
> **Last updated:** 2026-06-28 — v3.8.40

OmniRoute has a route-aware authorization pipeline that gates every API request. Classification is **deterministic** and **fail-closed** — anything that cannot be classified ends up as `MANAGEMENT` and demands a session or management-grade token. This page explains the model for engineers maintaining routes or designing new endpoints.

Expand Down Expand Up @@ -43,11 +43,11 @@ Some management routes accept **either** mode: cookie OR `Bearer <key>` when the

`src/server/authz/types.ts` defines three classes; any route that cannot be classified deterministically falls back to `MANAGEMENT`.

| Class | Description | Auth required |
| ------------ | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `PUBLIC` | Explicitly safe routes — login, logout, status, init, health, onboarding bootstrap. | None |
| `CLIENT_API` | Model-serving endpoints — `/api/v1/*`, plus aliases `/v1/*`, `/chat/completions`, `/responses`, `/models`, `/codex/*`. | Bearer key when the effective `REQUIRE_API_KEY` feature flag is enabled |
| `MANAGEMENT` | Dashboard pages, settings, providers, keys, admin and diagnostics endpoints. | Dashboard session OR Bearer with `manage` scope |
| Class | Description | Auth required |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `PUBLIC` | Explicitly safe routes — login, logout, status, init, health, onboarding bootstrap. | None |
| `CLIENT_API` | Model-serving endpoints — `/api/v1/*`, `/api/v1beta/*`, plus aliases `/v1/*`, `/v1beta/*`, `/chat/completions`, `/responses`, `/models`, `/codex/*`. | Bearer key when the effective `REQUIRE_API_KEY` feature flag is enabled |
| `MANAGEMENT` | Dashboard pages, settings, providers, keys, admin and diagnostics endpoints. | Dashboard session OR Bearer with `manage` scope |

## Pipeline

Expand All @@ -73,7 +73,7 @@ Trusted internal headers (defined in `src/server/authz/headers.ts`) are **stripp
Each route class has a policy in `src/server/authz/policies/`:

- **`publicPolicy`** (`policies/public.ts`) — always returns `allow({ kind: "anonymous", id: "anonymous" })`.
- **`clientApiPolicy`** (`policies/clientApi.ts`) — extracts Bearer, validates via `validateApiKey()`. Falls through to anonymous only when the effective `REQUIRE_API_KEY` feature flag is disabled. The effective flag is resolved through `isRequireApiKeyEnabled()` (`DB feature flag override > process.env.REQUIRE_API_KEY > default`) so Dashboard Feature Flags and environment variables govern `/api/v1/*` and aliases consistently; resolver failures fail closed. Allows dashboard-session requests on client API routes (including `/api/v1/models`, used by the dashboard model catalog).
- **`clientApiPolicy`** (`policies/clientApi.ts`) — extracts Bearer, validates via `validateApiKey()`. Falls through to anonymous only when the effective `REQUIRE_API_KEY` feature flag is disabled. The effective flag is resolved through `isRequireApiKeyEnabled()` (`DB feature flag override > process.env.REQUIRE_API_KEY > default`) so Dashboard Feature Flags and environment variables govern `/api/v1/*`, `/api/v1beta/*`, and aliases consistently; resolver failures fail closed. Allows dashboard-session requests on client API routes (including `/api/v1/models`, used by the dashboard model catalog).
- **`managementPolicy`** (`policies/management.ts`) — accepts dashboard session, internal model-sync requests (matched against `/api/providers/[name]/(sync-models|models)`), or skips entirely if `isAuthRequired()` returns false. Returns 403 (`AUTH_001`) when a Bearer token is present but invalid, 401 otherwise. Also enforces the route-guard tiers (LOCAL_ONLY / ALWAYS_PROTECTED) before any auth branch — see [Route Guard Tiers](../security/ROUTE_GUARD_TIERS.md). LOCAL_ONLY paths in `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` (today: `/api/mcp/`) may be accessed from non-loopback when the Bearer key carries the `manage` scope; all other LOCAL_ONLY paths remain strict-loopback regardless of scope.

A successful policy returns `AuthSubject` with `kind ∈ { client_api_key, dashboard_session, management_key, anonymous }`. Downstream handlers can read it via `assertAuth(request, "CLIENT_API")` in `src/server/authz/assertAuth.ts` instead of re-running auth logic.
Expand All @@ -99,13 +99,13 @@ PUBLIC_READONLY_API_ROUTE_PREFIXES = ["/api/monitoring/health", "/api/settings/r
PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
```

Read-only prefixes are public **only** for safe methods. Note: `classifyRoute()` excludes `/api/v1/*` from the PUBLIC fall-through — those are always `CLIENT_API` so the Bearer-key policy still applies.
Read-only prefixes are public **only** for safe methods. Note: `classifyRoute()` excludes `/api/v1/*` and `/api/v1beta/*` from the PUBLIC fall-through — those are always `CLIENT_API` so the Bearer-key policy still applies.

## Adding a New Route

### Pattern 1 — Public client API endpoint (Bearer-auth)

Routes under `/api/v1/` are classified `CLIENT_API` automatically. The middleware enforces the Bearer check; route handlers don't need to redo it but can read the subject if useful.
Routes under `/api/v1/` and `/api/v1beta/` are classified `CLIENT_API` automatically. The middleware enforces the Bearer check; route handlers don't need to redo it but can read the subject if useful.

```typescript
// src/app/api/v1/your-route/route.ts
Expand Down Expand Up @@ -173,7 +173,7 @@ Preset bundles (`MCP_SCOPE_PRESETS`): `readonly`, `full`, `monitor`, `agent`. Us
- No password configured **and** no `INITIAL_PASSWORD` env var → bootstrap mode allows the onboarding wizard and loopback requests, but exposed network requests still need credentials.
- Any DB error → fails closed (secure-by-default).

Client API key enforcement uses `isRequireApiKeyEnabled()` in `src/shared/utils/featureFlags.ts`, not a direct `process.env.REQUIRE_API_KEY` read. This matters for deployed instances: toggling `REQUIRE_API_KEY` in Dashboard → Feature Flags stores a DB override and immediately affects `/v1/*`, `/models`, `/responses`, `/chat/completions`, `/codex/*`, and other client-API auth checks that share this helper. If the feature flag store cannot be read, client API auth fails closed and requires a key.
Client API key enforcement uses `isRequireApiKeyEnabled()` in `src/shared/utils/featureFlags.ts`, not a direct `process.env.REQUIRE_API_KEY` read. This matters for deployed instances: toggling `REQUIRE_API_KEY` in Dashboard → Feature Flags stores a DB override and immediately affects `/v1/*`, `/v1beta/*`, `/models`, `/responses`, `/chat/completions`, `/codex/*`, and other client-API auth checks that share this helper. If the feature flag store cannot be read, client API auth fails closed and requires a key.

## Breaking Change — v3.8.0

Expand Down
7 changes: 6 additions & 1 deletion src/app/api/settings/authz-inventory/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ const MANAGEMENT_TIER_PREFIXES: ReadonlyArray<string> = [
"/api/api-keys",
];

const CLIENT_API_TIER_PREFIXES: ReadonlyArray<string> = ["/v1/", "/api/v1/"];
const CLIENT_API_TIER_PREFIXES: ReadonlyArray<string> = [
"/v1/",
"/api/v1/",
"/v1beta/",
"/api/v1beta/",
];

const PUBLIC_TIER_PREFIXES: ReadonlyArray<string> = ["/api/health", "/api/version", "/_next/"];

Expand Down
2 changes: 2 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const config = {
"/api/:path*",
"/v1/:path*",
"/v1",
"/v1beta/:path*",
"/v1beta",
"/chat/:path*",
"/responses/:path*",
"/responses",
Expand Down
13 changes: 13 additions & 0 deletions src/server/authz/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ function normalizePathname(rawPath: string): { path: string; reason?: Classifica
return { path: "/api/v1" + tail, reason: "client_api_double_prefix" };
}

if (path === "/v1beta" || path.startsWith("/v1beta/")) {
const tail = path.slice("/v1beta".length) || "";
return { path: "/api/v1beta" + tail, reason: "client_api_alias" };
}

if (path === "/v1" || path.startsWith("/v1/")) {
const tail = path.slice("/v1".length) || "";
return { path: "/api/v1" + tail, reason: "client_api_alias" };
Expand Down Expand Up @@ -87,6 +92,14 @@ export function classifyRoute(rawPath: string, method: string = "GET"): RouteCla
};
}

if (normalizedPath === "/api/v1beta" || normalizedPath.startsWith("/api/v1beta/")) {
return {
routeClass: "CLIENT_API",
reason: aliasReason ?? "client_api_v1",
normalizedPath,
};
}

if (normalizedPath.startsWith("/api/")) {
if (isClassifiedAsPublic(normalizedPath, method)) {
return {
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/api/authz-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ test("AC-1: GET returns 5 tiers with prefixes + bypass state envelope", async ()
assert.ok(alwaysProtected!.prefixes.includes("/api/shutdown"));
assert.equal(alwaysProtected!.bypassable, false);

const clientApi = body.tiers.find((t) => t.name === "CLIENT_API");
assert.ok(clientApi);
assert.ok(clientApi!.prefixes.includes("/v1/"));
assert.ok(clientApi!.prefixes.includes("/api/v1/"));
assert.ok(clientApi!.prefixes.includes("/v1beta/"));
assert.ok(clientApi!.prefixes.includes("/api/v1beta/"));

// Every tier carries a non-empty description.
for (const tier of body.tiers) {
assert.ok(tier.description.length > 0, `tier ${tier.name} missing description`);
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/authz/classify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ const cases: Case[] = [
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1/chat/completions",
},
{
name: "/v1beta alias",
path: "/v1beta",
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1beta",
},
{
name: "/v1beta generateContent alias",
path: "/v1beta/models/gemini-pro:generateContent",
expectedClass: "CLIENT_API",
expectedNormalized: "/api/v1beta/models/gemini-pro:generateContent",
},
{
name: "/api/v1beta generateContent",
path: "/api/v1beta/models/gemini-pro:generateContent",
expectedClass: "CLIENT_API",
},
{
name: "/v1/v1 double-prefix",
path: "/v1/v1",
Expand Down Expand Up @@ -189,4 +206,6 @@ test("classifyRoute strips trailing slash on root only when not '/'", () => {
test("classifyRoute treats /api/v1 prefix exactly", () => {
assert.equal(classifyRoute("/api/v1abc").routeClass, "MANAGEMENT");
assert.equal(classifyRoute("/api/v1/x").routeClass, "CLIENT_API");
assert.equal(classifyRoute("/api/v1betamax").routeClass, "MANAGEMENT");
assert.equal(classifyRoute("/api/v1beta/models").routeClass, "CLIENT_API");
});
32 changes: 32 additions & 0 deletions tests/unit/authz/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const pipeline = await import("../../../src/server/authz/pipeline.ts");
const ORIGINAL_JWT = process.env.JWT_SECRET;
const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD;
const ORIGINAL_AUTH_COOKIE_SECURE = process.env.AUTH_COOKIE_SECURE;
const ORIGINAL_REQUIRE_API_KEY = process.env.REQUIRE_API_KEY;

function resetEnvironment() {
core.resetDbInstance();
Expand All @@ -26,6 +27,7 @@ function resetEnvironment() {
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
process.env.JWT_SECRET = "pipeline-jwt-secret";
process.env.INITIAL_PASSWORD = "pipeline-initial-password";
process.env.REQUIRE_API_KEY = "true";
delete process.env.AUTH_COOKIE_SECURE;
globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 };
}
Expand Down Expand Up @@ -59,6 +61,8 @@ test.after(() => {
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL;
if (ORIGINAL_AUTH_COOKIE_SECURE === undefined) delete process.env.AUTH_COOKIE_SECURE;
else process.env.AUTH_COOKIE_SECURE = ORIGINAL_AUTH_COOKIE_SECURE;
if (ORIGINAL_REQUIRE_API_KEY === undefined) delete process.env.REQUIRE_API_KEY;
else process.env.REQUIRE_API_KEY = ORIGINAL_REQUIRE_API_KEY;
globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 };
});

Expand Down Expand Up @@ -193,6 +197,34 @@ test("runAuthzPipeline rejects oversized rewritten alias API bodies before auth"
assert.ok(response.headers.get("x-request-id"));
});

test("runAuthzPipeline rejects unauthenticated v1beta Gemini aliases as client API", async () => {
const response = await pipeline.runAuthzPipeline(
request("http://localhost/v1beta/models/gemini-pro:generateContent", {
method: "POST",
}),
{ enforce: true }
);
const body = await response.json();

assert.equal(response.status, 401);
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
assert.equal(body.error.code, "AUTH_002");
});

test("runAuthzPipeline rejects unauthenticated internal api v1beta routes as client API", async () => {
const response = await pipeline.runAuthzPipeline(
request("http://localhost/api/v1beta/models/gemini-pro:generateContent", {
method: "POST",
}),
{ enforce: true }
);
const body = await response.json();

assert.equal(response.status, 401);
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
assert.equal(body.error.code, "AUTH_002");
});

test("runAuthzPipeline rejects new API requests during shutdown drain", async () => {
globalThis.__omnirouteShutdown = { init: true, shuttingDown: true, activeRequests: 0 };

Expand Down
1 change: 1 addition & 0 deletions tests/unit/authz/proxy-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ test("proxy.ts config.matcher covers every /api/* route plus dashboard and v1 al
'"/api/:path*"',
'"/dashboard/:path*"',
'"/v1/:path*"',
'"/v1beta/:path*"',
'"/chat/:path*"',
'"/responses/:path*"',
'"/codex/:path*"',
Expand Down
Loading