mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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:
parent
c40828509b
commit
2fa0301ade
21 changed files with 493 additions and 145 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue