fix(skills): honor dashboard user and team grants (#51416)

This commit is contained in:
tin-berri 2026-11-12 10:12:32 -08:00 • committed by GitHub
parent d2711ac559
commit d29ac75af1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 406 additions and 44 deletions

View file

@ -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))

View file

@ -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");

View file

@ -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"

View file

@ -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} />);

View file

@ -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");

View file

@ -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

View file

@ -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();

View file

@ -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>

View file

@ -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