diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 4ecc840ecf..ddcb22f911 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -12,9 +12,12 @@ import { isKeyActive, isExpired, isRestricted as isKeyRestricted, + buildModelAccessSavePayload, classifyKeyStatus, computeApiKeyCounts, + formatProviderModelPermissionSummary, formatUsdCost, + restoreProviderScopeSelection, toLocalDateTimeInputValue, toggleKeyVisibility, } from "./apiManagerPageUtils"; @@ -28,6 +31,7 @@ import { UsageLimitSettings } from "./components/UsageLimitSettings"; import { ChaosModeAccessToggle } from "./components/ChaosModeAccessToggle"; import { BypassProviderQuotaToggle } from "./components/BypassProviderQuotaToggle"; import { ApiKeyCompressionToggle } from "./components/ApiKeyCompressionToggle"; +import ProviderModelPermissionList from "./components/ProviderModelPermissionList"; import ReasoningRoutingRules from "@/shared/components/ReasoningRoutingRules"; // Constants for validation @@ -112,6 +116,8 @@ interface ApiKey { name: string; key: string; allowedModels: string[] | null; + /** Public shape: "all" | "restricted". Absent on legacy keys. */ + modelAccessMode?: "all" | "restricted" | null; blockedModels?: string[] | null; allowedCombos: string[] | null; allowedConnections: string[] | null; @@ -799,7 +805,8 @@ export default function ApiManagerPageClient() { dailyUsageLimitUsd: number | null, weeklyUsageLimitUsd: number | null, blockedModels: string[], - chaosModeEnabled: boolean + chaosModeEnabled: boolean, + modelAccessMode: "all" | "restricted" ) => { if (!editingKey || !editingKey.id) return; @@ -849,6 +856,7 @@ export default function ApiManagerPageClient() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: sanitizedName, + modelAccessMode, allowedModels: validModels, blockedModels: validBlockedModels, allowedCombos: validCombos, @@ -1038,7 +1046,15 @@ export default function ApiManagerPageClient() { (() => { const renderKeyRow = (key: ApiKey) => { const stats = usageStats[key.id]; - const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0; + const isRestricted = isKeyRestricted(key); + const isModelRestricted = + key.modelAccessMode === "restricted" || + (Array.isArray(key.allowedModels) && key.allowedModels.length > 0); + const { providerWildcards, exactModels } = restoreProviderScopeSelection( + Array.isArray(key.allowedModels) ? key.allowedModels : [] + ); + const providerCount = providerWildcards.length; + const modelCount = exactModels.length; const hasComboRestrictions = Array.isArray(key.allowedCombos) && key.allowedCombos.length > 0; const hasConnectionRestrictions = @@ -1138,13 +1154,13 @@ export default function ApiManagerPageClient() { )} {/* Existing badges */} - {isRestricted ? ( + {isModelRestricted ? ( ) : ( + + + + {claudeCodeFamiliesExpanded && ( +
+
+ )} + + ); + } + + const provider = scope.slice(0, -2); + return ( + + {provider} - - - {claudeCodeFamiliesExpanded && ( -
-
- )} - - ); - } - - return ( - - - {getModelDisplayName(modelId)} - - - - ); - })} - + + {getModelDisplayName(modelId)} + + + + ))} + + + )} )} {/* Search and Model Selection (only in restrict mode) */} {!allowAll && ( - <> -
- onSearchChange(e.target.value)} - placeholder={t("searchModels")} - icon="search" - /> - {searchModel && ( - - )} -
- -
- {modelsByProvider.length === 0 ? ( -
- search_off -

{t("noModelsFound")}

-
- ) : ( - modelsByProvider.map(([provider, models]) => { - const selectedInProvider = selectedModels.filter((m) => - models.some((model) => model.id === m) - ).length; - const allSelected = models.every((m) => selectedModels.includes(m.id)); - const someSelected = selectedInProvider > 0 && !allSelected; - - return ( -
- - - {/* Expandable model list */} - {expandedProviders.has(provider) && ( -
-
- {models.map((model) => { - const isSelected = selectedModels.includes(model.id); - return ( - - ); - })} -
-
- )} -
- ); - }) - )} -
- + )} {/* Allowed Connections Section */} diff --git a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts index 39474592f0..7c89d9460b 100644 --- a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts @@ -12,6 +12,8 @@ export interface ApiKeyShape { scopes?: string[]; allowedModels?: string[] | null; allowedConnections?: string[] | null; + /** Public shape: "all" | "restricted". Absent on legacy keys. */ + modelAccessMode?: "all" | "restricted" | null; } export function isKeyActive(k: ApiKeyShape): boolean { @@ -31,6 +33,10 @@ export function isExpired(k: ApiKeyShape): boolean { } export function isRestricted(k: ApiKeyShape): boolean { + // Explicit restricted mode classifies as restricted even with an empty + // allow-list (restricted + zero selections means deny-all). Legacy keys + // without a mode keep the historical "empty means allow all" semantics. + if (k.modelAccessMode === "restricted") return true; const hasModelRestrictions = Array.isArray(k.allowedModels) && k.allowedModels.length > 0; const hasConnectionRestrictions = Array.isArray(k.allowedConnections) && k.allowedConnections.length > 0; @@ -125,3 +131,158 @@ export function toggleKeyVisibility(prev: Set, keyId: string): Set m.id === entry); + const fallback = options?.providerId ?? options?.ownedBy; + return model?.owned_by ?? model?.providerId ?? entry.split("/")[0] ?? fallback ?? entry; +} + +/** + * Resolve the canonical owner id of a catalog model. Prefers the catalog + * `owned_by` (alias-prefixed ids map to their canonical owner), then the + * provided provider fallback, then the model-id prefix. + */ +function resolveModelProvider(modelId: string, options?: ProviderScopeOptions): string { + const model = options?.providerModels?.find((m) => m.id === modelId); + return ( + model?.owned_by ?? + model?.providerId ?? + options?.ownedBy ?? + options?.providerId ?? + modelId.split("/")[0] + ); +} + +/** + * Toggle a canonical `provider/*` scope on/off inside a selection list. + * + * Selecting: removes every currently known exact entry owned by the provider + * (exact id-prefix children, alias-prefixed catalog children and the raw + * wildcard alike), then appends the canonical wildcard. Unknown/offline rules + * and exact selections from other providers are preserved. + * Deselecting: removes only that provider's wildcard, preserving everything else. + */ +export function toggleProviderWildcardSelection( + selected: string[], + providerId: string, + options?: ProviderScopeOptions +): string[] { + const wildcard = `${providerId}/*`; + if (selected.includes(wildcard)) { + return selected.filter((entry) => entry !== wildcard); + } + const kept = selected.filter( + (entry) => + providerWildcardId(entry) !== null || resolveEntryProvider(entry, options) !== providerId + ); + return [...kept, wildcard]; +} + +/** + * Whether a model is covered by a selected `provider/*` scope. Ownership is + * resolved via the canonical owner (catalog `owned_by` / provided fallback), + * not the model-id prefix, so alias-prefixed catalog ids still inherit. + */ +export function isModelInheritedByProviderScope( + selected: string[], + modelId: string, + options?: ProviderScopeOptions +): boolean { + const owner = resolveModelProvider(modelId, options); + return owner != null && selected.includes(`${owner}/*`); +} + +/** + * Whether a model may be toggled individually. Models inherited through a + * selected provider scope are disabled/non-toggleable; manual exact models + * from other providers stay toggleable. + */ +export function isModelIndividuallyToggleable( + selected: string[], + modelId: string, + options?: ProviderScopeOptions +): boolean { + return !isModelInheritedByProviderScope(selected, modelId, options); +} + +/** + * Split persisted allowedModels back into provider wildcards and exact model + * entries so reopening the modal restores provider-level selections. + * `providerModels` is accepted for forward-compatible owner normalization but + * is not required: wildcard entries are already canonical. + */ +export function restoreProviderScopeSelection( + allowedModels: string[], + _options?: { providerModels?: ProviderModelRef[] } +): { providerWildcards: string[]; exactModels: string[] } { + void _options; + const providerWildcards: string[] = []; + const exactModels: string[] = []; + for (const entry of allowedModels) { + if (providerWildcardId(entry) !== null) providerWildcards.push(entry); + else exactModels.push(entry); + } + return { providerWildcards, exactModels }; +} + +export function formatProviderModelPermissionSummary( + providerCount: number, + modelCount: number, + t: (key: string, values?: Record) => string, + tc: (key: string) => string +): string { + const providerLabel = + providerCount > 0 + ? `${providerCount} ${tc(providerCount === 1 ? "provider" : "providers")}` + : ""; + const modelLabel = modelCount > 0 ? t("modelsCount", { count: modelCount }) : ""; + return ( + [providerLabel, modelLabel].filter(Boolean).join(" · ") || t("selectedCount", { count: 0 }) + ); +} + +/** + * Persisted payload for the model access section. Allow All always saves an + * empty allow-list with mode "all"; Restrict (including zero selections) + * saves the exact selection with mode "restricted". + */ +export function buildModelAccessSavePayload(input: { + allowAll: boolean; + selectedModels: string[]; +}): { modelAccessMode: "all" | "restricted"; allowedModels: string[] } { + if (input.allowAll) return { modelAccessMode: "all", allowedModels: [] }; + return { modelAccessMode: "restricted", allowedModels: input.selectedModels }; +} diff --git a/src/app/(dashboard)/dashboard/api-manager/components/ProviderModelPermissionList.tsx b/src/app/(dashboard)/dashboard/api-manager/components/ProviderModelPermissionList.tsx new file mode 100644 index 0000000000..0924238c15 --- /dev/null +++ b/src/app/(dashboard)/dashboard/api-manager/components/ProviderModelPermissionList.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { getProviderDisplayName } from "@/lib/display/names"; +import { Input } from "@/shared/components"; +import { + isModelIndividuallyToggleable, + restoreProviderScopeSelection, + toggleProviderWildcardSelection, +} from "../apiManagerPageUtils"; +import type { ProviderModelRef } from "../apiManagerPageUtils"; + +export interface ProviderPermissionModel { + id: string; + owned_by: string; + name?: string; +} + +/** Tuple type for models grouped by provider display label. */ +export type ProviderGroup = [provider: string, models: ProviderPermissionModel[]]; + +interface ProviderModelPermissionListProps { + /** Search-filtered groups to render. */ + modelsByProvider: ProviderGroup[]; + /** Unfiltered catalog (provider selection spans the full provider, not just filtered rows). */ + allModels: ProviderPermissionModel[]; + selectedModels: string[]; + expandedProviders: Set; + searchModel: string; + onSearchChange: (value: string) => void; + onToggleExpand: (provider: string) => void; + onSelectionChange: (next: string[]) => void; + getModelDisplayName: (modelId: string) => string; + onClaudeCodeDefaultDeselected?: () => void; +} + +/** + * Resolve the single canonical provider owner for a visible group, or null + * when the group merges multiple owners (e.g. shared display label) or is + * combo-owned — those groups keep the legacy snapshot-selection behavior. + */ +function resolveGroupProviderOwner(models: ProviderPermissionModel[]): string | null { + let owner: string | null = null; + for (const model of models) { + const modelOwner = model.owned_by; + if (!modelOwner || modelOwner === "combo") return null; + if (owner === null) owner = modelOwner; + else if (modelOwner !== owner) return null; + } + return owner; +} + +function getModelRef(model: ProviderPermissionModel): ProviderModelRef { + return { id: model.id, owned_by: model.owned_by }; +} + +function ProviderSelectionCheckbox({ + provider, + checked, + indeterminate, + onChange, +}: { + provider: string; + checked: boolean; + indeterminate: boolean; + onChange: () => void; +}) { + const inputRef = useRef(null); + useEffect(() => { + if (inputRef.current) inputRef.current.indeterminate = indeterminate; + }, [indeterminate]); + + return ( + + ); +} + +const ProviderModelPermissionList = memo(function ProviderModelPermissionList({ + modelsByProvider, + allModels, + selectedModels, + expandedProviders, + searchModel, + onSearchChange, + onToggleExpand, + onSelectionChange, + getModelDisplayName, + onClaudeCodeDefaultDeselected, +}: ProviderModelPermissionListProps) { + const t = useTranslations("apiManager"); + + const providerModelRefs = useMemo(() => allModels.map(getModelRef), [allModels]); + + const allModelsByOwner = useMemo(() => { + const grouped = new Map(); + for (const model of allModels) { + const list = grouped.get(model.owned_by); + const ref = getModelRef(model); + if (list) list.push(ref); + else grouped.set(model.owned_by, [ref]); + } + return grouped; + }, [allModels]); + + const { providerWildcards } = useMemo( + () => restoreProviderScopeSelection(selectedModels), + [selectedModels] + ); + const selectedProviderScopes = useMemo(() => new Set(providerWildcards), [providerWildcards]); + + const groupOwners = useMemo( + () => + new Map( + modelsByProvider.map(([provider, models]) => [provider, resolveGroupProviderOwner(models)]) + ), + [modelsByProvider] + ); + + const handleProviderCheckedChange = useCallback( + (provider: string) => { + const owner = groupOwners.get(provider); + if (!owner) return; + const next = toggleProviderWildcardSelection(selectedModels, owner, { + providerId: owner, + providerModels: allModelsByOwner.get(owner) ?? providerModelRefs, + }); + onSelectionChange(next); + }, + [groupOwners, selectedModels, allModelsByOwner, providerModelRefs, onSelectionChange] + ); + + const handleSnapshotToggleProvider = useCallback( + (provider: string, models: ProviderPermissionModel[]) => { + // Legacy behavior for groups without a single real owner: toggle the + // exact ids across the full unfiltered catalog for that display label, + // not just the currently search-filtered subset. + const ownerModels = allModels.filter( + (model) => (getProviderDisplayName(model.owned_by) || model.owned_by) === provider + ); + const modelIds = (ownerModels.length > 0 ? ownerModels : models).map((model) => model.id); + const allSelected = modelIds.every((id) => selectedModels.includes(id)); + if (allSelected) { + onSelectionChange(selectedModels.filter((id) => !modelIds.includes(id))); + } else { + onSelectionChange([...new Set([...selectedModels, ...modelIds])]); + } + }, + [allModels, selectedModels, onSelectionChange] + ); + + const handleToggleModel = useCallback( + (model: ProviderPermissionModel) => { + if (!isModelIndividuallyToggleable(selectedModels, model.id, { ownedBy: model.owned_by })) + return; + if (selectedModels.includes(model.id)) { + if (onClaudeCodeDefaultDeselected) onClaudeCodeDefaultDeselected(); + onSelectionChange(selectedModels.filter((id) => id !== model.id)); + } else { + onSelectionChange([...selectedModels, model.id]); + } + }, + [selectedModels, onSelectionChange, onClaudeCodeDefaultDeselected] + ); + + return ( + <> +
+ onSearchChange(e.target.value)} + placeholder={t("searchModels")} + icon="search" + /> + {searchModel && ( + + )} +
+ +
+ {modelsByProvider.length === 0 ? ( +
+ search_off +

{t("noModelsFound")}

+
+ ) : ( + modelsByProvider.map(([provider, models]) => { + const owner = groupOwners.get(provider) ?? null; + const useProviderScope = owner !== null; + const providerScopeSelected = useProviderScope + ? selectedProviderScopes.has(`${owner}/*`) + : false; + const selectedInProvider = selectedModels.filter((m) => + models.some((model) => model.id === m) + ).length; + const allSelected = useProviderScope + ? providerScopeSelected + : models.every((m) => selectedModels.includes(m.id)); + const someSelected = !allSelected && (selectedInProvider > 0 || providerScopeSelected); + const selectedCount = useProviderScope ? models.length : selectedInProvider; + + return ( +
+
+ +
+ { + if (useProviderScope) handleProviderCheckedChange(provider); + else handleSnapshotToggleProvider(provider, models); + }} + /> + + + {models.length} + +
+ {selectedCount > 0 && ( + + {selectedCount} + + )} +
+ + {/* Expandable model list */} + {expandedProviders.has(provider) && ( +
+
+ {models.map((model) => { + const isSelected = + providerScopeSelected || selectedModels.includes(model.id); + const isToggleable = isModelIndividuallyToggleable( + selectedModels, + model.id, + { ownedBy: model.owned_by } + ); + return ( + + ); + })} +
+
+ )} +
+ ); + }) + )} +
+ + ); +}); + +export default ProviderModelPermissionList; diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index 5b8283553d..541a294f04 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -65,6 +65,7 @@ export async function PATCH(request, { params }) { } const { name, + modelAccessMode, allowedModels, blockedModels, allowedCombos, @@ -93,6 +94,7 @@ export async function PATCH(request, { params }) { const payload: Parameters[1] = {}; if (name !== undefined) payload.name = name; + if (modelAccessMode !== undefined) payload.modelAccessMode = modelAccessMode; if (allowedModels !== undefined) payload.allowedModels = allowedModels; if (blockedModels !== undefined) payload.blockedModels = blockedModels; if (allowedCombos !== undefined) payload.allowedCombos = allowedCombos; @@ -130,6 +132,7 @@ export async function PATCH(request, { params }) { return NextResponse.json({ message: "API key settings updated successfully", ...(name !== undefined && { name }), + ...(modelAccessMode !== undefined && { modelAccessMode }), ...(allowedModels !== undefined && { allowedModels }), ...(blockedModels !== undefined && { blockedModels }), ...(allowedCombos !== undefined && { allowedCombos }), diff --git a/src/app/api/settings/import-json/route.ts b/src/app/api/settings/import-json/route.ts index da32da8ddd..a08423dc8a 100644 --- a/src/app/api/settings/import-json/route.ts +++ b/src/app/api/settings/import-json/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from "next/server"; import { getDbInstance } from "@/lib/db/core"; import { backupDbFile } from "@/lib/db/backup"; +import { clearApiKeyCaches } from "@/lib/db/apiKeys"; +import { invalidateDbCache } from "@/lib/db/readCache"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { runJsonMigration, type LegacyJsonData } from "@/lib/db/jsonMigration"; import { getSettings } from "@/lib/db/settings"; @@ -67,6 +69,8 @@ export async function POST(request: Request) { // Delegate the actual migration to the shared helper (avoids duplication with core.ts) const counts = runJsonMigration(db, data); + clearApiKeyCaches(); + invalidateDbCache(); // Re-hydrate the in-memory Global System Prompt config — the migration writes it to // the DB but the in-memory state would stay stale until a restart otherwise (#2470). diff --git a/src/lib/db/apiKeyColumnFallbacks.ts b/src/lib/db/apiKeyColumnFallbacks.ts index af23f89908..a550198322 100644 --- a/src/lib/db/apiKeyColumnFallbacks.ts +++ b/src/lib/db/apiKeyColumnFallbacks.ts @@ -1,5 +1,10 @@ export const API_KEY_COLUMN_FALLBACKS = [ { name: "allowed_models", definition: "allowed_models TEXT" }, + { + name: "model_access_mode", + definition: + "model_access_mode TEXT NOT NULL DEFAULT 'all' CHECK (model_access_mode IN ('all', 'restricted'))", + }, { name: "blocked_models", definition: "blocked_models TEXT" }, { name: "allowed_combos", definition: "allowed_combos TEXT" }, { name: "no_log", definition: "no_log INTEGER NOT NULL DEFAULT 0" }, diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 5869216933..5129df0a7e 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -49,6 +49,7 @@ import { parseCacheDefaultMode, parseChaosModeEnabled, parseCompressionEnabled, + parseModelAccessMode, } from "./apiKeys/rowParsers"; import { clearModelPermissionCache, @@ -56,6 +57,11 @@ import { setCachedModelPermission, evictModelPermissionCache, } from "./apiKeys/modelPermissionCache"; +import type { ModelAccessMode } from "./apiKeys/modelAccessMode"; +import { + normalizeApiKeyPermissionsUpdate, + type ApiKeyPermissionsUpdate, +} from "./apiKeys/permissionsUpdate"; import { getModelCatalogCacheVersion, invalidateModelCatalogCache } from "./readCache"; import type { AccessSchedule, RateLimitRule } from "./apiKeys/types"; @@ -77,6 +83,7 @@ interface ApiKeyMetadata { id: string; name: string; machineId: string | null; + modelAccessMode: ModelAccessMode; allowedModels: string[]; blockedModels: string[]; allowedCombos: string[]; @@ -118,6 +125,8 @@ interface ApiKeyRow extends JsonRecord { machineId?: unknown; allowed_models?: unknown; allowedModels?: unknown; + model_access_mode?: unknown; + modelAccessMode?: unknown; blocked_models?: unknown; blockedModels?: unknown; allowed_combos?: unknown; @@ -177,6 +186,7 @@ interface ApiKeysStatements { interface ApiKeyView extends JsonRecord { id?: string; + modelAccessMode: ModelAccessMode; allowedModels: string[]; blockedModels: string[]; allowedCombos: string[]; @@ -409,7 +419,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( - "SELECT id, name, machine_id, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, 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, stream_default_mode, cache_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, compression_enabled, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?", + "SELECT id, name, machine_id, model_access_mode, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, 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, stream_default_mode, cache_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, compression_enabled, proxy_id 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", @@ -450,6 +460,10 @@ export async function getApiKeys(limit?: number, offset?: number) { } return rows.map((row) => { const camelRow = toRecord(rowToCamel(row)) as ApiKeyView; + camelRow.modelAccessMode = parseModelAccessMode( + camelRow.modelAccessMode, + camelRow.allowedModels + ); camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels); camelRow.blockedModels = parseAllowedModels(camelRow.blockedModels); camelRow.allowedCombos = parseAllowedCombos(camelRow.allowedCombos); @@ -521,6 +535,7 @@ export async function pickApiKeyForInternalUse( revokedAt?: string | null; isBanned?: boolean; scopes?: string[]; + modelAccessMode?: ModelAccessMode; allowedModels?: string[]; lastUsedAt?: string | number | null; }>; @@ -536,7 +551,11 @@ export async function pickApiKeyForInternalUse( // 2. Allow-all key (empty allowedModels means no model restrictions). const allowAllKey = keys.find( - (k) => isUsable(k) && Array.isArray(k.allowedModels) && k.allowedModels.length === 0, + (k) => + isUsable(k) && + k.modelAccessMode !== "restricted" && + Array.isArray(k.allowedModels) && + k.allowedModels.length === 0 ); if (allowAllKey?.key) return allowAllKey.key; @@ -564,6 +583,7 @@ export async function getApiKeyById(id: string) { const row = stmt.getKeyById.get(id); if (!row) return null; const camelRow = toRecord(rowToCamel(row)) as ApiKeyView; + camelRow.modelAccessMode = parseModelAccessMode(camelRow.modelAccessMode, camelRow.allowedModels); camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels); camelRow.blockedModels = parseAllowedModels(camelRow.blockedModels); camelRow.allowedCombos = parseAllowedCombos(camelRow.allowedCombos); @@ -620,6 +640,7 @@ export async function createApiKey(name: string, machineId: string, scopes: stri name: name, key: result.key, machineId: machineId, + modelAccessMode: "all" as const, allowedModels: [], // Empty array means all models allowed allowedCombos: [], // Empty array means no explicit combo restriction allowedConnections: [], // Empty array means all connections allowed @@ -683,85 +704,24 @@ export async function regenerateApiKey(id: string) { export async function updateApiKeyPermissions( id: string, - update: - | string[] - | { - name?: string; - allowedModels?: string[]; - blockedModels?: string[]; - allowedCombos?: string[]; - allowedConnections?: string[]; - allowedQuotas?: string[]; - noLog?: boolean; - autoResolve?: boolean; - isActive?: boolean; - accessSchedule?: AccessSchedule | null; - maxRequestsPerDay?: number | null; - maxRequestsPerMinute?: number | null; - throttleDelayMs?: number | null; - rateLimits?: RateLimitRule[] | null; - isBanned?: boolean; - expiresAt?: string | null; - // T08: max concurrent sessions for this key (0 = unlimited) - maxSessions?: number | null; - scopes?: string[] | null; - proxyId?: string | null; - allowedEndpoints?: string[] | null; - streamDefaultMode?: "legacy" | "json" | null; - cacheDefaultMode?: "legacy" | "bypass" | null; - disableNonPublicModels?: boolean; - allowUsageCommand?: boolean; - usageLimitEnabled?: boolean; - dailyUsageLimitUsd?: number | null; - weeklyUsageLimitUsd?: number | null; - chaosModeEnabled?: boolean; - compressionEnabled?: boolean; - }, + update: string[] | ApiKeyPermissionsUpdate, ) { const db = getDbInstance() as ApiKeysDbLike; getPreparedStatements(db); - const normalized = - Array.isArray(update) || update === undefined - ? { allowedModels: update || [] } - : { - name: update.name, - allowedModels: update.allowedModels, - blockedModels: update.blockedModels, - allowedCombos: update.allowedCombos, - allowedConnections: update.allowedConnections, - allowedQuotas: (update as { allowedQuotas?: string[] }).allowedQuotas, - noLog: update.noLog, - autoResolve: update.autoResolve, - isActive: update.isActive, - accessSchedule: update.accessSchedule, - maxRequestsPerDay: update.maxRequestsPerDay, - maxRequestsPerMinute: update.maxRequestsPerMinute, - throttleDelayMs: update.throttleDelayMs, - rateLimits: update.rateLimits, - isBanned: update.isBanned, - expiresAt: update.expiresAt, - maxSessions: (update as { maxSessions?: number | null }).maxSessions, - scopes: (update as { scopes?: string[] | null }).scopes, - proxyId: (update as { proxyId?: string | null }).proxyId, - allowedEndpoints: (update as { allowedEndpoints?: string[] | null }).allowedEndpoints, - streamDefaultMode: (update as { streamDefaultMode?: "legacy" | "json" | null }) - .streamDefaultMode, - cacheDefaultMode: (update as { cacheDefaultMode?: "legacy" | "bypass" | null }) - .cacheDefaultMode, - disableNonPublicModels: (update as { disableNonPublicModels?: boolean }) - .disableNonPublicModels, - allowUsageCommand: (update as { allowUsageCommand?: boolean }).allowUsageCommand, - usageLimitEnabled: (update as { usageLimitEnabled?: boolean }).usageLimitEnabled, - dailyUsageLimitUsd: (update as { dailyUsageLimitUsd?: number | null }).dailyUsageLimitUsd, - weeklyUsageLimitUsd: (update as { weeklyUsageLimitUsd?: number | null }) - .weeklyUsageLimitUsd, - chaosModeEnabled: (update as { chaosModeEnabled?: boolean }).chaosModeEnabled, - compressionEnabled: (update as { compressionEnabled?: boolean }).compressionEnabled, - }; + const normalized = normalizeApiKeyPermissionsUpdate(update); + const shouldInvalidateModelCatalog = + normalized.modelAccessMode !== undefined || + normalized.allowedModels !== undefined || + normalized.blockedModels !== undefined || + normalized.allowedCombos !== undefined || + normalized.allowedConnections !== undefined || + normalized.allowedQuotas !== undefined || + normalized.disableNonPublicModels !== undefined; if ( normalized.name === undefined && + normalized.modelAccessMode === undefined && normalized.allowedModels === undefined && normalized.blockedModels === undefined && normalized.allowedCombos === undefined && @@ -796,6 +756,7 @@ export async function updateApiKeyPermissions( const params: { id: string; name?: string; + modelAccessMode?: ModelAccessMode; allowedModels?: string; blockedModels?: string; allowedCombos?: string; @@ -830,10 +791,13 @@ export async function updateApiKeyPermissions( params.name = normalized.name; } + if (normalized.modelAccessMode !== undefined) { + updates.push("model_access_mode = @modelAccessMode"); + params.modelAccessMode = normalized.modelAccessMode; + } if (normalized.allowedModels !== undefined) { - // Empty array means all models are allowed updates.push("allowed_models = @allowedModels"); - params.allowedModels = JSON.stringify(normalized.allowedModels || []); + params.allowedModels = JSON.stringify(normalized.allowedModels); } if (normalized.blockedModels !== undefined) { @@ -1028,16 +992,6 @@ export async function updateApiKeyPermissions( if (changedRows === 0) return false; - const invalidatesModelCatalogCache = - normalized.allowedModels !== undefined || - normalized.blockedModels !== undefined || - allowedQuotasUpdate !== undefined || - normalized.disableNonPublicModels !== undefined; - - if (invalidatesModelCatalogCache) { - invalidateModelCatalogCache(); - } - const { logAuditEvent } = await import("@/lib/compliance"); if (normalized.isBanned !== undefined) { @@ -1090,8 +1044,9 @@ export async function updateApiKeyPermissions( setNoLog(id, normalized.noLog); } - // Invalidate caches since permissions changed + // Invalidate per-key policy and filtered model-catalog caches after the atomic write. invalidateCaches(); + if (shouldInvalidateModelCatalog) invalidateModelCatalogCache(); await deleteRedisAuthCacheForKeyId(db, id); @@ -1311,6 +1266,7 @@ export async function getApiKeyMetadata( id: "env-key", name: "Environment Key", machineId: "server-env", + modelAccessMode: "all", allowedModels: [], blockedModels: [], allowedCombos: [], @@ -1370,11 +1326,16 @@ export async function getApiKeyMetadata( const rawMaxSessions = record.max_sessions ?? record.maxSessions; + const rawAllowedModels = record.allowed_models ?? record.allowedModels; const metadata: ApiKeyMetadata = { id: metadataId, name: metadataName, machineId: metadataMachineId, - allowedModels: parseAllowedModels(record.allowed_models ?? record.allowedModels), + modelAccessMode: parseModelAccessMode( + record.model_access_mode ?? record.modelAccessMode, + rawAllowedModels + ), + allowedModels: parseAllowedModels(rawAllowedModels), blockedModels: parseAllowedModels(record.blocked_models ?? record.blockedModels), allowedCombos: parseAllowedCombos(record.allowed_combos ?? record.allowedCombos), allowedConnections: parseAllowedConnections( @@ -1471,7 +1432,7 @@ export async function isModelAllowedForKey( // SECURITY: Key not found in database = deny access (invalid/non-existent key) if (!metadata) return false; - const { allowedModels, blockedModels, disableNonPublicModels } = metadata; + const { modelAccessMode, allowedModels, blockedModels, disableNonPublicModels } = metadata; const modelPermissionCandidates = await getModelPermissionCandidates(modelId); // Deny-list patterns win over any allow-list entry. This lets operators keep @@ -1506,6 +1467,10 @@ export async function isModelAllowedForKey( } } + // Only explicit allow-all permits an empty list; restricted + [] is deny-all. + if (!allowedModels || allowedModels.length === 0) { + return modelAccessMode !== "restricted"; + } // Support exact match and prefix match (e.g., "openai/*" allows all OpenAI models) let allowed = !allowedModels || diff --git a/src/lib/db/apiKeys/modelAccessMode.ts b/src/lib/db/apiKeys/modelAccessMode.ts new file mode 100644 index 0000000000..7b982f751a --- /dev/null +++ b/src/lib/db/apiKeys/modelAccessMode.ts @@ -0,0 +1,44 @@ +export type ModelAccessMode = "all" | "restricted"; + +function hasLegacyModelRestriction(value: unknown): boolean { + if (Array.isArray(value)) return value.length > 0; + if (typeof value !== "string") return false; + + const trimmed = value.trim(); + if (trimmed === "" || trimmed === "[]" || trimmed === "null") return false; + + try { + const parsed: unknown = JSON.parse(trimmed); + return Array.isArray(parsed) ? parsed.length > 0 : parsed !== null; + } catch { + return true; + } +} + +/** Non-empty legacy allow-lists always remain restrictive, even beside a bad `all` mode. */ +export function parseModelAccessMode(value: unknown, rawAllowedModels?: unknown): ModelAccessMode { + if (hasLegacyModelRestriction(rawAllowedModels)) return "restricted"; + return value === "restricted" ? "restricted" : "all"; +} + +export function normalizeModelAccessUpdate( + modelAccessMode: ModelAccessMode | undefined, + allowedModels: string[] | undefined +): { modelAccessMode?: ModelAccessMode; allowedModels?: string[] } { + if (modelAccessMode === "all") { + return { modelAccessMode: "all", allowedModels: [] }; + } + if (modelAccessMode === "restricted") { + return { + modelAccessMode: "restricted", + ...(allowedModels !== undefined && { allowedModels }), + }; + } + if (allowedModels !== undefined) { + return { + modelAccessMode: allowedModels.length > 0 ? "restricted" : "all", + allowedModels, + }; + } + return {}; +} diff --git a/src/lib/db/apiKeys/modelPermissions.ts b/src/lib/db/apiKeys/modelPermissions.ts index 9d5605a92a..ee1780fbd7 100644 --- a/src/lib/db/apiKeys/modelPermissions.ts +++ b/src/lib/db/apiKeys/modelPermissions.ts @@ -67,9 +67,8 @@ export function modelPatternMatches(pattern: string, candidates: string[]): bool if (pattern === candidate) return true; if (pattern.endsWith("/*")) { const prefix = pattern.slice(0, -2); - if (candidate.startsWith(prefix + "/") || candidate.startsWith(prefix)) { - return true; - } + if (candidate.startsWith(prefix + "/")) return true; + continue; } if (pattern.includes("*") && matchesWildcardPattern(pattern, candidate)) { return true; diff --git a/src/lib/db/apiKeys/permissionsUpdate.ts b/src/lib/db/apiKeys/permissionsUpdate.ts new file mode 100644 index 0000000000..98e4799aa0 --- /dev/null +++ b/src/lib/db/apiKeys/permissionsUpdate.ts @@ -0,0 +1,77 @@ +import type { AccessSchedule, RateLimitRule } from "./types"; +import { normalizeModelAccessUpdate, type ModelAccessMode } from "./modelAccessMode"; + +export interface ApiKeyPermissionsUpdate { + name?: string; + modelAccessMode?: ModelAccessMode; + allowedModels?: string[]; + blockedModels?: string[]; + allowedCombos?: string[]; + allowedConnections?: string[]; + allowedQuotas?: string[]; + noLog?: boolean; + autoResolve?: boolean; + isActive?: boolean; + accessSchedule?: AccessSchedule | null; + maxRequestsPerDay?: number | null; + maxRequestsPerMinute?: number | null; + throttleDelayMs?: number | null; + rateLimits?: RateLimitRule[] | null; + isBanned?: boolean; + expiresAt?: string | null; + maxSessions?: number | null; + scopes?: string[] | null; + proxyId?: string | null; + allowedEndpoints?: string[] | null; + streamDefaultMode?: "legacy" | "json" | null; + cacheDefaultMode?: "legacy" | "bypass" | null; + disableNonPublicModels?: boolean; + allowUsageCommand?: boolean; + usageLimitEnabled?: boolean; + dailyUsageLimitUsd?: number | null; + weeklyUsageLimitUsd?: number | null; + chaosModeEnabled?: boolean; + compressionEnabled?: boolean; +} + +export function normalizeApiKeyPermissionsUpdate( + update: string[] | ApiKeyPermissionsUpdate | undefined +): ApiKeyPermissionsUpdate { + if (update === undefined) { + return normalizeModelAccessUpdate(undefined, []); + } + if (Array.isArray(update)) { + return normalizeModelAccessUpdate(undefined, update); + } + return { + name: update.name, + ...normalizeModelAccessUpdate(update.modelAccessMode, update.allowedModels), + blockedModels: update.blockedModels, + allowedCombos: update.allowedCombos, + allowedConnections: update.allowedConnections, + allowedQuotas: update.allowedQuotas, + noLog: update.noLog, + autoResolve: update.autoResolve, + isActive: update.isActive, + accessSchedule: update.accessSchedule, + maxRequestsPerDay: update.maxRequestsPerDay, + maxRequestsPerMinute: update.maxRequestsPerMinute, + throttleDelayMs: update.throttleDelayMs, + rateLimits: update.rateLimits, + isBanned: update.isBanned, + expiresAt: update.expiresAt, + maxSessions: update.maxSessions, + scopes: update.scopes, + proxyId: update.proxyId, + allowedEndpoints: update.allowedEndpoints, + streamDefaultMode: update.streamDefaultMode, + cacheDefaultMode: update.cacheDefaultMode, + disableNonPublicModels: update.disableNonPublicModels, + allowUsageCommand: update.allowUsageCommand, + usageLimitEnabled: update.usageLimitEnabled, + dailyUsageLimitUsd: update.dailyUsageLimitUsd, + weeklyUsageLimitUsd: update.weeklyUsageLimitUsd, + chaosModeEnabled: update.chaosModeEnabled, + compressionEnabled: update.compressionEnabled, + }; +} diff --git a/src/lib/db/apiKeys/rowParsers.ts b/src/lib/db/apiKeys/rowParsers.ts index 9f43787421..86be2bd155 100644 --- a/src/lib/db/apiKeys/rowParsers.ts +++ b/src/lib/db/apiKeys/rowParsers.ts @@ -9,6 +9,8 @@ */ import type { AccessSchedule, RateLimitRule } from "./types"; +export { parseModelAccessMode } from "./modelAccessMode"; +export type { ModelAccessMode } from "./modelAccessMode"; /** * Helper function to safely parse allowed_models JSON diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index e2c29be2dd..202d76be9f 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -38,6 +38,7 @@ import { migrateLegacyEncryptedString } from "./encryption"; import { invalidateDbCache } from "./readCache"; import { rowToCamel } from "./caseMapping"; import { isAutomatedTestProcess } from "@/shared/utils/testProcess"; +import { parseModelAccessMode } from "./apiKeys/modelAccessMode"; // Re-exported so existing call sites that pull these helpers off the core module keep working. export { toSnakeCase, toCamelCase, objToSnake, rowToCamel, cleanNulls } from "./caseMapping"; import { @@ -1580,11 +1581,9 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) { updatedAt: normalizedCombo.updatedAt || new Date().toISOString(), }); } - - // 5. API Keys const insertKey = db.prepare(` - INSERT OR REPLACE INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) - VALUES (@id, @name, @key, @machineId, @allowedModels, @noLog, @createdAt) + INSERT OR REPLACE INTO api_keys (id, name, key, machine_id, model_access_mode, allowed_models, no_log, created_at) + VALUES (@id, @name, @key, @machineId, @modelAccessMode, @allowedModels, @noLog, @createdAt) `); for (const apiKey of data.apiKeys || []) { insertKey.run({ @@ -1592,6 +1591,7 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) { name: apiKey.name, key: apiKey.key, machineId: apiKey.machineId || null, + modelAccessMode: parseModelAccessMode(apiKey.modelAccessMode, apiKey.allowedModels), allowedModels: JSON.stringify(apiKey.allowedModels || []), noLog: apiKey.noLog ? 1 : 0, createdAt: apiKey.createdAt || new Date().toISOString(), diff --git a/src/lib/db/jsonMigration.ts b/src/lib/db/jsonMigration.ts index 89bad747ed..320de5f6b8 100644 --- a/src/lib/db/jsonMigration.ts +++ b/src/lib/db/jsonMigration.ts @@ -15,6 +15,7 @@ import type { SqliteAdapter } from "./adapters/types"; import { normalizeRoutingStrategy } from "@/shared/constants/routingStrategies"; import { normalizeComboRecord } from "@/lib/combos/steps"; import { validateComboInvariant } from "@/lib/combos/invariants"; +import { parseModelAccessMode } from "./apiKeys/modelAccessMode"; import { resolveImportedUsageAccountIdentity, resolveOrphanedUsageAccountIdentity, @@ -99,8 +100,11 @@ export function runJsonMigration( `); const insertKey = db.prepare(` - INSERT OR REPLACE INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) - VALUES (@id, @name, @key, @machineId, @allowedModels, @noLog, @createdAt) + INSERT OR REPLACE INTO api_keys ( + id, name, key, machine_id, model_access_mode, allowed_models, no_log, created_at + ) VALUES ( + @id, @name, @key, @machineId, @modelAccessMode, @allowedModels, @noLog, @createdAt + ) `); const migrate = db.transaction(() => { @@ -219,12 +223,14 @@ export function runJsonMigration( // 6. API Keys for (const apiKey of data.apiKeys ?? []) { + const allowedModels = Array.isArray(apiKey.allowedModels) ? apiKey.allowedModels : []; insertKey.run({ id: apiKey.id, name: apiKey.name, key: apiKey.key, machineId: apiKey.machineId ?? null, - allowedModels: JSON.stringify(apiKey.allowedModels ?? []), + modelAccessMode: parseModelAccessMode(apiKey.modelAccessMode, allowedModels), + allowedModels: JSON.stringify(allowedModels), noLog: apiKey.noLog ? 1 : 0, createdAt: apiKey.createdAt ?? new Date().toISOString(), }); diff --git a/src/lib/db/migrations/147_api_keys_model_access_mode.sql b/src/lib/db/migrations/147_api_keys_model_access_mode.sql new file mode 100644 index 0000000000..af86ca5a73 --- /dev/null +++ b/src/lib/db/migrations/147_api_keys_model_access_mode.sql @@ -0,0 +1,13 @@ +ALTER TABLE api_keys +ADD COLUMN model_access_mode TEXT NOT NULL DEFAULT 'all' +CHECK (model_access_mode IN ('all', 'restricted')); + +UPDATE api_keys +SET model_access_mode = CASE + WHEN allowed_models IS NULL OR trim(allowed_models) = '' THEN 'all' + WHEN json_valid(allowed_models) = 1 AND ( + json_type(allowed_models) = 'null' + OR (json_type(allowed_models) = 'array' AND json_array_length(allowed_models) = 0) + ) THEN 'all' + ELSE 'restricted' +END; diff --git a/src/lib/db/readCache.ts b/src/lib/db/readCache.ts index 936428c5d2..0c87392da1 100644 --- a/src/lib/db/readCache.ts +++ b/src/lib/db/readCache.ts @@ -294,5 +294,5 @@ export function invalidateDbCache( // Settings/connections/combos all feed the unified model catalog builder // (blockedProviders + hidePaidModels, provider connections + excludedModels, // combo definitions, respectively) — pricing does too, via isFreeModel(). - modelCatalogCacheVersion++; + invalidateModelCatalogCache(); } diff --git a/src/lib/sync/bundle.ts b/src/lib/sync/bundle.ts index a9220e7637..3f13edf8fa 100644 --- a/src/lib/sync/bundle.ts +++ b/src/lib/sync/bundle.ts @@ -118,6 +118,7 @@ function sanitizeApiKeyForSync(apiKey: unknown): JsonRecord { "name", "key", "machineId", + "modelAccessMode", "allowedModels", "allowedCombos", "allowedConnections", diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts index c1183452a0..99ca41c192 100644 --- a/src/shared/utils/apiKeyPolicy.ts +++ b/src/shared/utils/apiKeyPolicy.ts @@ -73,6 +73,7 @@ interface AccessSchedule { export interface ApiKeyMetadata { id: string; name?: string; + modelAccessMode?: "all" | "restricted"; allowedModels?: string[]; allowedCombos?: string[]; allowedConnections?: string[]; @@ -319,7 +320,8 @@ async function validateStandardRoutingTarget( } const hasModelRestrictions = - (apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) || + apiKeyInfo.modelAccessMode === "restricted" || + Boolean(apiKeyInfo.allowedModels?.length) || apiKeyInfo.disableNonPublicModels === true; if (!requestedComboName && hasModelRestrictions && modelStr.startsWith("auto/")) { requestedComboName = modelStr; @@ -525,7 +527,9 @@ async function validateModelAccess(context: PolicyContext): Promise { + if (value.modelAccessMode === "all" && value.allowedModels && value.allowedModels.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "allowedModels must be empty when modelAccessMode is 'all'", + path: ["allowedModels"], + }); + } if ( value.name === undefined && + value.modelAccessMode === undefined && value.allowedModels === undefined && value.allowedCombos === undefined && value.allowedConnections === undefined && diff --git a/tests/e2e/api-keys-flow.spec.ts b/tests/e2e/api-keys-flow.spec.ts index 9ec5c5bcfc..d69863520f 100644 --- a/tests/e2e/api-keys-flow.spec.ts +++ b/tests/e2e/api-keys-flow.spec.ts @@ -11,6 +11,8 @@ type ApiKeyRecord = { fullKey: string; allowedModels: string[] | null; allowedConnections: string[] | null; + /** Public shape: "all" | "restricted". Absent on legacy keys. */ + modelAccessMode?: "all" | "restricted" | null; createdAt: string; }; @@ -640,4 +642,325 @@ test.describe("API keys flow", () => { await expect(permissionsDialog).not.toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS }); await expect(page.getByText("Renamed Key")).toBeVisible(); }); + + test("Restrict mode provider scope: selects ollama-cloud/* dynamically plus exact OpenAI model", async ({ + page, + }) => { + // R4 acceptance: selecting the Ollama Cloud provider must PATCH canonical + // ollama-cloud/* (not alias-prefixed snapshot IDs), with modelAccessMode restricted; + // reopening restores the provider checkbox and leaves children non-toggleable. + const state: { + keys: ApiKeyRecord[]; + nextId: number; + patchPayloads: Array>; + } = { + keys: [], + nextId: 1, + patchPayloads: [], + }; + + const catalogModels = [ + { + id: "ollamacloud/llama3", + owned_by: "ollama-cloud", + name: "llama3", + }, + { + id: "ollamacloud/qwen", + owned_by: "ollama-cloud", + name: "qwen", + }, + { + id: "openai/gpt-4.1", + owned_by: "openai", + name: "gpt-4.1", + }, + ]; + + // Override catalog fetches in-page. page.route alone can miss the early client + // catalog load against a reused Playwright server; this keeps the fixture deterministic. + // Note: init-script body is browser-serialized — keep it plain JS (no TS types). + await page.addInitScript((models) => { + const originalFetch = window.fetch.bind(window); + window.fetch = async (input, init) => { + const raw = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const path = String(raw).replace(/^https?:\/\/[^/]+/, ""); + + if (path.startsWith("/v1/models")) { + return new Response(JSON.stringify({ data: models }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + if (path.startsWith("/api/models")) { + return new Response( + JSON.stringify({ + models: models.map((m) => ({ + provider: m.owned_by, + model: m.id.split("/").slice(1).join("/"), + fullModel: m.id, + alias: m.name, + })), + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + if (path.startsWith("/api/combos")) { + return new Response(JSON.stringify({ combos: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + return originalFetch(input, init); + }; + }, catalogModels); + + // Defense in depth: also stub network for non-init-script navigations. + await page.route(/\/v1\/models(?:\?|$)/, async (route) => { + await fulfillJson(route, { data: catalogModels }); + }); + await page.route(/\/api\/models(?:\?|$)/, async (route) => { + await fulfillJson(route, { + models: catalogModels.map((m) => ({ + provider: m.owned_by, + model: m.id.split("/").slice(1).join("/"), + fullModel: m.id, + alias: m.name, + })), + }); + }); + await page.route(/\/api\/combos(?:\?|$)/, async (route) => { + await fulfillJson(route, { combos: [] }); + }); + + await page.route("**/api/settings", async (route) => { + await fulfillJson(route, {}); + }); + + await page.route("**/api/providers", async (route) => { + await fulfillJson(route, { + connections: [ + { + id: "conn-ollama-cloud", + name: "Ollama Cloud", + provider: "ollama-cloud", + isActive: true, + }, + { id: "conn-openai", name: "OpenAI Main", provider: "openai", isActive: true }, + ], + }); + }); + + await page.route(/\/api\/usage\/call-logs(?:\?.*)?$/, async (route) => { + await fulfillJson(route, []); + }); + + await page.route("**/api/sessions", async (route) => { + await fulfillJson(route, { byApiKey: {} }); + }); + + await page.route(/\/api\/keys\/[^/]+$/, async (route) => { + if (route.request().method() === "PATCH") { + const keyId = route.request().url().split("/").pop() || ""; + const payload = (await route.request().postDataJSON()) as { + name?: string; + allowedModels?: string[]; + modelAccessMode?: "all" | "restricted"; + }; + state.patchPayloads.push(payload); + const record = state.keys.find((key) => key.id === keyId); + if (!record) { + await fulfillJson(route, { error: "Key not found" }, 404); + return; + } + if (payload.name) record.name = payload.name; + if (Array.isArray(payload.allowedModels)) { + record.allowedModels = payload.allowedModels; + } + if (payload.modelAccessMode === "all" || payload.modelAccessMode === "restricted") { + record.modelAccessMode = payload.modelAccessMode; + } + await fulfillJson(route, { + message: "API key settings updated successfully", + ...payload, + }); + return; + } + + await fulfillJson(route, { error: "Method not allowed" }, 405); + }); + + await page.route("**/api/keys", async (route) => { + const method = route.request().method(); + + if (method === "GET") { + await fulfillJson(route, { + keys: state.keys.map(({ fullKey, ...record }) => record), + allowKeyReveal: true, + }); + return; + } + + if (method === "POST") { + const payload = (route.request().postDataJSON() as { name?: string }) || {}; + const id = `key-${state.nextId++}`; + const suffix = String(1000 + state.nextId); + const fullKey = `sk-live-${suffix}-demo-secret`; + const maskedKey = `sk-live-****${suffix}`; + state.keys.push({ + id, + name: payload.name || "New Key", + key: maskedKey, + fullKey, + allowedModels: null, + allowedConnections: null, + modelAccessMode: null, + createdAt: new Date("2026-04-05T20:00:00.000Z").toISOString(), + }); + await fulfillJson(route, { key: fullKey, id }); + return; + } + + await fulfillJson(route, { error: "Method not allowed" }, 405); + }); + + await gotoDashboardRoute(page, "/dashboard/api-manager", { + timeoutMs: NAVIGATION_TIMEOUT_MS, + }); + await waitForPageToSettle(page); + await waitForNextDevCompileToFinish(page); + + const createFirstKeyButton = page.getByRole("button", { + name: /create (your )?first key/i, + }); + await expect(createFirstKeyButton).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS }); + await createFirstKeyButton.click(); + + const createDialog = page.getByRole("dialog", { name: /create api key/i }); + await expect(createDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS }); + await createDialog.locator("input").first().fill("Provider Scope Key"); + await createDialog.getByRole("button", { name: /create api key/i }).click({ force: true }); + + const createdDialog = page.getByRole("dialog", { name: /api key created/i }); + await createdDialog.getByRole("button", { name: /done/i }).click(); + await waitForPageToSettle(page); + await waitForNextDevCompileToFinish(page); + await expect(page.getByText("Provider Scope Key")).toBeVisible({ + timeout: UI_STABILITY_TIMEOUT_MS, + }); + + const keyRow = page + .locator("div") + .filter({ has: page.getByText("Provider Scope Key", { exact: true }) }) + .first(); + await keyRow.locator('button[title="Edit permissions"]').click({ force: true }); + + const permissionsDialog = page.getByRole("dialog", { + name: /permissions: provider scope key/i, + }); + await expect(permissionsDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS }); + + // Enter Restrict mode via the primary Access Mode toggle (accessible name includes the lock icon text). + const accessModeRestrict = permissionsDialog.getByRole("button", { + name: /^lock\s+restrict$/i, + }); + await expect(accessModeRestrict).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS }); + await accessModeRestrict.click(); + + // Wait for the mocked catalog to render (search box appears only in Restrict mode). + const modelSearch = permissionsDialog.getByPlaceholder(/search models/i); + await expect(modelSearch).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS }); + await expect(permissionsDialog.getByText(/ollama[- ]?cloud/i).first()).toBeVisible({ + timeout: UI_STABILITY_TIMEOUT_MS, + }); + + const ollamaProviderCheckbox = permissionsDialog.getByRole("checkbox", { + name: /ollama[- ]?cloud/i, + }); + await expect(ollamaProviderCheckbox).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS }); + await ollamaProviderCheckbox.check(); + + // Exact OpenAI model selection coexists with the provider scope. + await modelSearch.fill("openai/gpt-4.1"); + const openaiModelButton = permissionsDialog.getByRole("button", { + name: "openai/gpt-4.1", + }); + await expect(openaiModelButton).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS }); + await openaiModelButton.click(); + await modelSearch.fill(""); + + const saveButton = permissionsDialog.getByRole("button", { name: /save permissions/i }); + await saveButton.click(); + + await expect.poll(() => state.patchPayloads.length).toBeGreaterThan(0); + const patch = state.patchPayloads[state.patchPayloads.length - 1]!; + const allowedModels = Array.isArray(patch.allowedModels) + ? (patch.allowedModels as string[]) + : []; + + expect(patch.modelAccessMode).toBe("restricted"); + expect(allowedModels).toEqual(expect.arrayContaining(["ollama-cloud/*", "openai/gpt-4.1"])); + expect(allowedModels).toContain("ollama-cloud/*"); + expect(allowedModels).toContain("openai/gpt-4.1"); + // Must not snapshot current Ollama catalog IDs (including alias-prefixed ones). + expect(allowedModels).not.toContain("ollamacloud/llama3"); + expect(allowedModels).not.toContain("ollamacloud/qwen"); + expect(allowedModels).not.toContain("ollama-cloud/llama3"); + expect(allowedModels).not.toContain("ollama-cloud/qwen"); + + await expect(permissionsDialog).not.toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS }); + + // Reopen after state/refetch: provider scope restored; children inherited/non-toggleable. + await keyRow.locator('button[title="Edit permissions"]').click({ force: true }); + const reopened = page.getByRole("dialog", { + name: /permissions: provider scope key/i, + }); + await expect(reopened).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS }); + + // Restrict mode should remain selected after reopen. + await expect(reopened.getByRole("button", { name: /^lock\s+restrict$/i })).toHaveClass( + /bg-primary/ + ); + + const reopenedProviderCheckbox = reopened.getByRole("checkbox", { + name: /ollama[- ]?cloud/i, + }); + await expect(reopenedProviderCheckbox).toBeChecked(); + + // Child models under the provider scope are inherited and not individually toggleable. + for (const childId of ["ollamacloud/llama3", "ollamacloud/qwen"]) { + const child = reopened.getByRole("button", { name: childId }); + await expect(child).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS }); + await expect(child).toBeDisabled(); + } + + // Exact OpenAI selection remains individually selected/toggleable. + const reopenedOpenAiModel = reopened.getByRole("button", { name: "openai/gpt-4.1" }); + await expect(reopenedOpenAiModel).toBeEnabled(); + + // Explicit Restrict with no provider/model selections is a persistent deny-all state. + await reopenedProviderCheckbox.uncheck(); + await reopenedOpenAiModel.click(); + await reopened.getByRole("button", { name: /save permissions/i }).click(); + await expect.poll(() => state.patchPayloads.length).toBeGreaterThan(1); + const denyAllPatch = state.patchPayloads[state.patchPayloads.length - 1]!; + expect(denyAllPatch.modelAccessMode).toBe("restricted"); + expect(denyAllPatch.allowedModels).toEqual([]); + + await keyRow.locator('button[title="Edit permissions"]').click({ force: true }); + const denyAllReopened = page.getByRole("dialog", { + name: /permissions: provider scope key/i, + }); + await expect(denyAllReopened).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS }); + await expect(denyAllReopened.getByRole("button", { name: /^lock\s+restrict$/i })).toHaveClass( + /bg-primary/ + ); + }); }); diff --git a/tests/unit/api-manager-page-static.test.ts b/tests/unit/api-manager-page-static.test.ts index 166e2e42cc..005bf3c6e4 100644 --- a/tests/unit/api-manager-page-static.test.ts +++ b/tests/unit/api-manager-page-static.test.ts @@ -9,6 +9,10 @@ const pagePath = path.join( repoRoot, "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx" ); +const providerModelPermissionListPath = path.join( + repoRoot, + "src/app/(dashboard)/dashboard/api-manager/components/ProviderModelPermissionList.tsx" +); const messagesDir = path.join(repoRoot, "src/i18n/messages"); const selfServiceScopeMessageKeys = [ @@ -107,11 +111,12 @@ test("permissions modal persists the per-key prompt-compression switch", () => { test("permissions modal exposes Claude Code default wildcard model", () => { const source = readApiManagerPage(); + const modelListSource = fs.readFileSync(providerModelPermissionListPath, "utf8"); assert.match(source, /const CLAUDE_CODE_DEFAULT_MODEL_ID = "cc\/\*";/); assert.match(source, /const CLAUDE_CODE_DEFAULT_MODEL_NAME = "Claude Code default";/); assert.match(source, /withClaudeCodeDefaultModel\(allModels\)/); - assert.match(source, /getModelDisplayName\(model\.id\)/); + assert.match(modelListSource, /getModelDisplayName\(model\.id\)/); assert.match( source, /modelId === CLAUDE_CODE_DEFAULT_MODEL_ID\s+\?\s+CLAUDE_CODE_DEFAULT_MODEL_NAME\s+:\s+modelId/ @@ -128,7 +133,7 @@ test("permissions modal expands Claude Code default families in selected models assert.match(source, /id: "opus",\s+label: "opus"/); assert.match(source, /id: "sonnet",\s+label: "sonnet"/); assert.match(source, /id: "haiku",\s+label: "haiku"/); - assert.match(source, /const orderedSelectedModels = useMemo/); + assert.match(source, /const orderedSelectedProviderScopes = useMemo/); assert.match(source, /modelId === CLAUDE_CODE_DEFAULT_MODEL_ID/); assert.match(source, /setClaudeCodeFamiliesExpanded/); assert.match( @@ -151,7 +156,10 @@ test("API-key model fallback preserves combo pseudo-models", () => { const source = readApiManagerPage(); const fallbackBlock = source.slice( source.indexOf("const [fallbackRes, combosRes] = await Promise.all"), - source.indexOf("} catch (error)", source.indexOf("const [fallbackRes, combosRes] = await Promise.all")) + source.indexOf( + "} catch (error)", + source.indexOf("const [fallbackRes, combosRes] = await Promise.all") + ) ); assert.match(fallbackBlock, /fetch\("\/api\/models\?all=true"\)/); @@ -161,6 +169,42 @@ test("API-key model fallback preserves combo pseudo-models", () => { assert.match(fallbackBlock, /seen\.has\(m\.id\)/); }); +test("provider wildcard permissions render separately from exact models", () => { + const source = readApiManagerPage(); + + assert.match( + source, + /const \{ providerWildcards, exactModels \} = restoreProviderScopeSelection\(/, + "the API-key row must split provider wildcards from exact model selections" + ); + assert.match(source, /const isModelRestricted =/); + assert.match(source, /const providerCount = providerWildcards\.length;/); + assert.match(source, /const modelCount = exactModels\.length;/); + assert.match(source, /formatProviderModelPermissionSummary\(\s*providerCount,/); + assert.doesNotMatch(source, /modelsCount\", \{ count: key\.allowedModels!\.length \}/); + + const summaryStart = source.indexOf("{/* Selected Models Summary"); + const summaryEnd = source.indexOf("{/* Search and Model Selection", summaryStart); + const summary = source.slice(summaryStart, summaryEnd); + assert.match(summary, /\{tc\("providers"\)\}/); + assert.match(summary, /\{tc\("models"\)\}/); + assert.match(summary, /\{selectedPermissionSummary\}/); + assert.match(summary, /orderedSelectedProviderScopes\.map/); + assert.match(summary, /selectedExactModels\.map/); + assert.doesNotMatch(summary, /orderedSelectedModels\.map/); + + const infoBannerStart = source.indexOf( + "{/* Info Banner */}", + source.indexOf("const PermissionsModal") + ); + const infoBannerEnd = source.indexOf("{/* Key Active Toggle */}", infoBannerStart); + const infoBanner = source.slice(infoBannerStart, infoBannerEnd); + assert.match( + infoBanner, + /selectedProviderCount > 0\s*\? selectedPermissionSummary\s*:\s*totalModels === 0/ + ); +}); + test("self-service API key scope labels do not expose missing placeholders", () => { const messageFiles = fs.readdirSync(messagesDir).filter((file) => file.endsWith(".json")); diff --git a/tests/unit/api-manager-provider-permissions.test.ts b/tests/unit/api-manager-provider-permissions.test.ts new file mode 100644 index 0000000000..5169219a24 --- /dev/null +++ b/tests/unit/api-manager-provider-permissions.test.ts @@ -0,0 +1,795 @@ +/** + * Acceptance: API Manager dynamic provider model permissions + * + * Confirmed examples (traceable rules): + * R1 Legacy empty allowedModels remains allow-all unless explicitly marked restricted. + * R2 Explicit restricted mode with zero provider/model selections is deny-all. + * R3 allowedModels ["ollama-cloud/*", "openai/gpt-4.1"] allows every current/future + * ollama-cloud/... model + that exact OpenAI model; denies unselected providers. + * R3b Namespace boundary: ollama-cloud/* must NOT match ollama-cloudx/model. + * R4 Selecting provider ollama-cloud saves canonical ollama-cloud/* (not snapshot IDs); + * children are inherited/non-individually-toggleable; reopening restores provider scope; + * manual exact selections from other providers coexist; alias-prefixed catalog IDs + * (e.g. ollamacloud/llama3 with owned_by ollama-cloud) map via canonical provider id. + * R5 Management PATCH persists explicit modelAccessMode ("all" | "restricted"). + * + * Public shape under test: modelAccessMode: "all" | "restricted". + */ + +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-provider-perms-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-api-key-secret-provider-perms"; +delete process.env.INITIAL_PASSWORD; +delete process.env.JWT_SECRET; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeys = await import("../../src/lib/db/apiKeys.ts"); +const schemas = await import("../../src/shared/validation/schemas.ts"); +const keysRoute = await import("../../src/app/api/keys/[id]/route.ts"); +const jsonImportRoute = await import("../../src/app/api/settings/import-json/route.ts"); +const jsonMigration = await import("../../src/lib/db/jsonMigration.ts"); +const catalogCache = await import("../../src/app/api/v1/models/catalogCache.ts"); +const apiKeyPolicy = await import("../../src/shared/utils/apiKeyPolicy.ts"); +const pageUtils = + await import("../../src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts"); + +async function resetStorage() { + apiKeys.resetApiKeyState(); + core.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 { + await new Promise((r) => setTimeout(r, 50 * (attempt + 1))); + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + core.getDbInstance(); + catalogCache.__resetCatalogBuilderRunsForTest(); +} + +await resetStorage(); + +test.after(async () => { + apiKeys.resetApiKeyState(); + core.resetDbInstance(); + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +}); + +// ──────────────── R1 / R2 / R3 — DB policy via isModelAllowedForKey ──────────────── + +test("R1: legacy empty allowedModels without restricted mode remains allow-all", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Legacy Allow All", "ma-provider-r1"); + + // Default create: empty allow-list, no explicit restricted mode. + const meta = await apiKeys.getApiKeyMetadata(created.key); + assert.ok(meta); + assert.deepEqual(meta!.allowedModels, []); + // Absent / non-restricted mode must keep the historical allow-all semantics. + assert.notEqual( + (meta as { modelAccessMode?: string | null }).modelAccessMode, + "restricted", + "legacy keys must not be treated as restricted" + ); + + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "ollama-cloud/llama3"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4.1"), true); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "anthropic/claude-3"), true); +}); + +test("R2: explicit restricted mode with zero selections is deny-all", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Restricted Empty", "ma-provider-r2"); + + const updated = await apiKeys.updateApiKeyPermissions(created.id, { + modelAccessMode: "restricted", + allowedModels: [], + } as Parameters[1] & { + modelAccessMode: "restricted"; + }); + assert.equal(updated, true); + apiKeys.resetApiKeyState(); + + // Core policy: restricted + empty selection denies every model (not legacy allow-all). + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "ollama-cloud/llama3"), false); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4.1"), false); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "any/model"), false); + + const meta = await apiKeys.getApiKeyMetadata(created.key); + assert.ok(meta); + assert.deepEqual(meta!.allowedModels, []); + assert.equal( + (meta as { modelAccessMode?: string }).modelAccessMode, + "restricted", + "modelAccessMode must round-trip on metadata" + ); + const policyRejection = await apiKeyPolicy.validateApiKeyRoutingTarget( + new Request("http://localhost/v1/chat/completions"), + created.key, + meta, + "openai/gpt-4.1" + ); + assert.equal(policyRejection?.status, 403, "the request-policy seam must also deny every model"); +}); + +test("R3: provider wildcard + exact model are OR-combined; unselected providers denied", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Provider Union", "ma-provider-r3"); + + await apiKeys.updateApiKeyPermissions(created.id, { + modelAccessMode: "restricted", + allowedModels: ["ollama-cloud/*", "openai/gpt-4.1"], + } as Parameters[1] & { + modelAccessMode: "restricted"; + }); + apiKeys.resetApiKeyState(); + + // Current and future ollama-cloud models inherit the provider scope. + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "ollama-cloud/llama3"), true); + assert.equal( + await apiKeys.isModelAllowedForKey(created.key, "ollamacloud/llama3"), + true, + "canonical provider scope must authorize an alias-prefixed runtime model id" + ); + const metadata = await apiKeys.getApiKeyMetadata(created.key); + const policyRejection = await apiKeyPolicy.validateApiKeyRoutingTarget( + new Request("http://localhost/v1/chat/completions"), + created.key, + metadata, + "ollamacloud/llama3" + ); + assert.equal(policyRejection, null, "the request-policy seam must accept provider inheritance"); + assert.equal( + await apiKeys.isModelAllowedForKey(created.key, "ollama-cloud/new-model-after-import"), + true + ); + // Exact OpenAI selection remains allowed. + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4.1"), true); + // Other OpenAI models and other providers stay denied. + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4o"), false); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "anthropic/claude-3"), false); + + const legacyAlias = await apiKeys.createApiKey("Legacy Alias Scope", "ma-provider-r3-alias"); + await apiKeys.updateApiKeyPermissions(legacyAlias.id, ["ollamacloud/*"]); + assert.equal( + await apiKeys.isModelAllowedForKey(legacyAlias.key, "ollama-cloud/llama3"), + true, + "legacy alias wildcard must continue authorizing the canonical runtime model id" + ); +}); + +test("R3b: provider namespace boundary — ollama-cloud/* denies ollama-cloudx/model", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Namespace Boundary", "ma-provider-r3b"); + + await apiKeys.updateApiKeyPermissions(created.id, { + modelAccessMode: "restricted", + allowedModels: ["ollama-cloud/*"], + } as Parameters[1] & { + modelAccessMode: "restricted"; + }); + apiKeys.resetApiKeyState(); + + // Positive control: true children of the provider scope remain allowed. + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "ollama-cloud/llama3"), true); + // Security: prefix must not leak across provider id boundaries. + assert.equal( + await apiKeys.isModelAllowedForKey(created.key, "ollama-cloudx/model"), + false, + "ollama-cloud/* must not authorize ollama-cloudx/model" + ); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "ollama-cloud-extra/foo"), false); + + // Pure matcher seam (same rule, no DB). + const modelPermissions = await import("../../src/lib/db/apiKeys/modelPermissions.ts"); + assert.equal( + modelPermissions.modelPatternMatches("ollama-cloud/*", ["ollama-cloudx/model"]), + false, + "modelPatternMatches must enforce provider segment boundary" + ); + assert.equal( + modelPermissions.modelPatternMatches("ollama-cloud/*", ["ollama-cloud/llama3"]), + true + ); +}); + +test("R2/R5: JSON import preserves explicit restricted + empty and infers legacy mode", async () => { + await resetStorage(); + + jsonMigration.runJsonMigration(core.getDbInstance(), { + apiKeys: [ + { + id: "json-restricted-empty", + name: "JSON Restricted Empty", + key: "omni_json_restricted_empty", + modelAccessMode: "restricted", + allowedModels: [], + }, + { + id: "json-legacy-restricted", + name: "JSON Legacy Restricted", + key: "omni_json_legacy_restricted", + allowedModels: ["ollama-cloud/*"], + }, + { + id: "json-legacy-all", + name: "JSON Legacy All", + key: "omni_json_legacy_all", + allowedModels: [], + }, + ], + }); + apiKeys.resetApiKeyState(); + + const restrictedEmpty = await apiKeys.getApiKeyMetadata("omni_json_restricted_empty"); + assert.equal(restrictedEmpty?.modelAccessMode, "restricted"); + assert.deepEqual(restrictedEmpty?.allowedModels, []); + assert.equal( + await apiKeys.isModelAllowedForKey("omni_json_restricted_empty", "openai/gpt-4.1"), + false + ); + + const legacyRestricted = await apiKeys.getApiKeyMetadata("omni_json_legacy_restricted"); + assert.equal(legacyRestricted?.modelAccessMode, "restricted"); + assert.equal( + await apiKeys.isModelAllowedForKey("omni_json_legacy_restricted", "ollama-cloud/new-model"), + true + ); + + const legacyAll = await apiKeys.getApiKeyMetadata("omni_json_legacy_all"); + assert.equal(legacyAll?.modelAccessMode, "all"); + assert.equal(await apiKeys.isModelAllowedForKey("omni_json_legacy_all", "any/model"), true); +}); + +test("R2/R5: startup db.json migration preserves explicit restricted-empty mode", async () => { + apiKeys.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + fs.writeFileSync( + path.join(TEST_DATA_DIR, "db.json"), + JSON.stringify({ + apiKeys: [ + { + id: "startup-restricted-empty", + name: "Startup Restricted Empty", + key: "omni_startup_restricted_empty", + modelAccessMode: "restricted", + allowedModels: [], + }, + ], + }) + ); + + core.getDbInstance(); + const metadata = await apiKeys.getApiKeyMetadata("omni_startup_restricted_empty"); + assert.equal(metadata?.modelAccessMode, "restricted"); + assert.deepEqual(metadata?.allowedModels, []); + assert.equal( + await apiKeys.isModelAllowedForKey("omni_startup_restricted_empty", "openai/gpt-4.1"), + false + ); +}); + +test("R2/R5: dashboard JSON import invalidates warm permission and catalog caches", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Import Cache", "ma-provider-import-cache"); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4.1"), true); + + const catalogRequest = () => + new Request("http://localhost/v1/models", { headers: { Authorization: created.key } }); + const headers = { corsHeaders: {}, diagnosticHeaders: {} }; + const catalogPayload = (body: string) => ({ + body, + headers: { "content-type": "application/json" }, + status: 200, + cacheTTL: 60_000, + }); + assert.equal( + await ( + await catalogCache.resolveCachedCatalogResponse(catalogRequest(), headers, async () => + catalogPayload("OLD") + ) + ).text(), + "OLD" + ); + + const response = await jsonImportRoute.POST( + new Request("http://localhost/api/settings/import-json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKeys: [ + { + id: created.id, + name: "Import Cache", + key: created.key, + modelAccessMode: "restricted", + allowedModels: [], + }, + ], + }), + }) + ); + assert.equal(response.status, 200); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4.1"), false); + + let freshBuilds = 0; + const catalogAfterImport = await catalogCache.resolveCachedCatalogResponse( + catalogRequest(), + headers, + async () => { + freshBuilds++; + return catalogPayload("NEW"); + } + ); + assert.equal(await catalogAfterImport.text(), "NEW"); + assert.equal(freshBuilds, 1); +}); + +test("R2/R5: updateApiKeyPermissions persists modelAccessMode on getApiKeyById", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Mode Persist", "ma-provider-r5-db"); + + await apiKeys.updateApiKeyPermissions(created.id, { + modelAccessMode: "restricted", + allowedModels: ["ollama-cloud/*"], + } as Parameters[1] & { + modelAccessMode: "restricted"; + }); + apiKeys.resetApiKeyState(); + + const row = await apiKeys.getApiKeyById(created.id); + assert.ok(row); + assert.equal( + (row as { modelAccessMode?: string }).modelAccessMode, + "restricted", + "getApiKeyById must expose persisted modelAccessMode" + ); + assert.deepEqual(row!.allowedModels, ["ollama-cloud/*"]); + + await apiKeys.updateApiKeyPermissions(created.id, { + modelAccessMode: "all", + allowedModels: [], + } as Parameters[1] & { + modelAccessMode: "all"; + }); + apiKeys.resetApiKeyState(); + + const allRow = await apiKeys.getApiKeyById(created.id); + assert.equal((allRow as { modelAccessMode?: string }).modelAccessMode, "all"); + assert.deepEqual(allRow!.allowedModels, []); +}); + +test("R5: unrelated API key writes retain the filtered model catalog cache", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Catalog Stable", "ma-provider-catalog-stable"); + const request = () => + new Request("http://localhost/v1/models", { headers: { Authorization: created.key } }); + const headers = { corsHeaders: {}, diagnosticHeaders: {} }; + const payload = { + body: "CACHED", + headers: { "content-type": "application/json" }, + status: 200, + cacheTTL: 60_000, + }; + + await catalogCache.resolveCachedCatalogResponse(request(), headers, async () => payload); + await apiKeys.updateApiKeyPermissions(created.id, { name: "Catalog Stable Renamed" }); + + let rebuilds = 0; + const response = await catalogCache.resolveCachedCatalogResponse(request(), headers, async () => { + rebuilds++; + return { ...payload, body: "UNEXPECTED" }; + }); + assert.equal(await response.text(), "CACHED"); + assert.equal(rebuilds, 0); +}); + +test("R4/R5: permission writes detach stale in-flight model catalog builds", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Catalog Invalidation", "ma-provider-catalog"); + const request = () => + new Request("http://localhost/v1/models", { headers: { Authorization: created.key } }); + const headers = { corsHeaders: {}, diagnosticHeaders: {} }; + const payload = (body: string) => ({ + body, + headers: { "content-type": "application/json" }, + status: 200, + cacheTTL: 60_000, + }); + + let releaseOld!: (value: ReturnType) => void; + let oldCalls = 0; + const oldGate = new Promise>((resolve) => { + releaseOld = resolve; + }); + const oldResponsePromise = catalogCache.resolveCachedCatalogResponse(request(), headers, () => { + oldCalls++; + return oldGate; + }); + await Promise.resolve(); + assert.equal(oldCalls, 1); + + await apiKeys.updateApiKeyPermissions(created.id, { + modelAccessMode: "restricted", + allowedModels: ["ollama-cloud/*"], + }); + + let freshCalls = 0; + const freshResponse = await catalogCache.resolveCachedCatalogResponse( + request(), + headers, + async () => { + freshCalls++; + return payload("NEW"); + } + ); + assert.equal(freshCalls, 1, "post-write request must not join the stale pre-write build"); + assert.equal(await freshResponse.text(), "NEW"); + + releaseOld(payload("OLD")); + assert.equal(await (await oldResponsePromise).text(), "OLD"); + + let unexpectedCalls = 0; + const cachedResponse = await catalogCache.resolveCachedCatalogResponse( + request(), + headers, + async () => { + unexpectedCalls++; + return payload("UNEXPECTED"); + } + ); + assert.equal(await cachedResponse.text(), "NEW"); + assert.equal(unexpectedCalls, 0, "stale completion must not overwrite the current catalog cache"); +}); + +// ──────────────── R5 — schema + management PATCH ──────────────── + +test("R5: updateKeyPermissionsSchema accepts and retains modelAccessMode", () => { + const restrictedEmpty = schemas.validateBody(schemas.updateKeyPermissionsSchema, { + modelAccessMode: "restricted", + allowedModels: [], + }); + assert.equal(restrictedEmpty.success, true, "restricted + empty allowedModels must be valid"); + assert.equal( + (restrictedEmpty as { success: true; data: { modelAccessMode?: string } }).data.modelAccessMode, + "restricted", + "schema must retain modelAccessMode (not strip it)" + ); + + const allMode = schemas.validateBody(schemas.updateKeyPermissionsSchema, { + modelAccessMode: "all", + }); + assert.equal(allMode.success, true, "modelAccessMode-only update must be a valid payload"); + assert.equal( + (allMode as { success: true; data: { modelAccessMode?: string } }).data.modelAccessMode, + "all" + ); + + const invalidMode = schemas.validateBody(schemas.updateKeyPermissionsSchema, { + modelAccessMode: "maybe", + }); + assert.equal(invalidMode.success, false, "unknown modelAccessMode values must be rejected"); + + const contradictoryAll = schemas.validateBody(schemas.updateKeyPermissionsSchema, { + modelAccessMode: "all", + allowedModels: ["ollama-cloud/*"], + }); + assert.equal(contradictoryAll.success, false, "all mode with a non-empty allow-list is invalid"); +}); + +test("R1/R5: legacy PATCH payloads infer mode from allowedModels", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Legacy Patch", "ma-provider-legacy-patch"); + + const restrictResponse = await keysRoute.PATCH( + new Request(`http://localhost/api/keys/${created.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ allowedModels: ["ollama-cloud/*"] }), + }), + { params: Promise.resolve({ id: created.id }) } + ); + assert.equal(restrictResponse.status, 200); + apiKeys.resetApiKeyState(); + assert.equal((await apiKeys.getApiKeyById(created.id))?.modelAccessMode, "restricted"); + + const unrelatedResponse = await keysRoute.PATCH( + new Request(`http://localhost/api/keys/${created.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Legacy Patch Renamed" }), + }), + { params: Promise.resolve({ id: created.id }) } + ); + assert.equal(unrelatedResponse.status, 200); + apiKeys.resetApiKeyState(); + const unchanged = await apiKeys.getApiKeyById(created.id); + assert.equal(unchanged?.modelAccessMode, "restricted"); + assert.deepEqual(unchanged?.allowedModels, ["ollama-cloud/*"]); + + const allowAllResponse = await keysRoute.PATCH( + new Request(`http://localhost/api/keys/${created.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ allowedModels: [] }), + }), + { params: Promise.resolve({ id: created.id }) } + ); + assert.equal(allowAllResponse.status, 200); + apiKeys.resetApiKeyState(); + const allowAll = await apiKeys.getApiKeyById(created.id); + assert.equal(allowAll?.modelAccessMode, "all"); + assert.deepEqual(allowAll?.allowedModels, []); +}); + +test("R5: PATCH /api/keys/[id] persists modelAccessMode restricted + empty allow-list", async () => { + await resetStorage(); + const created = await apiKeys.createApiKey("Patch Mode", "ma-provider-r5-patch"); + + const response = await keysRoute.PATCH( + new Request(`http://localhost/api/keys/${created.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + modelAccessMode: "restricted", + allowedModels: [], + }), + }), + { params: Promise.resolve({ id: created.id }) } + ); + const body = (await response.json()) as { + modelAccessMode?: string; + allowedModels?: string[]; + error?: unknown; + }; + + assert.equal( + response.status, + 200, + `expected 200, got ${response.status}: ${JSON.stringify(body)}` + ); + assert.equal(body.modelAccessMode, "restricted"); + assert.deepEqual(body.allowedModels, []); + + apiKeys.resetApiKeyState(); + const meta = await apiKeys.getApiKeyMetadata(created.key); + assert.equal((meta as { modelAccessMode?: string } | null)?.modelAccessMode, "restricted"); + assert.deepEqual(meta?.allowedModels, []); + assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4.1"), false); +}); + +// ──────────────── R2 / R4 — UI classification + provider-scope helpers ──────────────── + +test("R2: isRestricted treats explicit restricted mode with empty allowedModels as restricted", () => { + // Existing helper must honor the new dual empty-array semantics. + const restrictedEmpty = { + allowedModels: [] as string[], + allowedConnections: null, + modelAccessMode: "restricted" as const, + }; + assert.equal( + pageUtils.isRestricted(restrictedEmpty as Parameters[0]), + true, + "restricted + empty selections must classify as restricted (not standard allow-all)" + ); + + const legacyEmpty = { + allowedModels: [] as string[], + allowedConnections: null, + }; + assert.equal( + pageUtils.isRestricted(legacyEmpty), + false, + "legacy empty without restricted mode stays unrestricted" + ); +}); + +test("R4: provider selection helpers emit canonical provider/* and keep other exact selections", () => { + type ProviderModelRef = { id: string; owned_by?: string; providerId?: string }; + type ProviderScopeOptions = { + providerId?: string; + ownedBy?: string; + providerModels?: ProviderModelRef[]; + }; + + const utils = pageUtils as typeof pageUtils & { + toggleProviderWildcardSelection?: ( + selected: string[], + providerId: string, + options?: ProviderScopeOptions + ) => string[]; + isModelInheritedByProviderScope?: ( + selected: string[], + modelId: string, + options?: ProviderScopeOptions + ) => boolean; + isModelIndividuallyToggleable?: ( + selected: string[], + modelId: string, + options?: ProviderScopeOptions + ) => boolean; + restoreProviderScopeSelection?: ( + allowedModels: string[], + options?: { providerModels?: ProviderModelRef[] } + ) => { + providerWildcards: string[]; + exactModels: string[]; + }; + buildModelAccessSavePayload?: (input: { allowAll: boolean; selectedModels: string[] }) => { + modelAccessMode: "all" | "restricted"; + allowedModels: string[]; + }; + formatProviderModelPermissionSummary?: ( + providerCount: number, + modelCount: number, + t: (key: string, values?: Record) => string, + tc: (key: string) => string + ) => string; + }; + + assert.equal( + typeof utils.toggleProviderWildcardSelection, + "function", + "UI must expose toggleProviderWildcardSelection for provider-scope selection" + ); + assert.equal( + typeof utils.isModelInheritedByProviderScope, + "function", + "UI must expose isModelInheritedByProviderScope for inherited children" + ); + assert.equal( + typeof utils.isModelIndividuallyToggleable, + "function", + "UI must expose isModelIndividuallyToggleable so provider children are non-toggleable" + ); + assert.equal( + typeof utils.restoreProviderScopeSelection, + "function", + "UI must expose restoreProviderScopeSelection so reopening restores provider/*" + ); + assert.equal( + typeof utils.buildModelAccessSavePayload, + "function", + "UI must expose buildModelAccessSavePayload for modelAccessMode + allowedModels" + ); + assert.equal( + typeof utils.formatProviderModelPermissionSummary, + "function", + "UI must expose one provider/model summary formatter for every display surface" + ); + + const t = (key: string, values?: Record) => { + if (key === "modelsCount") return `${values?.count} model${values?.count === 1 ? "" : "s"}`; + if (key === "selectedCount") return `${values?.count} selected`; + return key; + }; + const tc = (key: string) => key; + assert.deepEqual(pageUtils.restoreProviderScopeSelection(["codex/*", "openai/gpt-4.1"]), { + providerWildcards: ["codex/*"], + exactModels: ["openai/gpt-4.1"], + }); + assert.equal(utils.formatProviderModelPermissionSummary!(1, 1, t, tc), "1 provider · 1 model"); + assert.equal(utils.formatProviderModelPermissionSummary!(1, 0, t, tc), "1 provider"); + assert.equal(utils.formatProviderModelPermissionSummary!(0, 2, t, tc), "2 models"); + + // Selecting ollama-cloud adds canonical wildcard; coexists with openai exact. + assert.deepEqual(utils.toggleProviderWildcardSelection!(["openai/gpt-4.1"], "ollama-cloud"), [ + "openai/gpt-4.1", + "ollama-cloud/*", + ]); + + // Selecting again removes only that provider wildcard. + assert.deepEqual( + utils.toggleProviderWildcardSelection!(["openai/gpt-4.1", "ollama-cloud/*"], "ollama-cloud"), + ["openai/gpt-4.1"] + ); + + // Selecting a provider replaces any previously expanded exact child IDs for that provider. + assert.deepEqual( + utils.toggleProviderWildcardSelection!( + ["ollama-cloud/llama3", "ollama-cloud/qwen", "openai/gpt-4.1"], + "ollama-cloud" + ), + ["openai/gpt-4.1", "ollama-cloud/*"] + ); + + // Alias-prefixed catalog IDs (id prefix ≠ canonical provider) still collapse via owned_by. + // Example: catalog model id `ollamacloud/llama3` with owned_by/providerId `ollama-cloud`. + const aliasPrefixedCatalog: ProviderModelRef[] = [ + { id: "ollamacloud/llama3", owned_by: "ollama-cloud", providerId: "ollama-cloud" }, + { id: "ollamacloud/qwen", owned_by: "ollama-cloud", providerId: "ollama-cloud" }, + ]; + assert.deepEqual( + utils.toggleProviderWildcardSelection!( + ["ollamacloud/llama3", "ollamacloud/qwen", "openai/gpt-4.1"], + "ollama-cloud", + { providerId: "ollama-cloud", providerModels: aliasPrefixedCatalog } + ), + ["openai/gpt-4.1", "ollama-cloud/*"], + "toggle must remove alias-prefixed children and emit canonical ollama-cloud/*" + ); + + // Children under a selected provider are inherited and not individually toggleable. + assert.equal( + utils.isModelInheritedByProviderScope!( + ["ollama-cloud/*", "openai/gpt-4.1"], + "ollama-cloud/llama3" + ), + true + ); + assert.equal( + utils.isModelInheritedByProviderScope!(["ollama-cloud/*"], "ollamacloud/llama3", { + providerId: "ollama-cloud", + ownedBy: "ollama-cloud", + }), + true, + "alias-prefixed id must still be recognized as inherited under canonical provider scope" + ); + assert.equal( + utils.isModelIndividuallyToggleable!( + ["ollama-cloud/*", "openai/gpt-4.1"], + "ollama-cloud/llama3" + ), + false + ); + assert.equal( + utils.isModelIndividuallyToggleable!(["ollama-cloud/*"], "ollamacloud/llama3", { + providerId: "ollama-cloud", + }), + false, + "alias-prefixed children of a selected provider must not be individually toggleable" + ); + assert.equal( + utils.isModelIndividuallyToggleable!(["ollama-cloud/*", "openai/gpt-4.1"], "openai/gpt-4.1"), + true + ); + + // Reopening restores provider-level scope from stored allowedModels. + assert.deepEqual(utils.restoreProviderScopeSelection!(["ollama-cloud/*", "openai/gpt-4.1"]), { + providerWildcards: ["ollama-cloud/*"], + exactModels: ["openai/gpt-4.1"], + }); + + // Restrict with zero selections must save restricted + []. + assert.deepEqual(utils.buildModelAccessSavePayload!({ allowAll: false, selectedModels: [] }), { + modelAccessMode: "restricted", + allowedModels: [], + }); + + // Allow-all mode. + assert.deepEqual( + utils.buildModelAccessSavePayload!({ + allowAll: true, + selectedModels: ["ollama-cloud/*"], + }), + { modelAccessMode: "all", allowedModels: [] } + ); + + // Restrict with provider + exact selections. + assert.deepEqual( + utils.buildModelAccessSavePayload!({ + allowAll: false, + selectedModels: ["ollama-cloud/*", "openai/gpt-4.1"], + }), + { + modelAccessMode: "restricted", + allowedModels: ["ollama-cloud/*", "openai/gpt-4.1"], + } + ); +}); diff --git a/tests/unit/migration-147-api-keys-model-access-mode.test.ts b/tests/unit/migration-147-api-keys-model-access-mode.test.ts new file mode 100644 index 0000000000..54d95b550f --- /dev/null +++ b/tests/unit/migration-147-api-keys-model-access-mode.test.ts @@ -0,0 +1,134 @@ +/** + * Acceptance: migration 147 — api_keys.model_access_mode + * + * Confirmed backfill: + * - Adds model_access_mode column (public shape: "all" | "restricted") + * - Legacy empty allowed_models rows → "all" + * - Legacy non-empty allowed_models rows → "restricted" + * + * The existence assertion keeps the migration artifact part of the accepted contract. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MIGRATION_147_PATH = path.join( + __dirname, + "../../src/lib/db/migrations/147_api_keys_model_access_mode.sql" +); + +interface TestDb { + exec: (sql: string) => unknown; + prepare: (sql: string) => { + run: (...p: unknown[]) => unknown; + get: (...p: unknown[]) => Record | undefined; + all: (...p: unknown[]) => Record[]; + }; + close: () => void; +} + +function makeLegacyApiKeysDb(): TestDb { + const db = new Database(":memory:") as unknown as TestDb; + db.exec(` + CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + key TEXT NOT NULL, + allowed_models TEXT DEFAULT '[]' + ); + `); + return db; +} + +function insertKey(db: TestDb, id: string, name: string, allowedModelsJson: string | null): void { + db.prepare("INSERT INTO api_keys (id, name, key, allowed_models) VALUES (?, ?, ?, ?)").run( + id, + name, + `omni_${id}`, + allowedModelsJson + ); +} + +function modeOf(db: TestDb, id: string): string | null { + const row = db.prepare("SELECT model_access_mode AS mode FROM api_keys WHERE id = ?").get(id); + return (row?.mode as string | undefined) ?? null; +} + +function hasModelAccessModeColumn(db: TestDb): boolean { + const cols = db.prepare("PRAGMA table_info(api_keys)").all(); + return cols.some((col) => col.name === "model_access_mode"); +} + +test("R-migration: 147_api_keys_model_access_mode.sql must exist", () => { + assert.ok( + fs.existsSync(MIGRATION_147_PATH), + "expected src/lib/db/migrations/147_api_keys_model_access_mode.sql" + ); +}); + +test("R-migration: 147 adds model_access_mode and backfills all/restricted from allowed_models", () => { + assert.ok( + fs.existsSync(MIGRATION_147_PATH), + "expected src/lib/db/migrations/147_api_keys_model_access_mode.sql" + ); + + const sql = fs.readFileSync(MIGRATION_147_PATH, "utf-8"); + const db = makeLegacyApiKeysDb(); + + insertKey(db, "legacy-all", "Legacy Allow All", "[]"); + insertKey(db, "legacy-spaced-all", "Legacy Spaced Allow All", "[ ]"); + insertKey(db, "legacy-nullish", "Legacy Nullish", "null"); + insertKey(db, "legacy-sql-null", "Legacy SQL Null", null); + insertKey(db, "legacy-restricted", "Legacy Restricted", '["ollama-cloud/*","openai/gpt-4.1"]'); + insertKey(db, "legacy-exact", "Legacy Exact", '["openai/gpt-4.1"]'); + insertKey(db, "legacy-scalar", "Legacy Scalar", "true"); + insertKey(db, "legacy-malformed", "Legacy Malformed", "not-json"); + + db.exec(sql); + + assert.equal(hasModelAccessModeColumn(db), true, "must add model_access_mode column"); + + assert.equal( + modeOf(db, "legacy-all"), + "all", + "legacy empty allowed_models must backfill to model_access_mode=all" + ); + assert.equal( + modeOf(db, "legacy-spaced-all"), + "all", + "valid empty JSON arrays remain allow-all regardless of whitespace" + ); + assert.equal( + modeOf(db, "legacy-nullish"), + "all", + "JSON null allowed_models must backfill to all" + ); + assert.equal(modeOf(db, "legacy-sql-null"), "all", "SQL NULL must backfill to all"); + assert.equal( + modeOf(db, "legacy-restricted"), + "restricted", + "legacy non-empty allowed_models must backfill to restricted" + ); + assert.equal( + modeOf(db, "legacy-exact"), + "restricted", + "legacy exact-model allow-list must backfill to restricted" + ); + assert.equal( + modeOf(db, "legacy-scalar"), + "restricted", + "non-array JSON values other than null must fail closed" + ); + assert.equal( + modeOf(db, "legacy-malformed"), + "restricted", + "malformed legacy values must fail closed" + ); + + db.close(); +}); diff --git a/tests/unit/pick-internal-api-key-6372.test.ts b/tests/unit/pick-internal-api-key-6372.test.ts index ab70247d31..927d332ae0 100644 --- a/tests/unit/pick-internal-api-key-6372.test.ts +++ b/tests/unit/pick-internal-api-key-6372.test.ts @@ -43,8 +43,29 @@ test("#6372: prefers a management-scoped key over a plain self:usage key", async assert.equal(picked, mgr.key, "should pick the management-scoped key, not the first row"); }); -test("#6372: falls back to an active key when none is management-scoped", async () => { - const only = await apiKeysDb.createApiKey("usage-key", "machine-a", ["self:usage"]); +test("#6372: a restricted-empty key does not outrank a real allow-all key", async () => { + const restricted = await apiKeysDb.createApiKey("restricted-empty", "machine-a", ["self:usage"]); + await apiKeysDb.updateApiKeyPermissions(restricted.id, { + modelAccessMode: "restricted", + allowedModels: [], + }); + const allowAll = await apiKeysDb.createApiKey("allow-all", "machine-a", ["self:usage"]); + const picked = await apiKeysDb.pickApiKeyForInternalUse("internal-probe"); - assert.equal(picked, only.key, "should still return a usable active key via fallback rules"); + assert.equal(picked, allowAll.key); +}); + +test("#6372: falls back to an active key when none is management-scoped or allow-all", async () => { + const only = await apiKeysDb.createApiKey("restricted-empty", "machine-a", ["self:usage"]); + await apiKeysDb.updateApiKeyPermissions(only.id, { + modelAccessMode: "restricted", + allowedModels: [], + }); + + const picked = await apiKeysDb.pickApiKeyForInternalUse("internal-probe"); + assert.equal( + picked, + only.key, + "last-resort fallback stays best-effort and does not bypass policy" + ); }); diff --git a/tests/unit/sync-bundle.test.ts b/tests/unit/sync-bundle.test.ts index fd830b6262..24215d0226 100644 --- a/tests/unit/sync-bundle.test.ts +++ b/tests/unit/sync-bundle.test.ts @@ -69,7 +69,11 @@ test("config sync bundle is deterministic, strips auth settings, and ignores vol models: ["openai/gpt-4o-mini"], strategy: "priority", }); - await apiKeysDb.createApiKey("Desktop", "machine-sync-1"); + const apiKey = await apiKeysDb.createApiKey("Desktop", "machine-sync-1"); + await apiKeysDb.updateApiKeyPermissions(apiKey.id, { + modelAccessMode: "restricted", + allowedModels: [], + }); const first = await syncBundle.buildConfigSyncEnvelope(); const second = await syncBundle.buildConfigSyncEnvelope(); @@ -81,6 +85,8 @@ test("config sync bundle is deterministic, strips auth settings, and ignores vol assert.equal(first.bundle.settings.cloudEnabled, undefined); assert.equal(first.bundle.providerConnections[0].apiKey, "sk-live-secret"); assert.equal(first.bundle.modelAliases["smart-default"], "openai/gpt-4o-mini"); + assert.equal(first.bundle.apiKeys[0].modelAccessMode, "restricted"); + assert.deepEqual(first.bundle.apiKeys[0].allowedModels, []); assert.deepEqual(first.bundle.reasoningRoutingRules, []); await providersDb.updateProviderConnection((connection as any).id, {