feat(api): add endpoint restrictions for client API keys (#2777)

Merge PR #2777 — feat(api): add endpoint restrictions for client API keys
This commit is contained in:
Jack
2026-05-27 10:20:40 +01:00
committed by GitHub
parent 30753d4dd5
commit af5635e422
8 changed files with 610 additions and 6 deletions

View File

@@ -5,6 +5,7 @@ import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useTranslations } from "next-intl";
import { getProviderDisplayName } from "@/lib/display/names";
import { ENDPOINT_CATEGORIES } from "@/shared/constants/endpointCategories";
import ApiKeyFilterBar from "./components/ApiKeyFilterBar";
import {
isKeyActive,
@@ -88,6 +89,7 @@ interface ApiKey {
accessSchedule?: AccessSchedule | null;
rateLimits?: Array<{ limit: number; window: number }> | null;
scopes?: string[];
allowedEndpoints?: string[];
createdAt: string;
}
@@ -461,7 +463,8 @@ export default function ApiManagerPageClient() {
maxSessions: number,
accessSchedule: AccessSchedule | null,
rateLimits: Array<{ limit: number; window: number }> | null,
scopes: string[]
scopes: string[],
allowedEndpoints: string[]
) => {
if (!editingKey || !editingKey.id) return;
@@ -521,6 +524,7 @@ export default function ApiManagerPageClient() {
accessSchedule,
rateLimits,
scopes,
allowedEndpoints,
}),
});
@@ -1161,7 +1165,8 @@ const PermissionsModal = memo(function PermissionsModal({
maxSessions: number,
accessSchedule: AccessSchedule | null,
rateLimits: Array<{ limit: number; window: number }> | null,
scopes: string[]
scopes: string[],
allowedEndpoints: string[]
) => void;
}) {
const t = useTranslations("apiManager");
@@ -1218,6 +1223,10 @@ const PermissionsModal = memo(function PermissionsModal({
return new Set();
});
const initialEndpoints = Array.isArray(apiKey?.allowedEndpoints) ? apiKey.allowedEndpoints : [];
const [selectedEndpoints, setSelectedEndpoints] = useState<string[]>(initialEndpoints);
const [allowAllEndpoints, setAllowAllEndpoints] = useState(initialEndpoints.length === 0);
// Memoize callbacks to prevent child re-renders
const handleToggleModel = useCallback(
(modelId: string) => {
@@ -1304,6 +1313,16 @@ const PermissionsModal = memo(function PermissionsModal({
[allowAllConnections]
);
const handleToggleEndpoint = useCallback(
(categoryId: string) => {
if (allowAllEndpoints) return;
setSelectedEndpoints((prev) =>
prev.includes(categoryId) ? prev.filter((e) => e !== categoryId) : [...prev, categoryId]
);
},
[allowAllEndpoints]
);
const handleSave = useCallback(() => {
// Clear previous inline errors
setNameError(null);
@@ -1351,7 +1370,8 @@ const PermissionsModal = memo(function PermissionsModal({
maxSessions,
schedule,
rateLimits.length > 0 ? rateLimits : null,
manageEnabled ? ["manage"] : []
manageEnabled ? ["manage"] : [],
allowAllEndpoints ? [] : selectedEndpoints
);
}, [
onSave,
@@ -1376,6 +1396,8 @@ const PermissionsModal = memo(function PermissionsModal({
scheduleDays,
scheduleTz,
rateLimits,
allowAllEndpoints,
selectedEndpoints,
t,
]);
@@ -2155,6 +2177,81 @@ const PermissionsModal = memo(function PermissionsModal({
</div>
)}
{/* Allowed Endpoints Section */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("endpointRestrictions")}</p>
<p className="text-xs text-text-muted">
{allowAllEndpoints
? t("allEndpointsAllowed")
: t("endpointsRestricted", {
count: selectedEndpoints.length,
})}
</p>
</div>
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
<button
onClick={() => {
setAllowAllEndpoints(true);
setSelectedEndpoints([]);
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
allowAllEndpoints
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{t("all")}
</button>
<button
onClick={() => setAllowAllEndpoints(false)}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
!allowAllEndpoints
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{t("restrict")}
</button>
</div>
</div>
{!allowAllEndpoints && (
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto">
{ENDPOINT_CATEGORIES.map((cat) => {
const isSelected = selectedEndpoints.includes(cat.id);
return (
<button
key={cat.id}
onClick={() => handleToggleEndpoint(cat.id)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-all ${
isSelected
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
}`}
>
<div
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
isSelected ? "bg-primary border-primary" : "border-border"
}`}
>
{isSelected && (
<span className="material-symbols-outlined text-white text-[10px]">
check
</span>
)}
</div>
<span className="truncate flex-1">{cat.label}</span>
<span className="text-[10px] text-text-muted shrink-0 truncate max-w-[140px]">
{cat.description}
</span>
</button>
);
})}
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2">
<Button onClick={handleSave} fullWidth>

View File

@@ -1470,6 +1470,9 @@
"keyCreatedNote": "Copy and store this key now — it won't be shown again.",
"done": "Done",
"savePermissions": "Save Permissions",
"endpointRestrictions": "Allowed Endpoints",
"allEndpointsAllowed": "This key can access all API endpoints.",
"endpointsRestricted": "Restricted to {count} endpoint{count, plural, one {} other {s}}.",
"autoResolve": "Auto-Resolve",
"autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.",
"keyActive": "Key Active",

View File

@@ -59,6 +59,7 @@ interface ApiKeyMetadata {
scopes: string[];
isBanned: boolean;
keyHash: string | null;
allowedEndpoints: string[];
}
interface ApiKeyRow extends JsonRecord {
@@ -119,6 +120,7 @@ interface ApiKeyView extends JsonRecord {
scopes: string[];
isBanned?: boolean;
expiresAt?: string | null;
allowedEndpoints: string[];
}
// LRU cache for API key validation (valid keys only)
@@ -153,6 +155,7 @@ const API_KEY_COLUMN_FALLBACKS = [
{ name: "rate_limits", definition: "rate_limits TEXT" },
{ name: "is_banned", definition: "is_banned INTEGER NOT NULL DEFAULT 0" },
{ name: "key_hash", definition: "key_hash TEXT" },
{ name: "allowed_endpoints", definition: "allowed_endpoints TEXT" },
] as const;
// Cache for model permission checks
@@ -352,7 +355,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
"SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?"
);
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
"SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash FROM api_keys WHERE key = ? OR key_hash = ?"
"SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints FROM api_keys WHERE key = ? OR key_hash = ?"
);
_stmtInsertKey = db.prepare(
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
@@ -397,6 +400,7 @@ export async function getApiKeys() {
camelRow.rateLimits = parseRateLimits(camelRow.rateLimits);
camelRow.isBanned = parseIsBanned(camelRow.isBanned);
camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes);
camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints);
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -420,6 +424,7 @@ export async function getApiKeyById(id: string) {
camelRow.rateLimits = parseRateLimits(camelRow.rateLimits);
camelRow.isBanned = parseIsBanned(camelRow.isBanned);
camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes);
camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints);
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -655,6 +660,7 @@ export async function updateApiKeyPermissions(
// T08: max concurrent sessions for this key (0 = unlimited)
maxSessions?: number | null;
scopes?: string[] | null;
allowedEndpoints?: string[] | null;
}
) {
const db = getDbInstance() as ApiKeysDbLike;
@@ -680,6 +686,7 @@ export async function updateApiKeyPermissions(
expiresAt: update.expiresAt,
maxSessions: (update as { maxSessions?: number | null }).maxSessions,
scopes: (update as { scopes?: string[] | null }).scopes,
allowedEndpoints: (update as { allowedEndpoints?: string[] | null }).allowedEndpoints,
};
if (
@@ -698,7 +705,8 @@ export async function updateApiKeyPermissions(
normalized.isBanned === undefined &&
normalized.expiresAt === undefined &&
(normalized as Record<string, unknown>).maxSessions === undefined &&
(normalized as Record<string, unknown>).scopes === undefined
(normalized as Record<string, unknown>).scopes === undefined &&
(normalized as Record<string, unknown>).allowedEndpoints === undefined
) {
return false;
}
@@ -805,6 +813,17 @@ export async function updateApiKeyPermissions(
params.maxSessions = typeof maxSessionsUpdate === "number" ? Math.max(0, maxSessionsUpdate) : 0;
}
const allowedEndpointsUpdate = (normalized as Record<string, unknown>).allowedEndpoints;
if (allowedEndpointsUpdate !== undefined) {
updates.push("allowed_endpoints = @allowedEndpoints");
const nextEndpoints: string[] = Array.isArray(allowedEndpointsUpdate)
? (allowedEndpointsUpdate as unknown[]).filter(
(s): s is string => typeof s === "string"
)
: [];
(params as Record<string, unknown>).allowedEndpoints = JSON.stringify(nextEndpoints);
}
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")
@@ -1151,6 +1170,7 @@ export async function getApiKeyMetadata(
isBanned: false,
keyHash: null,
scopes: ["manage"],
allowedEndpoints: [],
};
}
@@ -1205,6 +1225,9 @@ export async function getApiKeyMetadata(
scopes: parseStringList((record as JsonRecord).scopes),
isBanned: parseIsBanned(record.is_banned ?? (record as JsonRecord).isBanned),
keyHash: (record.key_hash ?? (record as JsonRecord).keyHash) as string | null,
allowedEndpoints: parseStringList(
(record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints
),
};
if (!metadata.id) {

View File

@@ -0,0 +1,132 @@
/**
* Endpoint Category Definitions — API key endpoint restrictions.
*
* Each category maps a stable ID to a set of `/v1/` route prefixes.
* The `resolveEndpointCategory()` function maps an incoming request path
* to its category for policy enforcement.
*
* Empty `allowedEndpoints` on a key = all endpoints allowed (backward compatible).
*
* @module shared/constants/endpointCategories
*/
export interface EndpointCategory {
id: string;
label: string;
description: string;
prefixes: string[];
}
export const ENDPOINT_CATEGORIES: readonly EndpointCategory[] = [
{
id: "chat",
label: "Chat / Messages",
description: "Chat completions, text completions, messages, and responses",
prefixes: [
"/v1/chat/completions",
"/v1/completions",
"/v1/messages",
"/v1/responses",
],
},
{
id: "search",
label: "Web Search",
description: "Web search and search analytics",
prefixes: ["/v1/search"],
},
{
id: "embeddings",
label: "Embeddings",
description: "Text embeddings generation",
prefixes: ["/v1/embeddings"],
},
{
id: "images",
label: "Images",
description: "Image generation and editing",
prefixes: ["/v1/images"],
},
{
id: "audio",
label: "Audio / Speech",
description: "Text-to-speech and speech-to-text",
prefixes: ["/v1/audio"],
},
{
id: "video",
label: "Video",
description: "Video generation",
prefixes: ["/v1/videos"],
},
{
id: "music",
label: "Music",
description: "Music generation",
prefixes: ["/v1/music"],
},
{
id: "rerank",
label: "Rerank",
description: "Document reranking",
prefixes: ["/v1/rerank"],
},
{
id: "models",
label: "Models",
description: "List available models (read-only)",
prefixes: ["/v1/models"],
},
{
id: "moderations",
label: "Moderations",
description: "Content moderation",
prefixes: ["/v1/moderations"],
},
{
id: "batches",
label: "Batch Processing",
description: "Batch API operations",
prefixes: ["/v1/batches"],
},
{
id: "files",
label: "Files",
description: "File upload and management",
prefixes: ["/v1/files"],
},
{
id: "web-fetch",
label: "Web Fetch",
description: "Web page fetching",
prefixes: ["/v1/web"],
},
{
id: "agents",
label: "Agents / A2A",
description: "Agent-to-agent protocol and task execution",
prefixes: ["/v1/agents"],
},
] as const;
/**
* Sorted longest-prefix-first so the most specific match wins
* (e.g. `/v1/chat/completions` before `/v1/chat`).
*/
const SORTED_PREFIXES: readonly { prefix: string; categoryId: string }[] =
ENDPOINT_CATEGORIES.flatMap((cat) =>
cat.prefixes.map((prefix) => ({ prefix, categoryId: cat.id }))
).sort((a, b) => b.prefix.length - a.prefix.length);
/**
* Map a request pathname to its endpoint category ID.
* Returns `null` if the path doesn't match any category (e.g. management routes).
*/
export function resolveEndpointCategory(pathname: string): string | null {
for (const { prefix, categoryId } of SORTED_PREFIXES) {
if (pathname === prefix || pathname.startsWith(prefix + "/")) {
return categoryId;
}
}
return null;
}

View File

@@ -16,6 +16,7 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "@/sse/utils/logger";
import { checkRateLimit, RateLimitRule } from "./rateLimiter";
import { resolveEndpointCategory } from "@/shared/constants/endpointCategories";
// Default to no per-key request cap. API keys can still opt into explicit
// limits via Settings/API Manager, while provider/account quota controls remain
@@ -74,6 +75,7 @@ export interface ApiKeyMetadata {
throttleDelayMs?: number | null;
maxSessions?: number | null;
rateLimits?: RateLimitRule[] | null;
allowedEndpoints?: string[];
}
/**
@@ -294,6 +296,26 @@ export async function enforceApiKeyPolicy(
}
}
// ── Check 2.5: Endpoint restriction ──
if (apiKeyInfo.allowedEndpoints && apiKeyInfo.allowedEndpoints.length > 0) {
try {
const url = new URL(request.url);
const category = resolveEndpointCategory(url.pathname);
if (category && !apiKeyInfo.allowedEndpoints.includes(category)) {
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(
HTTP_STATUS.FORBIDDEN,
`Endpoint category "${category}" is not allowed for this API key`
),
};
}
} catch {
// URL parse failure — fail open, let other checks decide
}
}
// ── Check 3: Model restriction ──
let requestedComboName: string | null = null;
if (modelStr && apiKeyInfo.allowedCombos && apiKeyInfo.allowedCombos.length > 0) {

View File

@@ -1758,6 +1758,7 @@ export const updateKeyPermissionsSchema = z
])
.optional(),
scopes: z.array(z.string().trim().min(1).max(64)).max(16).optional(),
allowedEndpoints: z.array(z.string().trim().min(1).max(64)).max(20).optional(),
})
.superRefine((value, ctx) => {
if (
@@ -1774,7 +1775,8 @@ export const updateKeyPermissionsSchema = z
value.maxSessions === undefined &&
value.accessSchedule === undefined &&
value.rateLimits === undefined &&
value.scopes === undefined
value.scopes === undefined &&
value.allowedEndpoints === undefined
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,

View File

@@ -0,0 +1,115 @@
/**
* Unit tests for endpoint category resolution and API key endpoint restrictions.
*
* Tests:
* 1. resolveEndpointCategory — path → category mapping
* 2. enforceApiKeyPolicy — endpoint restriction enforcement
*/
import test from "node:test";
import assert from "node:assert/strict";
// ─── resolveEndpointCategory: pure function tests ─────────────────────────
// Import the pure resolver without DB dependencies
const { resolveEndpointCategory } = await import(
"../../src/shared/constants/endpointCategories.ts"
);
test("resolveEndpointCategory: maps /v1/chat/completions to 'chat'", () => {
assert.equal(resolveEndpointCategory("/v1/chat/completions"), "chat");
});
test("resolveEndpointCategory: maps /v1/completions to 'chat'", () => {
assert.equal(resolveEndpointCategory("/v1/completions"), "chat");
});
test("resolveEndpointCategory: maps /v1/messages to 'chat'", () => {
assert.equal(resolveEndpointCategory("/v1/messages"), "chat");
});
test("resolveEndpointCategory: maps /v1/responses to 'chat'", () => {
assert.equal(resolveEndpointCategory("/v1/responses"), "chat");
});
test("resolveEndpointCategory: maps /v1/search to 'search'", () => {
assert.equal(resolveEndpointCategory("/v1/search"), "search");
});
test("resolveEndpointCategory: maps /v1/search/analytics to 'search'", () => {
assert.equal(resolveEndpointCategory("/v1/search/analytics"), "search");
});
test("resolveEndpointCategory: maps /v1/embeddings to 'embeddings'", () => {
assert.equal(resolveEndpointCategory("/v1/embeddings"), "embeddings");
});
test("resolveEndpointCategory: maps /v1/images/generations to 'images'", () => {
assert.equal(resolveEndpointCategory("/v1/images/generations"), "images");
});
test("resolveEndpointCategory: maps /v1/images/edits to 'images'", () => {
assert.equal(resolveEndpointCategory("/v1/images/edits"), "images");
});
test("resolveEndpointCategory: maps /v1/audio/speech to 'audio'", () => {
assert.equal(resolveEndpointCategory("/v1/audio/speech"), "audio");
});
test("resolveEndpointCategory: maps /v1/audio/transcriptions to 'audio'", () => {
assert.equal(resolveEndpointCategory("/v1/audio/transcriptions"), "audio");
});
test("resolveEndpointCategory: maps /v1/videos/generations to 'video'", () => {
assert.equal(resolveEndpointCategory("/v1/videos/generations"), "video");
});
test("resolveEndpointCategory: maps /v1/music/generations to 'music'", () => {
assert.equal(resolveEndpointCategory("/v1/music/generations"), "music");
});
test("resolveEndpointCategory: maps /v1/rerank to 'rerank'", () => {
assert.equal(resolveEndpointCategory("/v1/rerank"), "rerank");
});
test("resolveEndpointCategory: maps /v1/models to 'models'", () => {
assert.equal(resolveEndpointCategory("/v1/models"), "models");
});
test("resolveEndpointCategory: maps /v1/moderations to 'moderations'", () => {
assert.equal(resolveEndpointCategory("/v1/moderations"), "moderations");
});
test("resolveEndpointCategory: maps /v1/batches to 'batches'", () => {
assert.equal(resolveEndpointCategory("/v1/batches"), "batches");
});
test("resolveEndpointCategory: maps /v1/files to 'files'", () => {
assert.equal(resolveEndpointCategory("/v1/files"), "files");
});
test("resolveEndpointCategory: maps /v1/web/fetch to 'web-fetch'", () => {
assert.equal(resolveEndpointCategory("/v1/web/fetch"), "web-fetch");
});
test("resolveEndpointCategory: maps /v1/agents/tasks to 'agents'", () => {
assert.equal(resolveEndpointCategory("/v1/agents/tasks"), "agents");
});
test("resolveEndpointCategory: returns null for unknown path", () => {
assert.equal(resolveEndpointCategory("/v1/unknown"), null);
});
test("resolveEndpointCategory: returns null for management /api/keys", () => {
assert.equal(resolveEndpointCategory("/api/keys"), null);
});
test("resolveEndpointCategory: returns null for root path", () => {
assert.equal(resolveEndpointCategory("/"), null);
});
test("resolveEndpointCategory: handles sub-paths under category", () => {
assert.equal(resolveEndpointCategory("/v1/files/some-file-id"), "files");
assert.equal(resolveEndpointCategory("/v1/batches/batch-123"), "batches");
assert.equal(resolveEndpointCategory("/v1/responses/some/path"), "chat");
});

View File

@@ -0,0 +1,210 @@
/**
* Unit tests for API key endpoint restriction enforcement through enforceApiKeyPolicy.
*
* These tests require the full DB stack (same pattern as api-key-policy.test.ts).
*/
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";
import { pathToFileURL } from "node:url";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ep-policy-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "task-ep-policy-secret";
const coreDb = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const costRules = await import("../../src/domain/costRules.ts");
const rateLimiter = await import("../../src/shared/utils/rateLimiter.ts");
rateLimiter.setRateLimiterTestMode(true);
async function resetStorage() {
apiKeysDb.resetApiKeyState();
costRules.resetCostData();
coreDb.resetDbInstance();
for (let attempt = 0; attempt < 10; attempt++) {
try {
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
break;
} catch (error: any) {
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
} else {
throw error;
}
}
}
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
async function loadPolicy(label: string) {
const modulePath = path.join(process.cwd(), "src/shared/utils/apiKeyPolicy.ts");
return import(`${pathToFileURL(modulePath).href}?case=${label}-${Date.now()}`);
}
async function createKeyWithEndpoints(allowedEndpoints: string[]) {
const created = await apiKeysDb.createApiKey("EP Test Key", "machine-ep");
if (allowedEndpoints.length > 0) {
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedEndpoints });
}
return created;
}
function makeRequest(url: string, apiKey?: string) {
return new Request(url, {
method: "POST",
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
});
}
async function readErrorMessage(response: Response) {
const body = (await response.json()) as any;
return body.error.message as string;
}
test.beforeEach(async () => {
delete process.env.DEFAULT_RATE_LIMIT_PER_DAY;
await resetStorage();
});
test.after(async () => {
apiKeysDb.resetApiKeyState();
costRules.resetCostData();
coreDb.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ─── Policy tests ─────────────────────────────────────────────────────────
test("no restriction — all endpoints allowed", async () => {
const policy = await loadPolicy("no-endpoint-restriction");
const key = await createKeyWithEndpoints([]);
const request = makeRequest("http://localhost/v1/chat/completions", key.key);
const result = await policy.enforceApiKeyPolicy(request, "gpt-4");
assert.equal(result.rejection, null);
});
test("search-only key allows /v1/search", async () => {
const policy = await loadPolicy("search-only-allowed");
const key = await createKeyWithEndpoints(["search"]);
const request = makeRequest("http://localhost/v1/search", key.key);
const result = await policy.enforceApiKeyPolicy(request, "search");
assert.equal(result.rejection, null);
});
test("search-only key blocks /v1/chat/completions", async () => {
const policy = await loadPolicy("search-blocks-chat");
const key = await createKeyWithEndpoints(["search"]);
const request = makeRequest("http://localhost/v1/chat/completions", key.key);
const result = await policy.enforceApiKeyPolicy(request, "gpt-4");
assert.ok(result.rejection, "Should reject the request");
assert.equal(result.rejection.status, 403);
const msg = await readErrorMessage(result.rejection);
assert.ok(msg.includes("chat"), `Error message should mention 'chat', got: ${msg}`);
});
test("chat+embeddings key allows /v1/embeddings", async () => {
const policy = await loadPolicy("chat-emb-allowed");
const key = await createKeyWithEndpoints(["chat", "embeddings"]);
const request = makeRequest("http://localhost/v1/embeddings", key.key);
const result = await policy.enforceApiKeyPolicy(request, "text-embedding-3");
assert.equal(result.rejection, null);
});
test("chat-only key blocks /v1/embeddings", async () => {
const policy = await loadPolicy("chat-blocks-emb");
const key = await createKeyWithEndpoints(["chat"]);
const request = makeRequest("http://localhost/v1/embeddings", key.key);
const result = await policy.enforceApiKeyPolicy(request, "text-embedding-3");
assert.ok(result.rejection, "Should reject the request");
assert.equal(result.rejection.status, 403);
const msg = await readErrorMessage(result.rejection);
assert.ok(
msg.includes("embeddings"),
`Error message should mention 'embeddings', got: ${msg}`
);
});
test("search-only key blocks /v1/images/generations", async () => {
const policy = await loadPolicy("search-blocks-images");
const key = await createKeyWithEndpoints(["search"]);
const request = makeRequest("http://localhost/v1/images/generations", key.key);
const result = await policy.enforceApiKeyPolicy(request, "dall-e-3");
assert.ok(result.rejection, "Should reject the request");
assert.equal(result.rejection.status, 403);
});
test("no API key — endpoint check skipped", async () => {
const policy = await loadPolicy("no-key-endpoint");
const request = makeRequest("http://localhost/v1/chat/completions");
const result = await policy.enforceApiKeyPolicy(request, "gpt-4");
assert.equal(result.rejection, null);
});
test("search-only key allows /v1/search/analytics", async () => {
const policy = await loadPolicy("search-analytics-allowed");
const key = await createKeyWithEndpoints(["search"]);
const request = makeRequest("http://localhost/v1/search/analytics", key.key);
const result = await policy.enforceApiKeyPolicy(request, "analytics");
assert.equal(result.rejection, null);
});
// ─── DB persistence tests ──────────────────────────────────────────────────
test("updateApiKeyPermissions: persists allowedEndpoints", async () => {
const key = await apiKeysDb.createApiKey("EP Persist Key", "machine-persist");
await apiKeysDb.updateApiKeyPermissions(key.id, {
allowedEndpoints: ["search", "embeddings"],
});
const meta = await apiKeysDb.getApiKeyMetadata(key.key);
assert.ok(meta, "Metadata should exist");
assert.deepEqual(meta.allowedEndpoints, ["search", "embeddings"]);
});
test("updateApiKeyPermissions: empty allowedEndpoints", async () => {
const key = await apiKeysDb.createApiKey("EP All Key", "machine-all");
await apiKeysDb.updateApiKeyPermissions(key.id, {
allowedEndpoints: [],
});
const meta = await apiKeysDb.getApiKeyMetadata(key.key);
assert.ok(meta, "Metadata should exist");
assert.deepEqual(meta.allowedEndpoints, []);
});
test("getApiKeys: returns allowedEndpoints in listing", async () => {
const key = await apiKeysDb.createApiKey("EP List Key", "machine-list");
await apiKeysDb.updateApiKeyPermissions(key.id, {
allowedEndpoints: ["chat"],
});
const keys = await apiKeysDb.getApiKeys();
const found = keys.find((k: any) => k.id === key.id);
assert.ok(found, "Key should be in listing");
assert.deepEqual(found.allowedEndpoints, ["chat"]);
});