mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
132
src/shared/constants/endpointCategories.ts
Normal file
132
src/shared/constants/endpointCategories.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
115
tests/unit/endpoint-categories.test.ts
Normal file
115
tests/unit/endpoint-categories.test.ts
Normal 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");
|
||||
});
|
||||
210
tests/unit/endpoint-restrictions-policy.test.ts
Normal file
210
tests/unit/endpoint-restrictions-policy.test.ts
Normal 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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user