From d29ac75af1519566ab7593034e76935bbc203ec1 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Thu, 12 Nov 2026 10:12:32 -0800 Subject: [PATCH] fix(skills): honor dashboard user and team grants (#51416) --- .../claude_code_skill_access.py | 103 ++++++++-- .../users/_components/user_edit_view.test.tsx | 4 + .../users/_components/user_edit_view.tsx | 26 ++- .../user_info_view.integration.test.tsx | 41 ++++ .../view_users/user_info_view.test.tsx | 4 + .../_components/view_users/user_info_view.tsx | 19 +- .../src/components/public_model_hub.test.tsx | 38 +++- .../src/components/public_model_hub.tsx | 32 +-- .../test_claude_code_skill_access.py | 183 +++++++++++++++++- 9 files changed, 406 insertions(+), 44 deletions(-) diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_access.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_access.py index 4775498dec3..b0f77158d4b 100644 --- a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_access.py +++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_access.py @@ -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)) diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx index f71384d80d7..095c8f94360 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx @@ -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"); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx index 1a6c9b2aa77..e28275242bc 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx @@ -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>; type UserEditFormValues = z.output>; -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( 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({ )} - {canEditMcpPermissions && ( + {canEditPermissions && ( <> + + {({ value, onChange }) => ( + + )} + { 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(); + 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(); + 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(); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx index 44d964bf13a..ccca3daf6af 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx @@ -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"); diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx index 78c1875bb1d..e97eb4cac6a 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx @@ -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({ +
+

{t("objectPermissionsView.skills.title")}

+

+ {userData.object_permission?.skills?.join(", ") || + t("objectPermissionsView.skills.noneGrantedDirectly")} +

+
+

{t("users.userInfo.details.mcpPermissions")}

({ 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 = 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 = {}) => { const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); return render( - + + + , ); }; @@ -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(); diff --git a/litellm/ui/litellm-dashboard/src/components/public_model_hub.tsx b/litellm/ui/litellm-dashboard/src/components/public_model_hub.tsx index 7a76922c649..68670ad2d5e 100644 --- a/litellm/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/litellm/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -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 = ({ accessToken, isEmbedded const [selectedAgent, setSelectedAgent] = useState(null); const [selectedMcpServer, setSelectedMcpServer] = useState(null); const [activeTab, setActiveTab] = useState("models"); - const [skillHubData, setSkillHubData] = useState([]); - const [skillLoading, setSkillLoading] = useState(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 = ({ 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 = ({ accessToken, isEmbedded {/* Skill Hub Tab */} {!isChat && ( - + )} diff --git a/tests/unit/proxy/anthropic_endpoints/test_claude_code_skill_access.py b/tests/unit/proxy/anthropic_endpoints/test_claude_code_skill_access.py index a12a90b5a78..364cbdbddb9 100644 --- a/tests/unit/proxy/anthropic_endpoints/test_claude_code_skill_access.py +++ b/tests/unit/proxy/anthropic_endpoints/test_claude_code_skill_access.py @@ -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