mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
feat(authz): manage-scope API keys may reach /api/mcp/* from non-loopback (#2473)
feat(authz): manage-scope API keys may reach /api/mcp/* from non-loopback — integrated into release/v3.8.2
This commit is contained in:
18
README.md
18
README.md
@@ -1316,6 +1316,24 @@ omniroute --mcp
|
||||
curl http://localhost:20128/.well-known/agent.json
|
||||
```
|
||||
|
||||
### Remote MCP from a public hostname (v3.8.1+)
|
||||
|
||||
`/api/mcp/*` is LOCAL_ONLY by default. To reach the remote MCP server through
|
||||
a tunnel or reverse proxy, issue an API key with the `manage` scope (API
|
||||
Manager → toggle **Management Access** on the key) and send it as a Bearer:
|
||||
|
||||
```bash
|
||||
curl -i \
|
||||
-H "Authorization: Bearer sk-…" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"my-client","version":"0"}}}' \
|
||||
https://your-public-host.example/api/mcp/stream
|
||||
```
|
||||
|
||||
The carve-out is intentionally narrow — `/api/cli-tools/runtime/*` stays
|
||||
strict-loopback regardless of scope. See [docs/security/ROUTE_GUARD_TIERS.md](docs/security/ROUTE_GUARD_TIERS.md#manage-scope-carve-out).
|
||||
|
||||
### Key Environment Variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|
||||
20
SECURITY.md
20
SECURITY.md
@@ -38,15 +38,17 @@ Request → CORS → Authz pipeline (classify → policies → enforce)
|
||||
|
||||
### 🔐 Authentication & Authorization
|
||||
|
||||
| Feature | Implementation |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
|
||||
| **API Key Auth** | HMAC-signed keys with CRC validation |
|
||||
| **OAuth 2.0 + PKCE** | 14 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Qwen, Kiro, Qoder, Windsurf, GitLab Duo) |
|
||||
| **Token Refresh** | Automatic OAuth token refresh before expiry |
|
||||
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
|
||||
| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` |
|
||||
| **MCP Scopes** | ~13 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` |
|
||||
| Feature | Implementation |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
|
||||
| **API Key Auth** | HMAC-signed keys with CRC validation |
|
||||
| **OAuth 2.0 + PKCE** | 14 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Qwen, Kiro, Qoder, Windsurf, GitLab Duo) |
|
||||
| **Token Refresh** | Automatic OAuth token refresh before expiry |
|
||||
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
|
||||
| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` |
|
||||
| **Route Guard Tiers** | 3-tier model for management routes (LOCAL_ONLY / ALWAYS_PROTECTED / MANAGEMENT) — see `docs/security/ROUTE_GUARD_TIERS.md` |
|
||||
| **Manage-Scope MCP** | Remote `/api/mcp/*` access gated by API keys with `manage` scope; `/api/cli-tools/runtime/*` stays strict-loopback. See ROUTE_GUARD_TIERS |
|
||||
| **MCP Scopes** | ~13 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` |
|
||||
|
||||
### 🛡️ Encryption at Rest
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ 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 if `REQUIRE_API_KEY != "true"`. Allows dashboard-session GET on `/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.
|
||||
- **`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.
|
||||
|
||||
@@ -177,6 +177,10 @@ Preset bundles (`MCP_SCOPE_PRESETS`): `readonly`, `full`, `monitor`, `agent`. Us
|
||||
|
||||
The `/api/v1/agents/tasks/*` and `/api/resilience/model-cooldowns` endpoints **now require management auth** (commit `588a0333`). Clients previously sending a normal API key without the `manage` scope receive `403`. Migration: either issue the key the `manage` scope in the API Manager dashboard, or use a logged-in dashboard session.
|
||||
|
||||
## Behaviour Change — v3.8.1
|
||||
|
||||
`/api/mcp/*` (the remote MCP server) is still LOCAL_ONLY by default but now accepts non-loopback requests when the `Authorization: Bearer <api-key>` header carries the `manage` scope. The carve-out is gated explicitly per-path via `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` in `src/server/authz/routeGuard.ts`; the sibling LOCAL_ONLY prefix `/api/cli-tools/runtime/*` is intentionally NOT bypassable because it can spawn arbitrary subprocesses. Anonymous requests to `/api/mcp/*` from non-loopback continue to return `403 LOCAL_ONLY` — the default for any new LOCAL_ONLY path remains strict-loopback. See [Route Guard Tiers](../security/ROUTE_GUARD_TIERS.md#manage-scope-carve-out).
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: `tests/unit/authz/` — `classify.test.ts`, `pipeline.test.ts`, `client-api-policy.test.ts`, `management-policy.test.ts`, `public-policy.test.ts`.
|
||||
|
||||
@@ -41,6 +41,26 @@ The MCP server exposes three transports, all backed by the same `createMcpServer
|
||||
|
||||
The active HTTP transport (`sse` or `streamable-http`) is selected by the `mcpTransport` setting. Switching transports closes existing sessions on the other transport.
|
||||
|
||||
### Remote access (manage-scope bypass)
|
||||
|
||||
`/api/mcp/*` is in the LOCAL_ONLY tier (`src/server/authz/routeGuard.ts`) — by default only loopback hosts (`localhost`, `127.0.0.1`, `::1`) can reach it. Since v3.8.1, non-loopback clients may connect if they present an `Authorization: Bearer <api-key>` whose key carries the `manage` scope. This is the only way to reach the remote MCP server through a tunnel, reverse proxy, or public hostname.
|
||||
|
||||
```bash
|
||||
# Grant manage scope: open the dashboard API Manager and toggle
|
||||
# "Management Access" on the key, or POST scopes:["manage"] when creating.
|
||||
|
||||
# Then connect from a remote MCP client:
|
||||
curl -i \
|
||||
-H "Host: your-public-host.example" \
|
||||
-H "Authorization: Bearer sk-…" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"my-client","version":"0"}}}' \
|
||||
https://your-public-host.example/api/mcp/stream
|
||||
```
|
||||
|
||||
A non-manage key (or no Bearer) returns `403 LOCAL_ONLY`. The sibling prefix `/api/cli-tools/runtime/*` is intentionally NOT bypassable — see [Route Guard Tiers — Manage-scope carve-out](../security/ROUTE_GUARD_TIERS.md#manage-scope-carve-out).
|
||||
|
||||
## IDE Configuration
|
||||
|
||||
See [MCP Client Configuration](../guides/SETUP_GUIDE.md#mcp-client-configuration) for Claude Desktop,
|
||||
|
||||
@@ -4,30 +4,54 @@
|
||||
|
||||
All OmniRoute management API routes are classified into one of three protection
|
||||
tiers. Classification is static, defined in `src/server/authz/routeGuard.ts`,
|
||||
and evaluated unconditionally on every request before any auth logic runs.
|
||||
and evaluated before any other auth branch runs.
|
||||
|
||||
## Tiers
|
||||
|
||||
### Tier 1 — LOCAL_ONLY
|
||||
|
||||
**Enforced by:** `isLocalOnlyPath(path)` → loopback host check
|
||||
**Bypass:** None — not overridable by JWT, CLI token, or `requireLogin=false`
|
||||
**Enforced by:** `isLocalOnlyPath(path)` → loopback host check
|
||||
**Bypass:** None by default. Narrow carve-out for paths in
|
||||
`LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` when the request carries a valid
|
||||
API key with the `manage` scope (see [Manage-scope carve-out](#manage-scope-carve-out)).
|
||||
|
||||
These routes spawn child processes or execute runtime code. Exposing them to
|
||||
non-loopback traffic would allow an attacker who obtained a valid JWT (e.g.,
|
||||
via a Cloudflared/Ngrok tunnel) to trigger process spawning — a known CVE
|
||||
class (GHSA-fhh6-4qxv-rpqj).
|
||||
|
||||
| Prefix | Reason |
|
||||
| ------------------------- | -------------------------------------------------- |
|
||||
| `/api/mcp/` | MCP server — spawns stdio bridges and SSE handlers |
|
||||
| `/api/cli-tools/runtime/` | CLI tool runtime — executes plugin code |
|
||||
| Prefix | Reason | Bypassable by `manage`? |
|
||||
| ------------------------- | -------------------------------------------------- | ----------------------- |
|
||||
| `/api/mcp/` | MCP server — spawns stdio bridges and SSE handlers | Yes |
|
||||
| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | No (strict-loopback) |
|
||||
|
||||
**Response on violation:** `403 LOCAL_ONLY`
|
||||
|
||||
#### Manage-scope carve-out
|
||||
|
||||
A subset of LOCAL_ONLY paths MAY also be accessed from non-loopback if and
|
||||
only if the request carries an `Authorization: Bearer <api-key>` whose
|
||||
metadata includes the `manage` scope (or `admin`). The carve-out is gated
|
||||
explicitly per-path via `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` so the
|
||||
default for any new LOCAL_ONLY path remains strict-loopback. Unauthenticated
|
||||
requests and requests with non-manage keys are still rejected with
|
||||
`403 LOCAL_ONLY`.
|
||||
|
||||
Today the only bypassable prefix is `/api/mcp/`. `/api/cli-tools/runtime/`
|
||||
is intentionally excluded because it can spawn arbitrary subprocesses, which
|
||||
is the exact CVE class the LOCAL_ONLY tier exists to prevent.
|
||||
|
||||
| Request | Path | Result |
|
||||
| ------------------------------------------- | -------------------------- | ------------------- |
|
||||
| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY |
|
||||
| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow |
|
||||
| Non-loopback, Bearer without `manage` scope | `/api/mcp/*` | 403 LOCAL_ONLY |
|
||||
| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY |
|
||||
| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) |
|
||||
|
||||
### Tier 2 — ALWAYS_PROTECTED
|
||||
|
||||
**Enforced by:** `isAlwaysProtectedPath(path)` → skip `requireLogin=false` bypass
|
||||
**Enforced by:** `isAlwaysProtectedPath(path)` → skip `requireLogin=false` bypass
|
||||
**Bypass:** None when `requireLogin=false`; JWT always required
|
||||
|
||||
These routes are destructive or irreversible. Allowing them in a "no-password"
|
||||
@@ -51,7 +75,10 @@ configured. CLI tokens can authenticate these routes (loopback + valid HMAC).
|
||||
```
|
||||
managementPolicy.evaluate(ctx)
|
||||
1. isLocalOnlyPath(path)?
|
||||
→ not loopback → reject 403 LOCAL_ONLY
|
||||
→ loopback → fall through
|
||||
→ non-loopback, manage-scope Bearer
|
||||
AND isLocalOnlyBypassableByManageScope → allow (management_key)
|
||||
→ otherwise → reject 403 LOCAL_ONLY
|
||||
2. isInternalModelSyncRequest(ctx)?
|
||||
→ allow (system)
|
||||
3. hasValidCliToken(headers)?
|
||||
@@ -59,11 +86,17 @@ managementPolicy.evaluate(ctx)
|
||||
4. isAlwaysProtectedPath(path) or requireLogin=true?
|
||||
→ isDashboardSessionAuthenticated?
|
||||
→ allow (dashboard_session)
|
||||
→ manage-scope Bearer on a non-bypassable path?
|
||||
→ allow (management_key)
|
||||
→ reject 401/403
|
||||
5. requireLogin=false?
|
||||
→ allow (anonymous)
|
||||
```
|
||||
|
||||
Step 1's manage-scope branch is the only authenticated path that can satisfy a
|
||||
LOCAL_ONLY route; the auth-backend failure mode returns 503 (not 403) so an
|
||||
expired DB doesn't silently downgrade to "deny".
|
||||
|
||||
## Adding a new spawn-capable route
|
||||
|
||||
1. Add the path prefix to `LOCAL_ONLY_API_PREFIXES` in
|
||||
@@ -71,16 +104,32 @@ managementPolicy.evaluate(ctx)
|
||||
2. Add a test in `tests/unit/authz/routeGuard.test.ts` asserting that
|
||||
`isLocalOnlyPath()` returns true for the new prefix
|
||||
3. **Never skip this step** — see Hard Rule #15 in `CLAUDE.md`
|
||||
4. Decide: does this route ALSO belong in `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES`?
|
||||
Default answer is **no**. Only opt-in when the route is safe to expose to a
|
||||
manage-scope holder (i.e. does NOT spawn arbitrary user-controlled code).
|
||||
|
||||
## Adding a manage-scope-bypassable path
|
||||
|
||||
1. Confirm the route does not execute user-supplied code or commands. If it
|
||||
does, stop — this carve-out is the wrong tool.
|
||||
2. Append the prefix to `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` in
|
||||
`src/server/authz/routeGuard.ts`
|
||||
3. Add coverage in `tests/unit/authz/management-policy.test.ts` for all four
|
||||
request shapes: no Bearer (403), manage Bearer (allow), non-manage Bearer
|
||||
(403), and the per-prefix regression that `/api/cli-tools/runtime/*` stays
|
||||
strict-loopback even with a manage Bearer.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------- | ------------------------------ |
|
||||
| `src/server/authz/routeGuard.ts` | Constants and helper functions |
|
||||
| `src/server/authz/policies/management.ts` | Evaluation logic |
|
||||
| `tests/unit/authz/routeGuard.test.ts` | Unit tests |
|
||||
| File | Purpose |
|
||||
| -------------------------------------------- | ------------------------------ |
|
||||
| `src/server/authz/routeGuard.ts` | Constants and helper functions |
|
||||
| `src/server/authz/policies/management.ts` | Evaluation logic |
|
||||
| `tests/unit/authz/routeGuard.test.ts` | Unit tests for tier helpers |
|
||||
| `tests/unit/authz/management-policy.test.ts` | Unit tests for evaluate() |
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/security/CLI_TOKEN.md` — CLI machine-ID token
|
||||
- `docs/architecture/AUTHZ_GUIDE.md` — full authorization pipeline
|
||||
- `docs/frameworks/MCP-SERVER.md` — MCP server transports and scopes
|
||||
|
||||
@@ -108,6 +108,7 @@ export default function ApiManagerPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [newKeyManageEnabled, setNewKeyManageEnabled] = useState(false);
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
const [editingKey, setEditingKey] = useState<ApiKey | null>(null);
|
||||
const [showPermissionsModal, setShowPermissionsModal] = useState(false);
|
||||
@@ -255,7 +256,10 @@ export default function ApiManagerPageClient() {
|
||||
const res = await fetch("/api/keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: sanitizedName }),
|
||||
body: JSON.stringify({
|
||||
name: sanitizedName,
|
||||
scopes: newKeyManageEnabled ? ["manage"] : [],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
@@ -263,6 +267,7 @@ export default function ApiManagerPageClient() {
|
||||
setCreatedKey(data.key);
|
||||
await fetchData();
|
||||
setNewKeyName("");
|
||||
setNewKeyManageEnabled(false);
|
||||
setShowAddModal(false);
|
||||
} else {
|
||||
setCreateError(data.error || t("failedCreateKey"));
|
||||
@@ -829,6 +834,7 @@ export default function ApiManagerPageClient() {
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setNewKeyName("");
|
||||
setNewKeyManageEnabled(false);
|
||||
setNameError(null);
|
||||
setCreateError(null);
|
||||
}}
|
||||
@@ -851,6 +857,26 @@ export default function ApiManagerPageClient() {
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1.5">{t("keyNameDesc")}</p>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("managementAccess")}</p>
|
||||
<p className="text-xs text-text-muted">{t("managementAccessDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={newKeyManageEnabled}
|
||||
onClick={() => setNewKeyManageEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
|
||||
newKeyManageEnabled
|
||||
? "bg-rose-500/15 text-rose-700 dark:text-rose-300 border border-rose-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">admin_panel_settings</span>
|
||||
{newKeyManageEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
{createError && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/30">
|
||||
<span className="material-symbols-outlined text-red-500 text-sm">error</span>
|
||||
@@ -862,6 +888,7 @@ export default function ApiManagerPageClient() {
|
||||
onClick={() => {
|
||||
setShowAddModal(false);
|
||||
setNewKeyName("");
|
||||
setNewKeyManageEnabled(false);
|
||||
setNameError(null);
|
||||
setCreateError(null);
|
||||
}}
|
||||
@@ -1539,31 +1566,6 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{keyIsBanned ? "Banned" : "Active"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Management API Access Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("managementApiAccess")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Allow this key to call management routes (providers, combos, settings) via{" "}
|
||||
<code className="font-mono">Authorization: Bearer</code>. Use for LLM agents only.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={keyIsBanned}
|
||||
onClick={() => setKeyIsBanned((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-bold transition-colors ${
|
||||
keyIsBanned
|
||||
? "bg-red-600 text-white shadow-lg shadow-red-500/20"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">gavel</span>
|
||||
{keyIsBanned ? "BANNED" : "UNBANNED"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expiration Date */}
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Badge, Button, Card, Input, Modal, Toggle } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type TierName = "LOCAL_ONLY" | "ALWAYS_PROTECTED" | "MANAGEMENT" | "CLIENT_API" | "PUBLIC";
|
||||
|
||||
interface TierEntry {
|
||||
name: TierName;
|
||||
prefixes: string[];
|
||||
description: string;
|
||||
bypassable: boolean;
|
||||
}
|
||||
|
||||
interface InventoryPayload {
|
||||
tiers: TierEntry[];
|
||||
bypassEnabled: boolean;
|
||||
bypassPrefixes: string[];
|
||||
spawnCapablePrefixes: string[];
|
||||
}
|
||||
|
||||
interface StatusMessage {
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
type ErrorCode =
|
||||
| "PASSWORD_REQUIRED"
|
||||
| "PASSWORD_MISMATCH"
|
||||
| "INSUFFICIENT_SCOPE"
|
||||
| "BYPASS_PREFIX_NOT_ALLOWED"
|
||||
| "GENERIC";
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function tierBadgeVariant(
|
||||
tier: TierName,
|
||||
prefix: string,
|
||||
spawnCapable: ReadonlyArray<string>,
|
||||
bypassPrefixes: ReadonlyArray<string>,
|
||||
bypassEnabled: boolean
|
||||
): { variant: "default" | "success" | "warning" | "error" | "info"; key: string } {
|
||||
if (spawnCapable.some((p) => prefix === p || prefix.startsWith(p))) {
|
||||
return { variant: "error", key: "spawn_capable" };
|
||||
}
|
||||
if (tier === "LOCAL_ONLY") {
|
||||
const isLive = bypassEnabled && bypassPrefixes.some((p) => p === prefix);
|
||||
return isLive ? { variant: "warning", key: "bypassable" } : { variant: "info", key: "strict" };
|
||||
}
|
||||
if (tier === "ALWAYS_PROTECTED") return { variant: "error", key: "always_protected" };
|
||||
if (tier === "PUBLIC") return { variant: "default", key: "public" };
|
||||
return { variant: "info", key: "auth_required" };
|
||||
}
|
||||
|
||||
function parseErrorCode(payload: unknown): ErrorCode {
|
||||
if (!payload || typeof payload !== "object") return "GENERIC";
|
||||
const errorField = (payload as { error?: unknown }).error;
|
||||
if (typeof errorField === "string") {
|
||||
if (errorField.toLowerCase().includes("manage")) return "INSUFFICIENT_SCOPE";
|
||||
return "GENERIC";
|
||||
}
|
||||
if (errorField && typeof errorField === "object") {
|
||||
const code = (errorField as { code?: unknown }).code;
|
||||
if (
|
||||
code === "PASSWORD_REQUIRED" ||
|
||||
code === "PASSWORD_MISMATCH" ||
|
||||
code === "INSUFFICIENT_SCOPE" ||
|
||||
code === "BYPASS_PREFIX_NOT_ALLOWED"
|
||||
) {
|
||||
return code;
|
||||
}
|
||||
// Zod validation surface (T-011 emits BYPASS_PREFIX_NOT_ALLOWED inside
|
||||
// `error.details[].message`).
|
||||
const details = (errorField as { details?: unknown }).details;
|
||||
if (Array.isArray(details)) {
|
||||
for (const d of details) {
|
||||
const m = (d as { message?: unknown }).message;
|
||||
if (typeof m === "string" && m.includes("BYPASS_PREFIX_NOT_ALLOWED")) {
|
||||
return "BYPASS_PREFIX_NOT_ALLOWED";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "GENERIC";
|
||||
}
|
||||
|
||||
// ─── component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AuthzSection() {
|
||||
const t = useTranslations("settings");
|
||||
const [inventory, setInventory] = useState<InventoryPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Draft state — only persisted on Save (security-impacting fields require
|
||||
// a password re-prompt before the PATCH is fired).
|
||||
const [draftEnabled, setDraftEnabled] = useState<boolean>(true);
|
||||
const [draftPrefixes, setDraftPrefixes] = useState<string[]>([]);
|
||||
const [newPrefixInput, setNewPrefixInput] = useState("");
|
||||
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [status, setStatus] = useState<StatusMessage | null>(null);
|
||||
|
||||
const loadInventory = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/authz-inventory", {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const code = parseErrorCode(await res.json().catch(() => null));
|
||||
setLoadError(t(`authz.error.${code}`));
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as InventoryPayload;
|
||||
setInventory(data);
|
||||
setDraftEnabled(data.bypassEnabled);
|
||||
setDraftPrefixes([...data.bypassPrefixes]);
|
||||
} catch {
|
||||
setLoadError(t("authz.loadError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInventory();
|
||||
}, [loadInventory]);
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!inventory) return false;
|
||||
if (draftEnabled !== inventory.bypassEnabled) return true;
|
||||
if (draftPrefixes.length !== inventory.bypassPrefixes.length) return true;
|
||||
const a = [...draftPrefixes].sort();
|
||||
const b = [...inventory.bypassPrefixes].sort();
|
||||
return a.some((p, i) => p !== b[i]);
|
||||
}, [draftEnabled, draftPrefixes, inventory]);
|
||||
|
||||
const spawnCapable = inventory?.spawnCapablePrefixes ?? [];
|
||||
|
||||
const isSpawnCapable = useCallback(
|
||||
(prefix: string) => spawnCapable.some((p) => prefix === p || prefix.startsWith(p)),
|
||||
[spawnCapable]
|
||||
);
|
||||
|
||||
const handleAddPrefix = () => {
|
||||
const trimmed = newPrefixInput.trim();
|
||||
if (!trimmed) return;
|
||||
if (draftPrefixes.includes(trimmed)) {
|
||||
setNewPrefixInput("");
|
||||
return;
|
||||
}
|
||||
setDraftPrefixes((prev) => [...prev, trimmed]);
|
||||
setNewPrefixInput("");
|
||||
};
|
||||
|
||||
const handleRemovePrefix = (prefix: string) => {
|
||||
if (isSpawnCapable(prefix)) return;
|
||||
setDraftPrefixes((prev) => prev.filter((p) => p !== prefix));
|
||||
};
|
||||
|
||||
const handleSaveRequest = () => {
|
||||
if (!dirty) return;
|
||||
setCurrentPassword("");
|
||||
setStatus(null);
|
||||
setPasswordModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!currentPassword) {
|
||||
setStatus({ type: "error", message: t("authz.error.PASSWORD_REQUIRED") });
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
localOnlyManageScopeBypassEnabled: draftEnabled,
|
||||
localOnlyManageScopeBypassPrefixes: draftPrefixes,
|
||||
currentPassword,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => null);
|
||||
const code = parseErrorCode(payload);
|
||||
setStatus({ type: "error", message: t(`authz.error.${code}`) });
|
||||
return;
|
||||
}
|
||||
setStatus({ type: "success", message: t("authz.saved") });
|
||||
setPasswordModalOpen(false);
|
||||
setCurrentPassword("");
|
||||
await loadInventory();
|
||||
} catch {
|
||||
setStatus({ type: "error", message: t("authz.error.GENERIC") });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── render ─────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-info/10 text-info">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
shield_lock
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{t("authz.title")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted">{t("authz.loading")}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError || !inventory) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-info/10 text-info">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
shield_lock
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{t("authz.title")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-red-500">{loadError ?? t("authz.loadError")}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Authz header + tier inventory */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-info/10 text-info">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
shield_lock
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">{t("authz.title")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("authz.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{inventory.tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="font-semibold">{t(`authz.tier.${tier.name}`)}</h4>
|
||||
{tier.bypassable && (
|
||||
<Badge variant="warning" size="sm">
|
||||
{t("authz.badge.bypassable")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-3">{tier.description}</p>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{tier.prefixes.map((prefix) => {
|
||||
const badge = tierBadgeVariant(
|
||||
tier.name,
|
||||
prefix,
|
||||
inventory.spawnCapablePrefixes,
|
||||
inventory.bypassPrefixes,
|
||||
inventory.bypassEnabled
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={`${tier.name}:${prefix}`}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-border/40 bg-black/[0.02] dark:bg-white/[0.02] px-3 py-2"
|
||||
>
|
||||
<code className="text-xs font-mono">{prefix}</code>
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{t(`authz.badge.${badge.key}`)}
|
||||
</Badge>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bypass policy editor */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
tune
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{t("authz.bypass.section")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<p className="font-medium">{t("authz.bypass.kill_switch.label")}</p>
|
||||
<p className="text-sm text-text-muted">{t("authz.bypass.kill_switch.desc")}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={draftEnabled}
|
||||
onChange={() => setDraftEnabled((prev) => !prev)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="font-medium">{t("authz.bypass.prefix.label")}</p>
|
||||
<p className="text-sm text-text-muted">{t("authz.bypass.prefix.desc")}</p>
|
||||
</div>
|
||||
|
||||
{draftPrefixes.length === 0 && (
|
||||
<p className="text-sm text-text-muted italic">{t("authz.bypass.prefix.empty")}</p>
|
||||
)}
|
||||
|
||||
<ul className="flex flex-col gap-2">
|
||||
{draftPrefixes.map((prefix) => {
|
||||
const locked = isSpawnCapable(prefix);
|
||||
return (
|
||||
<li
|
||||
key={prefix}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-border/40 bg-black/[0.02] dark:bg-white/[0.02] px-3 py-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<code className="text-xs font-mono">{prefix}</code>
|
||||
{locked && (
|
||||
<span className="text-[10px] text-red-500 mt-1">
|
||||
{t("authz.bypass.cli_tools_runtime_note")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemovePrefix(prefix)}
|
||||
disabled={locked || submitting}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{/* Static read-only rows for spawn-capable prefixes that are NOT
|
||||
in the draft list — surface them so the operator understands
|
||||
they are intentionally not toggleable. */}
|
||||
{spawnCapable
|
||||
.filter((p) => !draftPrefixes.includes(p))
|
||||
.map((prefix) => (
|
||||
<li
|
||||
key={`locked:${prefix}`}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-red-500/30 bg-red-500/[0.04] px-3 py-2 opacity-80"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<code className="text-xs font-mono">{prefix}</code>
|
||||
<span className="text-[10px] text-red-500 mt-1">
|
||||
{t("authz.bypass.cli_tools_runtime_note")}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="error" size="sm">
|
||||
{t("authz.badge.spawn_capable")}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex gap-2 items-end pt-2">
|
||||
<Input
|
||||
type="text"
|
||||
label={t("authz.bypass.prefix.add")}
|
||||
placeholder={t("authz.bypass.prefix.placeholder")}
|
||||
value={newPrefixInput}
|
||||
onChange={(e) => setNewPrefixInput(e.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleAddPrefix}
|
||||
disabled={!newPrefixInput.trim() || submitting}
|
||||
>
|
||||
{t("authz.bypass.prefix.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save bar */}
|
||||
<div className="flex items-center justify-between gap-4 pt-4 mt-4 border-t border-border/50">
|
||||
<div className="text-sm">
|
||||
{dirty && (
|
||||
<span className="text-amber-600 dark:text-amber-400">{t("authz.pending")}</span>
|
||||
)}
|
||||
{status && (
|
||||
<span
|
||||
className={`ml-3 ${status.type === "error" ? "text-red-500" : "text-green-500"}`}
|
||||
>
|
||||
{status.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleSaveRequest} disabled={!dirty || submitting}>
|
||||
{t("authz.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Password re-auth modal — fires for every security-impacting PATCH */}
|
||||
<Modal
|
||||
isOpen={passwordModalOpen}
|
||||
onClose={() => {
|
||||
if (!submitting) {
|
||||
setPasswordModalOpen(false);
|
||||
setCurrentPassword("");
|
||||
}
|
||||
}}
|
||||
title={t("authz.password.prompt.label")}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setPasswordModalOpen(false);
|
||||
setCurrentPassword("");
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("authz.password.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={!currentPassword || submitting}
|
||||
>
|
||||
{t("authz.password.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-text-muted">{t("authz.password.prompt.desc")}</p>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("authz.password.placeholder")}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{status?.type === "error" && <p className="text-sm text-red-500">{status.message}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Card, Button, Input, Toggle } from "@/shared/components";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import IPFilterSection from "./IPFilterSection";
|
||||
import SessionInfoCard from "./SessionInfoCard";
|
||||
import AuthzSection from "./AuthzSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function SecurityTab() {
|
||||
@@ -274,6 +275,7 @@ export default function SecurityTab() {
|
||||
|
||||
<SessionInfoCard />
|
||||
<IPFilterSection />
|
||||
<AuthzSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
156
src/app/api/settings/authz-inventory/route.ts
Normal file
156
src/app/api/settings/authz-inventory/route.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
LOCAL_ONLY_API_PREFIXES,
|
||||
ALWAYS_PROTECTED_API_PATHS,
|
||||
LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES,
|
||||
SPAWN_CAPABLE_PREFIXES,
|
||||
} from "@/server/authz/routeGuard";
|
||||
|
||||
/**
|
||||
* Static MANAGEMENT-tier example prefixes. Render-only — never consulted by
|
||||
* the runtime policy. The actual MANAGEMENT classification is "any /api/*
|
||||
* that is not LOCAL_ONLY, not v1/client, not on the public allowlist", so the
|
||||
* inventory shows representative entries rather than a generated enumeration.
|
||||
*/
|
||||
const MANAGEMENT_TIER_PREFIXES: ReadonlyArray<string> = [
|
||||
"/api/settings",
|
||||
"/api/providers/",
|
||||
"/api/api-keys",
|
||||
];
|
||||
|
||||
const CLIENT_API_TIER_PREFIXES: ReadonlyArray<string> = ["/v1/", "/api/v1/"];
|
||||
|
||||
const PUBLIC_TIER_PREFIXES: ReadonlyArray<string> = ["/api/health", "/api/version", "/_next/"];
|
||||
|
||||
type TierName = "LOCAL_ONLY" | "ALWAYS_PROTECTED" | "MANAGEMENT" | "CLIENT_API" | "PUBLIC";
|
||||
|
||||
interface TierEntry {
|
||||
name: TierName;
|
||||
prefixes: string[];
|
||||
description: string;
|
||||
bypassable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OQ-5: viewing the inventory requires authentication (dashboard session OR
|
||||
* any valid API key, regardless of scope). The inventory leaks route-prefix
|
||||
* taxonomy + current bypass state (reconnaissance value), so we never expose
|
||||
* it anonymously — but a non-manage key holder may still inspect it.
|
||||
*
|
||||
* Compare with `requireManagementAuth` which would refuse anything below the
|
||||
* manage scope; this endpoint is intentionally read-only and one rung lower.
|
||||
*/
|
||||
async function requireInventoryReadAuth(request: Request): Promise<Response | null> {
|
||||
if (!(await isAuthRequired(request))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await isDashboardSessionAuthenticated(request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiKey = extractApiKey(request);
|
||||
if (apiKey) {
|
||||
try {
|
||||
if (await isValidApiKey(apiKey)) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return createErrorResponse({
|
||||
status: 503,
|
||||
message: "Service temporarily unavailable",
|
||||
type: "server_error",
|
||||
});
|
||||
}
|
||||
return createErrorResponse({
|
||||
status: 403,
|
||||
message: "Invalid API key",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
return createErrorResponse({
|
||||
status: 401,
|
||||
message: "Authentication required",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
function isBypassableConstant(prefix: string): boolean {
|
||||
// A LOCAL_ONLY prefix is bypassable iff it appears in the compile-time
|
||||
// bypass constant AND is not a SPAWN_CAPABLE prefix. Runtime DB state is
|
||||
// surfaced separately via `bypassEnabled` / `bypassPrefixes`.
|
||||
if (SPAWN_CAPABLE_PREFIXES.some((p) => p === prefix || prefix.startsWith(p))) {
|
||||
return false;
|
||||
}
|
||||
return LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES.some((p) => p === prefix);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireInventoryReadAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
|
||||
const tiers: TierEntry[] = [
|
||||
{
|
||||
name: "LOCAL_ONLY",
|
||||
prefixes: [...LOCAL_ONLY_API_PREFIXES],
|
||||
description:
|
||||
"Loopback-only routes. Spawn child processes; exposing them to non-local traffic is a known CVE class (GHSA-fhh6-4qxv-rpqj). Some entries are opt-in bypassable via the manage scope (kill-switch gated).",
|
||||
bypassable: LOCAL_ONLY_API_PREFIXES.some(isBypassableConstant),
|
||||
},
|
||||
{
|
||||
name: "ALWAYS_PROTECTED",
|
||||
prefixes: [...ALWAYS_PROTECTED_API_PATHS],
|
||||
description:
|
||||
"Auth required unconditionally, even when requireLogin=false. Covers destructive / irreversible operations (shutdown, database settings).",
|
||||
bypassable: false,
|
||||
},
|
||||
{
|
||||
name: "MANAGEMENT",
|
||||
prefixes: [...MANAGEMENT_TIER_PREFIXES],
|
||||
description:
|
||||
"Default tier for /api/* admin endpoints. Auth required unless requireLogin=false. PATCHes touching security-impacting keys require currentPassword re-auth.",
|
||||
bypassable: false,
|
||||
},
|
||||
{
|
||||
name: "CLIENT_API",
|
||||
prefixes: [...CLIENT_API_TIER_PREFIXES],
|
||||
description:
|
||||
"Client-facing inference APIs. Accept Bearer API keys; not gated by dashboard sessions.",
|
||||
bypassable: false,
|
||||
},
|
||||
{
|
||||
name: "PUBLIC",
|
||||
prefixes: [...PUBLIC_TIER_PREFIXES],
|
||||
description: "Unauthenticated routes: health probes, public assets, onboarding bootstrap.",
|
||||
bypassable: false,
|
||||
},
|
||||
];
|
||||
|
||||
const bypassEnabled =
|
||||
typeof settings.localOnlyManageScopeBypassEnabled === "boolean"
|
||||
? settings.localOnlyManageScopeBypassEnabled
|
||||
: true;
|
||||
const bypassPrefixesRaw = settings.localOnlyManageScopeBypassPrefixes;
|
||||
const bypassPrefixes = Array.isArray(bypassPrefixesRaw)
|
||||
? bypassPrefixesRaw.filter((p): p is string => typeof p === "string")
|
||||
: [...LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES];
|
||||
|
||||
return NextResponse.json({
|
||||
tiers,
|
||||
bypassEnabled,
|
||||
bypassPrefixes,
|
||||
spawnCapablePrefixes: [...SPAWN_CAPABLE_PREFIXES],
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error loading authz inventory:", error);
|
||||
return NextResponse.json({ error: "Failed to load authz inventory" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,124 @@ import {
|
||||
verifyManagementPassword,
|
||||
} from "@/lib/auth/managementPassword";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance";
|
||||
import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth";
|
||||
import { extractApiKey } from "@/sse/services/auth";
|
||||
import { getApiKeyMetadata } from "@/lib/db/apiKeys";
|
||||
|
||||
/**
|
||||
* Settings keys whose change broadens attack surface. Spec §Security:
|
||||
* password re-auth is required when any of these is present in a PATCH body.
|
||||
*
|
||||
* - `localOnlyManageScopeBypassEnabled` / `localOnlyManageScopeBypassPrefixes`:
|
||||
* T-011 bypass kill-switch + per-prefix list. Operator must re-confirm
|
||||
* before broadening the LOCAL_ONLY carve-out.
|
||||
* - `requireLogin`: dashboard login enforcement toggle.
|
||||
* - `newPassword`: password rotation (existing). Handled by the same gate so
|
||||
* the password-verify only fires ONCE per PATCH.
|
||||
*
|
||||
* Note: `mcpEnabled` is NOT gated server-side — the dedicated MCP page
|
||||
* (/dashboard/mcp) toggles it via patchSetting() without a currentPassword
|
||||
* prompt. The Authz section can still prompt client-side for consistency,
|
||||
* but the server accepts the change without re-auth.
|
||||
*/
|
||||
const SECURITY_IMPACTING_KEYS = [
|
||||
"localOnlyManageScopeBypassEnabled",
|
||||
"localOnlyManageScopeBypassPrefixes",
|
||||
"requireLogin",
|
||||
"newPassword",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Derive an audit actor string from the inbound request. Falls back to
|
||||
* `"dashboard"` for cookie sessions, `"apikey:<id>"` for Bearer API keys,
|
||||
* `"cli"` for CLI machine-token sessions, and `"anonymous"` otherwise. Best
|
||||
* effort — any lookup error degrades to `"unknown"` so the audit row still
|
||||
* carries actor context.
|
||||
*/
|
||||
async function deriveAuditActor(request: Request): Promise<string> {
|
||||
try {
|
||||
if (await isDashboardSessionAuthenticated(request)) return "dashboard";
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
try {
|
||||
if (await isCliTokenAuthValid(request)) return "cli";
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
try {
|
||||
const apiKey = extractApiKey(request);
|
||||
if (apiKey) {
|
||||
const meta = await getApiKeyMetadata(apiKey);
|
||||
if (meta?.id) return `apikey:${meta.id}`;
|
||||
return "apikey:unknown";
|
||||
}
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
return "anonymous";
|
||||
}
|
||||
|
||||
/** Deep-equality for diff detection. JSON round-trip handles plain settings. */
|
||||
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === null || b === null) return false;
|
||||
if (typeof a !== "object" || typeof b !== "object") return false;
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build per-key `{before, after}` diff for changed keys (top-level only). */
|
||||
function computeSettingsDiff(
|
||||
before: Record<string, unknown>,
|
||||
after: Record<string, unknown>,
|
||||
candidateKeys: string[]
|
||||
): Record<string, { before: unknown; after: unknown }> {
|
||||
const diff: Record<string, { before: unknown; after: unknown }> = {};
|
||||
for (const key of candidateKeys) {
|
||||
if (!isDeepEqual(before[key], after[key])) {
|
||||
diff[key] = { before: before[key], after: after[key] };
|
||||
}
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
/** List of top-level body keys the operator attempted to change (audit context). */
|
||||
function attemptedKeysOf(body: Record<string, unknown> | null | undefined): string[] {
|
||||
if (!body || typeof body !== "object") return [];
|
||||
return Object.keys(body).filter(
|
||||
(k) => k !== "currentPassword" && k !== "newPassword" && k !== "password"
|
||||
);
|
||||
}
|
||||
|
||||
/** Emit a settings.update_failed row. Never throws — audit must not break flow. */
|
||||
function emitSettingsFailureAudit(
|
||||
request: Request,
|
||||
actor: string,
|
||||
reason: string,
|
||||
attemptedKeys: string[]
|
||||
) {
|
||||
try {
|
||||
const { ipAddress, requestId } = getAuditRequestContext(request);
|
||||
logAuditEvent({
|
||||
action: "settings.update_failed",
|
||||
actor,
|
||||
target: "settings",
|
||||
resourceType: "settings",
|
||||
status: "failure",
|
||||
ipAddress: ipAddress || undefined,
|
||||
requestId: requestId || undefined,
|
||||
details: { reason, attempted_keys: attemptedKeys },
|
||||
});
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
@@ -46,40 +164,105 @@ export async function PATCH(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// Derive actor + raw body once so the rejection paths can audit consistently.
|
||||
const actor = await deriveAuditActor(request);
|
||||
let rawBody: Record<string, unknown> = {};
|
||||
try {
|
||||
const rawBody = await request.json();
|
||||
rawBody = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Malformed JSON — surface a zod-style failure path so the rejection
|
||||
// is auditable like every other 400.
|
||||
emitSettingsFailureAudit(request, actor, "INVALID_JSON", []);
|
||||
return NextResponse.json(
|
||||
{ error: { code: "INVALID_JSON", message: "Request body is not valid JSON" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const attemptedKeys = attemptedKeysOf(rawBody);
|
||||
|
||||
try {
|
||||
// Zod validation
|
||||
const validation = validateBody(updateSettingsSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
// Detect spawn-capable prefix rejection (spec AC-8) so the audit row
|
||||
// names the correct error code; otherwise fall back to the generic
|
||||
// validation-failure label.
|
||||
const isBypassPrefixRejection = (validation.error.details || []).some(
|
||||
(d) => typeof d.message === "string" && d.message.includes("BYPASS_PREFIX_NOT_ALLOWED")
|
||||
);
|
||||
emitSettingsFailureAudit(
|
||||
request,
|
||||
actor,
|
||||
isBypassPrefixRejection ? "BYPASS_PREFIX_NOT_ALLOWED" : "VALIDATION_FAILED",
|
||||
attemptedKeys
|
||||
);
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const body: typeof validation.data & { password?: string } = { ...validation.data };
|
||||
|
||||
// If updating password, hash it
|
||||
if (body.newPassword) {
|
||||
// Security-impacting gate (T-011, spec AC-4 / AC-5). Computed from the
|
||||
// VALIDATED body so we never trip on stray unknown keys. If any security
|
||||
// key is present, require currentPassword + verify against the stored
|
||||
// bcrypt hash. Dedupes with the previous inline newPassword reauth — the
|
||||
// password is verified at most once per PATCH.
|
||||
const touchedSecurityKeys = SECURITY_IMPACTING_KEYS.filter((k) => k in validation.data);
|
||||
let storedPasswordHash = "";
|
||||
if (touchedSecurityKeys.length > 0) {
|
||||
const settings = await getSettings();
|
||||
// Lazy-hash any plaintext INITIAL_PASSWORD migration BEFORE we read the
|
||||
// stored hash, so the gate works on fresh deploys too.
|
||||
const passwordState = await ensurePersistentManagementPasswordHash({
|
||||
settings,
|
||||
source: "settings.password_change",
|
||||
source: "settings.security_impacting_update",
|
||||
});
|
||||
const currentHash = getStoredManagementPassword(passwordState.settings);
|
||||
|
||||
if (currentHash) {
|
||||
storedPasswordHash = getStoredManagementPassword(passwordState.settings);
|
||||
// Cold-boot exception: same condition the existing newPassword path
|
||||
// honoured before T-011 — when no password is configured yet AND login
|
||||
// is currently disabled, allow the first write to set policy (incl.
|
||||
// the password itself). Once a hash exists the gate always fires.
|
||||
const isColdBoot = !storedPasswordHash && passwordState.settings.requireLogin === false;
|
||||
if (!isColdBoot) {
|
||||
if (!body.currentPassword) {
|
||||
return NextResponse.json({ error: "Current password required" }, { status: 400 });
|
||||
emitSettingsFailureAudit(request, actor, "PASSWORD_REQUIRED", attemptedKeys);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: "PASSWORD_REQUIRED",
|
||||
message: "currentPassword required for security-impacting setting changes",
|
||||
keys: touchedSecurityKeys,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const isValid = await verifyManagementPassword(body.currentPassword, currentHash);
|
||||
const isValid = await verifyManagementPassword(body.currentPassword, storedPasswordHash);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: "Invalid current password" }, { status: 401 });
|
||||
emitSettingsFailureAudit(request, actor, "PASSWORD_MISMATCH", attemptedKeys);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: "PASSWORD_MISMATCH",
|
||||
message: "Invalid current password",
|
||||
},
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
body.password = await hashManagementPassword(body.newPassword);
|
||||
delete body.newPassword;
|
||||
delete body.currentPassword;
|
||||
}
|
||||
|
||||
// Password rotation: hash the new value AFTER the gate has accepted the
|
||||
// currentPassword (or the cold-boot exception fired). The gate already
|
||||
// included `newPassword` in SECURITY_IMPACTING_KEYS, so no separate
|
||||
// verify happens here — strictly hashing + body rewriting.
|
||||
if (body.newPassword) {
|
||||
body.password = await hashManagementPassword(body.newPassword);
|
||||
delete body.newPassword;
|
||||
}
|
||||
delete body.currentPassword;
|
||||
|
||||
// Snapshot BEFORE the write so the success row can record a real diff.
|
||||
const beforeSnapshot = (await getSettings()) as Record<string, unknown>;
|
||||
const settings = await updateSettings(body);
|
||||
|
||||
// Sync CLIProxyAPI settings to upstream_proxy_config table
|
||||
@@ -88,6 +271,7 @@ export async function PATCH(request: Request) {
|
||||
if (cpaUrl && typeof cpaUrl === "string") {
|
||||
const urlValidation = validateProxyUrl(cpaUrl);
|
||||
if (urlValidation.valid === false) {
|
||||
emitSettingsFailureAudit(request, actor, "CLIPROXY_URL_INVALID", attemptedKeys);
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid CLIProxyAPI URL: ${urlValidation.error}` },
|
||||
{ status: 400 }
|
||||
@@ -106,6 +290,29 @@ export async function PATCH(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
// Audit success — diff of changed keys only. Idempotent PATCH (no diff)
|
||||
// intentionally writes NO row (spec §Observability + AC-9/AC-11).
|
||||
try {
|
||||
const afterSnapshot = settings as Record<string, unknown>;
|
||||
const candidateKeys = Object.keys(body);
|
||||
const diff = computeSettingsDiff(beforeSnapshot, afterSnapshot, candidateKeys);
|
||||
if (Object.keys(diff).length > 0) {
|
||||
const { ipAddress, requestId } = getAuditRequestContext(request);
|
||||
logAuditEvent({
|
||||
action: "settings.update",
|
||||
actor,
|
||||
target: "settings",
|
||||
resourceType: "settings",
|
||||
status: "success",
|
||||
ipAddress: ipAddress || undefined,
|
||||
requestId: requestId || undefined,
|
||||
details: { diff },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Audit failure must never break the write — swallow.
|
||||
}
|
||||
|
||||
const { password, ...safeSettings } = settings;
|
||||
return NextResponse.json(safeSettings);
|
||||
} catch (error) {
|
||||
|
||||
@@ -842,6 +842,7 @@
|
||||
"settingsAi": "AI Settings",
|
||||
"settingsSecurity": "Security",
|
||||
"settingsFeatureFlags": "Feature Flags",
|
||||
"settingsAuthz": "Authz",
|
||||
"settingsRouting": "Routing",
|
||||
"settingsResilience": "Resilience",
|
||||
"settingsAdvanced": "Advanced",
|
||||
@@ -913,6 +914,7 @@
|
||||
"settingsAdvancedSubtitle": "Power user options",
|
||||
"settingsSecuritySubtitle": "Auth and encryption",
|
||||
"settingsFeatureFlagsSubtitle": "Toggle system capabilities",
|
||||
"settingsAuthzSubtitle": "Route inventory and bypass policy",
|
||||
"docsSubtitle": "Documentation",
|
||||
"issuesSubtitle": "Report a bug",
|
||||
"changelogSubtitle": "Release notes"
|
||||
@@ -1288,6 +1290,7 @@
|
||||
"keyName": "Key Name",
|
||||
"keyNamePlaceholder": "e.g. Production Key",
|
||||
"keyNameDesc": "Choose a descriptive name to identify this key's purpose",
|
||||
"managementAccessDesc": "Allow this API key to manage OmniRoute configuration.",
|
||||
"keyCreated": "API Key Created",
|
||||
"keyCreatedSuccess": "Key created successfully!",
|
||||
"keyCreatedNote": "Copy and store this key now — it won't be shown again.",
|
||||
@@ -4587,7 +4590,62 @@
|
||||
"claudeFastModeHint": "Anthropic does not officially support Fast Mode for SDK-style clients. When enabled, OmniRoute forwards an X-CPA-Force-Fast-Mode header so a paired CLIProxyAPI build can opt-in spoof the entrypoint. Only the listed Opus models are gated by Anthropic's client-side check. Subscription tier, Max plan, and Fast Mode credit balance are still enforced server-side — Anthropic may return out_of_credits even when the toggle is on.",
|
||||
"claudeFastModeModelsLabel": "Applied to models ({count})",
|
||||
"claudeFastModeModelCheckbox": "Enable Fast Mode for {model}",
|
||||
"claudeFastModeSaveError": "Failed to update Claude Fast Mode setting"
|
||||
"claudeFastModeSaveError": "Failed to update Claude Fast Mode setting",
|
||||
"authz": {
|
||||
"title": "Authz Inventory",
|
||||
"description": "5-tier route classification with live bypass policy. Read shows the full taxonomy; mutations re-prompt for the management password.",
|
||||
"loading": "Loading inventory…",
|
||||
"loadError": "Failed to load authz inventory",
|
||||
"tier": {
|
||||
"LOCAL_ONLY": "Local only",
|
||||
"ALWAYS_PROTECTED": "Always protected",
|
||||
"MANAGEMENT": "Management",
|
||||
"CLIENT_API": "Client API",
|
||||
"PUBLIC": "Public"
|
||||
},
|
||||
"bypass": {
|
||||
"section": "Manage-scope bypass",
|
||||
"kill_switch": {
|
||||
"label": "Bypass kill-switch",
|
||||
"desc": "Master toggle. When off, no LOCAL_ONLY prefix is reachable from non-loopback regardless of the per-prefix list."
|
||||
},
|
||||
"prefix": {
|
||||
"label": "Bypassable prefixes",
|
||||
"desc": "LOCAL_ONLY prefixes that manage-scope API keys (or dashboard sessions) may reach from non-loopback hosts.",
|
||||
"add": "Add prefix",
|
||||
"placeholder": "/api/mcp/v2/",
|
||||
"empty": "No prefixes configured. Bypass effectively off."
|
||||
},
|
||||
"cli_tools_runtime_note": "Spawn-capable prefix. Compile-time deny; cannot be made bypassable. Shown read-only."
|
||||
},
|
||||
"password": {
|
||||
"prompt": {
|
||||
"label": "Current password",
|
||||
"desc": "Re-confirm to apply security-impacting changes."
|
||||
},
|
||||
"placeholder": "Current management password",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Apply"
|
||||
},
|
||||
"save": "Save changes",
|
||||
"saved": "Authz settings updated",
|
||||
"pending": "Unsaved changes",
|
||||
"badge": {
|
||||
"bypassable": "Bypassable via manage scope",
|
||||
"strict": "Strict loopback",
|
||||
"auth_required": "Auth required",
|
||||
"public": "Public",
|
||||
"always_protected": "Always protected",
|
||||
"spawn_capable": "Spawn-capable"
|
||||
},
|
||||
"error": {
|
||||
"PASSWORD_REQUIRED": "Current password required to apply these changes.",
|
||||
"PASSWORD_MISMATCH": "Current password is incorrect.",
|
||||
"INSUFFICIENT_SCOPE": "API key lacks the manage scope.",
|
||||
"BYPASS_PREFIX_NOT_ALLOWED": "One or more prefixes target spawn-capable routes and cannot be bypassed.",
|
||||
"GENERIC": "Failed to update authz settings."
|
||||
}
|
||||
}
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
|
||||
@@ -57,6 +57,11 @@ type AuditLogFilter = {
|
||||
};
|
||||
|
||||
type AuditLogRow = Record<string, unknown> & {
|
||||
id?: number | null;
|
||||
action?: string | null;
|
||||
actor?: string | null;
|
||||
target?: string | null;
|
||||
status?: string | null;
|
||||
details?: string | null;
|
||||
metadata?: string | null;
|
||||
ip_address?: string | null;
|
||||
@@ -65,6 +70,33 @@ type AuditLogRow = Record<string, unknown> & {
|
||||
timestamp?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Public shape of a normalized audit-log entry returned by `getAuditLog` /
|
||||
* `normalizeAuditLogRow`. Includes the column-level fields callers (and
|
||||
* tests) reach into directly — `action`, `actor`, `target`, `id`, `status`,
|
||||
* `timestamp` — alongside the derived/parsed fields. The
|
||||
* `Record<string, unknown>` intersection preserves the existing behaviour of
|
||||
* spreading any extra DB columns (e.g. schema additions) without losing
|
||||
* compile-time access to the known ones.
|
||||
*/
|
||||
export type AuditLogEntry = Record<string, unknown> & {
|
||||
id?: number | null;
|
||||
action?: string | null;
|
||||
actor?: string | null;
|
||||
target?: string | null;
|
||||
status: string | null;
|
||||
timestamp: string;
|
||||
createdAt: string;
|
||||
details: unknown;
|
||||
metadata: unknown;
|
||||
ip_address: string | null;
|
||||
ip: string | null;
|
||||
resource_type: string | null;
|
||||
resourceType: string | null;
|
||||
request_id: string | null;
|
||||
requestId: string | null;
|
||||
};
|
||||
|
||||
const AUDIT_LOG_REQUIRED_COLUMNS: Record<string, string> = {
|
||||
resource_type: "TEXT",
|
||||
status: "TEXT",
|
||||
@@ -227,7 +259,7 @@ function buildAuditLogQuery(filter: AuditLogFilter = {}): AuditLogQuery {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAuditLogRow(row: AuditLogRow) {
|
||||
function normalizeAuditLogRow(row: AuditLogRow): AuditLogEntry {
|
||||
const details = parseAuditValue(row.details);
|
||||
const metadata = parseAuditValue(row.metadata);
|
||||
const resourceType = typeof row.resource_type === "string" ? row.resource_type : null;
|
||||
@@ -349,7 +381,7 @@ export function logAuditEvent(entry: {
|
||||
* @param {number} [filter.offset=0] - Pagination offset
|
||||
* @returns {Array<{ id: number, timestamp: string, action: string, actor: string, target: string, details: any, ip_address: string }>}
|
||||
*/
|
||||
export function getAuditLog(filter: AuditLogFilter = {}) {
|
||||
export function getAuditLog(filter: AuditLogFilter = {}): AuditLogEntry[] {
|
||||
const db = getDb();
|
||||
if (!db) return [];
|
||||
|
||||
|
||||
@@ -14,13 +14,19 @@ export type RuntimeReloadSection =
|
||||
| "modelsDevSync"
|
||||
| "corsOrigins"
|
||||
| "ccBridgeTransforms"
|
||||
| "systemTransforms";
|
||||
| "systemTransforms"
|
||||
| "authzBypass";
|
||||
|
||||
export interface RuntimeReloadChange {
|
||||
section: RuntimeReloadSection;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface AuthzBypassSnapshot {
|
||||
enabled: boolean;
|
||||
prefixes: string[];
|
||||
}
|
||||
|
||||
interface RuntimeSettingsSnapshot {
|
||||
payloadRules: unknown;
|
||||
modelAliases: Record<string, string>;
|
||||
@@ -35,8 +41,17 @@ interface RuntimeSettingsSnapshot {
|
||||
corsOrigins: string;
|
||||
ccBridgeTransforms: unknown;
|
||||
systemTransforms: unknown;
|
||||
authzBypass: AuthzBypassSnapshot;
|
||||
}
|
||||
|
||||
// Default bypass policy: kill-switch on, `/api/mcp/` bypassable. Mirrors the
|
||||
// pre-T-011 compile-time constant so the route guard works identically before
|
||||
// the first `applyRuntimeSettings` call (e.g. cold-boot requests).
|
||||
const DEFAULT_AUTHZ_BYPASS_SNAPSHOT: AuthzBypassSnapshot = {
|
||||
enabled: true,
|
||||
prefixes: ["/api/mcp/"],
|
||||
};
|
||||
|
||||
const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
|
||||
payloadRules: null,
|
||||
modelAliases: {},
|
||||
@@ -51,10 +66,17 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
|
||||
corsOrigins: "",
|
||||
ccBridgeTransforms: null,
|
||||
systemTransforms: null,
|
||||
authzBypass: DEFAULT_AUTHZ_BYPASS_SNAPSHOT,
|
||||
};
|
||||
|
||||
let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null;
|
||||
|
||||
// Module-local mirror of the current bypass policy. Read by the route guard
|
||||
// on every non-loopback hit to a LOCAL_ONLY path via `getAuthzBypassSnapshot`.
|
||||
// Initialised to the default so cold-boot requests (before any
|
||||
// `applyRuntimeSettings` call) behave identically to PR #2473.
|
||||
let currentAuthzBypass: AuthzBypassSnapshot = DEFAULT_AUTHZ_BYPASS_SNAPSHOT;
|
||||
|
||||
function isTruthyEnvFlag(value: string | undefined): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
return new Set(["1", "true", "yes", "on"]).has(value.trim().toLowerCase());
|
||||
@@ -165,6 +187,41 @@ function normalizePayloadRules(value: unknown): unknown {
|
||||
return parseStoredJson(value, "payloadRules");
|
||||
}
|
||||
|
||||
function normalizeAuthzBypass(settings: Record<string, unknown>): AuthzBypassSnapshot {
|
||||
const enabled =
|
||||
settings.localOnlyManageScopeBypassEnabled === false
|
||||
? false
|
||||
: settings.localOnlyManageScopeBypassEnabled === true
|
||||
? true
|
||||
: DEFAULT_AUTHZ_BYPASS_SNAPSHOT.enabled;
|
||||
const rawPrefixes = settings.localOnlyManageScopeBypassPrefixes;
|
||||
const prefixes = Array.isArray(rawPrefixes)
|
||||
? Array.from(
|
||||
new Set(
|
||||
rawPrefixes
|
||||
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
||||
.filter((entry) => entry.length > 0 && entry.startsWith("/"))
|
||||
)
|
||||
)
|
||||
: [...DEFAULT_AUTHZ_BYPASS_SNAPSHOT.prefixes];
|
||||
return { enabled, prefixes };
|
||||
}
|
||||
|
||||
/**
|
||||
* O(1) accessor for the current LOCAL_ONLY manage-scope bypass policy.
|
||||
*
|
||||
* Consumed by the route-guard hot path (`isLocalOnlyBypassableByManageScope`).
|
||||
* Returns the default snapshot (`{ enabled: true, prefixes: ["/api/mcp/"] }`)
|
||||
* before the first `applyRuntimeSettings` call so cold-boot requests behave
|
||||
* identically to PR #2473. Mutated only by `applyAuthzBypassSection`.
|
||||
*
|
||||
* Hot-reload latency: <50 ms (no I/O, no async, pure read of module-local
|
||||
* state). Spec §Non-Functional Requirements / Performance.
|
||||
*/
|
||||
export function getAuthzBypassSnapshot(): AuthzBypassSnapshot {
|
||||
return currentAuthzBypass;
|
||||
}
|
||||
|
||||
export function buildRuntimeSettingsSnapshot(
|
||||
settings: Record<string, unknown>
|
||||
): RuntimeSettingsSnapshot {
|
||||
@@ -188,6 +245,7 @@ export function buildRuntimeSettingsSnapshot(
|
||||
corsOrigins: typeof settings.corsOrigins === "string" ? settings.corsOrigins : "",
|
||||
ccBridgeTransforms: parseStoredJson(settings.ccBridgeTransforms, "ccBridgeTransforms"),
|
||||
systemTransforms: parseStoredJson(settings.systemTransforms, "systemTransforms"),
|
||||
authzBypass: normalizeAuthzBypass(settings),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -283,6 +341,14 @@ async function applyCcBridgeTransformsSection(ccBridgeTransforms: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the in-process bypass policy. Synchronous, O(1), no I/O — the SLA
|
||||
* (<50 ms hot-reload) is structurally satisfied by this shape.
|
||||
*/
|
||||
function applyAuthzBypassSection(snapshot: AuthzBypassSnapshot) {
|
||||
currentAuthzBypass = { enabled: snapshot.enabled, prefixes: [...snapshot.prefixes] };
|
||||
}
|
||||
|
||||
async function applySystemTransformsSection(systemTransforms: unknown) {
|
||||
const { setSystemTransformsConfig, resetSystemTransformsConfig } =
|
||||
await import("@omniroute/open-sse/services/systemTransforms.ts");
|
||||
@@ -447,6 +513,11 @@ export async function applyRuntimeSettings(
|
||||
markChanged("systemTransforms");
|
||||
}
|
||||
|
||||
if (force || hasChanged(currentSnapshot.authzBypass, previousSnapshot.authzBypass)) {
|
||||
applyAuthzBypassSection(currentSnapshot.authzBypass);
|
||||
markChanged("authzBypass");
|
||||
}
|
||||
|
||||
lastAppliedSnapshot = currentSnapshot;
|
||||
return changes;
|
||||
}
|
||||
@@ -457,4 +528,5 @@ export function getLastAppliedRuntimeSettingsSnapshotForTests() {
|
||||
|
||||
export function resetRuntimeSettingsStateForTests() {
|
||||
lastAppliedSnapshot = null;
|
||||
currentAuthzBypass = DEFAULT_AUTHZ_BYPASS_SNAPSHOT;
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ interface ApiKeyView extends JsonRecord {
|
||||
isActive: boolean;
|
||||
accessSchedule: AccessSchedule | null;
|
||||
rateLimits: RateLimitRule[] | null;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
// LRU cache for API key validation (valid keys only)
|
||||
@@ -379,6 +380,7 @@ export async function getApiKeys() {
|
||||
camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule);
|
||||
camelRow.rateLimits = parseRateLimits(camelRow.rateLimits);
|
||||
camelRow.isBanned = parseIsBanned(camelRow.isBanned);
|
||||
camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes);
|
||||
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
|
||||
setNoLog(camelRow.id, camelRow.noLog === true);
|
||||
}
|
||||
@@ -400,6 +402,7 @@ export async function getApiKeyById(id: string) {
|
||||
camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule);
|
||||
camelRow.rateLimits = parseRateLimits(camelRow.rateLimits);
|
||||
camelRow.isBanned = parseIsBanned(camelRow.isBanned);
|
||||
camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes);
|
||||
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
|
||||
setNoLog(camelRow.id, camelRow.noLog === true);
|
||||
}
|
||||
@@ -762,14 +765,60 @@ export async function updateApiKeyPermissions(
|
||||
}
|
||||
|
||||
const scopesUpdate = (normalized as Record<string, unknown>).scopes;
|
||||
const nextScopes: string[] = Array.isArray(scopesUpdate)
|
||||
? (scopesUpdate as unknown[]).filter((s): s is string => typeof s === "string")
|
||||
: [];
|
||||
// Capture previous scopes BEFORE the UPDATE so we can compare for the audit
|
||||
// event below. We only fetch when the caller is actually changing scopes —
|
||||
// a privileged change ("manage" grants management API surface access) that
|
||||
// must always leave an audit trail per OWASP A09 / SOC2 CC7.2.
|
||||
//
|
||||
// The previous-scopes SELECT and the row UPDATE are wrapped in a single
|
||||
// transaction so a concurrent writer cannot slip in between and make the
|
||||
// audit log lie about what changed. SQLite is single-writer in practice,
|
||||
// but the transaction also gives us atomicity if the underlying driver
|
||||
// ever swaps to a backend that allows multiple writers (sqljsAdapter /
|
||||
// nodeSqliteAdapter fall-back per v3.8.1 db driver cascade).
|
||||
let previousScopes: string[] = [];
|
||||
let changedRows = 0;
|
||||
if (scopesUpdate !== undefined) {
|
||||
updates.push("scopes = @scopes");
|
||||
params.scopes = JSON.stringify(Array.isArray(scopesUpdate) ? scopesUpdate : []);
|
||||
params.scopes = JSON.stringify(nextScopes);
|
||||
|
||||
// SELECT-then-UPDATE wrapped in an explicit transaction so a concurrent
|
||||
// writer can't slip between the read and the write and make the audit
|
||||
// log lie about what changed. `exec("BEGIN"/"COMMIT")` works across all
|
||||
// driver backends (better-sqlite3 / node:sqlite / sql.js) wired by the
|
||||
// v3.8.1 db driver cascade — none of them expose `db.transaction()` via
|
||||
// ApiKeysDbLike, which is intentionally minimal.
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const prevRow = db
|
||||
.prepare<{ scopes: string | null }>("SELECT scopes FROM api_keys WHERE id = ?")
|
||||
.get(id);
|
||||
previousScopes = parseStringList(prevRow?.scopes ?? null);
|
||||
const upd = db
|
||||
.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`)
|
||||
.run(params);
|
||||
changedRows = upd.changes ?? 0;
|
||||
db.exec("COMMIT");
|
||||
} catch (err) {
|
||||
// Guard the ROLLBACK: if it throws (e.g. transaction already ended
|
||||
// due to an implicit commit, or backend in a bad state), the original
|
||||
// error from the try block is the actionable one — don't shadow it.
|
||||
try {
|
||||
db.exec("ROLLBACK");
|
||||
} catch {
|
||||
// swallow: original error is more important
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
const upd = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params);
|
||||
changedRows = upd.changes ?? 0;
|
||||
}
|
||||
|
||||
const result = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params);
|
||||
|
||||
if (result.changes === 0) return false;
|
||||
if (changedRows === 0) return false;
|
||||
|
||||
const { logAuditEvent } = await import("@/lib/compliance");
|
||||
|
||||
@@ -787,6 +836,38 @@ export async function updateApiKeyPermissions(
|
||||
});
|
||||
}
|
||||
|
||||
if (scopesUpdate !== undefined) {
|
||||
// Compare prev vs next scope sets and emit a dedicated audit event when
|
||||
// the privileged "manage" scope is granted or revoked. Other scope
|
||||
// mutations also emit a generic "apiKey.scopes.update" so the audit log
|
||||
// captures the full change history (action + details).
|
||||
const hadManage = previousScopes.includes("manage");
|
||||
const hasManage = nextScopes.includes("manage");
|
||||
if (!hadManage && hasManage) {
|
||||
logAuditEvent({
|
||||
action: "apiKey.scopes.grant",
|
||||
target: id,
|
||||
details: { scopes: nextScopes, previous: previousScopes },
|
||||
});
|
||||
} else if (hadManage && !hasManage) {
|
||||
logAuditEvent({
|
||||
action: "apiKey.scopes.revoke",
|
||||
target: id,
|
||||
details: { scopes: nextScopes, previous: previousScopes },
|
||||
});
|
||||
} else if (
|
||||
previousScopes.length !== nextScopes.length ||
|
||||
previousScopes.some((s) => !nextScopes.includes(s)) ||
|
||||
nextScopes.some((s) => !previousScopes.includes(s))
|
||||
) {
|
||||
logAuditEvent({
|
||||
action: "apiKey.scopes.update",
|
||||
target: id,
|
||||
details: { scopes: nextScopes, previous: previousScopes },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.noLog !== undefined) {
|
||||
setNoLog(id, normalized.noLog);
|
||||
}
|
||||
@@ -985,6 +1066,28 @@ export async function getApiKeyMetadata(
|
||||
|
||||
// persistent env-var key support (persistent passthrough keys) (#1350)
|
||||
if (isConfiguredEnvApiKey(key)) {
|
||||
// ─── Env-key management-scope bypass ──────────────────────────────────
|
||||
// The deployment-time env key (`OMNIROUTE_API_KEY` / `ROUTER_API_KEY`)
|
||||
// is granted the "manage" scope unconditionally. This is intentional:
|
||||
//
|
||||
// 1. The env key never exists in the SQLite `api_keys` table, so the
|
||||
// DB-backed scopes column does not apply. We synthesize the
|
||||
// metadata record here.
|
||||
// 2. The operator who set the env var is presumed to be the deployment
|
||||
// owner; rotating (or unsetting) the env var is the only way to
|
||||
// rotate this privilege. There is no UI to change it.
|
||||
// 3. Management API access via the env key still passes through
|
||||
// `requireManagementAuth` → `hasManageScope`, so policy decisions
|
||||
// remain centralised in `src/server/authz/*`.
|
||||
// 4. Requests authenticated by the env key are tagged with
|
||||
// `id: "env-key"` for downstream audit-log emitters, making it
|
||||
// possible to distinguish env-key activity from user-created keys
|
||||
// that happen to also hold "manage".
|
||||
//
|
||||
// DO NOT remove "manage" from this list — that would break the
|
||||
// deployment-time bootstrap path that operators rely on for headless
|
||||
// / CI / first-boot scenarios. If you need to disable env-key access,
|
||||
// unset the env var instead.
|
||||
return {
|
||||
id: "env-key",
|
||||
name: "Environment Key",
|
||||
|
||||
@@ -104,6 +104,14 @@ export async function getSettings() {
|
||||
wsAuth: false,
|
||||
maxBodySizeMb: requestBodyLimitMbFromEnv(process.env.MAX_BODY_SIZE_BYTES),
|
||||
debugMode: true,
|
||||
// LOCAL_ONLY manage-scope bypass policy defaults (T-011 / spec §Data Model).
|
||||
// Preserves PR #2473 behaviour on migration — the bypass starts ENABLED
|
||||
// for `/api/mcp/` so existing manage-scope Bearer clients keep working.
|
||||
// Operators flip the kill-switch to false (or drop the prefix) via the
|
||||
// Settings UI; the change hot-reloads through `applyRuntimeSettings` →
|
||||
// `applyAuthzBypassSection` → `getAuthzBypassSnapshot()`.
|
||||
localOnlyManageScopeBypassEnabled: true,
|
||||
localOnlyManageScopeBypassPrefixes: ["/api/mcp/"],
|
||||
};
|
||||
for (const row of rows) {
|
||||
const record = toRecord(row);
|
||||
|
||||
@@ -8,7 +8,12 @@ import { extractApiKey, isValidApiKey } from "../../../sse/services/auth";
|
||||
import { getApiKeyMetadata } from "../../../lib/db/apiKeys";
|
||||
import { hasManageScope } from "../../../lib/api/requireManagementAuth";
|
||||
import { CLI_TOKEN_HEADER } from "../headers";
|
||||
import { isAlwaysProtectedPath, isLocalOnlyPath, isLoopbackHost } from "../routeGuard";
|
||||
import {
|
||||
isAlwaysProtectedPath,
|
||||
isLocalOnlyBypassableByManageScope,
|
||||
isLocalOnlyPath,
|
||||
isLoopbackHost,
|
||||
} from "../routeGuard";
|
||||
|
||||
const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/;
|
||||
|
||||
@@ -41,10 +46,72 @@ export const managementPolicy: RoutePolicy = {
|
||||
const path = ctx.classification.normalizedPath;
|
||||
|
||||
// Tier 1: local-only gate — block spawn-capable routes from non-loopback.
|
||||
if (isLocalOnlyPath(path)) {
|
||||
if (!isLoopbackRequest(ctx.request.headers)) {
|
||||
return reject(403, "LOCAL_ONLY", "This endpoint requires localhost access");
|
||||
//
|
||||
// Carve-out: a small allow-list of LOCAL_ONLY paths (see
|
||||
// LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES) is reachable from non-loopback
|
||||
// when the caller presents EITHER (a) a valid API key with the `manage`
|
||||
// scope, or (b) an authenticated dashboard session. This lets:
|
||||
// - headless / remote MCP clients drive the management surface with a
|
||||
// manage-scope Bearer key, and
|
||||
// - the Dashboard UI itself (cookie session) render its MCP pages
|
||||
// (/api/mcp/status, /api/mcp/tools) from a public hostname.
|
||||
//
|
||||
// The strict-loopback default still applies to everything else (notably
|
||||
// the subprocess-spawning /api/cli-tools/runtime/* surface, which is NOT
|
||||
// in the bypass list).
|
||||
//
|
||||
// Anonymous (no Bearer / invalid key / wrong scope / no session) requests
|
||||
// still hit the same 403 LOCAL_ONLY they did before.
|
||||
if (isLocalOnlyPath(path) && !isLoopbackRequest(ctx.request.headers)) {
|
||||
if (isLocalOnlyBypassableByManageScope(path)) {
|
||||
const apiKey = extractApiKey(ctx.request as unknown as Request);
|
||||
if (apiKey) {
|
||||
try {
|
||||
if (await isValidApiKey(apiKey)) {
|
||||
const meta = await getApiKeyMetadata(apiKey);
|
||||
if (meta && hasManageScope(meta.scopes)) {
|
||||
// Distinguish admin vs manage in the audit label so log review
|
||||
// can tell which privilege actually granted the bypass.
|
||||
const grantedBy = meta.scopes.includes("admin") ? "admin" : "manage";
|
||||
return allow({
|
||||
kind: "management_key",
|
||||
id: meta.id,
|
||||
label: `api-key-${grantedBy}-scope-local-only-bypass`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Auth backend (DB / file store) failure: surface as 503 so the
|
||||
// caller can retry. Anything else (TypeError / ReferenceError /
|
||||
// programmer error) is logged so it's not silently swallowed —
|
||||
// the policy still degrades closed (503) to avoid leaking the
|
||||
// route, but we leave a breadcrumb for ops.
|
||||
console.error("[managementPolicy] manage-scope bypass auth check failed", err);
|
||||
return reject(503, "AUTH_BACKEND_UNAVAILABLE", "Service temporarily unavailable");
|
||||
}
|
||||
}
|
||||
// Dashboard session bypass: the Dashboard UI itself needs to render
|
||||
// /api/mcp/status, /api/mcp/tools, etc. from a public hostname. Cookie
|
||||
// auth is already proof of an authenticated admin — same trust level
|
||||
// as a manage-scope Bearer for the surface in scope here.
|
||||
try {
|
||||
if (await isDashboardSessionAuthenticated(ctx.request)) {
|
||||
return allow({
|
||||
kind: "dashboard_session",
|
||||
id: "dashboard",
|
||||
label: "dashboard-session-local-only-bypass",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Mirror the manage-scope branch above: degrade closed (503) rather
|
||||
// than leaking the route through an unhandled 500, but log a
|
||||
// breadcrumb for ops. Session-store DB failure / cookie parsing
|
||||
// error / JWT decode throw all land here.
|
||||
console.error("[managementPolicy] dashboard-session bypass auth check failed", err);
|
||||
return reject(503, "AUTH_BACKEND_UNAVAILABLE", "Service temporarily unavailable");
|
||||
}
|
||||
}
|
||||
return reject(403, "LOCAL_ONLY", "This endpoint requires localhost access");
|
||||
}
|
||||
|
||||
if (isInternalModelSyncRequest(ctx)) {
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
* child processes; exposing them to non-local traffic is a known CVE class
|
||||
* (GHSA-fhh6-4qxv-rpqj). Blocked unconditionally regardless of auth state.
|
||||
*
|
||||
* Carve-out: paths matching the live manage-scope bypass list (DB-stored,
|
||||
* read via `getAuthzBypassSnapshot()`) MAY also be accessed from
|
||||
* non-loopback if and only if the request carries an API key with the
|
||||
* `manage` scope (or an authenticated dashboard session — see
|
||||
* `policies/management.ts`). The bypass is opt-in per prefix and can be
|
||||
* killed globally via the `localOnlyManageScopeBypassEnabled` setting.
|
||||
* Unauthenticated requests to bypassable paths are still rejected with
|
||||
* 403 LOCAL_ONLY.
|
||||
*
|
||||
* Tier 2 — ALWAYS_PROTECTED: auth is always required, even when
|
||||
* requireLogin=false. Covers destructive / irreversible operations.
|
||||
*
|
||||
@@ -12,6 +21,8 @@
|
||||
* requireLogin=false (existing behaviour).
|
||||
*/
|
||||
|
||||
import { getAuthzBypassSnapshot } from "@/lib/config/runtimeSettings";
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
@@ -19,6 +30,34 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
"/api/cli-tools/runtime/",
|
||||
];
|
||||
|
||||
/**
|
||||
* Compile-time deny-list: route prefixes that can spawn arbitrary local
|
||||
* subprocesses on behalf of the caller. These MUST NEVER appear in the
|
||||
* manage-scope bypass list — regardless of DB state — because reaching them
|
||||
* from non-loopback would re-introduce the GHSA-fhh6-4qxv-rpqj surface that
|
||||
* the LOCAL_ONLY tier exists to close.
|
||||
*
|
||||
* Enforced at two layers:
|
||||
* 1. zod schema (`settingsSchemas.ts`): rejects `PATCH /api/settings` with
|
||||
* error code `BYPASS_PREFIX_NOT_ALLOWED` if any entry in
|
||||
* `localOnlyManageScopeBypassPrefixes` falls inside this set.
|
||||
* 2. runtime (`isLocalOnlyBypassableByManageScope` below): even if a
|
||||
* malformed DB row somehow claims a spawn-capable path is bypassable,
|
||||
* the policy still refuses to honour it.
|
||||
*/
|
||||
export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = ["/api/cli-tools/runtime/"];
|
||||
|
||||
/**
|
||||
* Compile-time default of the manage-scope bypass list. Kept as an exported
|
||||
* constant so the Settings inventory page (and audit code) can render the
|
||||
* "available bypassable prefixes" choices independent of current DB state.
|
||||
*
|
||||
* The RUNTIME decision in `isLocalOnlyBypassableByManageScope` does NOT
|
||||
* consult this constant — it reads `getAuthzBypassSnapshot().prefixes`,
|
||||
* which is hot-reloaded on every settings PATCH.
|
||||
*/
|
||||
export const LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES: ReadonlyArray<string> = ["/api/mcp/"];
|
||||
|
||||
export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray<string> = [
|
||||
"/api/shutdown",
|
||||
"/api/settings/database",
|
||||
@@ -42,6 +81,38 @@ export function isLocalOnlyPath(path: string): boolean {
|
||||
return LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime predicate consulted by the management policy on every non-loopback
|
||||
* request to a LOCAL_ONLY path. Reads the live snapshot:
|
||||
* - returns false if the global kill-switch is off
|
||||
* (`localOnlyManageScopeBypassEnabled === false`),
|
||||
* - returns true iff `path` matches one of the live bypass prefixes AND
|
||||
* that prefix is not in `SPAWN_CAPABLE_PREFIXES` (defence-in-depth: the
|
||||
* zod schema already rejects spawn-capable entries, but a malformed DB
|
||||
* row should not be able to grant a bypass).
|
||||
*
|
||||
* O(1) (no I/O, no async). Hot-reload SLA: <50 ms — satisfied structurally.
|
||||
*/
|
||||
export function isLocalOnlyBypassableByManageScope(path: string): boolean {
|
||||
const snapshot = getAuthzBypassSnapshot();
|
||||
if (!snapshot.enabled) return false;
|
||||
return snapshot.prefixes.some((p) => {
|
||||
// Defence-in-depth: reject a bypass prefix that is the same as, child of,
|
||||
// OR PARENT of any spawn-capable prefix. The parent case catches e.g.
|
||||
// `/api/cli-tools/` (parent of `/api/cli-tools/runtime/`) — a request to
|
||||
// `/api/cli-tools/runtime/foo` would otherwise satisfy `path.startsWith(p)`
|
||||
// and reach the spawn-capable surface without a loopback check.
|
||||
if (
|
||||
SPAWN_CAPABLE_PREFIXES.some(
|
||||
(spawn) => p === spawn || p.startsWith(spawn) || spawn.startsWith(p)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return path === p || path.startsWith(p);
|
||||
});
|
||||
}
|
||||
|
||||
export function isAlwaysProtectedPath(path: string): boolean {
|
||||
return ALWAYS_PROTECTED_API_PATHS.some((p) => path === p || path.startsWith(p));
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
||||
"settings-advanced",
|
||||
"settings-security",
|
||||
"settings-feature-flags",
|
||||
"settings-authz",
|
||||
// Help
|
||||
"docs",
|
||||
"issues",
|
||||
@@ -659,6 +660,13 @@ const CONFIGURATION_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
subtitleKey: "settingsFeatureFlagsSubtitle",
|
||||
icon: "flag",
|
||||
},
|
||||
{
|
||||
id: "settings-authz",
|
||||
href: "/dashboard/settings/authz",
|
||||
i18nKey: "settingsAuthz",
|
||||
subtitleKey: "settingsAuthzSubtitle",
|
||||
icon: "shield_lock",
|
||||
},
|
||||
];
|
||||
|
||||
const HELP_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
|
||||
@@ -10,251 +10,282 @@ import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
|
||||
import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize";
|
||||
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
|
||||
import { ACCOUNT_FALLBACK_STRATEGY_VALUES } from "@/shared/constants/routingStrategies";
|
||||
import { SPAWN_CAPABLE_PREFIXES } from "@/server/authz/routeGuard";
|
||||
|
||||
const signatureCacheModeValues = ["enabled", "bypass", "bypass-strict"] as const;
|
||||
|
||||
export const updateSettingsSchema = z.object({
|
||||
newPassword: z.string().min(1).max(200).optional(),
|
||||
currentPassword: z.string().max(200).optional(),
|
||||
theme: z.string().max(50).optional(),
|
||||
language: z.string().max(10).optional(),
|
||||
requireLogin: z.boolean().optional(),
|
||||
enableSocks5Proxy: z.boolean().optional(),
|
||||
instanceName: z.string().max(100).optional(),
|
||||
customLogoUrl: z.string().max(2000).optional(),
|
||||
customLogoBase64: z.string().max(100000).optional(),
|
||||
customFaviconUrl: z.string().max(2000).optional(),
|
||||
customFaviconBase64: z.string().max(50000).optional(),
|
||||
corsOrigins: z.string().max(500).optional(),
|
||||
cloudUrl: z.string().max(500).optional(),
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
setupComplete: z.boolean().optional(),
|
||||
blockedProviders: z.array(z.string().max(100)).optional(),
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
hideEndpointCloudflaredTunnel: z.boolean().optional(),
|
||||
hideEndpointTailscaleFunnel: z.boolean().optional(),
|
||||
hideEndpointNgrokTunnel: z.boolean().optional(),
|
||||
debugMode: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
codexServiceTier: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
tier: z.enum(["default", "priority", "flex"]).optional(),
|
||||
supportedModels: z.array(z.string().max(200)).max(200).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Claude Fast Mode: opt-in toggle that asks a paired CLIProxyAPI build
|
||||
// (claude-fastmode-spoof) to rewrite SDK-shaped entrypoints so requests can
|
||||
// reach Anthropic Fast Mode (speed:"fast"). Default off; only the listed
|
||||
// Opus models are gated by the Anthropic binary KT() check. Schema is
|
||||
// intentionally permissive on supportedModels so additional eligible model
|
||||
// ids can be enabled without a schema bump.
|
||||
claudeFastMode: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
supportedModels: z.array(z.string().max(200)).max(200).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
|
||||
requestRetry: z.number().int().min(0).max(10).optional(),
|
||||
maxRetryIntervalSec: z.number().int().min(0).max(300).optional(),
|
||||
maxBodySizeMb: z
|
||||
.number()
|
||||
.int()
|
||||
.min(MIN_REQUEST_BODY_LIMIT_MB)
|
||||
.max(MAX_REQUEST_BODY_LIMIT_MB)
|
||||
.optional(),
|
||||
// Auto intent classifier settings (multilingual routing)
|
||||
intentDetectionEnabled: z.boolean().optional(),
|
||||
intentSimpleMaxWords: z.number().int().min(1).max(500).optional(),
|
||||
intentExtraCodeKeywords: z.array(z.string().max(100)).optional(),
|
||||
intentExtraReasoningKeywords: z.array(z.string().max(100)).optional(),
|
||||
intentExtraSimpleKeywords: z.array(z.string().max(100)).optional(),
|
||||
// Protocol toggles (default: disabled)
|
||||
mcpEnabled: z.boolean().optional(),
|
||||
mcpTransport: z.enum(["stdio", "sse", "streamable-http"]).optional(),
|
||||
a2aEnabled: z.boolean().optional(),
|
||||
wsAuth: z.boolean().optional(),
|
||||
// CLI Fingerprint compatibility (per-provider)
|
||||
cliCompatProviders: z.array(z.string().max(100)).optional(),
|
||||
// CC bridge transforms (issue #2260): config-driven pipeline that normalizes
|
||||
// system blocks at the Claude Code bridge so any client (OpenCode, Cline,
|
||||
// Cursor, Continue, raw API) ends up with classifier-correct structure.
|
||||
ccBridgeTransforms: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
pipeline: z
|
||||
.array(
|
||||
z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_starts_with"),
|
||||
prefixes: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_text"),
|
||||
match: z.string().min(1).max(500),
|
||||
replacement: z.string().max(500),
|
||||
allOccurrences: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_regex"),
|
||||
pattern: z.string().min(1).max(500),
|
||||
flags: z.string().max(10).optional(),
|
||||
replacement: z.string().max(500),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_block_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("prepend_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("append_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("inject_billing_header"),
|
||||
entrypoint: z.string().min(1).max(50),
|
||||
versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]),
|
||||
cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]),
|
||||
version: z.string().max(50).optional(),
|
||||
}),
|
||||
])
|
||||
)
|
||||
.max(50),
|
||||
})
|
||||
.optional(),
|
||||
// System Transforms (issue #2260 v2): generic per-provider DSL covering
|
||||
// native `claude`, `anthropic-compatible-cc-*` bridge, and any other
|
||||
// provider key. Adds `obfuscate_words` op kind on top of the base set.
|
||||
systemTransforms: z
|
||||
.object({
|
||||
providers: z.record(
|
||||
z.string().max(100),
|
||||
z.object({
|
||||
enabled: z.boolean(),
|
||||
pipeline: z
|
||||
.array(
|
||||
z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_starts_with"),
|
||||
prefixes: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_text"),
|
||||
match: z.string().min(1).max(500),
|
||||
replacement: z.string().max(500),
|
||||
allOccurrences: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_regex"),
|
||||
pattern: z.string().min(1).max(500),
|
||||
flags: z.string().max(10).optional(),
|
||||
replacement: z.string().max(500),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_block_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("prepend_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("append_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("inject_billing_header"),
|
||||
entrypoint: z.string().min(1).max(50),
|
||||
versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]),
|
||||
cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]),
|
||||
version: z.string().max(50).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("obfuscate_words"),
|
||||
words: z.array(z.string().max(100)).max(200),
|
||||
targets: z
|
||||
.array(z.enum(["system", "messages", "tools"]))
|
||||
.max(3)
|
||||
.optional(),
|
||||
}),
|
||||
])
|
||||
)
|
||||
.max(50),
|
||||
})
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
// Strip provider/model prefix at proxy layer (e.g. "openai/gpt-4" → "gpt-4")
|
||||
stripModelPrefix: z.boolean().optional(),
|
||||
// Cache control preservation mode
|
||||
alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(),
|
||||
antigravitySignatureCacheMode: z.enum(signatureCacheModeValues).optional(),
|
||||
// Adaptive Volume Routing
|
||||
adaptiveVolumeRouting: z.boolean().optional(),
|
||||
// Usage token buffer — safety margin added to reported prompt/input token counts.
|
||||
// Prevents CLI tools from overrunning context windows. Set to 0 to disable.
|
||||
usageTokenBuffer: z.number().int().min(0).max(50000).optional(),
|
||||
// Custom CLI agent definitions for ACP
|
||||
customAgents: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().max(50),
|
||||
name: z.string().max(100),
|
||||
binary: z.string().max(200),
|
||||
versionCommand: z.string().max(300),
|
||||
providerAlias: z.string().max(50),
|
||||
spawnArgs: z.array(z.string().max(200)),
|
||||
protocol: z.enum(["stdio", "http"]),
|
||||
export const updateSettingsSchema = z
|
||||
.object({
|
||||
newPassword: z.string().min(1).max(200).optional(),
|
||||
currentPassword: z.string().max(200).optional(),
|
||||
theme: z.string().max(50).optional(),
|
||||
language: z.string().max(10).optional(),
|
||||
requireLogin: z.boolean().optional(),
|
||||
enableSocks5Proxy: z.boolean().optional(),
|
||||
instanceName: z.string().max(100).optional(),
|
||||
customLogoUrl: z.string().max(2000).optional(),
|
||||
customLogoBase64: z.string().max(100000).optional(),
|
||||
customFaviconUrl: z.string().max(2000).optional(),
|
||||
customFaviconBase64: z.string().max(50000).optional(),
|
||||
corsOrigins: z.string().max(500).optional(),
|
||||
cloudUrl: z.string().max(500).optional(),
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
setupComplete: z.boolean().optional(),
|
||||
blockedProviders: z.array(z.string().max(100)).optional(),
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
hideEndpointCloudflaredTunnel: z.boolean().optional(),
|
||||
hideEndpointTailscaleFunnel: z.boolean().optional(),
|
||||
hideEndpointNgrokTunnel: z.boolean().optional(),
|
||||
debugMode: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
codexServiceTier: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
tier: z.enum(["default", "priority", "flex"]).optional(),
|
||||
supportedModels: z.array(z.string().max(200)).max(200).optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
// SkillsMP marketplace API key
|
||||
skillsmpApiKey: z.string().max(200).optional(),
|
||||
// Active skills provider (single source of truth for skills page)
|
||||
skillsProvider: z.enum(["skillsmp", "skillssh"]).optional(),
|
||||
// models.dev sync settings
|
||||
modelsDevSyncEnabled: z.boolean().optional(),
|
||||
modelsDevSyncInterval: z.number().int().min(3600000).max(604800000).optional(),
|
||||
// Vision Bridge settings
|
||||
visionBridgeEnabled: z.boolean().optional(),
|
||||
visionBridgeModel: z.string().max(200).optional(),
|
||||
visionBridgePrompt: z.string().max(5000).optional(),
|
||||
visionBridgeTimeout: z.number().int().min(1000).max(300000).optional(),
|
||||
visionBridgeMaxImages: z.number().int().min(1).max(20).optional(),
|
||||
// Missing settings
|
||||
lkgpEnabled: z.boolean().optional(),
|
||||
backgroundDegradation: z.unknown().optional(),
|
||||
bruteForceProtection: z.boolean().optional(),
|
||||
// Auto-routing settings
|
||||
autoRoutingEnabled: z.boolean().optional(),
|
||||
autoRoutingDefaultVariant: z
|
||||
.enum(["lkgp", "coding", "fast", "cheap", "offline", "smart"])
|
||||
.optional(),
|
||||
});
|
||||
.optional(),
|
||||
// Claude Fast Mode: opt-in toggle that asks a paired CLIProxyAPI build
|
||||
// (claude-fastmode-spoof) to rewrite SDK-shaped entrypoints so requests can
|
||||
// reach Anthropic Fast Mode (speed:"fast"). Default off; only the listed
|
||||
// Opus models are gated by the Anthropic binary KT() check. Schema is
|
||||
// intentionally permissive on supportedModels so additional eligible model
|
||||
// ids can be enabled without a schema bump.
|
||||
claudeFastMode: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
supportedModels: z.array(z.string().max(200)).max(200).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
|
||||
requestRetry: z.number().int().min(0).max(10).optional(),
|
||||
maxRetryIntervalSec: z.number().int().min(0).max(300).optional(),
|
||||
maxBodySizeMb: z
|
||||
.number()
|
||||
.int()
|
||||
.min(MIN_REQUEST_BODY_LIMIT_MB)
|
||||
.max(MAX_REQUEST_BODY_LIMIT_MB)
|
||||
.optional(),
|
||||
// Auto intent classifier settings (multilingual routing)
|
||||
intentDetectionEnabled: z.boolean().optional(),
|
||||
intentSimpleMaxWords: z.number().int().min(1).max(500).optional(),
|
||||
intentExtraCodeKeywords: z.array(z.string().max(100)).optional(),
|
||||
intentExtraReasoningKeywords: z.array(z.string().max(100)).optional(),
|
||||
intentExtraSimpleKeywords: z.array(z.string().max(100)).optional(),
|
||||
// Protocol toggles (default: disabled)
|
||||
mcpEnabled: z.boolean().optional(),
|
||||
mcpTransport: z.enum(["stdio", "sse", "streamable-http"]).optional(),
|
||||
a2aEnabled: z.boolean().optional(),
|
||||
wsAuth: z.boolean().optional(),
|
||||
// CLI Fingerprint compatibility (per-provider)
|
||||
cliCompatProviders: z.array(z.string().max(100)).optional(),
|
||||
// CC bridge transforms (issue #2260): config-driven pipeline that normalizes
|
||||
// system blocks at the Claude Code bridge so any client (OpenCode, Cline,
|
||||
// Cursor, Continue, raw API) ends up with classifier-correct structure.
|
||||
ccBridgeTransforms: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
pipeline: z
|
||||
.array(
|
||||
z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_starts_with"),
|
||||
prefixes: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_text"),
|
||||
match: z.string().min(1).max(500),
|
||||
replacement: z.string().max(500),
|
||||
allOccurrences: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_regex"),
|
||||
pattern: z.string().min(1).max(500),
|
||||
flags: z.string().max(10).optional(),
|
||||
replacement: z.string().max(500),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_block_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("prepend_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("append_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("inject_billing_header"),
|
||||
entrypoint: z.string().min(1).max(50),
|
||||
versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]),
|
||||
cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]),
|
||||
version: z.string().max(50).optional(),
|
||||
}),
|
||||
])
|
||||
)
|
||||
.max(50),
|
||||
})
|
||||
.optional(),
|
||||
// System Transforms (issue #2260 v2): generic per-provider DSL covering
|
||||
// native `claude`, `anthropic-compatible-cc-*` bridge, and any other
|
||||
// provider key. Adds `obfuscate_words` op kind on top of the base set.
|
||||
systemTransforms: z
|
||||
.object({
|
||||
providers: z.record(
|
||||
z.string().max(100),
|
||||
z.object({
|
||||
enabled: z.boolean(),
|
||||
pipeline: z
|
||||
.array(
|
||||
z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_starts_with"),
|
||||
prefixes: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_text"),
|
||||
match: z.string().min(1).max(500),
|
||||
replacement: z.string().max(500),
|
||||
allOccurrences: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_regex"),
|
||||
pattern: z.string().min(1).max(500),
|
||||
flags: z.string().max(10).optional(),
|
||||
replacement: z.string().max(500),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_block_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("prepend_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("append_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("inject_billing_header"),
|
||||
entrypoint: z.string().min(1).max(50),
|
||||
versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]),
|
||||
cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]),
|
||||
version: z.string().max(50).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("obfuscate_words"),
|
||||
words: z.array(z.string().max(100)).max(200),
|
||||
targets: z
|
||||
.array(z.enum(["system", "messages", "tools"]))
|
||||
.max(3)
|
||||
.optional(),
|
||||
}),
|
||||
])
|
||||
)
|
||||
.max(50),
|
||||
})
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
// Strip provider/model prefix at proxy layer (e.g. "openai/gpt-4" → "gpt-4")
|
||||
stripModelPrefix: z.boolean().optional(),
|
||||
// Cache control preservation mode
|
||||
alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(),
|
||||
antigravitySignatureCacheMode: z.enum(signatureCacheModeValues).optional(),
|
||||
// Adaptive Volume Routing
|
||||
adaptiveVolumeRouting: z.boolean().optional(),
|
||||
// Usage token buffer — safety margin added to reported prompt/input token counts.
|
||||
// Prevents CLI tools from overrunning context windows. Set to 0 to disable.
|
||||
usageTokenBuffer: z.number().int().min(0).max(50000).optional(),
|
||||
// Custom CLI agent definitions for ACP
|
||||
customAgents: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().max(50),
|
||||
name: z.string().max(100),
|
||||
binary: z.string().max(200),
|
||||
versionCommand: z.string().max(300),
|
||||
providerAlias: z.string().max(50),
|
||||
spawnArgs: z.array(z.string().max(200)),
|
||||
protocol: z.enum(["stdio", "http"]),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
// SkillsMP marketplace API key
|
||||
skillsmpApiKey: z.string().max(200).optional(),
|
||||
// Active skills provider (single source of truth for skills page)
|
||||
skillsProvider: z.enum(["skillsmp", "skillssh"]).optional(),
|
||||
// models.dev sync settings
|
||||
modelsDevSyncEnabled: z.boolean().optional(),
|
||||
modelsDevSyncInterval: z.number().int().min(3600000).max(604800000).optional(),
|
||||
// Vision Bridge settings
|
||||
visionBridgeEnabled: z.boolean().optional(),
|
||||
visionBridgeModel: z.string().max(200).optional(),
|
||||
visionBridgePrompt: z.string().max(5000).optional(),
|
||||
visionBridgeTimeout: z.number().int().min(1000).max(300000).optional(),
|
||||
visionBridgeMaxImages: z.number().int().min(1).max(20).optional(),
|
||||
// Missing settings
|
||||
lkgpEnabled: z.boolean().optional(),
|
||||
backgroundDegradation: z.unknown().optional(),
|
||||
bruteForceProtection: z.boolean().optional(),
|
||||
// Auto-routing settings
|
||||
autoRoutingEnabled: z.boolean().optional(),
|
||||
autoRoutingDefaultVariant: z
|
||||
.enum(["lkgp", "coding", "fast", "cheap", "offline", "smart"])
|
||||
.optional(),
|
||||
// LOCAL_ONLY manage-scope bypass policy (T-011). Kill-switch + per-prefix
|
||||
// list, both DB-stored and hot-reloaded into `getAuthzBypassSnapshot()` on
|
||||
// each PATCH. The prefix list MUST NOT include any spawn-capable path —
|
||||
// enforced here and at runtime (GHSA-fhh6-4qxv-rpqj rationale).
|
||||
localOnlyManageScopeBypassEnabled: z.boolean().optional(),
|
||||
localOnlyManageScopeBypassPrefixes: z.array(z.string().startsWith("/")).max(20).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const prefixes = data.localOnlyManageScopeBypassPrefixes;
|
||||
if (!prefixes) return;
|
||||
for (let i = 0; i < prefixes.length; i++) {
|
||||
const prefix = prefixes[i];
|
||||
// Reject prefixes that are the same as, child of, or PARENT of any
|
||||
// spawn-capable prefix. The parent case catches e.g. `/api/cli-tools/`
|
||||
// — a bypass on the parent would grant non-loopback access to the
|
||||
// spawn-capable `/api/cli-tools/runtime/*` surface via path.startsWith.
|
||||
if (
|
||||
SPAWN_CAPABLE_PREFIXES.some(
|
||||
(spawn) => prefix === spawn || prefix.startsWith(spawn) || spawn.startsWith(prefix)
|
||||
)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["localOnlyManageScopeBypassPrefixes", i],
|
||||
message: `BYPASS_PREFIX_NOT_ALLOWED: ${prefix} is spawn-capable and cannot be bypassed`,
|
||||
params: { code: "BYPASS_PREFIX_NOT_ALLOWED", prefix },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const databaseSettingsSchema = z.object(
|
||||
{
|
||||
|
||||
@@ -26,6 +26,12 @@ export interface Settings {
|
||||
hideEndpointNgrokTunnel?: boolean;
|
||||
hiddenSidebarItems?: HideableSidebarItemId[];
|
||||
resilienceSettings?: ResilienceSettings;
|
||||
// LOCAL_ONLY manage-scope bypass policy (DB-stored, hot-reloaded by
|
||||
// `applyRuntimeSettings` → `applyAuthzBypassSection`). The route guard
|
||||
// consults `getAuthzBypassSnapshot()` on the hot path; these fields are
|
||||
// the persisted source of truth that feeds that snapshot.
|
||||
localOnlyManageScopeBypassEnabled?: boolean;
|
||||
localOnlyManageScopeBypassPrefixes?: string[];
|
||||
}
|
||||
|
||||
export interface ComboDefaults {
|
||||
|
||||
80
tests/unit/_mocks/settings.ts
Normal file
80
tests/unit/_mocks/settings.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Shared settings test fixture.
|
||||
*
|
||||
* Backs onto a real isolated SQLite DB + the production
|
||||
* `updateSettings → applyRuntimeSettings` pipeline so callers exercise the
|
||||
* actual hot-reload path. Tests that need to mock `getAuthzBypassSnapshot`
|
||||
* directly defeat the integration value of AC-7 — use this helper instead.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import { setupSettingsFixture, mockSettings, resetSettingsMock } from "../_mocks/settings";
|
||||
* const fixture = setupSettingsFixture("authz-bypass");
|
||||
* test.beforeEach(() => fixture.resetStorage());
|
||||
* await mockSettings({ localOnlyManageScopeBypassEnabled: false });
|
||||
* ```
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Settings } from "../../../src/types/settings";
|
||||
|
||||
let activeFixture: SettingsFixture | null = null;
|
||||
|
||||
export interface SettingsFixture {
|
||||
testDataDir: string;
|
||||
resetStorage(): Promise<void>;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate an isolated DATA_DIR + reset DB state per test. Must run BEFORE
|
||||
* any DB modules are imported by the test file.
|
||||
*/
|
||||
export function setupSettingsFixture(slug: string): SettingsFixture {
|
||||
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), `omr-settings-mock-${slug}-`));
|
||||
process.env.DATA_DIR = testDataDir;
|
||||
if (!process.env.API_KEY_SECRET) {
|
||||
process.env.API_KEY_SECRET = `test-settings-mock-secret-${Date.now()}`;
|
||||
}
|
||||
|
||||
const fixture: SettingsFixture = {
|
||||
testDataDir,
|
||||
async resetStorage() {
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const runtime = await import("../../../src/lib/config/runtimeSettings.ts");
|
||||
core.resetDbInstance();
|
||||
runtime.resetRuntimeSettingsStateForTests();
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(testDataDir, { recursive: true });
|
||||
},
|
||||
cleanup() {
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
activeFixture = fixture;
|
||||
return fixture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a partial Settings patch through the production
|
||||
* `updateSettings → applyRuntimeSettings` pipeline. Hot-reload side effects
|
||||
* (route guard snapshot, etc.) fire exactly as they do in `PATCH /api/settings`.
|
||||
*/
|
||||
export async function mockSettings(partial: Partial<Settings>): Promise<Record<string, unknown>> {
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
return settingsDb.updateSettings(partial as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the in-process runtime snapshot to the cold-boot default. Called by
|
||||
* test `beforeEach` hooks that need a clean slate without nuking the whole
|
||||
* fixture directory.
|
||||
*/
|
||||
export async function resetSettingsMock(): Promise<void> {
|
||||
const runtime = await import("../../../src/lib/config/runtimeSettings.ts");
|
||||
runtime.resetRuntimeSettingsStateForTests();
|
||||
if (activeFixture) {
|
||||
await activeFixture.resetStorage();
|
||||
}
|
||||
}
|
||||
210
tests/unit/api/authz-inventory.test.ts
Normal file
210
tests/unit/api/authz-inventory.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* T-013 — GET /api/settings/authz-inventory.
|
||||
*
|
||||
* Covers spec AC-1, AC-2, AC-12 (and AC-13 PATCH coverage continues to live
|
||||
* in `tests/unit/settings/authz-bypass.test.ts`; this file only asserts the
|
||||
* inventory endpoint itself).
|
||||
*
|
||||
* - AC-1 response shape: 5 tiers, each with prefixes; bypassEnabled / bypassPrefixes / spawnCapablePrefixes present.
|
||||
* - AC-2 bypass-state flags match getSettings() and update after PATCH.
|
||||
* - AC-12 anonymous request → 401/403 (no inventory leak).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupSettingsFixture } from "../_mocks/settings.ts";
|
||||
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
||||
|
||||
const fixture = setupSettingsFixture("authz-inventory");
|
||||
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
const runtime = await import("../../../src/lib/config/runtimeSettings.ts");
|
||||
const inventoryRoute = await import("../../../src/app/api/settings/authz-inventory/route.ts");
|
||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await fixture.resetStorage();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
runtime.resetRuntimeSettingsStateForTests();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fixture.cleanup();
|
||||
if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
|
||||
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
});
|
||||
|
||||
// ─── AC-1 — shape ─────────────────────────────────────────────────────────
|
||||
|
||||
test("AC-1: GET returns 5 tiers with prefixes + bypass state envelope", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac1";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const request = await makeManagementSessionRequest(
|
||||
"http://localhost/api/settings/authz-inventory",
|
||||
{ method: "GET" }
|
||||
);
|
||||
const response = await inventoryRoute.GET(request);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
tiers: Array<{ name: string; prefixes: string[]; description: string; bypassable: boolean }>;
|
||||
bypassEnabled: boolean;
|
||||
bypassPrefixes: string[];
|
||||
spawnCapablePrefixes: string[];
|
||||
};
|
||||
|
||||
assert.equal(body.tiers.length, 5);
|
||||
const names = body.tiers.map((t) => t.name).sort();
|
||||
assert.deepEqual(names, ["ALWAYS_PROTECTED", "CLIENT_API", "LOCAL_ONLY", "MANAGEMENT", "PUBLIC"]);
|
||||
|
||||
const localOnly = body.tiers.find((t) => t.name === "LOCAL_ONLY");
|
||||
assert.ok(localOnly);
|
||||
assert.ok(localOnly!.prefixes.includes("/api/mcp/"));
|
||||
assert.ok(localOnly!.prefixes.includes("/api/cli-tools/runtime/"));
|
||||
assert.equal(localOnly!.bypassable, true);
|
||||
|
||||
const alwaysProtected = body.tiers.find((t) => t.name === "ALWAYS_PROTECTED");
|
||||
assert.ok(alwaysProtected);
|
||||
assert.ok(alwaysProtected!.prefixes.includes("/api/shutdown"));
|
||||
assert.equal(alwaysProtected!.bypassable, false);
|
||||
|
||||
// Every tier carries a non-empty description.
|
||||
for (const tier of body.tiers) {
|
||||
assert.ok(tier.description.length > 0, `tier ${tier.name} missing description`);
|
||||
}
|
||||
|
||||
assert.ok(body.spawnCapablePrefixes.includes("/api/cli-tools/runtime/"));
|
||||
});
|
||||
|
||||
// ─── AC-2 — flags match getSettings() pre- and post-mutation ──────────────
|
||||
|
||||
test("AC-2: bypassEnabled + bypassPrefixes reflect getSettings() (defaults)", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac2a";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const response = await inventoryRoute.GET(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as {
|
||||
bypassEnabled: boolean;
|
||||
bypassPrefixes: string[];
|
||||
};
|
||||
// Default snapshot: kill-switch ON, single prefix /api/mcp/.
|
||||
assert.equal(body.bypassEnabled, true);
|
||||
assert.deepEqual(body.bypassPrefixes, ["/api/mcp/"]);
|
||||
});
|
||||
|
||||
test("AC-2: bypassEnabled flips after settings mutation", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac2b";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
// Mutate directly through the settings DB (bypasses the password gate —
|
||||
// we are not testing the gate here, only the inventory's reflection of
|
||||
// the persisted state).
|
||||
await settingsDb.updateSettings({ localOnlyManageScopeBypassEnabled: false });
|
||||
|
||||
const response = await inventoryRoute.GET(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { bypassEnabled: boolean };
|
||||
assert.equal(body.bypassEnabled, false);
|
||||
});
|
||||
|
||||
test("AC-2: bypassPrefixes additions land in the inventory", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac2c";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
await settingsDb.updateSettings({
|
||||
localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/mcp/v2/"],
|
||||
});
|
||||
|
||||
const response = await inventoryRoute.GET(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { bypassPrefixes: string[] };
|
||||
assert.deepEqual(body.bypassPrefixes, ["/api/mcp/", "/api/mcp/v2/"]);
|
||||
});
|
||||
|
||||
// ─── AC-12 — anonymous request rejected (no inventory leak) ───────────────
|
||||
|
||||
test("AC-12: anonymous request (no cookie, no Bearer) → 401", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac12";
|
||||
// Bootstrap a password so isAuthRequired() returns true even on loopback.
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const { ensurePersistentManagementPasswordHash } =
|
||||
await import("../../../src/lib/auth/managementPassword.ts");
|
||||
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
||||
|
||||
const anonRequest = new Request("https://dashboard.example/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
});
|
||||
const response = await inventoryRoute.GET(anonRequest);
|
||||
assert.ok(
|
||||
response.status === 401 || response.status === 403,
|
||||
`expected 401/403, got ${response.status}`
|
||||
);
|
||||
const body = (await response.json()) as { error?: { message?: string } };
|
||||
// Should NOT leak the inventory shape.
|
||||
assert.ok(!("tiers" in body));
|
||||
});
|
||||
|
||||
test("AC-12: anonymous request with bogus Bearer → 403", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac12b";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const { ensurePersistentManagementPasswordHash } =
|
||||
await import("../../../src/lib/auth/managementPassword.ts");
|
||||
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
||||
|
||||
const bogus = new Request("https://dashboard.example/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
headers: new Headers({ authorization: "Bearer not-a-real-key" }),
|
||||
});
|
||||
const response = await inventoryRoute.GET(bogus);
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
// ─── OQ-5 — any valid API key (no manage scope required) → 200 ────────────
|
||||
|
||||
test("OQ-5: any valid API key (read-only scope) → 200 inventory", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-inventory";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-oq5";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const { ensurePersistentManagementPasswordHash } =
|
||||
await import("../../../src/lib/auth/managementPassword.ts");
|
||||
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
||||
|
||||
// Key with NO manage scope — would be rejected by /api/settings PATCH,
|
||||
// but the inventory read endpoint is intentionally one rung lower (OQ-5).
|
||||
const created = await apiKeysDb.createApiKey("oq5-read", "machine-oq5", []);
|
||||
const request = new Request("https://dashboard.example/api/settings/authz-inventory", {
|
||||
method: "GET",
|
||||
headers: new Headers({ authorization: `Bearer ${created.key}` }),
|
||||
});
|
||||
const response = await inventoryRoute.GET(request);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { tiers: unknown[] };
|
||||
assert.equal(body.tiers.length, 5);
|
||||
});
|
||||
307
tests/unit/api/settings-audit.test.ts
Normal file
307
tests/unit/api/settings-audit.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* T-012 — Settings PATCH audit log.
|
||||
*
|
||||
* Covers spec AC-9 / AC-10 / AC-11 (plus idempotent no-op case):
|
||||
* - AC-9 success diff row carries `action=settings.update`, target,
|
||||
* actor, ip, and per-key {before, after} diff for every changed key.
|
||||
* - AC-10 each rejection path (PASSWORD_REQUIRED, PASSWORD_MISMATCH,
|
||||
* BYPASS_PREFIX_NOT_ALLOWED, zod validation failure) writes a
|
||||
* `settings.update_failed` row with the matching `reason` code and
|
||||
* NEVER persists settings.
|
||||
* - AC-11 every changed key shows up in the success diff — not only
|
||||
* security-impacting keys.
|
||||
* - Idempotent PATCH (body matches stored state) writes NO row.
|
||||
*
|
||||
* Runs through the real PATCH handler + `setupSettingsFixture` mock so the
|
||||
* production `updateSettings → applyRuntimeSettings → logAuditEvent` pipeline
|
||||
* fires exactly as it does in deployment.
|
||||
*
|
||||
* INSUFFICIENT_SCOPE is intentionally NOT exercised here — per spec AC-13 it
|
||||
* is rejected by `requireManagementAuth` before the audit-aware handler body
|
||||
* runs, so no row is written.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupSettingsFixture } from "../_mocks/settings.ts";
|
||||
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
||||
|
||||
// Allocate fixture FIRST so DATA_DIR is set before any DB import resolves.
|
||||
const fixture = setupSettingsFixture("settings-audit");
|
||||
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
const runtime = await import("../../../src/lib/config/runtimeSettings.ts");
|
||||
const settingsRoute = await import("../../../src/app/api/settings/route.ts");
|
||||
const compliance = await import("../../../src/lib/compliance/index.ts");
|
||||
const managementPassword = await import("../../../src/lib/auth/managementPassword.ts");
|
||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await fixture.resetStorage();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
runtime.resetRuntimeSettingsStateForTests();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fixture.cleanup();
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
|
||||
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
||||
});
|
||||
|
||||
async function bootstrapWithPassword(password: string): Promise<void> {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-settings-audit";
|
||||
process.env.INITIAL_PASSWORD = password;
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
await managementPassword.ensurePersistentManagementPasswordHash({
|
||||
source: "test.bootstrap",
|
||||
});
|
||||
}
|
||||
|
||||
function settingsRows() {
|
||||
// `getAuditLog`'s `AuditLogEntry[]` return type now exposes `action`,
|
||||
// `actor`, `target`, `status`, `details`, etc. directly — no local cast
|
||||
// needed. See src/lib/compliance/index.ts.
|
||||
return compliance.getAuditLog({ target: "settings", limit: 50 });
|
||||
}
|
||||
|
||||
// ─── AC-9 — success diff row written ──────────────────────────────────────
|
||||
|
||||
test("AC-9: successful PATCH writes settings.update with diff of changed keys", async () => {
|
||||
await bootstrapWithPassword("initial-pass-ac9");
|
||||
const before = await settingsDb.getSettings();
|
||||
assert.equal(before.localOnlyManageScopeBypassEnabled, true);
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassEnabled: false,
|
||||
currentPassword: "initial-pass-ac9",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const rows = settingsRows();
|
||||
const successRows = rows.filter((r) => r.action === "settings.update");
|
||||
assert.equal(successRows.length, 1, `expected 1 success row, got: ${JSON.stringify(rows)}`);
|
||||
const row = successRows[0];
|
||||
assert.equal(row.target, "settings");
|
||||
assert.equal(row.status, "success");
|
||||
assert.equal(row.resource_type, "settings");
|
||||
// Cookie session ⇒ actor=dashboard.
|
||||
assert.equal(row.actor, "dashboard");
|
||||
const details = row.details as { diff: Record<string, { before: unknown; after: unknown }> };
|
||||
assert.ok(details && typeof details === "object", "details must be parsed JSON");
|
||||
assert.ok(details.diff, "diff present");
|
||||
assert.deepEqual(details.diff.localOnlyManageScopeBypassEnabled, {
|
||||
before: true,
|
||||
after: false,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AC-10 — failure rows for each rejection path ────────────────────────
|
||||
|
||||
test("AC-10a: PASSWORD_REQUIRED failure writes settings.update_failed", async () => {
|
||||
await bootstrapWithPassword("initial-pass-ac10a");
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: { localOnlyManageScopeBypassEnabled: false },
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const rows = settingsRows().filter((r) => r.action === "settings.update_failed");
|
||||
assert.equal(rows.length, 1);
|
||||
const details = rows[0].details as { reason: string; attempted_keys: string[] };
|
||||
assert.equal(details.reason, "PASSWORD_REQUIRED");
|
||||
assert.ok(details.attempted_keys.includes("localOnlyManageScopeBypassEnabled"));
|
||||
// No raw payload values — only the key NAMES are recorded under
|
||||
// `attempted_keys`. There must be no `before`/`after` or `diff` block on a
|
||||
// failure row, and no other fields beyond reason+attempted_keys in details.
|
||||
assert.deepEqual(
|
||||
Object.keys(details).sort(),
|
||||
["attempted_keys", "reason"],
|
||||
"failure details must only contain reason + attempted_keys (no payload echo)"
|
||||
);
|
||||
|
||||
// Persisted state unchanged.
|
||||
const after = await settingsDb.getSettings();
|
||||
assert.equal(after.localOnlyManageScopeBypassEnabled, true);
|
||||
});
|
||||
|
||||
test("AC-10b: PASSWORD_MISMATCH failure writes settings.update_failed", async () => {
|
||||
await bootstrapWithPassword("initial-pass-ac10b");
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassEnabled: false,
|
||||
currentPassword: "definitely-wrong",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
const rows = settingsRows().filter((r) => r.action === "settings.update_failed");
|
||||
assert.equal(rows.length, 1);
|
||||
const details = rows[0].details as { reason: string; attempted_keys: string[] };
|
||||
assert.equal(details.reason, "PASSWORD_MISMATCH");
|
||||
// Password attempt MUST NOT leak — only key names.
|
||||
const serialized = JSON.stringify(rows[0]);
|
||||
assert.equal(
|
||||
serialized.includes("definitely-wrong"),
|
||||
false,
|
||||
"rejected currentPassword must not appear in audit row"
|
||||
);
|
||||
|
||||
const after = await settingsDb.getSettings();
|
||||
assert.equal(after.localOnlyManageScopeBypassEnabled, true);
|
||||
});
|
||||
|
||||
test("AC-10c: BYPASS_PREFIX_NOT_ALLOWED failure writes settings.update_failed", async () => {
|
||||
await bootstrapWithPassword("initial-pass-ac10c");
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/cli-tools/runtime/"],
|
||||
currentPassword: "initial-pass-ac10c",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const rows = settingsRows().filter((r) => r.action === "settings.update_failed");
|
||||
assert.equal(rows.length, 1);
|
||||
const details = rows[0].details as { reason: string };
|
||||
assert.equal(details.reason, "BYPASS_PREFIX_NOT_ALLOWED");
|
||||
|
||||
// Snapshot untouched.
|
||||
const after = await settingsDb.getSettings();
|
||||
assert.deepEqual(after.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]);
|
||||
});
|
||||
|
||||
test("AC-10d: zod validation failure (wrong type) writes settings.update_failed", async () => {
|
||||
await bootstrapWithPassword("initial-pass-ac10d");
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassEnabled: "definitely-not-a-boolean",
|
||||
currentPassword: "initial-pass-ac10d",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const rows = settingsRows().filter((r) => r.action === "settings.update_failed");
|
||||
assert.equal(rows.length, 1);
|
||||
const details = rows[0].details as { reason: string };
|
||||
assert.equal(details.reason, "VALIDATION_FAILED");
|
||||
});
|
||||
|
||||
// AC-13 sanity: INSUFFICIENT_SCOPE rejection happens upstream in
|
||||
// requireManagementAuth and never reaches the handler body, so no audit row.
|
||||
// We cover it implicitly by NOT having an INSUFFICIENT_SCOPE failure test —
|
||||
// the route-level rejection is already covered by api-auth.test.ts.
|
||||
|
||||
// ─── AC-11 — diff covers every changed key (not only security keys) ──────
|
||||
|
||||
test("AC-11: diff records every changed key, including non-security keys", async () => {
|
||||
await bootstrapWithPassword("initial-pass-ac11");
|
||||
|
||||
// Seed an initial value for a non-security key so the diff is meaningful.
|
||||
await settingsDb.updateSettings({ theme: "light", instanceName: "before" });
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
theme: "dark",
|
||||
instanceName: "after",
|
||||
localOnlyManageScopeBypassEnabled: false,
|
||||
currentPassword: "initial-pass-ac11",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const rows = settingsRows().filter((r) => r.action === "settings.update");
|
||||
assert.equal(rows.length, 1);
|
||||
const details = rows[0].details as { diff: Record<string, { before: unknown; after: unknown }> };
|
||||
// Security key AND multiple non-security keys must all be in diff.
|
||||
assert.ok(details.diff.localOnlyManageScopeBypassEnabled, "security key in diff");
|
||||
assert.ok(details.diff.theme, "theme (non-security) in diff");
|
||||
assert.ok(details.diff.instanceName, "instanceName (non-security) in diff");
|
||||
assert.deepEqual(details.diff.theme, { before: "light", after: "dark" });
|
||||
assert.deepEqual(details.diff.instanceName, { before: "before", after: "after" });
|
||||
});
|
||||
|
||||
// ─── Idempotent no-op writes NO row ──────────────────────────────────────
|
||||
|
||||
test("idempotent PATCH (body matches current state) writes NO audit row", async () => {
|
||||
await bootstrapWithPassword("initial-pass-noop");
|
||||
|
||||
// Settings already at default — patch the same value back.
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassEnabled: true, // same as default
|
||||
currentPassword: "initial-pass-noop",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const rows = settingsRows();
|
||||
assert.equal(
|
||||
rows.length,
|
||||
0,
|
||||
`idempotent PATCH must not emit an audit row, got: ${JSON.stringify(rows)}`
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Multi-row sanity: success + failure sequence ─────────────────────────
|
||||
|
||||
test("sequence: failure then success produces exactly 1 failure row + 1 success row", async () => {
|
||||
await bootstrapWithPassword("initial-pass-seq");
|
||||
|
||||
// 1) failure
|
||||
await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: { localOnlyManageScopeBypassEnabled: false, currentPassword: "wrong" },
|
||||
})
|
||||
);
|
||||
// 2) success
|
||||
await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassEnabled: false,
|
||||
currentPassword: "initial-pass-seq",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const rows = settingsRows();
|
||||
const failures = rows.filter((r) => r.action === "settings.update_failed");
|
||||
const successes = rows.filter((r) => r.action === "settings.update");
|
||||
assert.equal(failures.length, 1);
|
||||
assert.equal(successes.length, 1);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SignJWT } from "jose";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-mgmt-policy-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
@@ -45,6 +46,24 @@ async function loadPolicy() {
|
||||
return mod.managementPolicy;
|
||||
}
|
||||
|
||||
async function dashboardCookieHeader(expiresIn = "1h"): Promise<string> {
|
||||
// Mirrors tests/unit/authz/pipeline.test.ts: mint a real HS256 auth_token
|
||||
// JWT against process.env.JWT_SECRET so isDashboardSessionAuthenticated()
|
||||
// accepts it. The header path is sufficient — the policy reads the cookie
|
||||
// from `request.headers.get("cookie")` when there's no `request.cookies`
|
||||
// accessor on the plain ctx() object.
|
||||
assert.ok(
|
||||
process.env.JWT_SECRET,
|
||||
"JWT_SECRET must be set before minting dashboard cookie (otherwise TextEncoder would encode the string 'undefined' and silently mint a wrong-secret JWT)"
|
||||
);
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const token = await new SignJWT({ authenticated: true })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime(expiresIn)
|
||||
.sign(secret);
|
||||
return `auth_token=${token}`;
|
||||
}
|
||||
|
||||
function ctx(headers: Headers, method = "GET", path = "/api/keys") {
|
||||
return {
|
||||
request: { method, headers, url: `http://localhost${path}`, nextUrl: { pathname: path } },
|
||||
@@ -189,6 +208,155 @@ test("managementPolicy: rejects invalid API keys with 403 when bearer is present
|
||||
}
|
||||
});
|
||||
|
||||
// ─── LOCAL_ONLY manage-scope bypass for /api/mcp/* ───────────────────────────
|
||||
//
|
||||
// `/api/mcp/*` is in LOCAL_ONLY_API_PREFIXES (because it can spawn child
|
||||
// processes for unauthenticated callers) AND in
|
||||
// LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES (so a manage-scoped API key
|
||||
// presented from non-loopback may reach it). `/api/cli-tools/runtime/*` is
|
||||
// LOCAL_ONLY but NOT bypassable — the carve-out is path-scoped.
|
||||
//
|
||||
// `ctx()` uses `new Headers()` without an explicit `host`, so
|
||||
// `isLoopbackHost(null)` returns false → the policy treats it as non-loopback,
|
||||
// which is the exact case this block exercises.
|
||||
|
||||
test("LOCAL_ONLY manage-scope bypass: no Bearer + non-loopback → 403 (regression guard)", 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(), "GET", "/api/mcp/stream"));
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "LOCAL_ONLY");
|
||||
}
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY manage-scope bypass: non-manage key + non-loopback → 403", 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("chat-only", "machine-chat-only", ["chat"]);
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/api/mcp/stream")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "LOCAL_ONLY");
|
||||
}
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY manage-scope bypass: manage-scope key + non-loopback → allow", 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("mcp-bypass-key", "machine-mcp-bypass", ["manage"]);
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/api/mcp/stream")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "management_key");
|
||||
assert.equal(out.subject.id, created.id);
|
||||
assert.ok(
|
||||
(out.subject.label ?? "").includes("local-only-bypass"),
|
||||
`expected label to include 'local-only-bypass', got ${out.subject.label}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY manage-scope bypass: carve-out does not extend to /api/cli-tools/runtime/*", 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("cli-runtime-denied", "machine-cli-runtime-denied", [
|
||||
"manage",
|
||||
]);
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(
|
||||
new Headers({ authorization: `Bearer ${created.key}` }),
|
||||
"GET",
|
||||
"/api/cli-tools/runtime/foo"
|
||||
)
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "LOCAL_ONLY");
|
||||
}
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY manage-scope bypass: loopback + no Bearer → allow (local CLI flow preserved)", async () => {
|
||||
// Match the fresh-bootstrap pattern used by the "allows when auth not
|
||||
// required" test above: no password configured + loopback request →
|
||||
// `isAuthRequired` returns false → anonymous-allow fires once the LOCAL_ONLY
|
||||
// gate is satisfied via the loopback `host` header.
|
||||
await settingsDb.updateSettings({ requireLogin: true, password: null });
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ host: "localhost:20128" }), "GET", "/api/mcp/stream")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
});
|
||||
|
||||
// ─── LOCAL_ONLY dashboard-session bypass ─────────────────────────────────────
|
||||
//
|
||||
// Regression cover for commit ca284a91 ("refine LOCAL_ONLY bypass — dashboard
|
||||
// cookie + admin label + error log"). The dashboard-session bypass mirrors the
|
||||
// manage-scope bypass: an authenticated `auth_token` cookie reaching a
|
||||
// bypassable LOCAL_ONLY path (e.g. /api/mcp/status) from a public hostname is
|
||||
// allowed, but the cli-tools-runtime carve-out is NOT extended to it.
|
||||
|
||||
test("LOCAL_ONLY dashboard-session bypass: authenticated dashboard cookie + non-loopback → allow", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const cookie = await dashboardCookieHeader();
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(ctx(new Headers({ cookie }), "GET", "/api/mcp/stream"));
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "dashboard_session");
|
||||
assert.equal(out.subject.id, "dashboard");
|
||||
assert.equal(out.subject.label, "dashboard-session-local-only-bypass");
|
||||
}
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY dashboard-session bypass: authenticated dashboard cookie + /api/cli-tools/runtime/ → 403 LOCAL_ONLY", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const cookie = await dashboardCookieHeader();
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ cookie }), "GET", "/api/cli-tools/runtime/foo")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "LOCAL_ONLY");
|
||||
}
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isLocalOnlyPath,
|
||||
isLocalOnlyBypassableByManageScope,
|
||||
isAlwaysProtectedPath,
|
||||
isLoopbackHost,
|
||||
} from "../../../src/server/authz/routeGuard.ts";
|
||||
@@ -25,6 +26,19 @@ test("isLocalOnlyPath: regular management routes are not local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/providers"), false);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: /api/mcp/ prefix is bypassable", () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/mcp/"), true);
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/mcp/stream"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: /api/cli-tools/runtime/* is NOT bypassable", () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/cli-tools/runtime/foo"), false);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: non-local-only routes are not bypassable", () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/settings"), false);
|
||||
});
|
||||
|
||||
test("isAlwaysProtectedPath: /api/shutdown is always protected", () => {
|
||||
assert.equal(isAlwaysProtectedPath("/api/shutdown"), true);
|
||||
});
|
||||
|
||||
242
tests/unit/db/api-keys.test.ts
Normal file
242
tests/unit/db/api-keys.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Unit coverage for the api_keys DB layer — focused on the `scopes` column
|
||||
* and the audit-event emission contract for privileged scope changes.
|
||||
*
|
||||
* The fixtures here intentionally mirror tests/unit/api-auth.test.ts so the
|
||||
* two suites share the same bootstrap shape (isolated DATA_DIR, fresh DB per
|
||||
* test, env reset).
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-api-keys-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret";
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
||||
const compliance = await import("../../../src/lib/compliance/index.ts");
|
||||
const { hasManageScope } = await import("../../../src/shared/constants/managementScopes.ts");
|
||||
|
||||
const MACHINE_ID = "machine1234567890";
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// createApiKey + scopes round-trip
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("createApiKey persists scopes to the api_keys row", async () => {
|
||||
const created = await apiKeysDb.createApiKey("with-manage", MACHINE_ID, ["manage"]);
|
||||
assert.ok(created.id);
|
||||
assert.ok(created.key);
|
||||
assert.deepEqual(created.scopes, ["manage"]);
|
||||
|
||||
// Verify the row hit the DB by reading raw column.
|
||||
const db = core.getDbInstance() as unknown as {
|
||||
prepare: (sql: string) => { get: (id: string) => { scopes: string | null } | undefined };
|
||||
};
|
||||
const row = db.prepare("SELECT scopes FROM api_keys WHERE id = ?").get(created.id);
|
||||
assert.equal(row?.scopes, JSON.stringify(["manage"]));
|
||||
});
|
||||
|
||||
test("createApiKey with default scopes writes an empty JSON array", async () => {
|
||||
const created = await apiKeysDb.createApiKey("no-scope", MACHINE_ID);
|
||||
const db = core.getDbInstance() as unknown as {
|
||||
prepare: (sql: string) => { get: (id: string) => { scopes: string | null } | undefined };
|
||||
};
|
||||
const row = db.prepare("SELECT scopes FROM api_keys WHERE id = ?").get(created.id);
|
||||
assert.equal(row?.scopes, "[]");
|
||||
});
|
||||
|
||||
test("getApiKeyMetadata returns the scopes for a key created with manage", async () => {
|
||||
const created = await apiKeysDb.createApiKey("metadata-readback", MACHINE_ID, ["manage"]);
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.deepEqual(meta!.scopes, ["manage"]);
|
||||
assert.equal(hasManageScope(meta!.scopes), true);
|
||||
});
|
||||
|
||||
test("getApiKeyMetadata returns an empty scopes array for a key created without scopes", async () => {
|
||||
const created = await apiKeysDb.createApiKey("no-manage", MACHINE_ID);
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.deepEqual(meta!.scopes, []);
|
||||
assert.equal(hasManageScope(meta!.scopes), false);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Legacy NULL scopes (pre-migration-032 row simulation)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("legacy rows with NULL scopes parse to an empty array and never hold manage", async () => {
|
||||
const created = await apiKeysDb.createApiKey("legacy-null", MACHINE_ID);
|
||||
// Simulate a pre-migration row by force-NULL on the scopes column.
|
||||
const db = core.getDbInstance() as unknown as {
|
||||
prepare: (sql: string) => { run: (...args: unknown[]) => unknown };
|
||||
};
|
||||
db.prepare("UPDATE api_keys SET scopes = NULL WHERE id = ?").run(created.id);
|
||||
apiKeysDb.clearApiKeyCaches();
|
||||
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.deepEqual(meta!.scopes, []);
|
||||
assert.equal(hasManageScope(meta!.scopes), false);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// updateApiKeyPermissions — audit events for scope changes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("updateApiKeyPermissions granting manage emits apiKey.scopes.grant", async () => {
|
||||
const created = await apiKeysDb.createApiKey("for-grant", MACHINE_ID);
|
||||
|
||||
const before = compliance.getAuditLog({ limit: 100 });
|
||||
const beforeGrant = before.filter(
|
||||
(e) => e.action === "apiKey.scopes.grant" && e.target === created.id
|
||||
);
|
||||
assert.equal(beforeGrant.length, 0);
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: ["manage"] });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const after = compliance.getAuditLog({ limit: 100 });
|
||||
const grants = after.filter((e) => e.action === "apiKey.scopes.grant" && e.target === created.id);
|
||||
assert.equal(grants.length, 1, "expected exactly one grant audit event");
|
||||
|
||||
// Confirm the round-trip — manage should now be on the key.
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.equal(hasManageScope(meta!.scopes), true);
|
||||
});
|
||||
|
||||
test("updateApiKeyPermissions revoking manage emits apiKey.scopes.revoke", async () => {
|
||||
const created = await apiKeysDb.createApiKey("for-revoke", MACHINE_ID, ["manage"]);
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: [] });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const after = compliance.getAuditLog({ limit: 100 });
|
||||
const revokes = after.filter(
|
||||
(e) => e.action === "apiKey.scopes.revoke" && e.target === created.id
|
||||
);
|
||||
assert.equal(revokes.length, 1, "expected exactly one revoke audit event");
|
||||
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.equal(hasManageScope(meta!.scopes), false);
|
||||
});
|
||||
|
||||
test("updateApiKeyPermissions setting same manage scope does not emit duplicate audit events", async () => {
|
||||
const created = await apiKeysDb.createApiKey("idempotent-manage", MACHINE_ID, ["manage"]);
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: ["manage"] });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const after = compliance.getAuditLog({ limit: 100 });
|
||||
const scopeEvents = after.filter(
|
||||
(e) =>
|
||||
(e.action === "apiKey.scopes.grant" ||
|
||||
e.action === "apiKey.scopes.revoke" ||
|
||||
e.action === "apiKey.scopes.update") &&
|
||||
e.target === created.id
|
||||
);
|
||||
assert.equal(
|
||||
scopeEvents.length,
|
||||
0,
|
||||
"no-op scope update should not emit grant/revoke/update events"
|
||||
);
|
||||
});
|
||||
|
||||
test("updateApiKeyPermissions changing non-manage scopes emits apiKey.scopes.update", async () => {
|
||||
const created = await apiKeysDb.createApiKey("non-manage-update", MACHINE_ID, []);
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: ["read:logs"] });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const after = compliance.getAuditLog({ limit: 100 });
|
||||
const updates = after.filter(
|
||||
(e) => e.action === "apiKey.scopes.update" && e.target === created.id
|
||||
);
|
||||
assert.equal(updates.length, 1, "expected exactly one non-manage scope update event");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Regression — scopes and ban state are orthogonal (T-008)
|
||||
//
|
||||
// The Permissions modal previously had two chips bound to `isBanned` (one
|
||||
// labelled "Management API Access" with manage-scope copy). A user toggling
|
||||
// it expected manage-scope grant; instead it flipped the ban flag. These
|
||||
// tests guard against the inverse cross-wire ever returning: updating scopes
|
||||
// must not touch isBanned, and toggling isBanned must not touch scopes.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("updating scopes to manage leaves isBanned untouched", async () => {
|
||||
const created = await apiKeysDb.createApiKey("banned-then-manage", MACHINE_ID, []);
|
||||
const banOk = await apiKeysDb.updateApiKeyPermissions(created.id, { isBanned: true });
|
||||
assert.equal(banOk, true);
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: ["manage"] });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.deepEqual(meta!.scopes, ["manage"]);
|
||||
assert.equal(meta!.isBanned, true, "ban flag must survive a scopes-only update");
|
||||
});
|
||||
|
||||
test("toggling isBanned does not touch scopes", async () => {
|
||||
const created = await apiKeysDb.createApiKey("manage-then-ban", MACHINE_ID, ["manage"]);
|
||||
|
||||
const banOk = await apiKeysDb.updateApiKeyPermissions(created.id, { isBanned: true });
|
||||
assert.equal(banOk, true);
|
||||
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta);
|
||||
assert.deepEqual(meta!.scopes, ["manage"], "scopes must survive a ban-only update");
|
||||
assert.equal(meta!.isBanned, true);
|
||||
|
||||
const unbanOk = await apiKeysDb.updateApiKeyPermissions(created.id, { isBanned: false });
|
||||
assert.equal(unbanOk, true);
|
||||
|
||||
const meta2 = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(meta2);
|
||||
assert.deepEqual(meta2!.scopes, ["manage"], "scopes must survive an unban update");
|
||||
assert.equal(meta2!.isBanned, false);
|
||||
});
|
||||
|
||||
test("updateApiKeyPermissions without scopes field does not emit any scope audit event", async () => {
|
||||
const created = await apiKeysDb.createApiKey("no-scope-change", MACHINE_ID);
|
||||
|
||||
const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { name: "renamed" });
|
||||
assert.equal(ok, true);
|
||||
|
||||
const after = compliance.getAuditLog({ limit: 100 });
|
||||
const scopeEvents = after.filter(
|
||||
(e) =>
|
||||
(e.action === "apiKey.scopes.grant" ||
|
||||
e.action === "apiKey.scopes.revoke" ||
|
||||
e.action === "apiKey.scopes.update") &&
|
||||
e.target === created.id
|
||||
);
|
||||
assert.equal(scopeEvents.length, 0);
|
||||
});
|
||||
@@ -79,5 +79,8 @@ test("settings route password update rejects the wrong current password after mi
|
||||
);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(await response.json(), { error: "Invalid current password" });
|
||||
// T-011 unified security-impacting gate: structured error code.
|
||||
assert.deepEqual(await response.json(), {
|
||||
error: { code: "PASSWORD_MISMATCH", message: "Invalid current password" },
|
||||
});
|
||||
});
|
||||
|
||||
286
tests/unit/settings/authz-bypass.test.ts
Normal file
286
tests/unit/settings/authz-bypass.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* T-011 — DB-stored authz bypass policy + hot-reload.
|
||||
*
|
||||
* Covers spec AC-3 through AC-8:
|
||||
* - AC-3 kill-switch flip → bypass disabled → /api/mcp/ from non-loopback → 403
|
||||
* - AC-4 PATCH missing currentPassword → 400 PASSWORD_REQUIRED
|
||||
* - AC-5 wrong currentPassword → 401 PASSWORD_MISMATCH
|
||||
* - AC-6 toggle list reflects DB after PATCH
|
||||
* - AC-7 add a new prefix → persists + applyRuntimeSettings fires + snapshot reflects
|
||||
* - AC-8 add /api/cli-tools/runtime/ → 400 BYPASS_PREFIX_NOT_ALLOWED, snapshot unchanged
|
||||
*
|
||||
* Goes through the production `updateSettings → applyRuntimeSettings` and
|
||||
* the real PATCH route handler — no direct `getAuthzBypassSnapshot` mocks.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupSettingsFixture, mockSettings } from "../_mocks/settings.ts";
|
||||
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
|
||||
|
||||
// Allocate fixture FIRST so DATA_DIR is set before any DB import resolves.
|
||||
const fixture = setupSettingsFixture("authz-bypass");
|
||||
// API-key auth check uses a Redis-backed cache otherwise — disable so
|
||||
// isValidApiKey() does not stall on ETIMEDOUT in the local test loop.
|
||||
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
const runtime = await import("../../../src/lib/config/runtimeSettings.ts");
|
||||
const routeGuard = await import("../../../src/server/authz/routeGuard.ts");
|
||||
const settingsRoute = await import("../../../src/app/api/settings/route.ts");
|
||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await fixture.resetStorage();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
// Force the route guard to start each test from cold-boot default
|
||||
// (enabled=true, prefixes=["/api/mcp/"]).
|
||||
runtime.resetRuntimeSettingsStateForTests();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fixture.cleanup();
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
|
||||
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
||||
});
|
||||
|
||||
function nonLoopbackCtx(headers: Headers, path = "/api/mcp/stream") {
|
||||
return {
|
||||
request: {
|
||||
method: "GET",
|
||||
headers,
|
||||
url: `https://dashboard.example${path}`,
|
||||
nextUrl: { pathname: path },
|
||||
},
|
||||
classification: {
|
||||
routeClass: "MANAGEMENT" as const,
|
||||
normalizedPath: path,
|
||||
reason: "management_api" as const,
|
||||
},
|
||||
requestId: "req_authz_bypass_test",
|
||||
};
|
||||
}
|
||||
|
||||
// ─── AC-3 — kill-switch flips bypass off → request 403 ───────────────────
|
||||
|
||||
test("AC-3: kill-switch off → /api/mcp/* with manage-scope Bearer from non-loopback → 403 LOCAL_ONLY", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
// Seed DB with default snapshot first (kill-switch ON) and confirm the
|
||||
// bypass works.
|
||||
await mockSettings({ requireLogin: true });
|
||||
const managePolicy = await import("../../../src/server/authz/policies/management.ts");
|
||||
const created = await apiKeysDb.createApiKey("ac3-mgmt", "machine-ac3", ["manage"]);
|
||||
|
||||
const headers = new Headers({ authorization: `Bearer ${created.key}` });
|
||||
|
||||
// Sanity: bypass ENABLED → 200/allow.
|
||||
const before = await managePolicy.managementPolicy.evaluate(nonLoopbackCtx(headers));
|
||||
assert.equal(before.allow, true, "default kill-switch ON should allow manage-scope bypass");
|
||||
|
||||
// Flip the kill-switch off via the production pipeline.
|
||||
await mockSettings({ localOnlyManageScopeBypassEnabled: false });
|
||||
assert.equal(routeGuard.isLocalOnlyBypassableByManageScope("/api/mcp/stream"), false);
|
||||
|
||||
// After the hot-reload, the policy must reject.
|
||||
const after = await managePolicy.managementPolicy.evaluate(nonLoopbackCtx(headers));
|
||||
assert.equal(after.allow, false);
|
||||
if (!after.allow) {
|
||||
assert.equal(after.status, 403);
|
||||
assert.equal(after.code, "LOCAL_ONLY");
|
||||
}
|
||||
});
|
||||
|
||||
// ─── AC-4 — missing currentPassword → 400 PASSWORD_REQUIRED ──────────────
|
||||
|
||||
test("AC-4: PATCH missing currentPassword for security-impacting key → 400 PASSWORD_REQUIRED", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac4";
|
||||
// Bootstrap a password so the cold-boot exception does NOT fire.
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const { ensurePersistentManagementPasswordHash } =
|
||||
await import("../../../src/lib/auth/managementPassword.ts");
|
||||
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: { localOnlyManageScopeBypassEnabled: false },
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const body = (await response.json()) as { error: { code: string; keys: string[] } };
|
||||
assert.equal(body.error.code, "PASSWORD_REQUIRED");
|
||||
assert.ok(body.error.keys.includes("localOnlyManageScopeBypassEnabled"));
|
||||
|
||||
// Persisted state unchanged — kill-switch still on by default.
|
||||
const settings = await settingsDb.getSettings();
|
||||
assert.equal(settings.localOnlyManageScopeBypassEnabled, true);
|
||||
});
|
||||
|
||||
// ─── AC-5 — wrong currentPassword → 401 PASSWORD_MISMATCH ────────────────
|
||||
|
||||
test("AC-5: PATCH wrong currentPassword for security-impacting key → 401 PASSWORD_MISMATCH", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac5";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const { ensurePersistentManagementPasswordHash } =
|
||||
await import("../../../src/lib/auth/managementPassword.ts");
|
||||
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassEnabled: false,
|
||||
currentPassword: "definitely-wrong",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
const body = (await response.json()) as { error: { code: string } };
|
||||
assert.equal(body.error.code, "PASSWORD_MISMATCH");
|
||||
|
||||
const settings = await settingsDb.getSettings();
|
||||
assert.equal(settings.localOnlyManageScopeBypassEnabled, true);
|
||||
});
|
||||
|
||||
// ─── AC-6 — bypass list reflects DB after PATCH ──────────────────────────
|
||||
|
||||
test("AC-6: PATCH with correct currentPassword + new prefix list → persists to DB", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac6";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const { ensurePersistentManagementPasswordHash } =
|
||||
await import("../../../src/lib/auth/managementPassword.ts");
|
||||
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassPrefixes: ["/api/mcp/"],
|
||||
currentPassword: "initial-pass-ac6",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const settings = await settingsDb.getSettings();
|
||||
assert.deepEqual(settings.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]);
|
||||
});
|
||||
|
||||
// ─── AC-7 — add prefix → applyRuntimeSettings fires + snapshot reflects ──
|
||||
|
||||
test("AC-7: PATCH adds prefix → applyRuntimeSettings fires + getAuthzBypassSnapshot reflects (hot-reload <50ms)", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac7";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const { ensurePersistentManagementPasswordHash } =
|
||||
await import("../../../src/lib/auth/managementPassword.ts");
|
||||
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
||||
// Prime the snapshot from the persisted defaults so we measure a real diff.
|
||||
const seeded = await settingsDb.getSettings();
|
||||
await runtime.applyRuntimeSettings(seeded);
|
||||
|
||||
const before = routeGuard.LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES;
|
||||
assert.deepEqual([...before], ["/api/mcp/"]);
|
||||
|
||||
// Measure the snapshot-read latency (spec SLA: <50 ms).
|
||||
const t0 = process.hrtime.bigint();
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/mcp/v2/"],
|
||||
currentPassword: "initial-pass-ac7",
|
||||
},
|
||||
})
|
||||
);
|
||||
const snapshotAfterPatch = runtime.getAuthzBypassSnapshot();
|
||||
const elapsedNs = process.hrtime.bigint() - t0;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(snapshotAfterPatch.prefixes, ["/api/mcp/", "/api/mcp/v2/"]);
|
||||
assert.equal(snapshotAfterPatch.enabled, true);
|
||||
// The hot-path accessor itself is O(1) — total PATCH→snapshot covers
|
||||
// bcrypt + SQLite write, so we only assert the in-memory accessor is fast.
|
||||
// Microbench: dedicated snapshot read.
|
||||
const tSnap0 = process.hrtime.bigint();
|
||||
for (let i = 0; i < 10_000; i++) runtime.getAuthzBypassSnapshot();
|
||||
const snapElapsedNs = process.hrtime.bigint() - tSnap0;
|
||||
assert.ok(
|
||||
snapElapsedNs < 50_000_000n,
|
||||
`getAuthzBypassSnapshot x10k must complete in <50 ms (got ${Number(snapElapsedNs) / 1e6} ms)`
|
||||
);
|
||||
// Whole PATCH < 5 s sanity bound (bcrypt-bounded).
|
||||
assert.ok(elapsedNs < 5_000_000_000n);
|
||||
|
||||
// Live route-guard predicate reflects the new prefix.
|
||||
assert.equal(routeGuard.isLocalOnlyBypassableByManageScope("/api/mcp/v2/foo"), true);
|
||||
});
|
||||
|
||||
// ─── AC-8 — spawn-capable prefix → 400 BYPASS_PREFIX_NOT_ALLOWED ─────────
|
||||
|
||||
test("AC-8: PATCH with /api/cli-tools/runtime/ in bypass list → 400 BYPASS_PREFIX_NOT_ALLOWED + snapshot unchanged", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass-ac8";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const { ensurePersistentManagementPasswordHash } =
|
||||
await import("../../../src/lib/auth/managementPassword.ts");
|
||||
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
|
||||
// Prime snapshot from default DB state.
|
||||
const seeded = await settingsDb.getSettings();
|
||||
await runtime.applyRuntimeSettings(seeded);
|
||||
const snapshotBefore = runtime.getAuthzBypassSnapshot();
|
||||
|
||||
const response = await settingsRoute.PATCH(
|
||||
await makeManagementSessionRequest("http://localhost/api/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/cli-tools/runtime/"],
|
||||
currentPassword: "initial-pass-ac8",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const body = (await response.json()) as {
|
||||
error: { details?: Array<{ field: string; message: string }> };
|
||||
};
|
||||
// zod path is the array path; the message embeds the BYPASS_PREFIX_NOT_ALLOWED code.
|
||||
const offending = body.error.details?.find((d) =>
|
||||
d.message.includes("BYPASS_PREFIX_NOT_ALLOWED")
|
||||
);
|
||||
assert.ok(offending, `expected BYPASS_PREFIX_NOT_ALLOWED in details: ${JSON.stringify(body)}`);
|
||||
|
||||
// Persisted state untouched.
|
||||
const settings = await settingsDb.getSettings();
|
||||
assert.deepEqual(settings.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]);
|
||||
// Runtime snapshot untouched.
|
||||
const snapshotAfter = runtime.getAuthzBypassSnapshot();
|
||||
assert.deepEqual(snapshotAfter.prefixes, snapshotBefore.prefixes);
|
||||
assert.equal(snapshotAfter.enabled, snapshotBefore.enabled);
|
||||
});
|
||||
|
||||
// ─── Defence-in-depth: snapshot mutation alone cannot grant spawn bypass ─
|
||||
|
||||
test("Defence-in-depth: even if a malformed snapshot lists /api/cli-tools/runtime/, the runtime predicate rejects it", async () => {
|
||||
// applyRuntimeSettings wires the snapshot through normalizeAuthzBypass,
|
||||
// which does not filter spawn-capable entries (zod is the gate). The
|
||||
// routeGuard predicate must still refuse them at runtime.
|
||||
await runtime.applyRuntimeSettings({
|
||||
localOnlyManageScopeBypassEnabled: true,
|
||||
localOnlyManageScopeBypassPrefixes: ["/api/cli-tools/runtime/"],
|
||||
});
|
||||
|
||||
assert.equal(routeGuard.isLocalOnlyBypassableByManageScope("/api/cli-tools/runtime/foo"), false);
|
||||
});
|
||||
Reference in New Issue
Block a user