fix(ui): gate /config/list and /credentials behind proxy-admin capabilities

Both routes answer 401 for every non-admin session role, so the dashboard
was firing them from pages non-admins can legitimately reach. Add
viewProxyConfig and viewCredentials to the capability map, wired to the
proxy-admin role set the proxy actually serves, and use them to skip the
call and drop the UI it feeds.

Org admins are excluded deliberately: /config/list sits in
admin_viewer_routes, which org_admin_allowed_routes includes, but
_user_is_org_admin needs an organization_id in the request payload that a
bare GET never carries, so the proxy refuses them too.
This commit is contained in:
Yuneng Jiang 2026-08-10 15:22:38 -07:00
parent c40828509b
commit 2fa0301ade
No known key found for this signature in database
21 changed files with 493 additions and 145 deletions

View file

@ -9,6 +9,7 @@ import PromptCompressionTab from "./PromptCompressionTab";
import PromptCachingTab from "./PromptCachingTab";
import AutoRouterBenchmarksTab from "./AutoRouterBenchmarksTab";
import { useDailyActivityRange } from "./useDailyActivityRange";
import { hasCapability } from "@/utils/capabilities";
interface CostOptimizationViewProps {
accessToken: string | null;
@ -33,7 +34,13 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
{
key: "caching",
label: "Prompt Caching",
children: <PromptCachingTab accessToken={accessToken} activity={activity} />,
children: (
<PromptCachingTab
accessToken={accessToken}
activity={activity}
canViewProxyConfig={hasCapability(userRole, "viewProxyConfig")}
/>
),
},
{
key: "autorouter-usage",

View file

@ -1,5 +1,5 @@
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockGetGeneralSettingsCall = vi.fn();
@ -23,21 +23,42 @@ vi.mock("./CacheLeakageCard", () => ({
import PromptCachingTab from "./PromptCachingTab";
const activityFixture = () => ({
dateValue: {},
onDateChange: vi.fn(),
results: [],
loading: false,
isFetchingMore: false,
});
describe("PromptCachingTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the cache leakage table alongside the caching settings", async () => {
mockGetGeneralSettingsCall.mockResolvedValue([]);
const activity = {
dateValue: {},
onDateChange: vi.fn(),
results: [],
loading: false,
isFetchingMore: false,
};
const { getByTestId } = render(<PromptCachingTab accessToken="test-token" activity={activity} />);
const activity = activityFixture();
const { getByTestId } = render(
<PromptCachingTab accessToken="test-token" activity={activity} canViewProxyConfig />,
);
expect(getByTestId("caching-settings")).toBeInTheDocument();
expect(getByTestId("cache-leakage-card")).toBeInTheDocument();
await waitFor(() => expect(mockCacheLeakageCard).toHaveBeenCalledWith(expect.objectContaining({ activity })));
});
it("keeps the cache leakage table but skips /config/list when the caller cannot read proxy config", async () => {
mockGetGeneralSettingsCall.mockResolvedValue([]);
const { getByTestId, queryByTestId } = render(
<PromptCachingTab accessToken="test-token" activity={activityFixture()} canViewProxyConfig={false} />,
);
expect(queryByTestId("caching-settings")).not.toBeInTheDocument();
expect(getByTestId("cache-leakage-card")).toBeInTheDocument();
await waitFor(() => expect(mockCacheLeakageCard).toHaveBeenCalled());
expect(mockGetGeneralSettingsCall).not.toHaveBeenCalled();
});
});

View file

@ -14,13 +14,14 @@ import { DailyActivityRange } from "./useDailyActivityRange";
interface PromptCachingTabProps {
accessToken: string | null;
activity: DailyActivityRange;
canViewProxyConfig: boolean;
}
const PromptCachingTab: React.FC<PromptCachingTabProps> = ({ accessToken, activity }) => {
const PromptCachingTab: React.FC<PromptCachingTabProps> = ({ accessToken, activity, canViewProxyConfig }) => {
const [settings, setSettings] = useState<generalSettingsItem[]>([]);
const loadSettings = useCallback(() => {
if (!accessToken) {
if (!accessToken || !canViewProxyConfig) {
return;
}
getGeneralSettingsCall(accessToken)
@ -29,7 +30,7 @@ const PromptCachingTab: React.FC<PromptCachingTabProps> = ({ accessToken, activi
console.error("Failed to load prompt caching settings:", error);
NotificationsManager.fromBackend("Failed to load prompt caching settings");
});
}, [accessToken]);
}, [accessToken, canViewProxyConfig]);
useEffect(() => {
loadSettings();
@ -47,7 +48,9 @@ const PromptCachingTab: React.FC<PromptCachingTabProps> = ({ accessToken, activi
return (
<div className="w-full space-y-6">
<PromptCachingPanel accessToken={accessToken} settings={settings} onChange={handleChange} />
{canViewProxyConfig && (
<PromptCachingPanel accessToken={accessToken} settings={settings} onChange={handleChange} />
)}
<CacheLeakageCard activity={activity} />
</div>
);

View file

@ -176,6 +176,52 @@ describe("useCredentials", () => {
expect(result.current.data).toBeUndefined();
});
it.each(["Internal User", "Internal Viewer", "Org Admin", "internal_user", "org_admin"])(
"should not call GET /credentials for %s",
async (userRole) => {
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userRole,
userId: "test-user-id",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
const { result } = renderHook(() => useCredentials(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetched).toBe(false);
expect(credentialListCall).not.toHaveBeenCalled();
},
);
it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])(
"should call GET /credentials for %s",
async (userRole) => {
(credentialListCall as any).mockResolvedValue(mockCredentialsResponse);
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userRole,
userId: "test-user-id",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
const { result } = renderHook(() => useCredentials(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(credentialListCall).toHaveBeenCalledWith("test-access-token");
},
);
it("should execute query when accessToken is present", async () => {
// Mock successful API call
(credentialListCall as any).mockResolvedValue(mockCredentialsResponse);

View file

@ -2,14 +2,16 @@ import { credentialListCall, CredentialsResponse } from "@/components/networking
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
const credentialsKeys = createQueryKeys("credentials");
export const useCredentials = () => {
const { accessToken } = useAuthorized();
const canViewCredentials = useCan("viewCredentials");
return useQuery<CredentialsResponse>({
queryKey: credentialsKeys.list({}),
queryFn: async () => await credentialListCall(accessToken!),
enabled: Boolean(accessToken),
enabled: Boolean(accessToken) && canViewCredentials,
});
};

View file

@ -156,6 +156,58 @@ describe("useProxyConfig", () => {
expect(result.current.isLoading).toBe(true);
});
it.each(["Internal User", "Internal Viewer", "Org Admin", "internal_user", "org_admin"])(
"should not call GET /config/list for %s",
(userRole) => {
mockUseAuthorized.mockReturnValue({
accessToken: mockAccessToken,
userId: "test-user-id",
userRole,
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
const { result } = renderHook(() => useProxyConfig(ConfigType.GENERAL_SETTINGS), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetched).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
},
);
it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])(
"should call GET /config/list for %s",
async (userRole) => {
(fetchSpy as any).mockResolvedValue({
ok: true,
json: async () => mockProxyConfigResponse,
});
mockUseAuthorized.mockReturnValue({
accessToken: mockAccessToken,
userId: "test-user-id",
userRole,
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
const { result } = renderHook(() => useProxyConfig(ConfigType.GENERAL_SETTINGS), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(fetchSpy).toHaveBeenCalledWith(
`${mockProxyBaseUrl}/config/list?config_type=${ConfigType.GENERAL_SETTINGS}`,
expect.objectContaining({ method: "GET" }),
);
},
);
it("should return proxy config data when query is successful", async () => {
(fetchSpy as any).mockResolvedValue({
ok: true,

View file

@ -1,6 +1,7 @@
import { useQuery, useMutation, UseMutationResult, useQueryClient } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import useAuthorized from "../useAuthorized";
import useCan from "../useCan";
import { proxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
/**
@ -147,6 +148,7 @@ export const deleteProxyConfigFieldCall = async (
*/
export const useProxyConfig = (configType: ConfigType) => {
const { accessToken } = useAuthorized();
const canViewProxyConfig = useCan("viewProxyConfig");
return useQuery<ProxyConfigResponse>({
queryKey: proxyConfigKeys.list({
filters: {
@ -154,7 +156,7 @@ export const useProxyConfig = (configType: ConfigType) => {
},
}),
queryFn: async () => await getProxyConfigCall(accessToken!, configType),
enabled: Boolean(accessToken),
enabled: Boolean(accessToken) && canViewProxyConfig,
});
};

View file

@ -314,6 +314,26 @@ describe("AllModelsTab", () => {
expect(screen.getByTestId("model-settings-modal")).toBeInTheDocument();
});
it.each(["Internal User", "Internal Viewer", "Org Admin"])(
"hides the model settings trigger from %s, who cannot read /config/list",
(userRole) => {
vi.spyOn(useAuthorizedModule, "default").mockReturnValue({ ...MOCK_AUTHORIZED, userRole });
render(<AllModelsTab {...defaultProps} />);
expect(screen.queryByTestId("models-settings-trigger")).not.toBeInTheDocument();
expect(screen.queryByTestId("model-settings-modal")).not.toBeInTheDocument();
},
);
it.each(["Admin", "Admin Viewer"])("keeps the model settings trigger for %s", (userRole) => {
vi.spyOn(useAuthorizedModule, "default").mockReturnValue({ ...MOCK_AUTHORIZED, userRole });
render(<AllModelsTab {...defaultProps} />);
expect(screen.getByTestId("models-settings-trigger")).toBeInTheDocument();
});
it("opens the model detail view from the model ID cell", async () => {
const user = userEvent.setup();
render(<AllModelsTab {...defaultProps} />);

View file

@ -3,6 +3,7 @@
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal";
import { ModelData } from "@/components/model_dashboard/types";
@ -48,6 +49,7 @@ const AllModelsTab = ({
}: AllModelsTabProps) => {
const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap();
const { accessToken, userId, userRole } = useAuthorized();
const canViewProxyConfig = useCan("viewProxyConfig");
const { data: teams, isLoading: isLoadingTeams } = useTeams();
const queryClient = useQueryClient();
@ -283,7 +285,7 @@ const AllModelsTab = ({
isLoadingTeams={isLoadingTeams}
viewMode={modelViewMode}
onViewModeChange={setModelViewMode}
onOpenModelSettings={handleOpenModelSettings}
onOpenModelSettings={canViewProxyConfig ? handleOpenModelSettings : null}
availableModelGroups={availableModelGroups}
availableModelAccessGroups={availableModelAccessGroups}
userRole={userRole}
@ -351,11 +353,13 @@ const AllModelsTab = ({
onOk={handleDeleteModel}
confirmLoading={deleteLoading}
/>
<ModelSettingsModal
isVisible={isModelSettingsModalVisible}
onCancel={() => setIsModelSettingsModalVisible(false)}
onSuccess={() => setIsModelSettingsModalVisible(false)}
/>
{canViewProxyConfig && (
<ModelSettingsModal
isVisible={isModelSettingsModalVisible}
onCancel={() => setIsModelSettingsModalVisible(false)}
onSuccess={() => setIsModelSettingsModalVisible(false)}
/>
)}
</div>
);
};

View file

@ -68,7 +68,7 @@ interface AllModelsTableProps {
isLoadingTeams: boolean;
viewMode: ModelViewMode;
onViewModeChange: (viewMode: ModelViewMode) => void;
onOpenModelSettings: () => void;
onOpenModelSettings: (() => void) | null;
availableModelGroups: string[];
availableModelAccessGroups: string[];
userRole: string;
@ -249,18 +249,22 @@ export function AllModelsTable({
</SelectContent>
</Select>
<ToolbarSeparator className="mx-0.5" />
{onOpenModelSettings !== null && (
<>
<ToolbarSeparator className="mx-0.5" />
<Button
variant="outline"
size="icon-sm"
aria-label="Model Settings"
title="Model Settings"
data-testid="models-settings-trigger"
onClick={onOpenModelSettings}
>
<Settings />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="Model Settings"
title="Model Settings"
data-testid="models-settings-trigger"
onClick={onOpenModelSettings}
>
<Settings />
</Button>
</>
)}
</DataTableToolbar>
<DataTableFilterDrawer
table={table}

View file

@ -0,0 +1,76 @@
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CredentialItem } from "@/components/networking";
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
const storedCredentials: CredentialItem[] = [
{
credential_name: "openai-key",
credential_values: {},
credential_info: { custom_llm_provider: "openai" },
},
];
const mockUseCredentials = vi.fn();
vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({
useCredentials: () => mockUseCredentials(),
}));
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
useModelCostMap: () => ({ data: {}, isLoading: false, error: null }),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => ({ data: [], isLoading: false, error: null }),
}));
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
});
const addModelFormProps = vi.fn();
vi.mock("@/components/add_model/AddModelForm", () => ({
__esModule: true,
default: (props: { credentials: CredentialItem[] | null }) => {
addModelFormProps(props);
return <div data-testid="add-model-form" />;
},
}));
import AddModelPanel from "./AddModelPanel";
const credentialsPassedToForm = (): CredentialItem[] | null =>
addModelFormProps.mock.calls.at(-1)?.[0].credentials ?? null;
describe("AddModelPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseCredentials.mockReturnValue({ data: { credentials: storedCredentials } });
});
const renderAs = (userRole: string) => {
mockUseAuthorized.mockReturnValue({ accessToken: "sk-test", userRole, userId: "user-1" });
render(<AddModelPanel />);
};
it.each(["Internal User", "Internal Viewer", "Org Admin"])(
"hides the credential picker from %s by passing null",
(userRole) => {
renderAs(userRole);
expect(credentialsPassedToForm()).toBeNull();
},
);
it.each(["Admin", "Admin Viewer"])("passes the fetched credentials through for %s", (userRole) => {
renderAs(userRole);
expect(credentialsPassedToForm()).toEqual(storedCredentials);
});
});

View file

@ -11,10 +11,12 @@ import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
export default function AddModelPanel() {
const { accessToken } = useAuthorized();
const canViewCredentials = useCan("viewCredentials");
const [form] = Form.useForm();
const queryClient = useQueryClient();
const { data: modelCostMapData } = useModelCostMap();
@ -51,7 +53,7 @@ export default function AddModelPanel() {
showAdvancedSettings={showAdvancedSettings}
setShowAdvancedSettings={setShowAdvancedSettings}
teams={teams ?? null}
credentials={credentialsResponse?.credentials || []}
credentials={canViewCredentials ? credentialsResponse?.credentials ?? [] : null}
/>
);
}

View file

@ -19,7 +19,8 @@ interface VectorStoreFormProps {
onCancel: () => void;
onSuccess: () => void;
accessToken: string | null;
credentials: CredentialItem[];
/** `null` when the caller may not read stored credentials, which hides the reuse picker entirely. */
credentials: CredentialItem[] | null;
}
const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
@ -353,32 +354,34 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item
label={
<span>
Existing Credentials{" "}
<Tooltip title="Optionally select API provider credentials for this vector store eg. Bedrock API KEY">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="litellm_credential_name"
>
<Select
showSearch
placeholder="Select or search for existing credentials"
optionFilterProp="children"
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
options={[
{ value: null, label: "None" },
...credentials.map((credential) => ({
value: credential.credential_name,
label: credential.credential_name,
})),
]}
allowClear
/>
</Form.Item>
{credentials !== null && (
<Form.Item
label={
<span>
Existing Credentials{" "}
<Tooltip title="Optionally select API provider credentials for this vector store eg. Bedrock API KEY">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="litellm_credential_name"
>
<Select
showSearch
placeholder="Select or search for existing credentials"
optionFilterProp="children"
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
options={[
{ value: null, label: "None" },
...credentials.map((credential) => ({
value: credential.credential_name,
label: credential.credential_name,
})),
]}
allowClear
/>
</Form.Item>
)}
<Form.Item
label={

View file

@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { vectorStoreListCall } from "@/components/networking";
import { credentialListCall, vectorStoreListCall } from "@/components/networking";
import VectorStoreManagement from "./index";
@ -25,6 +25,7 @@ vi.mock("./CreateVectorStore", () => ({ __esModule: true, default: () => null })
vi.mock("./TestVectorStoreTab", () => ({ __esModule: true, default: () => null }));
const mockVectorStoreListCall = vi.mocked(vectorStoreListCall);
const mockCredentialListCall = vi.mocked(credentialListCall);
const openManageTab = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("tab", { name: "Manage Vector Stores" }));
@ -60,3 +61,33 @@ describe("VectorStoreManagement loading state", () => {
expect(mockVectorStoreListCall).toHaveBeenCalledWith("sk-test");
});
});
describe("VectorStoreManagement credential access", () => {
beforeEach(() => {
vi.clearAllMocks();
mockVectorStoreListCall.mockResolvedValue({ data: [] });
mockCredentialListCall.mockResolvedValue({ credentials: [] });
});
it.each(["Internal User", "Internal Viewer", "Org Admin"])(
"should not call GET /credentials for %s",
async (userRole) => {
const user = userEvent.setup();
render(<VectorStoreManagement accessToken="sk-test" userID="user-1" userRole={userRole} />);
await openManageTab(user);
expect(await screen.findByText("table-loaded")).toBeInTheDocument();
expect(mockVectorStoreListCall).toHaveBeenCalledWith("sk-test");
expect(mockCredentialListCall).not.toHaveBeenCalled();
},
);
it.each(["Admin", "Admin Viewer"])("should call GET /credentials for %s", async (userRole) => {
const user = userEvent.setup();
render(<VectorStoreManagement accessToken="sk-test" userID="user-1" userRole={userRole} />);
await openManageTab(user);
expect(await screen.findByText("table-loaded")).toBeInTheDocument();
expect(mockCredentialListCall).toHaveBeenCalledWith("sk-test");
});
});

View file

@ -14,6 +14,7 @@ import VectorStoreInfoView from "./vector_store_info";
import CreateVectorStore from "./CreateVectorStore";
import TestVectorStoreTab from "./TestVectorStoreTab";
import { isAdminRole } from "@/utils/roles";
import { hasCapability } from "@/utils/capabilities";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -26,6 +27,7 @@ interface VectorStoreProps {
}
const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID, userRole }) => {
const canViewCredentials = hasCapability(userRole, "viewCredentials");
const [vectorStores, setVectorStores] = useState<VectorStore[]>([]);
const [isLoadingVectorStores, setIsLoadingVectorStores] = useState(true);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
@ -55,7 +57,7 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
};
const fetchCredentials = async () => {
if (!accessToken) return;
if (!accessToken || !canViewCredentials) return;
try {
const response = await credentialListCall(accessToken);
setCredentials(response.credentials || []);
@ -132,6 +134,7 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
onClose={handleCloseInfo}
accessToken={accessToken}
is_admin={isAdminRole(userRole || "")}
canViewCredentials={canViewCredentials}
editVectorStore={editVectorStore}
/>
</div>
@ -196,7 +199,7 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
onCancel={() => setIsCreateModalVisible(false)}
onSuccess={handleCreateSuccess}
accessToken={accessToken}
credentials={credentials}
credentials={canViewCredentials ? credentials : null}
/>
{/* Delete Confirmation Modal */}

View file

@ -21,6 +21,7 @@ interface VectorStoreInfoViewProps {
onClose: () => void;
accessToken: string | null;
is_admin: boolean;
canViewCredentials: boolean;
editVectorStore: boolean;
}
@ -29,6 +30,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
onClose,
accessToken,
is_admin,
canViewCredentials,
editVectorStore,
}) => {
const [form] = Form.useForm();
@ -69,7 +71,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
};
const fetchCredentials = async () => {
if (!accessToken) return;
if (!accessToken || !canViewCredentials) return;
try {
const response = await credentialListCall(accessToken);
setCredentials(response.credentials || []);
@ -192,37 +194,41 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
</Select2>
</Form.Item>
{/* Credentials */}
<div className="mb-4">
<Text className="text-sm text-gray-500 mb-2">
Either select existing credentials OR enter provider credentials below
</Text>
</div>
{canViewCredentials && (
<>
{/* Credentials */}
<div className="mb-4">
<Text className="text-sm text-gray-500 mb-2">
Either select existing credentials OR enter provider credentials below
</Text>
</div>
<Form.Item label="Existing Credentials" name="litellm_credential_name">
<Select2
showSearch
placeholder="Select or search for existing credentials"
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
options={[
{ value: null, label: "None" },
...credentials.map((credential) => ({
value: credential.credential_name,
label: credential.credential_name,
})),
]}
allowClear
/>
</Form.Item>
<Form.Item label="Existing Credentials" name="litellm_credential_name">
<Select2
showSearch
placeholder="Select or search for existing credentials"
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
options={[
{ value: null, label: "None" },
...credentials.map((credential) => ({
value: credential.credential_name,
label: credential.credential_name,
})),
]}
allowClear
/>
</Form.Item>
<div className="flex items-center my-4">
<div className="grow border-t border-gray-200"></div>
<span className="px-4 text-gray-500 text-sm">OR</span>
<div className="grow border-t border-gray-200"></div>
</div>
<div className="flex items-center my-4">
<div className="grow border-t border-gray-200"></div>
<span className="px-4 text-gray-500 text-sm">OR</span>
<div className="grow border-t border-gray-200"></div>
</div>
</>
)}
<Form.Item
label={

View file

@ -298,6 +298,27 @@ describe("AddModelForm", () => {
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
});
it("should offer the existing-credentials picker when the caller can read credentials", async () => {
const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true));
renderWithProviders(<AddModelForm {...createTestProps()} />);
expect(await screen.findByText("Existing Credentials")).toBeInTheDocument();
expect(screen.getByText(/Either select existing credentials/i)).toBeInTheDocument();
});
it("should drop the existing-credentials picker, keeping provider fields, when credentials are unreadable", async () => {
const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true));
renderWithProviders(<AddModelForm {...createTestProps()} credentials={null} />);
expect(await screen.findByText("Provider")).toBeInTheDocument();
expect(screen.queryByText("Existing Credentials")).not.toBeInTheDocument();
expect(screen.queryByText(/Either select existing credentials/i)).not.toBeInTheDocument();
});
it("should display the provider field and the Test Connect / Add Model buttons", async () => {
const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true));

View file

@ -33,7 +33,8 @@ interface AddModelFormProps {
showAdvancedSettings: boolean;
setShowAdvancedSettings: (show: boolean) => void;
teams: Team[] | null;
credentials: CredentialItem[];
/** `null` when the caller may not read stored credentials, which hides the reuse picker entirely. */
credentials: CredentialItem[] | null;
}
const { Title, Link } = Typography;
@ -228,55 +229,63 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
</Col>
</Row>
{/* Credentials */}
<div className="mb-4">
<Typography.Text className="text-sm text-gray-500 mb-2">
Either select existing credentials OR enter new provider credentials below
</Typography.Text>
</div>
{credentials === null ? (
<ProviderSpecificFields selectedProvider={selectedProvider} uploadProps={uploadProps} />
) : (
<>
{/* Credentials */}
<div className="mb-4">
<Typography.Text className="text-sm text-gray-500 mb-2">
Either select existing credentials OR enter new provider credentials below
</Typography.Text>
</div>
<Form.Item label="Existing Credentials" name="litellm_credential_name" initialValue={null}>
<AntdSelect
showSearch
placeholder="Select or search for existing credentials"
optionFilterProp="children"
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
options={[
{ value: null, label: "None" },
...credentials.map((credential) => ({
value: credential.credential_name,
label: credential.credential_name,
})),
]}
allowClear
/>
</Form.Item>
<Form.Item label="Existing Credentials" name="litellm_credential_name" initialValue={null}>
<AntdSelect
showSearch
placeholder="Select or search for existing credentials"
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
options={[
{ value: null, label: "None" },
...credentials.map((credential) => ({
value: credential.credential_name,
label: credential.credential_name,
})),
]}
allowClear
/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.litellm_credential_name !== currentValues.litellm_credential_name ||
prevValues.provider !== currentValues.provider
}
>
{({ getFieldValue }) => {
const credentialName = getFieldValue("litellm_credential_name");
// Only show provider specific fields if no credentials selected
if (!credentialName) {
return (
<>
<div className="flex items-center my-4">
<div className="grow border-t border-gray-200"></div>
<span className="px-4 text-gray-500 text-sm">OR</span>
<div className="grow border-t border-gray-200"></div>
</div>
<ProviderSpecificFields selectedProvider={selectedProvider} uploadProps={uploadProps} />
</>
);
}
return null;
}}
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.litellm_credential_name !== currentValues.litellm_credential_name ||
prevValues.provider !== currentValues.provider
}
>
{({ getFieldValue }) => {
const credentialName = getFieldValue("litellm_credential_name");
// Only show provider specific fields if no credentials selected
if (!credentialName) {
return (
<>
<div className="flex items-center my-4">
<div className="grow border-t border-gray-200"></div>
<span className="px-4 text-gray-500 text-sm">OR</span>
<div className="grow border-t border-gray-200"></div>
</div>
<ProviderSpecificFields selectedProvider={selectedProvider} uploadProps={uploadProps} />
</>
);
}
return null;
}}
</Form.Item>
</>
)}
<div className="flex items-center my-4">
<div className="grow border-t border-gray-200"></div>
<span className="px-4 text-gray-500 text-sm">Additional Model Info Settings</span>

View file

@ -3,8 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../tests/test-utils";
import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav";
vi.mock("../utils/roles", () => {
vi.mock("../utils/roles", async (importOriginal) => {
const actual = await importOriginal<typeof import("../utils/roles")>();
return {
...actual,
all_admin_roles: ["admin", "admin_viewer"],
internalUserRoles: ["internal"],
rolesWithWriteAccess: ["admin", "internal"],

View file

@ -23,8 +23,20 @@ const ADMIN_ONLY_CAPABILITIES: Capability[] = [
"viewPrompts",
"viewOrganizationUsage",
"viewAgentUsage",
"viewProxyConfig",
"viewCredentials",
];
// `/config/list` sits in `admin_viewer_routes`, which `org_admin_allowed_routes`
// includes, so an org admin looks allowed on paper. At runtime `_user_is_org_admin`
// needs an `organization_id` in the request payload, which a bare GET never carries,
// so the proxy answers 401 for org admins on both of these routes.
const PROXY_ADMIN_ONLY_CAPABILITIES: Capability[] = ["viewProxyConfig", "viewCredentials"];
// A team admin carries no distinct session role; the dashboard sees their user_role.
const TEAM_ADMIN_SESSION_ROLE = "Internal User";
const ORG_ADMIN_ROLES = ["Org Admin", "org_admin"];
describe("hasCapability", () => {
describe.each(ADMIN_ONLY_CAPABILITIES)("%s", (capability) => {
it.each(ADMIN_ROLES)("should grant it to %s", (role) => {
@ -37,6 +49,24 @@ describe("hasCapability", () => {
});
});
describe.each(PROXY_ADMIN_ONLY_CAPABILITIES)("hasCapability - %s", (capability) => {
it.each(ORG_ADMIN_ROLES)("should deny it to an org admin (%s)", (role) => {
expect(hasCapability(role, capability)).toBe(false);
});
it("should deny it to a team admin", () => {
expect(hasCapability(TEAM_ADMIN_SESSION_ROLE, capability)).toBe(false);
});
it.each(["internal_user", "internal_user_viewer", "Internal Viewer"])("should deny it to %s", (role) => {
expect(hasCapability(role, capability)).toBe(false);
});
it.each(["Admin", "proxy_admin", "Admin Viewer", "proxy_admin_viewer"])("should grant it to %s", (role) => {
expect(hasCapability(role, capability)).toBe(true);
});
});
describe("rolesWithCapability", () => {
it("should return a copy so callers cannot mutate the capability map", () => {
const roles = rolesWithCapability("viewToolPolicies");

View file

@ -1,4 +1,6 @@
import { all_admin_roles } from "./roles";
import { all_admin_roles, old_admin_roles } from "./roles";
const proxyAdminOnlyRoles = [...old_admin_roles, "proxy_admin", "proxy_admin_viewer"];
const CAPABILITY_ROLES = {
viewToolPolicies: all_admin_roles,
@ -8,6 +10,8 @@ const CAPABILITY_ROLES = {
viewPrompts: all_admin_roles,
viewOrganizationUsage: all_admin_roles,
viewAgentUsage: all_admin_roles,
viewProxyConfig: proxyAdminOnlyRoles,
viewCredentials: proxyAdminOnlyRoles,
} as const satisfies Record<string, readonly string[]>;
export type Capability = keyof typeof CAPABILITY_ROLES;