mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
fix(skills): honor dashboard user and team grants (#51416)
This commit is contained in:
parent
d2711ac559
commit
d29ac75af1
9 changed files with 406 additions and 44 deletions
|
|
@ -2,20 +2,25 @@
|
|||
Claude Code marketplace visibility: enabled plugins are public, disabled plugins
|
||||
are private and resolve only for proxy admins or keys granted them via
|
||||
``object_permission.skills`` or via ``access_skill_names`` on an access group
|
||||
attached to the key or its team.
|
||||
attached to the key or its team. Dashboard sessions inherit their user's and live teams' grants.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final, Protocol, TypeAlias
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import UI_TEAM_ID, LiteLLM_ObjectPermissionTable, UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
|
||||
from litellm.proxy.management.teams.authz import is_team_member
|
||||
from litellm.proxy.mcp_server.ui_session_utils import is_ui_session_credential
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma.types import LiteLLM_ClaudeCodePluginTableWhereInput
|
||||
|
||||
from litellm.proxy._types import LiteLLM_TeamTable
|
||||
from litellm.proxy._types import LiteLLM_TeamTable, LiteLLM_UserTable
|
||||
|
||||
|
||||
class _SkillRecord(Protocol):
|
||||
|
|
@ -113,6 +118,84 @@ async def _team_object(user_api_key_dict: UserAPIKeyAuth, check_db_only: bool) -
|
|||
return None if team_obj.blocked else team_obj
|
||||
|
||||
|
||||
async def _team_group_skills(team: "LiteLLM_TeamTable | None", check_db_only: bool) -> frozenset[str]:
|
||||
from litellm.proxy.auth.auth_checks import get_skill_names_from_access_groups
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
|
||||
|
||||
return await get_skill_names_from_access_groups(
|
||||
access_group_ids=tuple(team.access_group_ids or ()) if team is not None else (),
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
strict_errors=True,
|
||||
)
|
||||
|
||||
|
||||
async def _ui_team_skills(caller: UserAPIKeyAuth, team_id: str) -> frozenset[str]:
|
||||
try:
|
||||
team: Final = await _team_object(caller.model_copy(update={"team_id": team_id}), check_db_only=True)
|
||||
if team is None or not is_team_member(caller, team):
|
||||
return frozenset()
|
||||
inherited: Final = await _team_group_skills(team, check_db_only=True)
|
||||
except Exception: # noqa: BLE001 # a failed team source grants nothing without removing independent sources
|
||||
verbose_proxy_logger.warning("Cannot resolve dashboard team's skill grants")
|
||||
return frozenset()
|
||||
return _skills_of(team.object_permission) | inherited
|
||||
|
||||
|
||||
async def _ui_user_skills(user: "LiteLLM_UserTable", caller: UserAPIKeyAuth) -> frozenset[str]:
|
||||
from litellm.proxy.auth.auth_checks import get_object_permission
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
|
||||
|
||||
if not user.object_permission_id:
|
||||
return _skills_of(user.object_permission)
|
||||
try:
|
||||
permission: Final = await get_object_permission(
|
||||
object_permission_id=user.object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=caller.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=True,
|
||||
)
|
||||
except Exception: # noqa: BLE001 # an unreadable direct grant must not fall back to cached permission
|
||||
verbose_proxy_logger.warning("Cannot resolve dashboard user's skill permission")
|
||||
return frozenset()
|
||||
return _skills_of(permission)
|
||||
|
||||
|
||||
async def _ui_skill_visibility(caller: UserAPIKeyAuth) -> SkillVisibility:
|
||||
from litellm.proxy.auth.auth_checks import get_user_object
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
|
||||
|
||||
if prisma_client is None:
|
||||
return PUBLIC_ONLY
|
||||
try:
|
||||
user: Final = await get_user_object(
|
||||
user_id=caller.user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
parent_otel_span=caller.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=True,
|
||||
)
|
||||
except Exception: # noqa: BLE001 # missing or unreadable user identity cannot establish private grants
|
||||
verbose_proxy_logger.warning("Cannot resolve dashboard user's skill grants")
|
||||
return PUBLIC_ONLY
|
||||
if user is None:
|
||||
return PUBLIC_ONLY
|
||||
metadata: Final = TypeAdapter(dict[str, object]).validate_python(
|
||||
user.model_dump(include={"metadata"})["metadata"] or {}
|
||||
)
|
||||
if metadata.get("scim_active") is False:
|
||||
return PUBLIC_ONLY
|
||||
own: Final = await _ui_user_skills(user, caller)
|
||||
teams: Final = [await _ui_team_skills(caller, team_id) for team_id in frozenset(user.teams) if team_id]
|
||||
return SkillVisibility(granted=own.union(*teams), sees_private=False)
|
||||
|
||||
|
||||
async def access_group_skills(user_api_key_dict: UserAPIKeyAuth) -> AccessGroupSkills:
|
||||
"""Resolve ``access_skill_names`` of the groups attached to the key and to its team.
|
||||
|
||||
|
|
@ -122,9 +205,8 @@ async def access_group_skills(user_api_key_dict: UserAPIKeyAuth) -> AccessGroupS
|
|||
from litellm.proxy.auth.auth_checks import (
|
||||
AccessGroupNotFoundError,
|
||||
get_authorized_resources_from_key_access_groups,
|
||||
get_skill_names_from_access_groups,
|
||||
)
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
return NO_ACCESS_GROUP_SKILLS
|
||||
|
|
@ -140,14 +222,7 @@ async def access_group_skills(user_api_key_dict: UserAPIKeyAuth) -> AccessGroupS
|
|||
strict_errors=True,
|
||||
)
|
||||
)
|
||||
team_skills: Final = await get_skill_names_from_access_groups(
|
||||
access_group_ids=tuple(team_obj.access_group_ids or ()) if team_obj is not None else (),
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=check_db_only,
|
||||
strict_errors=True,
|
||||
)
|
||||
team_skills: Final = await _team_group_skills(team_obj, check_db_only)
|
||||
return AccessGroupSkills(key=key_skills, team=team_skills)
|
||||
except AccessGroupNotFoundError:
|
||||
return AccessGroupSkills(unresolved=True)
|
||||
|
|
@ -160,7 +235,9 @@ async def resolve_skill_visibility(
|
|||
user_api_key_dict: UserAPIKeyAuth | None,
|
||||
load_access_group_skills: AccessGroupSkillsLoader = access_group_skills,
|
||||
) -> SkillVisibility:
|
||||
"""``skill_visibility`` with the access-group grants of the key and its team folded in."""
|
||||
"""Resolve credential-scoped grants, or live user/team grants for a dashboard session."""
|
||||
if user_api_key_dict is None or is_proxy_admin(user_api_key_dict):
|
||||
return skill_visibility(user_api_key_dict)
|
||||
if is_ui_session_credential(user_api_key_dict):
|
||||
return await _ui_skill_visibility(user_api_key_dict)
|
||||
return skill_visibility(user_api_key_dict, await load_access_group_skills(user_api_key_dict))
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ vi.mock("@/components/key_team_helpers/fetch_available_models_team_key", () => (
|
|||
|
||||
vi.mock("@/utils/roles", () => ({
|
||||
all_admin_roles: ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer", "org_admin"],
|
||||
isProxyAdminRole: (role: string) => role === "Admin" || role === "proxy_admin",
|
||||
}));
|
||||
|
||||
describe("UserEditView", () => {
|
||||
|
|
@ -66,6 +67,7 @@ describe("UserEditView", () => {
|
|||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);
|
||||
vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
|
||||
vi.mocked(networking.getClaudeCodePluginsList).mockResolvedValue({ plugins: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -652,6 +654,7 @@ describe("UserEditView", () => {
|
|||
"mcp_tool_permissions",
|
||||
"metadata",
|
||||
"models",
|
||||
"skills",
|
||||
"user_alias",
|
||||
"user_email",
|
||||
"user_id",
|
||||
|
|
@ -668,6 +671,7 @@ describe("UserEditView", () => {
|
|||
metadata: { key1: "value1", key2: "value2" },
|
||||
mcp_servers_and_groups: { servers: [], accessGroups: [], toolsets: [] },
|
||||
mcp_tool_permissions: {},
|
||||
skills: [],
|
||||
};
|
||||
expect(payload).toStrictEqual(expectedPayload);
|
||||
expect(typeof payload.max_budget).toBe("number");
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { TFunction } from "i18next";
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import { all_admin_roles, isProxyAdminRole } from "@/utils/roles";
|
||||
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
|
||||
import { ModelMaxBudget, ModelMaxBudgetField } from "@/components/key_team_helpers/ModelMaxBudgetEditor";
|
||||
import { modelMaxBudgetUpdate } from "@/components/key_team_helpers/modelMaxBudgetPayload";
|
||||
|
|
@ -11,6 +11,7 @@ import { getModelDisplayName } from "@/components/key_team_helpers/fetch_availab
|
|||
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
|
||||
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
|
||||
import type { ObjectPermission } from "@/components/object_permission_types";
|
||||
import SkillSelector from "@/components/skills/SkillSelector";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
|
|
@ -70,6 +71,7 @@ const userEditShape = (t: TFunction) => ({
|
|||
rpm_limit: rateLimitField(t),
|
||||
mcp_servers_and_groups: MCP_SELECTION_SHAPE.optional(),
|
||||
mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const userEditSchema = (unlimitedBudget: boolean, t: TFunction) =>
|
||||
|
|
@ -84,13 +86,14 @@ const userEditSchema = (unlimitedBudget: boolean, t: TFunction) =>
|
|||
type UserEditFormInput = z.input<ReturnType<typeof userEditSchema>>;
|
||||
type UserEditFormValues = z.output<ReturnType<typeof userEditSchema>>;
|
||||
|
||||
const buildMcpFieldValues = (objectPermission: ObjectPermission | null | undefined) => ({
|
||||
const buildPermissionFieldValues = (objectPermission: ObjectPermission | null | undefined) => ({
|
||||
mcp_servers_and_groups: {
|
||||
servers: objectPermission?.mcp_servers ?? [],
|
||||
accessGroups: objectPermission?.mcp_access_groups ?? [],
|
||||
toolsets: objectPermission?.mcp_toolsets ?? [],
|
||||
},
|
||||
mcp_tool_permissions: objectPermission?.mcp_tool_permissions ?? {},
|
||||
skills: objectPermission?.skills ?? [],
|
||||
});
|
||||
|
||||
// antd only reported the fields that were actually mounted, so the identity and
|
||||
|
|
@ -100,7 +103,7 @@ const toFormValues = (
|
|||
userData: any,
|
||||
objectPermission: ObjectPermission | null | undefined,
|
||||
isBulkEdit: boolean,
|
||||
canEditMcpPermissions: boolean,
|
||||
canEditPermissions: boolean,
|
||||
): UserEditFormInput => {
|
||||
const maxBudget = userData.user_info?.max_budget;
|
||||
const isUnlimited = maxBudget === null || maxBudget === undefined;
|
||||
|
|
@ -119,7 +122,7 @@ const toFormValues = (
|
|||
max_budget: isUnlimited ? "" : maxBudget,
|
||||
budget_duration: userData.user_info?.budget_duration,
|
||||
metadata: userData.user_info?.metadata ? JSON.stringify(userData.user_info.metadata, null, 2) : undefined,
|
||||
...(canEditMcpPermissions ? buildMcpFieldValues(objectPermission) : {}),
|
||||
...(canEditPermissions ? buildPermissionFieldValues(objectPermission) : {}),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -165,7 +168,7 @@ export function UserEditView({
|
|||
premiumUser = false,
|
||||
}: UserEditViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const canEditMcpPermissions = !isBulkEdit && all_admin_roles.includes(userRole || "");
|
||||
const canEditPermissions = !isBulkEdit && isProxyAdminRole(userRole || "");
|
||||
const [unlimitedBudget, setUnlimitedBudget] = useState(false);
|
||||
const [modelMaxBudget, setModelMaxBudget] = useSeededState<ModelMaxBudget>(
|
||||
userData.user_id,
|
||||
|
|
@ -173,14 +176,14 @@ export function UserEditView({
|
|||
);
|
||||
const schema = useMemo(() => userEditSchema(unlimitedBudget, t), [unlimitedBudget, t]);
|
||||
const form = useZodForm(schema, {
|
||||
defaultValues: toFormValues(userData, objectPermission, isBulkEdit, canEditMcpPermissions),
|
||||
defaultValues: toFormValues(userData, objectPermission, isBulkEdit, canEditPermissions),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const maxBudget = userData.user_info?.max_budget;
|
||||
setUnlimitedBudget(maxBudget === null || maxBudget === undefined);
|
||||
form.reset(toFormValues(userData, objectPermission, isBulkEdit, canEditMcpPermissions));
|
||||
}, [userData, objectPermission, canEditMcpPermissions, isBulkEdit, form]);
|
||||
form.reset(toFormValues(userData, objectPermission, isBulkEdit, canEditPermissions));
|
||||
}, [userData, objectPermission, canEditPermissions, isBulkEdit, form]);
|
||||
|
||||
const handleUnlimitedBudgetChange = (checked: boolean) => {
|
||||
setUnlimitedBudget(checked);
|
||||
|
|
@ -400,8 +403,13 @@ export function UserEditView({
|
|||
)}
|
||||
</FormField>
|
||||
|
||||
{canEditMcpPermissions && (
|
||||
{canEditPermissions && (
|
||||
<>
|
||||
<FormField control={form.control} name="skills" label={t("objectPermissionsView.skills.title")}>
|
||||
{({ value, onChange }) => (
|
||||
<SkillSelector value={value} onChange={onChange} accessToken={accessToken || ""} />
|
||||
)}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mcp_servers_and_groups"
|
||||
|
|
|
|||
|
|
@ -80,6 +80,9 @@ vi.mock("@/components/networking", async (importOriginal) => {
|
|||
getProxyBaseUrl: () => "https://litellm.test",
|
||||
fetchMCPServers: (...args: unknown[]) => mockFetchMCPServers(...args),
|
||||
fetchMCPToolsets: vi.fn().mockResolvedValue([]),
|
||||
getClaudeCodePluginsList: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ plugins: [{ id: "private-skill", name: "private-skill", enabled: false, source: {} }] }),
|
||||
listMCPTools: (...args: unknown[]) => mockListMCPTools(...args),
|
||||
};
|
||||
});
|
||||
|
|
@ -123,6 +126,44 @@ describe("UserInfoView add-to-team form", () => {
|
|||
mockListMCPTools.mockResolvedValue({ tools: [] });
|
||||
});
|
||||
|
||||
it.each(["grant", "revoke", "keep"])("saves direct user skills on %s without dropping MCP grants", async (action) => {
|
||||
mockUserGetInfoV2.mockResolvedValue({
|
||||
...MOCK_USER_DATA,
|
||||
object_permission: {
|
||||
...MOCK_USER_DATA.object_permission,
|
||||
skills: action === "grant" ? [] : ["private-skill"],
|
||||
},
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<UserInfoView {...defaultProps} initialTab={1} startInEditMode />);
|
||||
const selector = await screen.findByRole("combobox", { name: "Select skills (optional)" });
|
||||
if (action === "grant") {
|
||||
await user.click(selector);
|
||||
await user.click(await screen.findByRole("option", { name: /private-skill/ }));
|
||||
await user.keyboard("{Escape}");
|
||||
} else if (action === "revoke") {
|
||||
await user.click(screen.getByRole("button", { name: "Clear all skills" }));
|
||||
}
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
await waitFor(() => expect(mockUserUpdateUserCall).toHaveBeenCalledTimes(1));
|
||||
const payload = mockUserUpdateUserCall.mock.calls[0][1];
|
||||
expect(payload).not.toHaveProperty("skills");
|
||||
expect(payload.object_permission).toEqual({
|
||||
...MOCK_USER_DATA.object_permission,
|
||||
mcp_toolsets: [],
|
||||
skills: action === "revoke" ? [] : ["private-skill"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["Internal User", "proxy_admin_viewer", "org_admin"])(
|
||||
"does not expose the Skills writer to %s",
|
||||
async (userRole) => {
|
||||
render(<UserInfoView {...defaultProps} userRole={userRole} initialTab={1} startInEditMode />);
|
||||
await screen.findByRole("button", { name: "Save Changes" });
|
||||
expect(screen.queryByRole("combobox", { name: "Select skills (optional)" })).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("loads only the selected user's Activity, after opening the tab", async () => {
|
||||
mockUserDailyActivityAggregatedCall.mockResolvedValue(EMPTY_DAILY_ACTIVITY_RESPONSE);
|
||||
render(<UserInfoView {...defaultProps} />);
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ vi.mock("@/components/networking", () => {
|
|||
getUiPageUrl: (url: string) => url,
|
||||
fetchMCPServers: (...args: unknown[]) => mockFetchMCPServers(...args),
|
||||
fetchMCPToolsets: vi.fn().mockResolvedValue([]),
|
||||
getClaudeCodePluginsList: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ plugins: [{ id: "private-skill", name: "private-skill", enabled: false, source: {} }] }),
|
||||
listMCPTools: (...args: unknown[]) => mockListMCPTools(...args),
|
||||
};
|
||||
});
|
||||
|
|
@ -368,6 +371,7 @@ describe("UserInfoView", () => {
|
|||
mcp_access_groups: ["dev-group"],
|
||||
mcp_toolsets: [],
|
||||
mcp_tool_permissions: { "srv-1": ["list_issues"] },
|
||||
skills: [],
|
||||
};
|
||||
expect(payload.object_permission).toEqual(expectedObjectPermission);
|
||||
expect(payload).not.toHaveProperty("mcp_servers_and_groups");
|
||||
|
|
|
|||
|
|
@ -324,15 +324,18 @@ export default function UserInfoView({
|
|||
if (!accessToken || !userData) return;
|
||||
|
||||
const mcpEntitlement = extractMcpEntitlement(formValues, allMcpServers, allMcpToolsets);
|
||||
const skills: string[] | undefined = formValues.skills;
|
||||
const objectPermission =
|
||||
mcpEntitlement || skills !== undefined ? { ...mcpEntitlement, ...(skills !== undefined && { skills }) } : null;
|
||||
const userFields = Object.fromEntries(
|
||||
Object.entries(formValues).filter(
|
||||
([field]) => field !== "mcp_servers_and_groups" && field !== "mcp_tool_permissions",
|
||||
([field]) => field !== "mcp_servers_and_groups" && field !== "mcp_tool_permissions" && field !== "skills",
|
||||
),
|
||||
);
|
||||
|
||||
await userUpdateUserCall(
|
||||
accessToken,
|
||||
mcpEntitlement ? { ...userFields, object_permission: mcpEntitlement } : userFields,
|
||||
objectPermission ? { ...userFields, object_permission: objectPermission } : userFields,
|
||||
null,
|
||||
);
|
||||
|
||||
|
|
@ -349,8 +352,8 @@ export default function UserInfoView({
|
|||
formValues.budget_duration === undefined ? userData.budget_duration : formValues.budget_duration,
|
||||
metadata: formValues.metadata ?? userData.metadata,
|
||||
model_max_budget: formValues.model_max_budget ?? userData.model_max_budget,
|
||||
object_permission: mcpEntitlement
|
||||
? { ...userData.object_permission, ...mcpEntitlement }
|
||||
object_permission: objectPermission
|
||||
? { ...userData.object_permission, ...objectPermission }
|
||||
: userData.object_permission,
|
||||
});
|
||||
|
||||
|
|
@ -726,6 +729,14 @@ export default function UserInfoView({
|
|||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium mb-2">{t("objectPermissionsView.skills.title")}</p>
|
||||
<p>
|
||||
{userData.object_permission?.skills?.join(", ") ||
|
||||
t("objectPermissionsView.skills.noneGrantedDirectly")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium mb-2">{t("users.userInfo.details.mcpPermissions")}</p>
|
||||
<MCPServerPermissions
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NuqsTestingAdapter } from "nuqs/adapters/testing";
|
||||
import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import PublicModelHub from "./public_model_hub";
|
||||
import { getClaudeCodePluginsList, skillHubPublicCall } from "./networking";
|
||||
import { getPublicMCPHubColumns, MCPServerData, ModelGroupInfo } from "./PublicModelHubTableColumns";
|
||||
|
||||
const { apiGetMock } = vi.hoisted(() => ({ apiGetMock: vi.fn() }));
|
||||
|
|
@ -33,6 +35,7 @@ vi.mock("./networking", async (importOriginal) => {
|
|||
agentHubPublicModelsCall: vi.fn().mockResolvedValue([]),
|
||||
mcpHubPublicServersCall: vi.fn().mockResolvedValue([]),
|
||||
skillHubPublicCall: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
getClaudeCodePluginsList: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
getUiConfig: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
|
|
@ -82,6 +85,7 @@ const respondWith = (
|
|||
facetValues: Record<string, string[]> = FACET_VALUES,
|
||||
) =>
|
||||
apiGetMock.mockImplementation((path: string) => {
|
||||
if (path === "/skill-hub/skills/private-skill/versions") return Promise.resolve({ versions: [], total: 0 });
|
||||
const facet = facetValues[path];
|
||||
if (facet) {
|
||||
return Promise.resolve({
|
||||
|
|
@ -114,7 +118,9 @@ const renderHub = (props: React.ComponentProps<typeof PublicModelHub> = {}) => {
|
|||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<PublicModelHub {...props} />
|
||||
<NuqsTestingAdapter>
|
||||
<PublicModelHub {...props} />
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
|
@ -137,6 +143,8 @@ beforeAll(() => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getClaudeCodePluginsList).mockReset().mockResolvedValue({ plugins: [] });
|
||||
vi.mocked(skillHubPublicCall).mockReset().mockResolvedValue({ plugins: [] });
|
||||
respondWith(DEFAULT_MODELS);
|
||||
Storage.prototype.getItem = vi.fn(() => "false");
|
||||
Storage.prototype.setItem = vi.fn();
|
||||
|
|
@ -151,6 +159,34 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
describe("PublicModelHub", () => {
|
||||
it.each([true, false])("keeps Skills catalog credentials scoped to embedded=%s", async (isEmbedded) => {
|
||||
vi.mocked(getClaudeCodePluginsList).mockResolvedValueOnce({
|
||||
plugins: [{ id: "private-skill", name: "private-skill", source: {}, enabled: false }],
|
||||
});
|
||||
vi.mocked(skillHubPublicCall).mockResolvedValueOnce({
|
||||
plugins: [{ id: "public-skill", name: "public-skill", source: {}, enabled: true }],
|
||||
});
|
||||
renderHub({ isEmbedded, accessToken: "dashboard-session" });
|
||||
await userEvent.click(await screen.findByRole("tab", { name: "Skills Hub" }));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: isEmbedded ? "private-skill" : "public-skill" }),
|
||||
).toBeInTheDocument();
|
||||
if (isEmbedded) {
|
||||
expect(getClaudeCodePluginsList).toHaveBeenCalledWith("dashboard-session");
|
||||
expect(skillHubPublicCall).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "private-skill" }));
|
||||
await userEvent.click(await screen.findByRole("tab", { name: "Version history" }));
|
||||
await waitFor(() =>
|
||||
expect(apiGetMock).toHaveBeenCalledWith(
|
||||
"/skill-hub/skills/private-skill/versions",
|
||||
expect.objectContaining({ accessToken: "dashboard-session" }),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
expect(getClaudeCodePluginsList).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders", () => {
|
||||
const { container } = renderHub();
|
||||
expect(container).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { ExternalLinkIcon, SearchIcon } from "@heroicons/react/outline";
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Copy, Inbox, Info } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
|
@ -33,6 +34,7 @@ import Navbar from "./navbar";
|
|||
import {
|
||||
agentHubPublicModelsCall,
|
||||
skillHubPublicCall,
|
||||
getClaudeCodePluginsList,
|
||||
getGlobalLitellmHeaderName,
|
||||
getProxyBaseUrl,
|
||||
getPublicModelHubInfo,
|
||||
|
|
@ -124,8 +126,13 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
const [selectedAgent, setSelectedAgent] = useState<null | AgentCard>(null);
|
||||
const [selectedMcpServer, setSelectedMcpServer] = useState<null | MCPServerData>(null);
|
||||
const [activeTab, setActiveTab] = useState<string>("models");
|
||||
const [skillHubData, setSkillHubData] = useState<Plugin[]>([]);
|
||||
const [skillLoading, setSkillLoading] = useState<boolean>(false);
|
||||
const skillsToken = isEmbedded ? accessToken : null;
|
||||
const skillsQuery = useQuery({
|
||||
queryKey: ["ai-hub-skills", skillsToken],
|
||||
queryFn: () => (skillsToken ? getClaudeCodePluginsList(skillsToken) : skillHubPublicCall()),
|
||||
enabled: !isChat,
|
||||
});
|
||||
const skillHubData: Plugin[] = skillsQuery.data?.plugins ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const initializeAndFetch = async () => {
|
||||
|
|
@ -171,23 +178,10 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
setUsefulLinks(publicModelHubInfo.useful_links || {});
|
||||
};
|
||||
|
||||
const fetchSkillData = async () => {
|
||||
try {
|
||||
setSkillLoading(true);
|
||||
const response = await skillHubPublicCall();
|
||||
setSkillHubData(response.plugins ?? []);
|
||||
} catch (error) {
|
||||
console.error("There was an error fetching the public skill data", error);
|
||||
} finally {
|
||||
setSkillLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPublicModelHubInfo();
|
||||
|
||||
fetchAgentData();
|
||||
fetchMcpData();
|
||||
fetchSkillData();
|
||||
};
|
||||
|
||||
initializeAndFetch();
|
||||
|
|
@ -804,7 +798,13 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
{/* Skill Hub Tab */}
|
||||
{!isChat && (
|
||||
<TabsContent value="skills">
|
||||
<SkillHubDashboard skills={skillHubData} isLoading={skillLoading} publicPage={true} />
|
||||
<SkillHubDashboard
|
||||
key={skillsToken}
|
||||
skills={skillHubData}
|
||||
isLoading={skillsQuery.isLoading}
|
||||
accessToken={skillsToken}
|
||||
publicPage={!skillsToken}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
"""Unit tests for claude_code_skill_access.py: key/team grant resolution."""
|
||||
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy._types import (
|
||||
UI_TEAM_ID,
|
||||
LiteLLM_AccessGroupTable,
|
||||
LiteLLM_ObjectPermissionTable,
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
Member,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import (
|
||||
PUBLIC_ONLY,
|
||||
AccessGroupSkills,
|
||||
|
|
@ -409,3 +419,174 @@ async def test_shared_group_loader_skips_missing_groups_unless_strict(
|
|||
await call
|
||||
return
|
||||
assert await call == ["mcp-1"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard_db(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> MagicMock:
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
|
||||
prisma: Final = MagicMock()
|
||||
prisma.db = prisma.writer_db
|
||||
team_id: Final = request.node.name
|
||||
prisma.writer_db.litellm_usertable.find_unique = AsyncMock(return_value=LiteLLM_UserTable(
|
||||
user_id="dashboard-member", teams=[team_id], object_permission_id="user-permission",
|
||||
))
|
||||
prisma.writer_db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=_perm(["user-only"]).model_copy(
|
||||
update={"object_permission_id": "user-permission"},
|
||||
))
|
||||
prisma.writer_db.litellm_teamtable.find_unique = AsyncMock(return_value=LiteLLM_TeamTable(
|
||||
team_id=team_id, members_with_roles=[Member(user_id="dashboard-member", role="user")],
|
||||
object_permission=_perm(["team-skill"]), access_group_ids=["team-group"],
|
||||
))
|
||||
prisma.writer_db.litellm_accessgrouptable.find_unique = AsyncMock(return_value=LiteLLM_AccessGroupTable(
|
||||
access_group_id="team-group", access_group_name="team-group", access_skill_names=["group-skill"],
|
||||
))
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", UserApiKeyCache())
|
||||
monkeypatch.setattr(proxy_server, "proxy_logging_obj", None)
|
||||
return prisma
|
||||
|
||||
|
||||
def _dashboard_caller(role: LitellmUserRoles = LitellmUserRoles.INTERNAL_USER) -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(
|
||||
user_id="dashboard-member", team_id=UI_TEAM_ID, user_role=role,
|
||||
object_permission=_perm(["session-only"]),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_visibility_without_user_identity_is_public_only(dashboard_db: MagicMock) -> None:
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import _ui_skill_visibility
|
||||
|
||||
caller: Final = _dashboard_caller().model_copy(update={"user_id": None})
|
||||
assert await _ui_skill_visibility(caller) == PUBLIC_ONLY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("role", (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY))
|
||||
async def test_dashboard_reads_own_user_team_and_group_grants_without_session_grants(
|
||||
dashboard_db: MagicMock, role: LitellmUserRoles,
|
||||
) -> None:
|
||||
visibility: Final = await resolve_skill_visibility(_dashboard_caller(role))
|
||||
assert visibility.granted == frozenset({"team-skill", "group-skill", "user-only"})
|
||||
assert not visibility.sees_private
|
||||
assert visibility.allows(_plugin("team-skill", enabled=False))
|
||||
assert visibility.allows(_plugin("user-only", enabled=False))
|
||||
assert not visibility.allows(_plugin("session-only", enabled=False))
|
||||
assert visibility.where() == {"OR": [
|
||||
{"enabled": True}, {"name": {"in": ["group-skill", "team-skill", "user-only"]}},
|
||||
]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("change", ("roster", "blocked", "grants", "team", "group", "memberships", "user"))
|
||||
async def test_dashboard_revocation_is_visible_with_warm_caches(
|
||||
dashboard_db: MagicMock, change: str,
|
||||
) -> None:
|
||||
caller: Final = _dashboard_caller()
|
||||
assert (await resolve_skill_visibility(caller)).granted == frozenset({"team-skill", "group-skill", "user-only"})
|
||||
table: Final = dashboard_db.writer_db.litellm_teamtable
|
||||
team: Final = table.find_unique.return_value
|
||||
if change == "roster":
|
||||
table.find_unique.return_value = team.model_copy(update={"members_with_roles": [
|
||||
Member(user_id="another-user", role="admin"),
|
||||
]})
|
||||
elif change == "blocked":
|
||||
table.find_unique.return_value = team.model_copy(update={"blocked": True})
|
||||
elif change == "grants":
|
||||
table.find_unique.return_value = team.model_copy(update={"object_permission": None, "access_group_ids": []})
|
||||
elif change == "team":
|
||||
table.find_unique.return_value = None
|
||||
elif change == "group":
|
||||
dashboard_db.writer_db.litellm_accessgrouptable.find_unique.return_value = None
|
||||
elif change == "memberships":
|
||||
users: Final = dashboard_db.writer_db.litellm_usertable
|
||||
users.find_unique.return_value = users.find_unique.return_value.model_copy(update={"teams": []})
|
||||
else:
|
||||
dashboard_db.writer_db.litellm_usertable.find_unique.return_value = None
|
||||
dashboard_db.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
|
||||
assert (await resolve_skill_visibility(caller)).granted == (
|
||||
frozenset() if change == "user" else frozenset({"user-only"})
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_unions_independent_team_grants(dashboard_db: MagicMock) -> None:
|
||||
users: Final = dashboard_db.writer_db.litellm_usertable
|
||||
first: Final = dashboard_db.writer_db.litellm_teamtable.find_unique.return_value
|
||||
second: Final = first.model_copy(update={
|
||||
"team_id": "second-team", "object_permission": _perm(["second-skill"]), "access_group_ids": [],
|
||||
})
|
||||
users.find_unique.return_value = users.find_unique.return_value.model_copy(update={
|
||||
"teams": [first.team_id, second.team_id],
|
||||
})
|
||||
dashboard_db.writer_db.litellm_teamtable.find_unique.side_effect = [first, second]
|
||||
assert (await resolve_skill_visibility(_dashboard_caller())).granted == frozenset({
|
||||
"team-skill", "group-skill", "second-skill", "user-only",
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("source", ("litellm_teamtable", "litellm_accessgrouptable"))
|
||||
async def test_dashboard_team_read_fault_keeps_only_independent_grants(
|
||||
dashboard_db: MagicMock, source: str,
|
||||
) -> None:
|
||||
caller: Final = _dashboard_caller()
|
||||
assert (await resolve_skill_visibility(caller)).granted == frozenset({"team-skill", "group-skill", "user-only"})
|
||||
getattr(dashboard_db.writer_db, source).find_unique.side_effect = RuntimeError("database unavailable")
|
||||
assert (await resolve_skill_visibility(caller)).granted == frozenset({"user-only"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("team_id", (None, "ordinary-team"))
|
||||
async def test_ordinary_key_does_not_inherit_owners_other_teams(
|
||||
dashboard_db: MagicMock, team_id: str | None,
|
||||
) -> None:
|
||||
caller: Final = _dashboard_caller().model_copy(update={"team_id": team_id})
|
||||
loader: Final = AsyncMock(return_value=AccessGroupSkills())
|
||||
assert (await resolve_skill_visibility(caller, loader)).granted == frozenset({"session-only"})
|
||||
dashboard_db.writer_db.litellm_usertable.find_unique.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("change", ("clear", "unlink", "deleted", "malformed", "fault"))
|
||||
async def test_dashboard_direct_grant_revocation_preserves_independent_team_grants(
|
||||
dashboard_db: MagicMock, change: str,
|
||||
) -> None:
|
||||
caller: Final = _dashboard_caller()
|
||||
assert "user-only" in (await resolve_skill_visibility(caller)).granted
|
||||
permissions: Final = dashboard_db.writer_db.litellm_objectpermissiontable
|
||||
if change == "clear":
|
||||
permissions.find_unique.return_value = _perm([])
|
||||
elif change == "unlink":
|
||||
users: Final = dashboard_db.writer_db.litellm_usertable
|
||||
users.find_unique.return_value = users.find_unique.return_value.model_copy(update={"object_permission_id": None})
|
||||
elif change == "deleted":
|
||||
permissions.find_unique.return_value = None
|
||||
elif change == "malformed":
|
||||
permissions.find_unique.return_value = _perm([]).model_copy(update={"skills": 42})
|
||||
else:
|
||||
permissions.find_unique.side_effect = RuntimeError("database unavailable")
|
||||
assert (await resolve_skill_visibility(caller)).granted == frozenset({"team-skill", "group-skill"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("change", ("inactive", "malformed", "fault", "database"))
|
||||
async def test_dashboard_unreadable_or_inactive_user_never_reuses_grants(
|
||||
dashboard_db: MagicMock, change: str, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
caller: Final = _dashboard_caller()
|
||||
assert "user-only" in (await resolve_skill_visibility(caller)).granted
|
||||
users: Final = dashboard_db.writer_db.litellm_usertable
|
||||
if change == "inactive":
|
||||
users.find_unique.return_value = users.find_unique.return_value.model_copy(update={"metadata": {"scim_active": False}})
|
||||
elif change == "malformed":
|
||||
users.find_unique.return_value = users.find_unique.return_value.model_copy(update={"user_id": None})
|
||||
elif change == "database":
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
||||
else:
|
||||
users.find_unique.side_effect = RuntimeError("database unavailable")
|
||||
assert await resolve_skill_visibility(caller) == PUBLIC_ONLY
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue