Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 100 additions & 3 deletions src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -88,6 +89,7 @@ interface ApiKey {
accessSchedule?: AccessSchedule | null;
rateLimits?: Array<{ limit: number; window: number }> | null;
scopes?: string[];
allowedEndpoints?: string[];
createdAt: string;
}

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -521,6 +524,7 @@ export default function ApiManagerPageClient() {
accessSchedule,
rateLimits,
scopes,
allowedEndpoints,
}),
});

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1351,7 +1370,8 @@ const PermissionsModal = memo(function PermissionsModal({
maxSessions,
schedule,
rateLimits.length > 0 ? rateLimits : null,
manageEnabled ? ["manage"] : []
manageEnabled ? ["manage"] : [],
allowAllEndpoints ? [] : selectedEndpoints
);
}, [
onSave,
Expand All @@ -1376,6 +1396,8 @@ const PermissionsModal = memo(function PermissionsModal({
scheduleDays,
scheduleTz,
rateLimits,
allowAllEndpoints,
selectedEndpoints,
t,
]);

Expand Down Expand Up @@ -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>
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 23 additions & 2 deletions src/lib/db/apiKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ interface ApiKeyMetadata {
scopes: string[];
isBanned: boolean;
keyHash: string | null;
allowedEndpoints: string[];
}

interface ApiKeyRow extends JsonRecord {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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 (
Expand All @@ -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;
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -1151,6 +1170,7 @@ export async function getApiKeyMetadata(
isBanned: false,
keyHash: null,
scopes: ["manage"],
allowedEndpoints: [],
};
}

Expand Down Expand Up @@ -1205,6 +1225,7 @@ 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).allowedEndpoints),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

In getApiKeyMetadata, rowToCamel is not called on the database row. Therefore, the database column allowed_endpoints is not converted to camelCase. Accessing (record as JsonRecord).allowedEndpoints directly will always return undefined, resulting in an empty array and bypassing the endpoint restrictions for all API requests. You should check both record.allowed_endpoints and record.allowedEndpoints to ensure backward compatibility and correct behavior.

Suggested change
allowedEndpoints: parseStringList((record as JsonRecord).allowedEndpoints),
allowedEndpoints: parseStringList(record.allowed_endpoints ?? (record as JsonRecord).allowedEndpoints),

};

if (!metadata.id) {
Expand Down
Loading