mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 03:32:21 +03:00
feat(api-manager): add provider-level model permissions (#9313)
* feat(api-manager): add provider-level model permissions Persist canonical provider wildcards alongside exact model grants and preserve explicit restricted-empty deny-all semantics across API, SQLite, JSON import, sync, runtime policy, and the dashboard. Invalidate filtered model catalogs on permission changes and guard against stale in-flight catalog builders repopulating invalidated cache entries. * fix(api-manager): show provider and model counts separately in summary Provider wildcard selections (provider/*) are no longer counted as individual models in the Selected Models Summary. The header now shows "N providers · M models" when both are present, or just the non-empty category when only one type is selected. * fix(api-manager): separate provider and model permission displays * fix(api-manager): separate provider wildcard permissions in UI
This commit is contained in:
@@ -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() {
|
||||
</span>
|
||||
)}
|
||||
{/* Existing badges */}
|
||||
{isRestricted ? (
|
||||
{isModelRestricted ? (
|
||||
<button
|
||||
onClick={() => handleOpenPermissions(key)}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">lock</span>
|
||||
{t("modelsCount", { count: key.allowedModels!.length })}
|
||||
{formatProviderModelPermissionSummary(providerCount, modelCount, t, tc)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -1657,7 +1673,8 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
dailyUsageLimitUsd: number | null,
|
||||
weeklyUsageLimitUsd: number | null,
|
||||
blockedModels: string[],
|
||||
chaosModeEnabled: boolean
|
||||
chaosModeEnabled: boolean,
|
||||
modelAccessMode: "all" | "restricted"
|
||||
) => void;
|
||||
}) {
|
||||
const t = useTranslations("apiManager");
|
||||
@@ -1680,7 +1697,11 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
>(() => getBlockedClaudeCodeFamilies(initialBlockedModels));
|
||||
const [claudeCodeFamiliesExpanded, setClaudeCodeFamiliesExpanded] = useState(false);
|
||||
const [selectedCombos, setSelectedCombos] = useState<string[]>(initialCombos);
|
||||
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
|
||||
// Explicit restricted mode reopens in Restrict even with zero selections
|
||||
// (restricted + empty = deny-all); legacy absent mode keeps "empty = allow all".
|
||||
const [allowAll, setAllowAll] = useState(
|
||||
apiKey?.modelAccessMode === "restricted" ? false : initialModels.length === 0
|
||||
);
|
||||
const [allowAllCombos, setAllowAllCombos] = useState(initialCombos.length === 0);
|
||||
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
|
||||
const [autoResolveEnabled, setAutoResolveEnabled] = useState(apiKey?.autoResolve === true);
|
||||
@@ -1765,15 +1786,20 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
);
|
||||
|
||||
// Memoize callbacks to prevent child re-renders
|
||||
const handleSelectionChange = useCallback((next: string[]) => {
|
||||
setSelectedModels(next);
|
||||
}, []);
|
||||
|
||||
const handleClaudeCodeDefaultDeselected = useCallback(() => {
|
||||
setClaudeCodeFamiliesExpanded(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleModel = useCallback(
|
||||
(modelId: string) => {
|
||||
if (allowAll) return;
|
||||
|
||||
setSelectedModels((prev) => {
|
||||
if (prev.includes(modelId)) {
|
||||
if (modelId === CLAUDE_CODE_DEFAULT_MODEL_ID) {
|
||||
setClaudeCodeFamiliesExpanded(false);
|
||||
}
|
||||
if (modelId === CLAUDE_CODE_DEFAULT_MODEL_ID) setClaudeCodeFamiliesExpanded(false);
|
||||
return prev.filter((m) => m !== modelId);
|
||||
}
|
||||
return [...prev, modelId];
|
||||
@@ -1782,22 +1808,6 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
[allowAll]
|
||||
);
|
||||
|
||||
const handleToggleProvider = useCallback(
|
||||
(provider: string, models: Model[]) => {
|
||||
if (allowAll) return;
|
||||
|
||||
const modelIds = models.map((m) => m.id);
|
||||
setSelectedModels((prev) => {
|
||||
const allSelected = modelIds.every((id) => prev.includes(id));
|
||||
if (allSelected) {
|
||||
return prev.filter((m) => !modelIds.includes(m));
|
||||
}
|
||||
return [...new Set([...prev, ...modelIds])];
|
||||
});
|
||||
},
|
||||
[allowAll]
|
||||
);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setAllowAll(true);
|
||||
setSelectedModels([]);
|
||||
@@ -1924,9 +1934,10 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
blockedModels.push(...CLAUDE_CODE_FAMILY_BLOCK_PATTERNS[familyId]);
|
||||
}
|
||||
}
|
||||
const modelAccess = buildModelAccessSavePayload({ allowAll, selectedModels });
|
||||
onSave(
|
||||
keyName,
|
||||
allowAll ? [] : selectedModels,
|
||||
modelAccess.allowedModels,
|
||||
allowAllCombos ? [] : selectedCombos,
|
||||
noLogEnabled,
|
||||
allowAllConnections ? [] : selectedConnections,
|
||||
@@ -1953,7 +1964,8 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
parseUsdLimitInput(dailyUsageLimitUsd),
|
||||
parseUsdLimitInput(weeklyUsageLimitUsd),
|
||||
blockedModels,
|
||||
chaosModeEnabled
|
||||
chaosModeEnabled,
|
||||
modelAccess.modelAccessMode
|
||||
);
|
||||
}, [
|
||||
onSave,
|
||||
@@ -1998,17 +2010,30 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
t,
|
||||
]);
|
||||
|
||||
// Provider wildcards ("ollama-cloud/*") are counted as providers, not models.
|
||||
// Inherited children render selected via the owner lookup inside the list component.
|
||||
const { providerWildcards: selectedProviderScopes, exactModels: selectedExactModels } =
|
||||
restoreProviderScopeSelection(selectedModels);
|
||||
const selectedProviderCount = selectedProviderScopes.length;
|
||||
const selectedModelCount = selectedExactModels.length;
|
||||
const selectedCount = selectedModels.length;
|
||||
const selectedPermissionSummary = formatProviderModelPermissionSummary(
|
||||
selectedProviderCount,
|
||||
selectedModelCount,
|
||||
t,
|
||||
tc
|
||||
);
|
||||
|
||||
const totalModels = allModels.length;
|
||||
const hasClaudeCodeDefaultSelected =
|
||||
!allowAll && selectedModels.includes(CLAUDE_CODE_DEFAULT_MODEL_ID);
|
||||
const orderedSelectedModels = useMemo(() => {
|
||||
if (!hasClaudeCodeDefaultSelected) return selectedModels;
|
||||
const orderedSelectedProviderScopes = useMemo(() => {
|
||||
if (!hasClaudeCodeDefaultSelected) return selectedProviderScopes;
|
||||
return [
|
||||
CLAUDE_CODE_DEFAULT_MODEL_ID,
|
||||
...selectedModels.filter((modelId) => modelId !== CLAUDE_CODE_DEFAULT_MODEL_ID),
|
||||
...selectedProviderScopes.filter((scope) => scope !== CLAUDE_CODE_DEFAULT_MODEL_ID),
|
||||
];
|
||||
}, [hasClaudeCodeDefaultSelected, selectedModels]);
|
||||
}, [hasClaudeCodeDefaultSelected, selectedProviderScopes]);
|
||||
const visibleClaudeCodeFamilies = useMemo(
|
||||
() =>
|
||||
CLAUDE_CODE_DEFAULT_FAMILIES.filter(
|
||||
@@ -2106,9 +2131,11 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
? t("allowAllDesc")
|
||||
: !modelsLoaded
|
||||
? t("restrictLoading")
|
||||
: totalModels === 0
|
||||
? t("restrictCatalogUnavailable", { selectedCount })
|
||||
: t("restrictDesc", { selectedCount, totalModels })}
|
||||
: selectedProviderCount > 0
|
||||
? selectedPermissionSummary
|
||||
: totalModels === 0
|
||||
? t("restrictCatalogUnavailable", { selectedCount })
|
||||
: t("restrictDesc", { selectedCount, totalModels })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2626,9 +2653,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{!allowAll && selectedCount > 0 && (
|
||||
<div className="flex flex-col gap-1.5 p-2 bg-primary/5 rounded-lg border border-primary/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{t("selectedCount", { count: selectedCount })}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-primary">{selectedPermissionSummary}</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={handleSelectAllModels}
|
||||
@@ -2644,230 +2669,157 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 max-h-28 overflow-y-auto content-start">
|
||||
{orderedSelectedModels.map((modelId) => {
|
||||
if (modelId === CLAUDE_CODE_DEFAULT_MODEL_ID) {
|
||||
return (
|
||||
<div key={modelId} className="flex flex-col gap-1 basis-full">
|
||||
<span className="inline-flex w-fit items-center gap-0.5 px-1.5 py-0.5 bg-primary/10 text-text-main text-[10px] rounded border border-primary/35">
|
||||
{selectedProviderCount > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-text-muted">
|
||||
{tc("providers")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{orderedSelectedProviderScopes.map((scope) => {
|
||||
if (scope === CLAUDE_CODE_DEFAULT_MODEL_ID) {
|
||||
return (
|
||||
<div key={scope} className="flex flex-col gap-1 basis-full">
|
||||
<span className="inline-flex w-fit items-center gap-0.5 px-1.5 py-0.5 bg-primary/10 text-text-main text-[10px] rounded border border-primary/35">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClaudeCodeFamiliesExpanded((prev) => !prev)}
|
||||
className="inline-flex items-center gap-1 font-mono text-text-main"
|
||||
title={t("expandClaudeCodeFamilies")}
|
||||
aria-expanded={claudeCodeFamiliesExpanded}
|
||||
>
|
||||
<span className="truncate max-w-[140px]" title={scope}>
|
||||
{CLAUDE_CODE_DEFAULT_MODEL_NAME}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[12px] text-primary">
|
||||
{claudeCodeFamiliesExpanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleModel(scope)}
|
||||
className="text-text-muted hover:text-red-500 transition-colors"
|
||||
title={t("removeClaudeCodeDefault")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
{claudeCodeFamiliesExpanded && (
|
||||
<div className="relative ml-2 flex flex-wrap gap-1 pl-5 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1.5 top-0 bottom-1 w-px bg-primary/25"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1.5 top-3 h-px w-3 bg-primary/25"
|
||||
/>
|
||||
{visibleClaudeCodeFamilies.map((family) => {
|
||||
const canBlock = family.id !== "other";
|
||||
return (
|
||||
<span
|
||||
key={family.id}
|
||||
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] rounded border ${
|
||||
canBlock
|
||||
? "bg-white dark:bg-surface text-text-main border-border"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border-border"
|
||||
}`}
|
||||
title={
|
||||
canBlock
|
||||
? `Allow ${family.label} family through Claude Code default`
|
||||
: "Catch-all for other Claude Code models"
|
||||
}
|
||||
>
|
||||
<span className="font-mono">{family.label}</span>
|
||||
{canBlock && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleBlockClaudeCodeFamily(
|
||||
family.id as ClaudeCodeBlockableFamilyId
|
||||
)
|
||||
}
|
||||
className="text-text-muted hover:text-red-500 transition-colors"
|
||||
title={`Block ${family.label} family`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const provider = scope.slice(0, -2);
|
||||
return (
|
||||
<span
|
||||
key={scope}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-primary/10 text-text-main text-[10px] rounded border border-primary/35"
|
||||
title={scope}
|
||||
>
|
||||
<span className="font-mono truncate max-w-[120px]">{provider}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClaudeCodeFamiliesExpanded((prev) => !prev)}
|
||||
className="inline-flex items-center gap-1 font-mono text-text-main"
|
||||
title={t("expandClaudeCodeFamilies")}
|
||||
aria-expanded={claudeCodeFamiliesExpanded}
|
||||
>
|
||||
<span className="truncate max-w-[140px]" title={modelId}>
|
||||
{getModelDisplayName(modelId)}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[12px] text-primary">
|
||||
{claudeCodeFamiliesExpanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleModel(modelId)}
|
||||
onClick={() => handleToggleModel(scope)}
|
||||
className="text-text-muted hover:text-red-500 transition-colors"
|
||||
title={t("removeClaudeCodeDefault")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
{claudeCodeFamiliesExpanded && (
|
||||
<div className="relative ml-2 flex flex-wrap gap-1 pl-5 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1.5 top-0 bottom-1 w-px bg-primary/25"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1.5 top-3 h-px w-3 bg-primary/25"
|
||||
/>
|
||||
{visibleClaudeCodeFamilies.map((family) => {
|
||||
const canBlock = family.id !== "other";
|
||||
return (
|
||||
<span
|
||||
key={family.id}
|
||||
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] rounded border ${
|
||||
canBlock
|
||||
? "bg-white dark:bg-surface text-text-main border-border"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border-border"
|
||||
}`}
|
||||
title={
|
||||
canBlock
|
||||
? `Allow ${family.label} family through Claude Code default`
|
||||
: "Catch-all for other Claude Code models"
|
||||
}
|
||||
>
|
||||
<span className="font-mono">{family.label}</span>
|
||||
{canBlock && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleBlockClaudeCodeFamily(
|
||||
family.id as ClaudeCodeBlockableFamilyId
|
||||
)
|
||||
}
|
||||
className="text-text-muted hover:text-red-500 transition-colors"
|
||||
title={`Block ${family.label} family`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={modelId}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-white dark:bg-surface text-text-main text-[10px] rounded border border-border"
|
||||
>
|
||||
<span className="font-mono truncate max-w-[120px]" title={modelId}>
|
||||
{getModelDisplayName(modelId)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleModel(modelId)}
|
||||
className="text-text-muted hover:text-red-500 transition-colors"
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedModelCount > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-text-muted">
|
||||
{tc("models")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 max-h-28 overflow-y-auto content-start">
|
||||
{selectedExactModels.map((modelId) => (
|
||||
<span
|
||||
key={modelId}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-white dark:bg-surface text-text-main text-[10px] rounded border border-border"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="font-mono truncate max-w-[120px]" title={modelId}>
|
||||
{getModelDisplayName(modelId)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleModel(modelId)}
|
||||
className="text-text-muted hover:text-red-500 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Model Selection (only in restrict mode) */}
|
||||
{!allowAll && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={searchModel}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("searchModels")}
|
||||
icon="search"
|
||||
/>
|
||||
{searchModel && (
|
||||
<button
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-main"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[280px] overflow-y-auto border border-border rounded-lg divide-y divide-border">
|
||||
{modelsByProvider.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-text-muted">
|
||||
<span className="material-symbols-outlined text-2xl mb-1">search_off</span>
|
||||
<p className="text-xs">{t("noModelsFound")}</p>
|
||||
</div>
|
||||
) : (
|
||||
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 (
|
||||
<div key={provider} className="group">
|
||||
<button
|
||||
onClick={() => handleToggleExpand(provider)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-surface/50 transition-colors text-left"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-base transition-transform duration-200 ${
|
||||
expandedProviders.has(provider) ? "rotate-90" : ""
|
||||
}`}
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="relative flex items-center cursor-pointer shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleProvider(provider, models);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 rounded border-2 transition-colors flex items-center justify-center ${
|
||||
allSelected
|
||||
? "bg-primary border-primary"
|
||||
: someSelected
|
||||
? "bg-primary/20 border-primary"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{allSelected && (
|
||||
<span className="material-symbols-outlined text-white text-[12px]">
|
||||
check
|
||||
</span>
|
||||
)}
|
||||
{someSelected && !allSelected && (
|
||||
<span className="material-symbols-outlined text-primary text-[12px]">
|
||||
remove
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-text-main truncate">
|
||||
{provider}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted bg-surface px-1 py-0.5 rounded shrink-0">
|
||||
{models.length}
|
||||
</span>
|
||||
</div>
|
||||
{selectedInProvider > 0 && (
|
||||
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full shrink-0">
|
||||
{selectedInProvider}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expandable model list */}
|
||||
{expandedProviders.has(provider) && (
|
||||
<div className="px-3 pb-2 pl-9">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{models.map((model) => {
|
||||
const isSelected = selectedModels.includes(model.id);
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleToggleModel(model.id)}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-mono transition-all ${
|
||||
isSelected
|
||||
? "bg-primary text-white"
|
||||
: "bg-surface border border-border text-text-muted hover:border-primary/50 hover:text-text-main"
|
||||
}`}
|
||||
title={model.id}
|
||||
>
|
||||
{getModelDisplayName(model.id)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<ProviderModelPermissionList
|
||||
modelsByProvider={modelsByProvider}
|
||||
allModels={allModels}
|
||||
selectedModels={selectedModels}
|
||||
expandedProviders={expandedProviders}
|
||||
searchModel={searchModel}
|
||||
onSearchChange={onSearchChange}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
getModelDisplayName={getModelDisplayName}
|
||||
onClaudeCodeDefaultDeselected={handleClaudeCodeDefaultDeselected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Allowed Connections Section */}
|
||||
|
||||
@@ -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<string>, keyId: string): Set<strin
|
||||
else next.add(keyId);
|
||||
return next;
|
||||
}
|
||||
|
||||
// ──────────────── Provider-scope (dynamic provider/*) model permissions ────────────────
|
||||
|
||||
/** Reference to a catalog model used to resolve provider ownership. */
|
||||
export interface ProviderModelRef {
|
||||
id: string;
|
||||
owned_by?: string;
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
export interface ProviderScopeOptions {
|
||||
/** Canonical provider id (fallback owner for alias-prefixed catalog ids). */
|
||||
providerId?: string;
|
||||
/** Canonical owner id (fallback owner when providerId is absent). */
|
||||
ownedBy?: string;
|
||||
/** Catalog models used to map alias-prefixed ids to their canonical owner. */
|
||||
providerModels?: ProviderModelRef[];
|
||||
}
|
||||
|
||||
/** Canonical provider id segment of a wildcard entry ("ollama-cloud/*" → "ollama-cloud"). */
|
||||
function providerWildcardId(entry: string): string | null {
|
||||
return entry.endsWith("/*") ? entry.slice(0, -2) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical owner id of a single entry. Exact model ids resolve
|
||||
* through the catalog (alias-prefixed ids like `ollamacloud/llama3` map to
|
||||
* their canonical `owned_by`), falling back to the id's prefix segment and
|
||||
* then to the provided canonical provider fallback. Wildcards resolve to
|
||||
* their own provider segment (already canonical).
|
||||
*/
|
||||
function resolveEntryProvider(entry: string, options?: ProviderScopeOptions): string {
|
||||
const wildcard = providerWildcardId(entry);
|
||||
if (wildcard !== null) return wildcard;
|
||||
const model = options?.providerModels?.find((m) => 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, unknown>) => 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 };
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
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<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
|
||||
}, [indeterminate]);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="checkbox"
|
||||
aria-label={`${provider} model access`}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className="w-4 h-4 rounded border-border accent-[var(--color-primary)] cursor-pointer shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, ProviderModelRef[]>();
|
||||
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<string, string | null>(
|
||||
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 (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={searchModel}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("searchModels")}
|
||||
icon="search"
|
||||
/>
|
||||
{searchModel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-main"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[280px] overflow-y-auto border border-border rounded-lg divide-y divide-border">
|
||||
{modelsByProvider.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-text-muted">
|
||||
<span className="material-symbols-outlined text-2xl mb-1">search_off</span>
|
||||
<p className="text-xs">{t("noModelsFound")}</p>
|
||||
</div>
|
||||
) : (
|
||||
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 (
|
||||
<div key={provider} className="group">
|
||||
<div className="w-full flex items-center gap-2 px-3 py-2 hover:bg-surface/50 transition-colors text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleExpand(provider)}
|
||||
className="flex items-center shrink-0"
|
||||
aria-label={provider}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-base transition-transform duration-200 ${
|
||||
expandedProviders.has(provider) ? "rotate-90" : ""
|
||||
}`}
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<ProviderSelectionCheckbox
|
||||
provider={provider}
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={() => {
|
||||
if (useProviderScope) handleProviderCheckedChange(provider);
|
||||
else handleSnapshotToggleProvider(provider, models);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleExpand(provider)}
|
||||
className="text-xs font-semibold text-text-main truncate"
|
||||
>
|
||||
{provider}
|
||||
</button>
|
||||
<span className="text-[10px] text-text-muted bg-surface px-1 py-0.5 rounded shrink-0">
|
||||
{models.length}
|
||||
</span>
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full shrink-0">
|
||||
{selectedCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable model list */}
|
||||
{expandedProviders.has(provider) && (
|
||||
<div className="px-3 pb-2 pl-9">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{models.map((model) => {
|
||||
const isSelected =
|
||||
providerScopeSelected || selectedModels.includes(model.id);
|
||||
const isToggleable = isModelIndividuallyToggleable(
|
||||
selectedModels,
|
||||
model.id,
|
||||
{ ownedBy: model.owned_by }
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
disabled={!isToggleable}
|
||||
onClick={() => handleToggleModel(model)}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-mono transition-all ${
|
||||
isSelected
|
||||
? "bg-primary text-white"
|
||||
: "bg-surface border border-border text-text-muted hover:border-primary/50 hover:text-text-main"
|
||||
} ${isToggleable ? "" : "opacity-70 cursor-not-allowed"}`}
|
||||
title={model.id}
|
||||
>
|
||||
{getModelDisplayName(model.id)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProviderModelPermissionList;
|
||||
@@ -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<typeof updateApiKeyPermissions>[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 }),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<ApiKeyRow>(
|
||||
"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 ||
|
||||
|
||||
44
src/lib/db/apiKeys/modelAccessMode.ts
Normal file
44
src/lib/db/apiKeys/modelAccessMode.ts
Normal file
@@ -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 {};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
77
src/lib/db/apiKeys/permissionsUpdate.ts
Normal file
77
src/lib/db/apiKeys/permissionsUpdate.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
13
src/lib/db/migrations/147_api_keys_model_access_mode.sql
Normal file
13
src/lib/db/migrations/147_api_keys_model_access_mode.sql
Normal file
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ function sanitizeApiKeyForSync(apiKey: unknown): JsonRecord {
|
||||
"name",
|
||||
"key",
|
||||
"machineId",
|
||||
"modelAccessMode",
|
||||
"allowedModels",
|
||||
"allowedCombos",
|
||||
"allowedConnections",
|
||||
|
||||
@@ -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<Response | n
|
||||
let requestedComboName = comboAccess.comboName;
|
||||
|
||||
const hasModelRestrictions =
|
||||
Boolean(apiKeyInfo.allowedModels?.length) || apiKeyInfo.disableNonPublicModels === true;
|
||||
apiKeyInfo.modelAccessMode === "restricted" ||
|
||||
Boolean(apiKeyInfo.allowedModels?.length) ||
|
||||
apiKeyInfo.disableNonPublicModels === true;
|
||||
if (!requestedComboName && hasModelRestrictions) {
|
||||
if (modelStr.startsWith("auto/") || modelStr.startsWith("qtSd/")) {
|
||||
requestedComboName = modelStr;
|
||||
|
||||
@@ -82,6 +82,7 @@ export const setTokenLimitSchema = z
|
||||
export const updateKeyPermissionsSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(200).optional(),
|
||||
modelAccessMode: z.enum(["all", "restricted"]).optional(),
|
||||
allowedModels: z.array(z.string().trim().min(1)).max(1000).optional(),
|
||||
allowedCombos: z.array(z.string().trim().min(1).max(200)).max(500).optional(),
|
||||
allowedConnections: z.array(z.string().uuid()).max(100).optional(),
|
||||
@@ -116,8 +117,16 @@ export const updateKeyPermissionsSchema = z
|
||||
chaosModeEnabled: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
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 &&
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
} = {
|
||||
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/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
795
tests/unit/api-manager-provider-permissions.test.ts
Normal file
795
tests/unit/api-manager-provider-permissions.test.ts
Normal file
@@ -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<typeof apiKeys.updateApiKeyPermissions>[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<typeof apiKeys.updateApiKeyPermissions>[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<typeof apiKeys.updateApiKeyPermissions>[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<typeof apiKeys.updateApiKeyPermissions>[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<typeof apiKeys.updateApiKeyPermissions>[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<typeof payload>) => void;
|
||||
let oldCalls = 0;
|
||||
const oldGate = new Promise<ReturnType<typeof payload>>((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<typeof pageUtils.isRestricted>[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, unknown>) => 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<string, unknown>) => {
|
||||
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"],
|
||||
}
|
||||
);
|
||||
});
|
||||
134
tests/unit/migration-147-api-keys-model-access-mode.test.ts
Normal file
134
tests/unit/migration-147-api-keys-model-access-mode.test.ts
Normal file
@@ -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<string, unknown> | undefined;
|
||||
all: (...p: unknown[]) => Record<string, unknown>[];
|
||||
};
|
||||
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();
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user