feat(access-groups): grant Claude Code skills through access groups (#46294)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yassin Kortam 2026-10-14 16:18:50 -07:00 • committed by GitHub
parent dba46c0513
commit 10e4ecc313
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1018 additions and 50 deletions

View file

@ -0,0 +1,4 @@
SET LOCAL lock_timeout = '10s';
-- AlterTable
ALTER TABLE "LiteLLM_AccessGroupTable" ADD COLUMN IF NOT EXISTS "access_skill_names" TEXT[] DEFAULT ARRAY[]::TEXT[];

View file

@ -1590,6 +1590,7 @@ model LiteLLM_AccessGroupTable {
access_mcp_server_ids String[] @default([])
mcp_tool_permissions Json?
access_agent_ids String[] @default([])
access_skill_names String[] @default([])
assigned_team_ids String[] @default([])
assigned_key_ids String[] @default([])

View file

@ -19,6 +19,7 @@ class LiteLLM_AccessGroupTable(LiteLLMPydanticObjectBase):
access_mcp_server_ids: list[str] = []
mcp_tool_permissions: Mapping[str, Sequence[str]] | None = None
access_agent_ids: list[str] = []
access_skill_names: tuple[str, ...] = ()
assigned_team_ids: list[str] = []
assigned_key_ids: list[str] = []
created_at: datetime | None = None

View file

@ -648,6 +648,20 @@
],
"title": "Access Model Names"
},
"access_skill_names": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Access Skill Names"
},
"assigned_key_ids": {
"anyOf": [
{
@ -829,6 +843,14 @@
"title": "Access Model Names",
"type": "array"
},
"access_skill_names": {
"default": [],
"items": {
"type": "string"
},
"title": "Access Skill Names",
"type": "array"
},
"assigned_key_ids": {
"items": {
"type": "string"
@ -991,6 +1013,20 @@
],
"title": "Access Model Names"
},
"access_skill_names": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Access Skill Names"
},
"assigned_key_ids": {
"anyOf": [
{
@ -7695,7 +7731,7 @@
"paths": {
"/claude-code/marketplace.json": {
"get": {
"description": "Serve marketplace.json for Claude Code plugin discovery.\n\nThis endpoint is accessed by Claude Code CLI when users run:\n- claude plugin marketplace add <url>\n- claude plugin install <name>@<marketplace>\n\nWithout `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`\nthe key is authenticated and the catalog also holds the disabled plugins granted\nto it through `object_permission.skills` on the key or its team.\n\nReturns:\n Marketplace catalog with list of available plugins and their git sources.\n\nExample:\n ```bash\n claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json\n claude plugin marketplace add \"http://localhost:4000/claude-code/marketplace.json?key=sk-...\"\n claude plugin install my-plugin@litellm\n ```",
"description": "Serve marketplace.json for Claude Code plugin discovery.\n\nThis endpoint is accessed by Claude Code CLI when users run:\n- claude plugin marketplace add <url>\n- claude plugin install <name>@<marketplace>\n\nWithout `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`\nthe key is authenticated and the catalog also holds the disabled plugins granted\nto it through `object_permission.skills` or an access group on the key or its team.\n\nReturns:\n Marketplace catalog with list of available plugins and their git sources.\n\nExample:\n ```bash\n claude plugin marketplace add http://localhost:4000/claude-code/marketplace.json\n claude plugin marketplace add \"http://localhost:4000/claude-code/marketplace.json?key=sk-...\"\n claude plugin install my-plugin@litellm\n ```",
"operationId": "get_marketplace_claude_code_marketplace_json_get",
"parameters": [
{

View file

@ -4260,6 +4260,7 @@ class TeamAccessGroupModelGrant(LiteLLMPydanticObjectBase):
models: tuple[str, ...]
mcp_server_ids: tuple[str, ...] = ()
agent_ids: tuple[str, ...] = ()
skill_names: tuple[str, ...] = ()
class TeamInfoMember(Member):
@ -4297,6 +4298,9 @@ class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
access_group_models: list[str] | None = None
access_group_mcp_server_ids: list[str] | None = None
access_group_agent_ids: list[str] | None = None
access_group_skill_names: tuple[str, ...] | None = None
# Listed group ids with no row; skill grants are suspended while this is non-empty.
unresolved_access_group_ids: tuple[str, ...] | None = None
access_group_details: tuple[TeamAccessGroupModelGrant, ...] | None = None
# Parent org's model ceiling, reported only to callers who can manage the team.
# None = no org or not a manager; [] or ["all-proxy-models"] = no ceiling.

View file

@ -33,7 +33,7 @@ from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, ProxyException, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import (
SkillVisibility,
skill_visibility,
resolve_skill_visibility,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
@ -122,7 +122,7 @@ async def get_marketplace(request: Request, key: str | None = None):
Without `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`
the key is authenticated and the catalog also holds the disabled plugins granted
to it through `object_permission.skills` on the key or its team.
to it through `object_permission.skills` or an access group on the key or its team.
Returns:
Marketplace catalog with list of available plugins and their git sources.
@ -140,7 +140,7 @@ async def get_marketplace(request: Request, key: str | None = None):
caller: Final[UserAPIKeyAuth | None] = (
await user_api_key_auth(request=request, api_key=f"Bearer {key}") if key else None
)
visibility: Final[SkillVisibility] = skill_visibility(caller)
visibility: Final[SkillVisibility] = await resolve_skill_visibility(caller)
plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many(
where=visibility.where()
)
@ -464,7 +464,7 @@ async def list_plugins(
try:
prisma_client: Final = await get_prisma_client()
visibility: Final[SkillVisibility] = skill_visibility(user_api_key_dict)
visibility: Final[SkillVisibility] = await resolve_skill_visibility(user_api_key_dict)
plugins: Final[Sequence[_PluginRecord]] = await ClaudeCodePluginRepository(prisma_client).table.find_many(
where={"enabled": True} if enabled_only else visibility.where()
)
@ -545,7 +545,7 @@ async def get_plugin(
detail={"error": f"Plugin '{plugin_name}' not found"},
)
if not skill_visibility(user_api_key_dict).allows(plugin):
if not (await resolve_skill_visibility(user_api_key_dict)).allows(plugin):
raise HTTPException(
status_code=403,
detail={"error": f"Plugin '{plugin_name}' is not granted to this key"},

View file

@ -1,18 +1,22 @@
"""
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``.
``object_permission.skills`` or via ``access_skill_names`` on an access group
attached to the key or its team.
"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, Protocol
from typing import TYPE_CHECKING, Final, Protocol, TypeAlias
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth
from litellm.proxy._types import UI_TEAM_ID, LiteLLM_ObjectPermissionTable, UserAPIKeyAuth
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
if TYPE_CHECKING:
from prisma.types import LiteLLM_ClaudeCodePluginTableWhereInput
from litellm.proxy._types import LiteLLM_TeamTable
class _SkillRecord(Protocol):
name: str
@ -23,14 +27,28 @@ def _skills_of(permission: LiteLLM_ObjectPermissionTable | None) -> frozenset[st
return frozenset(permission.skills or ()) if permission is not None else frozenset()
def granted_skills(user_api_key_dict: UserAPIKeyAuth) -> frozenset[str]:
@dataclass(frozen=True, slots=True)
class AccessGroupSkills:
"""Plugin names inherited from access groups, split by the scope the group is attached to."""
key: frozenset[str] = frozenset()
team: frozenset[str] = frozenset()
unresolved: bool = False
"""A listed group returned no row, so the policy is incomplete and only public plugins show."""
NO_ACCESS_GROUP_SKILLS: Final = AccessGroupSkills()
def granted_skills(
user_api_key_dict: UserAPIKeyAuth, inherited: AccessGroupSkills = NO_ACCESS_GROUP_SKILLS
) -> frozenset[str]:
"""Key grant intersected with the team grant when both are non-empty; either alone applies as is.
An empty list is the Prisma column default for every object-permission row, so it means
"no private grants configured here" and defers to the other scope, same as the agents check.
An empty list is the Prisma column default, so it means "no private grants configured here".
"""
key_skills: Final = _skills_of(user_api_key_dict.object_permission)
team_skills: Final = _skills_of(user_api_key_dict.team_object_permission)
key_skills: Final = _skills_of(user_api_key_dict.object_permission) | inherited.key
team_skills: Final = _skills_of(user_api_key_dict.team_object_permission) | inherited.team
match (bool(key_skills), bool(team_skills)):
case (True, True):
return key_skills & team_skills
@ -59,9 +77,87 @@ class SkillVisibility:
PUBLIC_ONLY: Final = SkillVisibility(granted=frozenset(), sees_private=False)
def skill_visibility(user_api_key_dict: UserAPIKeyAuth | None) -> SkillVisibility:
def skill_visibility(
user_api_key_dict: UserAPIKeyAuth | None, inherited: AccessGroupSkills = NO_ACCESS_GROUP_SKILLS
) -> SkillVisibility:
if user_api_key_dict is None:
return PUBLIC_ONLY
if is_proxy_admin(user_api_key_dict):
return SkillVisibility(granted=frozenset(), sees_private=True)
return SkillVisibility(granted=granted_skills(user_api_key_dict), sees_private=False)
if inherited.unresolved:
return PUBLIC_ONLY
return SkillVisibility(granted=granted_skills(user_api_key_dict, inherited), sees_private=False)
async def _team_object(user_api_key_dict: UserAPIKeyAuth, check_db_only: bool) -> "LiteLLM_TeamTable | None":
from litellm.proxy.auth.auth_checks import TeamNotFoundError, get_team_object
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
team_id: Final = user_api_key_dict.team_id
if not team_id or team_id == UI_TEAM_ID or prisma_client is None:
return None
try:
team_obj: Final = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=user_api_key_dict.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
check_db_only=check_db_only,
)
except TeamNotFoundError:
return None
return None if team_obj.blocked else team_obj
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.
Key-side groups count only when the group lists the key or its team as an owner, same as
the model and MCP checks, so a key cannot claim a group it was never assigned to.
"""
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
if prisma_client is None:
return NO_ACCESS_GROUP_SKILLS
check_db_only: Final = bool(user_api_key_dict.requires_fresh_policy)
team_obj: Final = await _team_object(user_api_key_dict, check_db_only)
try:
key_skills: Final = frozenset(
await get_authorized_resources_from_key_access_groups(
valid_token=user_api_key_dict,
team_object=team_obj,
resource_field="access_skill_names",
check_db_only=check_db_only,
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,
)
return AccessGroupSkills(key=key_skills, team=team_skills)
except AccessGroupNotFoundError:
return AccessGroupSkills(unresolved=True)
AccessGroupSkillsLoader: TypeAlias = Callable[[UserAPIKeyAuth], Awaitable[AccessGroupSkills]]
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."""
if user_api_key_dict is None or is_proxy_admin(user_api_key_dict):
return skill_visibility(user_api_key_dict)
return skill_visibility(user_api_key_dict, await load_access_group_skills(user_api_key_dict))

View file

@ -4550,9 +4550,11 @@ async def get_access_group_tables(
check_db_only: bool = False,
*,
unreadable: Literal["skip", "raise"] = "skip",
missing: Literal["skip", "raise"] = "skip",
) -> tuple[LiteLLM_AccessGroupTable, ...]:
"""Load unified access groups by id (cache first, then DB). A group that provably does not exist is
skipped, it grants nothing. A group that exists but cannot be read is skipped too unless
skipped, it grants nothing, unless ``missing`` is ``"raise"`` and the caller wants to see
``AccessGroupNotFoundError`` itself. A group that exists but cannot be read is skipped too unless
``check_db_only`` is set or ``unreadable`` is ``"raise"``, in which case the failure propagates so a
caller enforcing policy can deny instead of shedding a restriction it could not read."""
if not access_group_ids:
@ -4580,6 +4582,8 @@ async def get_access_group_tables(
check_db_only=check_db_only,
)
except AccessGroupNotFoundError:
if missing == "raise":
raise
verbose_proxy_logger.debug("One of the requested access groups does not exist, it grants nothing")
return None
except Exception:
@ -4594,19 +4598,24 @@ async def get_access_group_tables(
async def _get_resources_from_access_groups(
access_group_ids: Sequence[str],
resource_field: Literal["access_model_names", "access_mcp_server_ids", "access_agent_ids"],
resource_field: Literal["access_model_names", "access_mcp_server_ids", "access_agent_ids", "access_skill_names"],
prisma_client: DatabaseClient | None = None,
user_api_key_cache: UserApiKeyCache | None = None,
proxy_logging_obj: ProxyLogging | None = None,
check_db_only: bool = False,
strict_errors: bool = False,
) -> list[str]:
"""Deduplicated ``resource_field`` values across the resolved access groups."""
"""Deduplicated ``resource_field`` values across the resolved access groups. With ``strict_errors`` a
missing or unreadable group raises instead of being skipped, so policy code never loses a ceiling."""
strictness: Final[Literal["skip", "raise"]] = "raise" if strict_errors else "skip"
groups: Final = await get_access_group_tables(
access_group_ids=access_group_ids,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
check_db_only=check_db_only,
unreadable=strictness,
missing=strictness,
)
return list(
{resource for ag in groups for resource in _access_group_field(ag, resource_field)} # comprehension-ok: flatten
@ -4615,7 +4624,7 @@ async def _get_resources_from_access_groups(
def _access_group_field(
group: LiteLLM_AccessGroupTable,
resource_field: Literal["access_model_names", "access_mcp_server_ids", "access_agent_ids"],
resource_field: Literal["access_model_names", "access_mcp_server_ids", "access_agent_ids", "access_skill_names"],
) -> list[str]:
match resource_field:
case "access_model_names":
@ -4624,6 +4633,8 @@ def _access_group_field(
return group.access_mcp_server_ids
case "access_agent_ids":
return group.access_agent_ids
case "access_skill_names":
return list(group.access_skill_names or ())
async def get_models_from_access_groups(
@ -4696,6 +4707,28 @@ async def get_agent_ids_from_access_groups(
_get_agent_ids_from_access_groups: Final = get_agent_ids_from_access_groups
async def get_skill_names_from_access_groups(
access_group_ids: Sequence[str],
prisma_client: PrismaClient | None = None,
user_api_key_cache: UserApiKeyCache | None = None,
proxy_logging_obj: ProxyLogging | None = None,
check_db_only: bool = False,
strict_errors: bool = False,
) -> frozenset[str]:
"""Claude Code plugin names granted by unified access groups, matched by plugin name."""
return frozenset(
await _get_resources_from_access_groups(
access_group_ids=access_group_ids,
resource_field="access_skill_names",
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=strict_errors,
)
)
def _resolve_all_team_model_sentinel_for_auth_check(
models: list[str],
llm_router: Router | None,
@ -5615,7 +5648,9 @@ async def can_team_access_model(
async def get_authorized_resources_from_key_access_groups(
valid_token: UserAPIKeyAuth | None,
team_object: LiteLLM_TeamTable | None,
resource_field: Literal["access_model_names", "access_mcp_server_ids", "access_agent_ids"],
resource_field: Literal["access_model_names", "access_mcp_server_ids", "access_agent_ids", "access_skill_names"],
check_db_only: bool = False,
strict_errors: bool = False,
) -> list[str]:
"""
For each access_group_id on the key, fetch the LiteLLM_AccessGroupTable row
@ -5649,8 +5684,15 @@ async def get_authorized_resources_from_key_access_groups(
prisma_client=_prisma_client,
user_api_key_cache=_user_api_key_cache,
proxy_logging_obj=_proxy_logging_obj,
check_db_only=check_db_only,
)
except AccessGroupNotFoundError:
if check_db_only or strict_errors:
raise
continue
except Exception:
if check_db_only or strict_errors:
raise
continue
team_authorized = bool(key_team_id and key_team_id in (ag.assigned_team_ids or []))
key_authorized = bool(key_token and key_token in (ag.assigned_key_ids or []))

View file

@ -558,6 +558,7 @@ async def create_access_group(
"access_mcp_server_ids": data.access_mcp_server_ids or [],
"mcp_tool_permissions": _tool_permissions_json(data.mcp_tool_permissions),
"access_agent_ids": data.access_agent_ids or [],
"access_skill_names": list(data.access_skill_names or ()),
"assigned_team_ids": data.assigned_team_ids or [],
"assigned_key_ids": data.assigned_key_ids or [],
"created_by": user_api_key_dict.user_id,
@ -649,18 +650,15 @@ async def update_access_group(
update_fields: Final = data.model_dump(exclude_unset=True)
update_data: Final[dict] = {"updated_by": user_api_key_dict.user_id}
for field, value in update_fields.items():
if (
field
in (
"assigned_team_ids",
"assigned_key_ids",
"access_model_names",
"access_mcp_server_ids",
"access_agent_ids",
)
and value is None
if field in (
"assigned_team_ids",
"assigned_key_ids",
"access_model_names",
"access_mcp_server_ids",
"access_agent_ids",
"access_skill_names",
):
value = []
value = list(value or ())
update_data[field] = (
_tool_permissions_json(data.mcp_tool_permissions) if field == "mcp_tool_permissions" else value
)

View file

@ -18,6 +18,7 @@ from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Set as AbstractSet
from dataclasses import dataclass
from datetime import datetime, timezone
from itertools import chain
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
@ -4972,6 +4973,16 @@ def _parent_organization_models(team_row: BaseModel) -> list[str] | None:
return organization.models if organization is not None else None
def _effective_access_group_skill_names(
resolved_groups: "Sequence[prisma_models.LiteLLM_AccessGroupTable]", unresolved_ids: Sequence[str]
) -> tuple[str, ...]:
"""Skill names the team's keys can actually use: none while a listed group has no row,
because the skill check then clamps the team to public plugins (see skill_visibility)."""
if unresolved_ids:
return ()
return tuple(frozenset(chain.from_iterable(group.access_skill_names or () for group in resolved_groups)))
async def _resolve_team_access_group_resources(
_team_info: TeamInfoResponseObjectTeamTable,
) -> TeamInfoResponseObjectTeamTable:
@ -4983,6 +4994,9 @@ async def _resolve_team_access_group_resources(
resolved_groups: Final = tuple(
ag_lookup[ag_id] for ag_id in dict.fromkeys(_team_info.access_group_ids) if ag_id in ag_lookup
)
unresolved_ids: Final = tuple(
ag_id for ag_id in dict.fromkeys(_team_info.access_group_ids) if ag_id not in ag_lookup
)
return _team_info.model_copy(
update={
"access_group_models": list({m for group in resolved_groups for m in (group.access_model_names or [])}),
@ -4990,6 +5004,8 @@ async def _resolve_team_access_group_resources(
{s for group in resolved_groups for s in (group.access_mcp_server_ids or [])}
),
"access_group_agent_ids": list({a for group in resolved_groups for a in (group.access_agent_ids or [])}),
"access_group_skill_names": _effective_access_group_skill_names(resolved_groups, unresolved_ids),
"unresolved_access_group_ids": unresolved_ids,
"access_group_details": tuple(
TeamAccessGroupModelGrant(
access_group_id=group.access_group_id,
@ -4997,6 +5013,7 @@ async def _resolve_team_access_group_resources(
models=tuple(group.access_model_names or ()),
mcp_server_ids=tuple(group.access_mcp_server_ids or ()),
agent_ids=tuple(group.access_agent_ids or ()),
skill_names=tuple(group.access_skill_names or ()),
)
for group in resolved_groups
),
@ -5649,6 +5666,7 @@ def _convert_teams_to_response_models(
**team_dict,
members_count=members_count,
keys_count=keys_count,
access_group_skill_names=None,
)
)
return team_list
@ -5936,6 +5954,9 @@ async def list_team_v2(
team_item.access_group_agent_ids = list(
{a for group in team_groups for a in (group.access_agent_ids or [])}
)
team_item.access_group_skill_names = _effective_access_group_skill_names(
team_groups, tuple(ag_id for ag_id in (team_item.access_group_ids or []) if ag_id not in ag_lookup)
)
return {
"teams": team_list,

View file

@ -1590,6 +1590,7 @@ model LiteLLM_AccessGroupTable {
access_mcp_server_ids String[] @default([])
mcp_tool_permissions Json?
access_agent_ids String[] @default([])
access_skill_names String[] @default([])
assigned_team_ids String[] @default([])
assigned_key_ids String[] @default([])

View file

@ -11,6 +11,7 @@ class AccessGroupCreateRequest(LiteLLMBaseModel):
access_mcp_server_ids: list[str] | None = None
mcp_tool_permissions: Mapping[str, Sequence[str]] | None = None
access_agent_ids: list[str] | None = None
access_skill_names: tuple[str, ...] | None = None
assigned_team_ids: list[str] | None = None
assigned_key_ids: list[str] | None = None
@ -22,6 +23,7 @@ class AccessGroupUpdateRequest(LiteLLMBaseModel):
access_mcp_server_ids: list[str] | None = None
mcp_tool_permissions: Mapping[str, Sequence[str]] | None = None
access_agent_ids: list[str] | None = None
access_skill_names: tuple[str, ...] | None = None
assigned_team_ids: list[str] | None = None
assigned_key_ids: list[str] | None = None
@ -41,6 +43,7 @@ class AccessGroupResponse(LiteLLMBaseModel):
access_mcp_server_ids: list[str]
mcp_tool_permissions: Mapping[str, Sequence[str]] | None = None
access_agent_ids: list[str]
access_skill_names: tuple[str, ...] = ()
assigned_team_ids: list[str]
assigned_key_ids: list[str]
access_mcp_servers: tuple[AccessGroupResource, ...]

View file

@ -92,6 +92,7 @@ class TeamListItem(LiteLLM_TeamTable):
access_group_models: list[str] | None = None
access_group_mcp_server_ids: list[str] | None = None
access_group_agent_ids: list[str] | None = None
access_group_skill_names: tuple[str, ...] | None = None
class TeamListResponse(LiteLLMBaseModel):

View file

@ -1590,6 +1590,7 @@ model LiteLLM_AccessGroupTable {
access_mcp_server_ids String[] @default([])
mcp_tool_permissions Json?
access_agent_ids String[] @default([])
access_skill_names String[] @default([])
assigned_team_ids String[] @default([])
assigned_key_ids String[] @default([])

View file

@ -1,12 +1,16 @@
"""Unit tests for claude_code_skill_access.py: key/team grant resolution."""
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
import pytest
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import (
PUBLIC_ONLY,
AccessGroupSkills,
access_group_skills,
granted_skills,
resolve_skill_visibility,
skill_visibility,
)
@ -77,3 +81,331 @@ def test_where_clause_bounds_the_plugin_query_to_what_the_caller_may_see():
assert skill_visibility(_key(["b", "a"], None)).where() == {"OR": [{"enabled": True}, {"name": {"in": ["a", "b"]}}]}
assert skill_visibility(_key(["a", "b"], ["b"])).where() == {"OR": [{"enabled": True}, {"name": {"in": ["b"]}}]}
assert skill_visibility(admin).where() == {}
@pytest.mark.parametrize(
("key_skills", "team_skills", "inherited", "expected"),
[
(None, None, AccessGroupSkills(key=frozenset({"g"})), frozenset({"g"})),
(None, None, AccessGroupSkills(team=frozenset({"g"})), frozenset({"g"})),
(None, None, AccessGroupSkills(), frozenset()),
([], [], AccessGroupSkills(key=frozenset({"g1"}), team=frozenset({"g2"})), frozenset()),
(["a"], None, AccessGroupSkills(key=frozenset({"g"})), frozenset({"a", "g"})),
(None, ["a"], AccessGroupSkills(team=frozenset({"g"})), frozenset({"a", "g"})),
(None, ["a"], AccessGroupSkills(key=frozenset({"g"})), frozenset()),
(["a"], ["a", "b"], AccessGroupSkills(key=frozenset({"b"})), frozenset({"a", "b"})),
(["a"], ["a"], AccessGroupSkills(key=frozenset({"g"}), team=frozenset({"g"})), frozenset({"a", "g"})),
],
)
def test_granted_skills_adds_access_group_skills_to_their_own_scope_before_intersecting(
key_skills, team_skills, inherited, expected
):
assert granted_skills(_key(key_skills, team_skills), inherited) == expected
def test_visibility_with_inherited_skills_only_opens_the_inherited_private_plugin():
inherited = AccessGroupSkills(key=frozenset({"group-skill"}))
visibility = skill_visibility(_key(None, None), inherited)
assert visibility.allows(_plugin("group-skill", enabled=False))
assert not visibility.allows(_plugin("other-private", enabled=False))
assert visibility.allows(_plugin("public", enabled=True))
assert visibility.where() == {"OR": [{"enabled": True}, {"name": {"in": ["group-skill"]}}]}
@pytest.mark.asyncio
async def test_resolve_skill_visibility_folds_in_access_group_skills_for_non_admins():
loader = AsyncMock(return_value=AccessGroupSkills(team=frozenset({"group-skill"})))
caller = _key(None, None)
visibility = await resolve_skill_visibility(caller, loader)
loader.assert_awaited_once_with(caller)
assert visibility.allows(_plugin("group-skill", enabled=False))
assert not visibility.allows(_plugin("other-private", enabled=False))
@pytest.mark.asyncio
async def test_resolve_skill_visibility_skips_group_lookup_for_anonymous_and_admin_callers():
loader = AsyncMock(return_value=AccessGroupSkills(key=frozenset({"group-skill"})))
admin = UserAPIKeyAuth(api_key="sk-9876", user_role=LitellmUserRoles.PROXY_ADMIN)
assert not (await resolve_skill_visibility(None, loader)).allows(_plugin("group-skill", enabled=False))
assert (await resolve_skill_visibility(admin, loader)).sees_private
loader.assert_not_awaited()
def _team(team_id: str, access_group_ids: list[str], blocked: bool = False) -> MagicMock:
team = MagicMock()
team.team_id = team_id
team.access_group_ids = access_group_ids
team.blocked = blocked
return team
def _group(access_group_id: str, skills: list[str], assigned_key_ids: list[str] | None = None) -> MagicMock:
group = MagicMock()
group.access_group_id = access_group_id
group.access_skill_names = skills
group.assigned_team_ids = []
group.assigned_key_ids = assigned_key_ids or []
return group
@pytest.mark.asyncio
@pytest.mark.parametrize(
("team_blocked", "expected_team_skills"),
[(False, frozenset({"team-group-skill"})), (True, frozenset())],
)
async def test_access_group_skills_resolves_key_groups_and_team_groups(monkeypatch, team_blocked, expected_team_skills):
from litellm.proxy import proxy_server
from litellm.proxy.auth import auth_checks
caller = UserAPIKeyAuth(
api_key="sk-user",
user_role=LitellmUserRoles.INTERNAL_USER,
team_id="team-1",
access_group_ids=["key-group", "stray-group"],
).model_copy(update={"requires_fresh_policy": True})
groups = {
"key-group": _group("key-group", ["key-group-skill"], assigned_key_ids=[caller.token or ""]),
"stray-group": _group("stray-group", ["stray-skill"]),
"team-group": _group("team-group", ["team-group-skill"]),
}
stale_groups = {group_id: _group(group_id, ["stale-skill"]) for group_id in groups}
async def fake_access_object(access_group_id, check_db_only=False, **_kwargs):
return (groups if check_db_only else stale_groups)[access_group_id]
async def fake_skill_names(access_group_ids, **_kwargs):
return frozenset().union(*(groups[group_id].access_skill_names for group_id in access_group_ids))
monkeypatch.setattr(auth_checks, "get_access_object", fake_access_object)
monkeypatch.setattr(auth_checks, "get_skill_names_from_access_groups", fake_skill_names)
monkeypatch.setattr(
auth_checks, "get_team_object", AsyncMock(return_value=_team("team-1", ["team-group"], blocked=team_blocked))
)
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
inherited = await access_group_skills(caller)
assert inherited == AccessGroupSkills(key=frozenset({"key-group-skill"}), team=expected_team_skills)
@pytest.mark.asyncio
@pytest.mark.parametrize("fresh", [True, False])
async def test_access_group_skills_surfaces_key_group_lookup_failures(monkeypatch, fresh):
from litellm.proxy import proxy_server
from litellm.proxy.auth import auth_checks
caller = UserAPIKeyAuth(
api_key="sk-user", user_role=LitellmUserRoles.INTERNAL_USER, access_group_ids=["key-group"]
).model_copy(update={"requires_fresh_policy": fresh})
monkeypatch.setattr(auth_checks, "get_access_object", AsyncMock(side_effect=RuntimeError("writer down")))
monkeypatch.setattr(auth_checks, "get_skill_names_from_access_groups", AsyncMock(return_value=frozenset()))
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
with pytest.raises(RuntimeError, match="writer down"):
await access_group_skills(caller)
@pytest.mark.asyncio
async def test_access_group_skills_without_groups_or_team_grants_nothing(monkeypatch):
from litellm.proxy import proxy_server
from litellm.proxy.auth import auth_checks
get_team_object = AsyncMock()
monkeypatch.setattr(auth_checks, "get_team_object", get_team_object)
monkeypatch.setattr(auth_checks, "get_skill_names_from_access_groups", AsyncMock(return_value=frozenset()))
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
caller = UserAPIKeyAuth(api_key="sk-user", user_role=LitellmUserRoles.INTERNAL_USER)
assert await access_group_skills(caller) == AccessGroupSkills()
get_team_object.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("fresh", [True, False])
async def test_access_group_skills_keeps_team_ceiling_when_team_lookup_fails(monkeypatch, fresh):
from litellm.proxy import proxy_server
from litellm.proxy.auth import auth_checks
caller = UserAPIKeyAuth(
api_key="sk-user", user_role=LitellmUserRoles.INTERNAL_USER, team_id="team-1", access_group_ids=["key-group"]
).model_copy(update={"requires_fresh_policy": fresh})
key_group = _group("key-group", ["secret-skill"], assigned_key_ids=[caller.token or ""])
monkeypatch.setattr(auth_checks, "get_team_object", AsyncMock(side_effect=RuntimeError("writer down")))
monkeypatch.setattr(auth_checks, "get_access_object", AsyncMock(return_value=key_group))
monkeypatch.setattr(auth_checks, "get_skill_names_from_access_groups", AsyncMock(return_value=frozenset()))
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
with pytest.raises(RuntimeError, match="writer down"):
await access_group_skills(caller)
@pytest.mark.asyncio
@pytest.mark.parametrize("fresh", [True, False])
async def test_access_group_skills_surfaces_team_group_lookup_failures(monkeypatch, fresh):
from litellm.proxy import proxy_server
from litellm.proxy.auth import auth_checks
caller = UserAPIKeyAuth(
api_key="sk-user", user_role=LitellmUserRoles.INTERNAL_USER, team_id="team-1", access_group_ids=["key-group"]
).model_copy(update={"requires_fresh_policy": fresh})
key_group = _group("key-group", ["secret-skill"], assigned_key_ids=[caller.token or ""])
monkeypatch.setattr(auth_checks, "get_team_object", AsyncMock(return_value=_team("team-1", ["team-group"])))
async def lookup(access_group_id: str, **_kwargs: object) -> MagicMock:
if access_group_id == "team-group":
raise RuntimeError("writer down")
return key_group
monkeypatch.setattr(auth_checks, "get_access_object", lookup)
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
with pytest.raises(RuntimeError, match="writer down"):
await access_group_skills(caller)
@pytest.mark.asyncio
async def test_access_group_skills_treats_confirmed_missing_team_like_no_team(monkeypatch):
from litellm.proxy import proxy_server
from litellm.proxy.auth import auth_checks
caller = UserAPIKeyAuth(
api_key="sk-user",
user_role=LitellmUserRoles.INTERNAL_USER,
team_id="removed-team",
access_group_ids=["key-group"],
)
key_group = _group("key-group", ["secret-skill"], assigned_key_ids=[caller.token or ""])
monkeypatch.setattr(
auth_checks, "get_team_object", AsyncMock(side_effect=auth_checks.TeamNotFoundError(team_id="removed-team"))
)
monkeypatch.setattr(auth_checks, "get_access_object", AsyncMock(return_value=key_group))
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
assert await access_group_skills(caller) == AccessGroupSkills(key=frozenset({"secret-skill"}))
@pytest.mark.asyncio
@pytest.mark.parametrize("fresh", [True, False])
@pytest.mark.parametrize("missing_scope", ["key", "team"])
async def test_access_group_skills_marks_scope_unresolved_when_a_listed_group_is_missing(
monkeypatch, fresh, missing_scope
):
from litellm.proxy import proxy_server
from litellm.proxy.auth import auth_checks
key_groups = ["typo-group", "key-group"] if missing_scope == "key" else ["key-group"]
team_groups = ["team-group"] if missing_scope == "key" else ["stale-team-group", "team-group"]
caller = UserAPIKeyAuth(
api_key="sk-user", user_role=LitellmUserRoles.INTERNAL_USER, team_id="team-1", access_group_ids=key_groups
).model_copy(update={"requires_fresh_policy": fresh})
groups = {
"key-group": _group("key-group", ["secret-skill"], assigned_key_ids=[caller.token or ""]),
"team-group": _group("team-group", ["team-skill"]),
}
async def lookup(access_group_id: str, **_kwargs: object) -> MagicMock:
if access_group_id not in groups:
raise auth_checks.AccessGroupNotFoundError(access_group_id=access_group_id)
return groups[access_group_id]
monkeypatch.setattr(auth_checks, "get_team_object", AsyncMock(return_value=_team("team-1", team_groups)))
monkeypatch.setattr(auth_checks, "get_access_object", lookup)
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
assert await access_group_skills(caller) == AccessGroupSkills(unresolved=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("missing_scope", ["key", "team"])
async def test_access_group_skills_marks_scope_unresolved_through_the_real_group_lookup(monkeypatch, missing_scope):
from litellm.proxy import proxy_server
from litellm.proxy._types import LiteLLM_AccessGroupTable
from litellm.proxy.auth import auth_checks
from litellm.proxy.auth.auth_checks import cache_access_object
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
key_groups = ["typo-group", "key-group"] if missing_scope == "key" else ["key-group"]
team_groups = ["team-group"] if missing_scope == "key" else ["stale-team-group", "team-group"]
caller = UserAPIKeyAuth(
api_key="sk-user", user_role=LitellmUserRoles.INTERNAL_USER, team_id="team-1", access_group_ids=key_groups
)
cache = UserApiKeyCache()
for group in (
LiteLLM_AccessGroupTable(
access_group_id="key-group",
access_group_name="key-group",
access_skill_names=["secret-skill"],
assigned_key_ids=[caller.token or ""],
),
LiteLLM_AccessGroupTable(
access_group_id="team-group", access_group_name="team-group", access_skill_names=["team-skill"]
),
):
await cache_access_object(
access_group_id=group.access_group_id, access_group_table=group, user_api_key_cache=cache
)
prisma = MagicMock()
prisma.db.litellm_accessgrouptable.find_unique = AsyncMock(return_value=None)
monkeypatch.setattr(auth_checks, "get_team_object", AsyncMock(return_value=_team("team-1", team_groups)))
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
assert await access_group_skills(caller) == AccessGroupSkills(unresolved=True)
prisma.db.litellm_accessgrouptable.find_unique.assert_awaited_once()
def test_skill_visibility_is_public_only_while_a_group_scope_is_unresolved():
caller = UserAPIKeyAuth(
api_key="sk-user",
user_role=LitellmUserRoles.INTERNAL_USER,
object_permission=_perm(["private-a"]),
team_object_permission=_perm(["private-a", "private-b"]),
)
assert skill_visibility(caller, AccessGroupSkills(unresolved=True)) == PUBLIC_ONLY
assert skill_visibility(caller, AccessGroupSkills()).granted == frozenset({"private-a"})
@pytest.mark.asyncio
@pytest.mark.parametrize("check_db_only", [True, False])
@pytest.mark.parametrize(("strict_errors", "expect_raise"), [(True, True), (False, False)])
async def test_shared_group_loader_skips_missing_groups_unless_strict(
monkeypatch, check_db_only, strict_errors, expect_raise
):
"""A group with no row grants nothing on cached and fresh reads alike; only ``strict_errors`` callers,
which turn the typed absence into an unresolved policy, see ``AccessGroupNotFoundError``."""
from litellm.proxy.auth import auth_checks
groups = {"real-group": _group("real-group", [])}
groups["real-group"].access_mcp_server_ids = ["mcp-1"]
async def lookup(access_group_id: str, **_kwargs: object) -> MagicMock:
if access_group_id not in groups:
raise auth_checks.AccessGroupNotFoundError(access_group_id=access_group_id)
return groups[access_group_id]
monkeypatch.setattr(auth_checks, "get_access_object", lookup)
call = auth_checks._get_resources_from_access_groups(
access_group_ids=["typo-group", "real-group"],
resource_field="access_mcp_server_ids",
prisma_client=MagicMock(),
user_api_key_cache=MagicMock(),
proxy_logging_obj=MagicMock(),
check_db_only=check_db_only,
strict_errors=strict_errors,
)
if expect_raise:
with pytest.raises(auth_checks.AccessGroupNotFoundError):
await call
return
assert await call == ["mcp-1"]

View file

@ -2169,3 +2169,31 @@ def test_is_mcp_discovery_request(route, request_body, expected):
from litellm.proxy.auth.auth_checks import is_mcp_discovery_request
assert is_mcp_discovery_request(route=route, request_body=request_body) is expected
@pytest.mark.asyncio
async def test_get_skill_names_from_access_groups_reads_cached_group_rows():
from litellm.proxy._types import LiteLLM_AccessGroupTable
from litellm.proxy.auth.auth_checks import cache_access_object, get_skill_names_from_access_groups
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
cache = UserApiKeyCache()
for group_id, skills in (("group-a", ["skill-a", "shared"]), ("group-b", ["skill-b", "shared"])):
await cache_access_object(
access_group_id=group_id,
access_group_table=LiteLLM_AccessGroupTable(
access_group_id=group_id, access_group_name=group_id, access_skill_names=skills
),
user_api_key_cache=cache,
)
untouched_prisma = MagicMock()
untouched_prisma.db.litellm_accessgrouptable.find_unique = AsyncMock(
side_effect=AssertionError("cache hit must not reach the DB")
)
assert await get_skill_names_from_access_groups(
access_group_ids=("group-a", "group-b"),
prisma_client=untouched_prisma,
user_api_key_cache=cache,
proxy_logging_obj=MagicMock(),
) == frozenset({"skill-a", "skill-b", "shared"})

View file

@ -29,6 +29,7 @@ def _make_access_group_record(
access_model_names: list | None = None,
access_mcp_server_ids: list | None = None,
access_agent_ids: list | None = None,
access_skill_names: list | None = None,
assigned_team_ids: list | None = None,
assigned_key_ids: list | None = None,
created_by: str | None = "admin-user",
@ -44,6 +45,7 @@ def _make_access_group_record(
"access_model_names": access_model_names or [],
"access_mcp_server_ids": access_mcp_server_ids or [],
"access_agent_ids": access_agent_ids or [],
"access_skill_names": access_skill_names or [],
"assigned_team_ids": assigned_team_ids or [],
"assigned_key_ids": assigned_key_ids or [],
"created_at": created_at_val,
@ -89,6 +91,7 @@ def client_and_mocks(monkeypatch):
access_model_names=data.get("access_model_names", []),
access_mcp_server_ids=data.get("access_mcp_server_ids", []),
access_agent_ids=data.get("access_agent_ids", []),
access_skill_names=data.get("access_skill_names", []),
assigned_team_ids=data.get("assigned_team_ids", []),
assigned_key_ids=data.get("assigned_key_ids", []),
created_by=data.get("created_by"),
@ -106,6 +109,7 @@ def client_and_mocks(monkeypatch):
access_model_names=data.get("access_model_names", []),
access_mcp_server_ids=data.get("access_mcp_server_ids", []),
access_agent_ids=data.get("access_agent_ids", []),
access_skill_names=data.get("access_skill_names", []),
assigned_team_ids=data.get("assigned_team_ids", []),
assigned_key_ids=data.get("assigned_key_ids", []),
updated_by=data.get("updated_by"),
@ -1664,3 +1668,69 @@ def test_get_access_group_returns_mcp_tool_permissions(client_and_mocks):
assert resp.status_code == 200, resp.text
assert resp.json()["mcp_tool_permissions"] == {"mcp-1": []}
def test_create_access_group_persists_and_returns_skill_names(client_and_mocks):
client, _, mock_table, *_ = client_and_mocks
resp = client.post(
"/v1/access_group",
json={"access_group_name": "skills-group", "access_skill_names": ["private-plugin", "other-plugin"]},
)
assert resp.status_code == 201
assert resp.json()["access_skill_names"] == ["private-plugin", "other-plugin"]
assert mock_table.create.await_args.kwargs["data"]["access_skill_names"] == ["private-plugin", "other-plugin"]
def test_create_access_group_without_skills_stores_empty_list(client_and_mocks):
client, _, mock_table, *_ = client_and_mocks
resp = client.post("/v1/access_group", json={"access_group_name": "plain-group"})
assert resp.status_code == 201
assert resp.json()["access_skill_names"] == []
assert mock_table.create.await_args.kwargs["data"]["access_skill_names"] == []
@pytest.mark.parametrize(
("update_payload", "expected_skills"),
[
({"access_skill_names": ["private-plugin"]}, ["private-plugin"]),
({"access_skill_names": []}, []),
({"access_skill_names": None}, []),
],
)
def test_update_access_group_writes_skill_names(client_and_mocks, update_payload, expected_skills):
client, _, mock_table, *_ = client_and_mocks
mock_table.find_unique = AsyncMock(
return_value=_make_access_group_record(access_group_id="ag-skills", access_skill_names=["old-plugin"])
)
resp = client.put("/v1/access_group/ag-skills", json=update_payload)
assert resp.status_code == 200
assert resp.json()["access_skill_names"] == expected_skills
assert mock_table.update.await_args.kwargs["data"]["access_skill_names"] == expected_skills
def test_update_access_group_without_skill_field_leaves_skills_untouched(client_and_mocks):
client, _, mock_table, *_ = client_and_mocks
mock_table.find_unique = AsyncMock(
return_value=_make_access_group_record(access_group_id="ag-skills", access_skill_names=["old-plugin"])
)
resp = client.put("/v1/access_group/ag-skills", json={"description": "renamed"})
assert resp.status_code == 200
assert "access_skill_names" not in mock_table.update.await_args.kwargs["data"]
def test_get_and_list_access_group_return_skill_names(client_and_mocks):
client, _, mock_table, *_ = client_and_mocks
record = _make_access_group_record(access_group_id="ag-skills", access_skill_names=["private-plugin"])
mock_table.find_unique = AsyncMock(return_value=record)
mock_table.find_many = AsyncMock(return_value=[record])
assert client.get("/v1/access_group/ag-skills").json()["access_skill_names"] == ["private-plugin"]
assert client.get("/v1/access_group").json()[0]["access_skill_names"] == ["private-plugin"]

View file

@ -11386,6 +11386,7 @@ class TestResolveTeamAccessGroupResources:
row1.access_model_names = ["gpt-4", "claude-3"]
row1.access_mcp_server_ids = ["mcp-1"]
row1.access_agent_ids = []
row1.access_skill_names = ["private-plugin", "shared-plugin"]
row2 = MagicMock()
row2.access_group_id = "ag-2"
@ -11393,6 +11394,7 @@ class TestResolveTeamAccessGroupResources:
row2.access_model_names = ["claude-3", "gemini"]
row2.access_mcp_server_ids = []
row2.access_agent_ids = ["agent-1"]
row2.access_skill_names = ["shared-plugin"]
fake_prisma = MagicMock()
fake_prisma.db.litellm_accessgrouptable.find_many = AsyncMock(
@ -11413,14 +11415,44 @@ class TestResolveTeamAccessGroupResources:
]
assert resolved.access_group_mcp_server_ids == ["mcp-1"]
assert resolved.access_group_agent_ids == ["agent-1"]
assert resolved.access_group_skill_names == ()
assert resolved.unresolved_access_group_ids == ("ag-missing",)
assert [
(d.access_group_id, d.access_group_name, d.models, d.mcp_server_ids, d.agent_ids)
(d.access_group_id, d.access_group_name, d.models, d.mcp_server_ids, d.agent_ids, d.skill_names)
for d in (resolved.access_group_details or [])
] == [
("ag-1", "shared-models", ("gpt-4", "claude-3"), ("mcp-1",), ()),
("ag-2", "extra-models", ("claude-3", "gemini"), (), ("agent-1",)),
("ag-1", "shared-models", ("gpt-4", "claude-3"), ("mcp-1",), (), ("private-plugin", "shared-plugin")),
("ag-2", "extra-models", ("claude-3", "gemini"), (), ("agent-1",), ("shared-plugin",)),
]
@pytest.mark.asyncio
async def test_skill_names_resolve_when_every_listed_group_exists(self):
"""The flat skill list is the effective grant: names from every group when all rows
exist, nothing while any listed id has no row, since the skill check then clamps
the team's keys to public plugins."""
from litellm.proxy._types import TeamInfoResponseObjectTeamTable
from litellm.proxy.management_endpoints.team_endpoints import (
_resolve_team_access_group_resources,
)
row = MagicMock()
row.access_group_id = "ag-1"
row.access_group_name = "skills-group"
row.access_model_names = []
row.access_mcp_server_ids = []
row.access_agent_ids = []
row.access_skill_names = ["private-plugin"]
fake_prisma = MagicMock()
fake_prisma.db.litellm_accessgrouptable.find_many = AsyncMock(return_value=[row])
with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma):
resolved = await _resolve_team_access_group_resources(
TeamInfoResponseObjectTeamTable(team_id="team-1", access_group_ids=["ag-1"])
)
assert resolved.access_group_skill_names == ("private-plugin",)
assert resolved.unresolved_access_group_ids == ()
@pytest.mark.asyncio
async def test_no_access_groups_leaves_details_unset(self):
from litellm.proxy._types import TeamInfoResponseObjectTeamTable

View file

@ -58,6 +58,7 @@ const createMockAccessGroup = (overrides: Partial<AccessGroupResponse> = {}): Ac
assigned_key_ids: ["key-1", "key-2"],
access_mcp_servers: [{ id: "mcp-1", name: "GitHub MCP" }],
access_agents: [{ id: "agent-1", name: "Support Agent" }],
access_skill_names: ["private-plugin"],
assigned_teams: [{ id: "team-1", name: "Platform Team" }],
assigned_keys: [
{ id: "key-1", name: "ci-key" },
@ -396,6 +397,26 @@ describe("AccessGroupDetail", () => {
});
});
describe("Skills tab", () => {
it("should list the plugin names granted by the group", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
await user.click(screen.getByRole("tab", { name: /Skills/i }));
expect(screen.getByText("private-plugin")).toBeInTheDocument();
});
it("should show empty state when none assigned", async () => {
const user = userEvent.setup();
renderWith({ access_skill_names: [] });
await user.click(screen.getByRole("tab", { name: /Skills/i }));
expect(screen.getByText("No skills assigned to this group")).toBeInTheDocument();
});
});
it("should show empty state in Models tab when no models assigned", () => {
renderWith({ access_model_names: [] });

View file

@ -1,5 +1,14 @@
import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails";
import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react";
import {
ArrowLeftIcon,
BotIcon,
EditIcon,
KeyIcon,
LayersIcon,
ServerIcon,
SparklesIcon,
UsersIcon,
} from "lucide-react";
import { useState } from "react";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
import { BadgeLink } from "@/components/shared/BadgeLink";
@ -126,6 +135,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
const models = accessGroup.access_model_names.map((id) => ({ id, name: null }));
const mcpServers = accessGroup.access_mcp_servers;
const agents = accessGroup.access_agents;
const skills = (accessGroup.access_skill_names ?? []).map((name) => ({ id: name, name: null }));
const keys = accessGroup.assigned_keys;
const teams = accessGroup.assigned_teams;
@ -262,6 +272,11 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
Agents
<Badge variant="secondary">{agents.length}</Badge>
</TabsTrigger>
<TabsTrigger value="skills" className="flex-none gap-2 rounded-none px-4 py-2">
<SparklesIcon className="size-4" />
Skills
<Badge variant="secondary">{skills.length}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="models" className="pt-4">
<ResourceList items={models} emptyMessage="No models assigned to this group" />
@ -273,6 +288,9 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
<TabsContent value="agents" className="pt-4">
<ResourceList items={agents} emptyMessage="No agents assigned to this group" />
</TabsContent>
<TabsContent value="skills" className="pt-4">
<ResourceList items={skills} emptyMessage="No skills assigned to this group" />
</TabsContent>
</Tabs>
</CardContent>
</Card>

View file

@ -1,6 +1,6 @@
"use client";
import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react";
import { BotIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { z } from "zod";
@ -12,6 +12,7 @@ import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { MultiSelect } from "@/components/shared/MultiSelect";
import SkillSelector from "@/components/skills/SkillSelector";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
@ -23,6 +24,7 @@ export const accessGroupFormSchema = z.object({
mcpServerIds: z.array(z.string()),
mcpToolPermissions: z.record(z.string(), z.array(z.string())),
agentIds: z.array(z.string()),
skillNames: z.array(z.string()),
});
export type AccessGroupFormValues = z.output<typeof accessGroupFormSchema>;
@ -31,6 +33,7 @@ export const GENERAL_TAB = "general";
export const MODELS_TAB = "models";
export const MCP_SERVERS_TAB = "mcp-servers";
export const AGENTS_TAB = "agents";
export const SKILLS_TAB = "skills";
interface AccessGroupBaseFormProps {
form: UseFormReturn<AccessGroupFormValues>;
@ -77,6 +80,7 @@ export function AccessGroupBaseForm({
onTabChange,
initialServerIds,
}: AccessGroupBaseFormProps) {
const { accessToken } = useAuthorized();
const { data: agentsData } = useAgents();
const { data: mcpServersData } = useMCPServers();
@ -108,6 +112,10 @@ export function AccessGroupBaseForm({
<BotIcon size={16} />
Agents
</TabsTrigger>
<TabsTrigger value={SKILLS_TAB}>
<SparklesIcon size={16} />
Skills
</TabsTrigger>
</TabsList>
<TabsContent value={GENERAL_TAB} className="pt-4">
@ -164,6 +172,24 @@ export function AccessGroupBaseForm({
)}
</FormField>
</TabsContent>
<TabsContent value={SKILLS_TAB} className="pt-4">
<FormField
control={form.control}
name="skillNames"
label="Allowed Skills"
description="Enabled skills are visible to everyone. Grant disabled (private) Claude Code plugins to the teams and keys in this group here."
>
{({ value, onChange }) => (
<SkillSelector
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder="Select skills (optional)"
/>
)}
</FormField>
</TabsContent>
</Tabs>
);
}

View file

@ -34,6 +34,17 @@ vi.mock("@/components/ModelSelect/ModelSelect", () => ({
vi.mock("@/components/networking");
vi.mock("@/components/skills/SkillSelector", () => ({
default: ({ value, onChange }: { value?: string[]; onChange: (next: string[]) => void }) => (
<div>
<button type="button" aria-label="skill-select" onClick={() => onChange([...(value ?? []), "private-plugin"])}>
{(value ?? []).join(",")}
</button>
<button type="button" aria-label="skill-clear" onClick={() => onChange([])} />
</div>
),
}));
vi.mock("@/lib/toast", () => ({
toast: { success: vi.fn(), fromError: vi.fn(), error: vi.fn() },
}));
@ -49,6 +60,7 @@ const accessGroup: AccessGroupResponse = {
access_mcp_server_ids: ["srv-1"],
mcp_tool_permissions: { "srv-1": ["read_file"] },
access_agent_ids: ["agent-1"],
access_skill_names: ["old-plugin"],
assigned_team_ids: [],
assigned_key_ids: [],
access_mcp_servers: [{ id: "srv-1", name: "Server One" }],
@ -102,9 +114,37 @@ describe("AccessGroupEditModal submit payload", () => {
access_model_names: undefined,
access_mcp_server_ids: undefined,
access_agent_ids: undefined,
access_skill_names: undefined,
});
});
it("sends the skills picked on the Skills tab", async () => {
const user = setup();
renderModal();
await screen.findByDisplayValue("Engineering");
await user.click(screen.getByRole("tab", { name: /Skills/ }));
await user.click(screen.getByLabelText("skill-select"));
await save(user);
await waitFor(() => expect(mutate).toHaveBeenCalled());
expect(variables().params.access_skill_names).toStrictEqual(["old-plugin", "private-plugin"]);
expect(variables().params.access_agent_ids).toBeUndefined();
});
it("sends an empty list when every skill is removed on the Skills tab", async () => {
const user = setup();
renderModal();
await screen.findByDisplayValue("Engineering");
await user.click(screen.getByRole("tab", { name: /Skills/ }));
await user.click(screen.getByLabelText("skill-clear"));
await save(user);
await waitFor(() => expect(mutate).toHaveBeenCalled());
expect(variables().params.access_skill_names).toStrictEqual([]);
});
it("sends a tab's field only once that tab has been visited", async () => {
const user = setup();
renderModal();
@ -122,6 +162,7 @@ describe("AccessGroupEditModal submit payload", () => {
access_model_names: undefined,
access_mcp_server_ids: ["srv-1"],
access_agent_ids: ["agent-1"],
access_skill_names: undefined,
});
});

View file

@ -13,6 +13,7 @@ import {
AccessGroupBaseForm,
accessGroupFormSchema,
AGENTS_TAB,
SKILLS_TAB,
GENERAL_TAB,
MCP_SERVERS_TAB,
MODELS_TAB,
@ -34,6 +35,7 @@ const toFormValues = (accessGroup: AccessGroupResponse): AccessGroupFormValues =
mcpServerIds: accessGroup.access_mcp_server_ids ?? [],
mcpToolPermissions: accessGroup.mcp_tool_permissions ?? {},
agentIds: accessGroup.access_agent_ids ?? [],
skillNames: accessGroup.access_skill_names ?? [],
});
function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit<AccessGroupEditModalProps, "visible">) {
@ -57,6 +59,7 @@ function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit<AccessGr
access_mcp_server_ids: visitedTabs.has(MCP_SERVERS_TAB) ? values.mcpServerIds : undefined,
...(dirtyFields.mcpToolPermissions && { mcp_tool_permissions: values.mcpToolPermissions }),
access_agent_ids: visitedTabs.has(AGENTS_TAB) ? values.agentIds : undefined,
access_skill_names: visitedTabs.has(SKILLS_TAB) ? values.skillNames : undefined,
};
editMutation.mutate(

View file

@ -13,6 +13,7 @@ const mockAccessGroups: AccessGroupResponse[] = [
access_model_names: ["m1", "m2"],
access_mcp_server_ids: ["s1"],
access_agent_ids: ["a1"],
access_skill_names: [],
assigned_team_ids: [],
assigned_key_ids: [],
access_mcp_servers: [{ id: "s1", name: "Server One" }],
@ -31,6 +32,7 @@ const mockAccessGroups: AccessGroupResponse[] = [
access_model_names: ["m1"],
access_mcp_server_ids: [],
access_agent_ids: [],
access_skill_names: [],
assigned_team_ids: [],
assigned_key_ids: [],
access_mcp_servers: [],

View file

@ -22,6 +22,7 @@ function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
modelIds: r.access_model_names,
mcpServerIds: r.access_mcp_server_ids,
agentIds: r.access_agent_ids,
skillNames: r.access_skill_names ?? [],
keyIds: r.assigned_key_ids,
teamIds: r.assigned_team_ids,
createdAt: r.created_at,

View file

@ -1,7 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Bot, Layers, MoreHorizontal, Server, Trash2 } from "lucide-react";
import { Bot, Layers, MoreHorizontal, Server, Sparkles, Trash2 } from "lucide-react";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdentityCell } from "@/components/shared/table_cells";
@ -21,7 +21,7 @@ interface ResourceTone {
className: string;
}
const RESOURCE_TONES: Record<"models" | "mcpServers" | "agents", ResourceTone> = {
const RESOURCE_TONES: Record<"models" | "mcpServers" | "agents" | "skills", ResourceTone> = {
models: { icon: Layers, className: "bg-info/10 text-info ring-blue-600/20" },
mcpServers: { icon: Server, className: "bg-info/10 text-info ring-cyan-600/20" },
agents: {
@ -29,6 +29,11 @@ const RESOURCE_TONES: Record<"models" | "mcpServers" | "agents", ResourceTone> =
className:
"bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-950 dark:text-purple-300 dark:ring-purple-400/30",
},
skills: {
icon: Sparkles,
className:
"bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-300 dark:ring-amber-400/30",
},
};
function ResourcesCell({ group }: { group: AccessGroup }) {
@ -36,6 +41,7 @@ function ResourcesCell({ group }: { group: AccessGroup }) {
{ key: "models" as const, label: "Models", count: group.modelIds.length },
{ key: "mcpServers" as const, label: "MCP Servers", count: group.mcpServerIds.length },
{ key: "agents" as const, label: "Agents", count: group.agentIds.length },
{ key: "skills" as const, label: "Skills", count: group.skillNames.length },
];
return (

View file

@ -1,17 +1,19 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react";
import { BotIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react";
import * as React from "react";
import { accessGroupKeys } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import { toast } from "@/lib/toast";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { MultiSelect } from "@/components/shared/MultiSelect";
import SkillSelector from "@/components/skills/SkillSelector";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@ -46,6 +48,7 @@ export const AccessGroupCreateDialog = ({
const form = useZodForm(accessGroupCreateSchema, { defaultValues: emptyAccessGroupFormValues });
const [activeTab, setActiveTab] = React.useState(GENERAL_TAB);
const { accessToken } = useAuthorized();
const { data: agentsData } = useAgents();
const { data: mcpServersData } = useMCPServers();
@ -119,6 +122,10 @@ export const AccessGroupCreateDialog = ({
<BotIcon />
Agents
</TabsTrigger>
<TabsTrigger value="skills">
<SparklesIcon />
Skills
</TabsTrigger>
</TabsList>
<TabsContent value={GENERAL_TAB} className="pt-4">
@ -177,6 +184,24 @@ export const AccessGroupCreateDialog = ({
)}
</FormField>
</TabsContent>
<TabsContent value="skills" className="pt-4">
<FormField
control={form.control}
name="skillNames"
label="Allowed Skills"
description="Enabled skills are visible to everyone. Grant disabled (private) Claude Code plugins to the teams and keys in this group here."
>
{({ value, onChange }) => (
<SkillSelector
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder="Select skills (optional)"
/>
)}
</FormField>
</TabsContent>
</Tabs>
<DialogFooter className="mt-6">

View file

@ -24,6 +24,7 @@ describe("buildAccessGroupCreateBody", () => {
mcpServerIds: ["srv-1", "srv-2"],
mcpToolPermissions: { "srv-1": ["read_file"], "srv-2": [] },
agentIds: ["agent-1"],
skillNames: ["private-plugin"],
}),
).toStrictEqual({
access_group_name: "g",
@ -32,6 +33,13 @@ describe("buildAccessGroupCreateBody", () => {
access_mcp_server_ids: ["srv-1", "srv-2"],
mcp_tool_permissions: { "srv-1": ["read_file"], "srv-2": [] },
access_agent_ids: ["agent-1"],
access_skill_names: ["private-plugin"],
});
});
it("omits access_skill_names when no skill is selected", () => {
expect(buildAccessGroupCreateBody({ ...emptyAccessGroupFormValues, name: "g", skillNames: [] })).toStrictEqual({
access_group_name: "g",
});
});

View file

@ -11,6 +11,7 @@ export const emptyAccessGroupFormValues: AccessGroupCreateFormValues = {
mcpServerIds: [],
mcpToolPermissions: {},
agentIds: [],
skillNames: [],
};
export const buildAccessGroupCreateBody = (values: AccessGroupCreateFormValues): AccessGroupCreateBody => ({
@ -21,4 +22,5 @@ export const buildAccessGroupCreateBody = (values: AccessGroupCreateFormValues):
...(values.mcpServerIds.length > 0 &&
Object.keys(values.mcpToolPermissions).length > 0 && { mcp_tool_permissions: values.mcpToolPermissions }),
...(values.agentIds.length > 0 && { access_agent_ids: values.agentIds }),
...(values.skillNames.length > 0 && { access_skill_names: values.skillNames }),
});

View file

@ -7,6 +7,7 @@ export const accessGroupCreateSchema = z.object({
mcpServerIds: z.array(z.string()),
mcpToolPermissions: z.record(z.string(), z.array(z.string())),
agentIds: z.array(z.string()),
skillNames: z.array(z.string()),
});
export type AccessGroupCreateFormValues = z.output<typeof accessGroupCreateSchema>;

View file

@ -5,6 +5,7 @@ export interface AccessGroup {
modelIds: string[];
mcpServerIds: string[];
agentIds: string[];
skillNames: string[];
keyIds: string[];
teamIds: string[];
createdAt: string;

View file

@ -44,6 +44,7 @@ const mockAccessGroups: AccessGroupResponse[] = [
access_model_names: [],
access_mcp_server_ids: [],
access_agent_ids: [],
access_skill_names: [],
assigned_team_ids: [],
assigned_key_ids: [],
access_mcp_servers: [],

View file

@ -11,6 +11,7 @@ export interface AccessGroupUpdateParams {
access_model_names?: string[];
access_mcp_server_ids?: string[];
access_agent_ids?: string[];
access_skill_names?: string[];
assigned_team_ids?: string[];
assigned_key_ids?: string[];
}

View file

@ -0,0 +1,84 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import ObjectPermissionsView from "./object_permissions_view";
vi.mock("./permissions/VectorStorePermissions", () => ({ default: () => <div data-testid="vector-stores" /> }));
vi.mock("./permissions/MCPServerPermissions", () => ({ default: () => <div data-testid="mcp-servers" /> }));
vi.mock("./permissions/AgentPermissions", () => ({ default: () => <div data-testid="agents" /> }));
const objectPermission = { object_permission_id: "perm-1", skills: [] as string[] };
describe("ObjectPermissionsView skills card", () => {
it("shows plugin names granted only through access groups with their source", () => {
render(
<ObjectPermissionsView
objectPermission={objectPermission}
inheritedSkills={[{ id: "private-plugin", accessGroupNames: ["skills-group"] }]}
variant="inline"
/>,
);
expect(screen.getByText(/private-plugin/)).toBeInTheDocument();
expect(screen.getByTitle(/skills-group/)).toHaveTextContent("private-plugin (access group)");
expect(screen.queryByText(/No private skills granted/)).not.toBeInTheDocument();
});
it("lists direct grants once even when an access group grants the same plugin", () => {
render(
<ObjectPermissionsView
objectPermission={{ ...objectPermission, skills: ["private-plugin"] }}
inheritedSkills={[
{ id: "private-plugin", accessGroupNames: ["skills-group"] },
{ id: "team-plugin", accessGroupNames: ["skills-group"] },
]}
variant="inline"
/>,
);
expect(screen.getAllByText(/private-plugin/)).toHaveLength(1);
expect(screen.getByTitle(/skills-group/)).toHaveTextContent("team-plugin (access group)");
});
it("keeps the empty state when nothing grants a private plugin", () => {
render(<ObjectPermissionsView objectPermission={objectPermission} inheritedSkills={[]} variant="inline" />);
expect(
screen.getByText("No private skills granted. Only enabled (public) Claude Code plugins are visible."),
).toBeInTheDocument();
});
it("says access-group grants are not listed when the view resolves none (key page)", () => {
render(<ObjectPermissionsView objectPermission={objectPermission} variant="inline" />);
expect(screen.getByText(/Grants from access groups are not listed on this page/)).toBeInTheDocument();
});
it("treats the API null for unresolved group ids as no missing groups", () => {
render(
<ObjectPermissionsView
objectPermission={{ ...objectPermission, skills: ["private-plugin"] }}
inheritedSkills={[]}
unresolvedAccessGroupIds={null}
variant="inline"
/>,
);
expect(screen.getByText("private-plugin")).toBeInTheDocument();
expect(screen.queryByText(/suspended/)).not.toBeInTheDocument();
});
it("reports suspended private access instead of grants while a listed group has no row", () => {
render(
<ObjectPermissionsView
objectPermission={{ ...objectPermission, skills: ["private-plugin"] }}
inheritedSkills={[{ id: "team-plugin", accessGroupNames: ["skills-group"] }]}
unresolvedAccessGroupIds={["deleted-group"]}
variant="inline"
/>,
);
expect(screen.getByText(/access group deleted-group not found/)).toBeInTheDocument();
expect(screen.queryByText(/private-plugin/)).not.toBeInTheDocument();
expect(screen.queryByText(/team-plugin/)).not.toBeInTheDocument();
});
});

View file

@ -3,12 +3,14 @@ import VectorStorePermissions from "./permissions/VectorStorePermissions";
import MCPServerPermissions from "./permissions/MCPServerPermissions";
import AgentPermissions from "./permissions/AgentPermissions";
import type { ObjectPermission } from "./object_permission_types";
import type { InheritedGrant } from "./permissions/inheritedGrants";
import { inheritedGrantTooltip, type InheritedGrant } from "./permissions/inheritedGrants";
interface ObjectPermissionsViewProps {
objectPermission?: ObjectPermission | null;
inheritedMcpServers?: InheritedGrant[];
inheritedAgents?: InheritedGrant[];
inheritedSkills?: InheritedGrant[];
unresolvedAccessGroupIds?: string[] | null;
variant?: "card" | "inline";
className?: string;
accessToken?: string | null;
@ -18,6 +20,8 @@ export function ObjectPermissionsView({
objectPermission,
inheritedMcpServers = [],
inheritedAgents = [],
inheritedSkills,
unresolvedAccessGroupIds,
variant = "card",
className = "",
accessToken,
@ -31,6 +35,41 @@ export function ObjectPermissionsView({
const agentAccessGroups = objectPermission?.agent_access_groups || [];
const searchTools = objectPermission?.search_tools || [];
const skills = objectPermission?.skills || [];
const showsInheritedSkills = inheritedSkills !== undefined;
const missingAccessGroupIds = unresolvedAccessGroupIds ?? [];
const inheritedOnlySkills = (inheritedSkills ?? []).filter((grant) => !skills.includes(grant.id));
const skillsContent = (() => {
if (missingAccessGroupIds.length > 0) {
return (
<p className="mt-1 block text-xs break-words text-destructive">
Private plugin access is suspended: access group {missingAccessGroupIds.join(", ")} not found. Only enabled
(public) Claude Code plugins are visible until the id is removed or the group recreated.
</p>
);
}
if (skills.length === 0 && inheritedOnlySkills.length === 0) {
return (
<p className="mt-1 block text-xs text-muted-foreground">
{showsInheritedSkills
? "No private skills granted. Only enabled (public) Claude Code plugins are visible."
: "No private skills granted directly here. Grants from access groups are not listed on this page; only enabled (public) Claude Code plugins are visible by default."}
</p>
);
}
return (
<p className="mt-1 block text-xs break-words text-foreground">
{skills.join(", ")}
{skills.length > 0 && inheritedOnlySkills.length > 0 ? ", " : ""}
{inheritedOnlySkills.map((grant, index) => (
<span key={grant.id} title={inheritedGrantTooltip(grant)} className="text-muted-foreground">
{index > 0 ? ", " : ""}
{grant.id} (access group)
</span>
))}
</p>
);
})();
const content = (
<div className={variant === "card" ? "grid grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 gap-6" : "space-y-4"}>
@ -61,13 +100,7 @@ export function ObjectPermissionsView({
</div>
<div className="min-w-0 rounded-md border border-border p-4">
<p className="text-sm font-medium text-foreground">Skills</p>
{skills.length === 0 ? (
<p className="mt-1 block text-xs text-muted-foreground">
No private skills granted. Only enabled (public) Claude Code plugins are visible.
</p>
) : (
<p className="mt-1 block text-xs break-words text-foreground">{skills.join(", ")}</p>
)}
{skillsContent}
</div>
</div>
);

View file

@ -328,6 +328,8 @@ export interface TeamData {
access_group_models?: string[];
access_group_mcp_server_ids?: string[];
access_group_agent_ids?: string[];
access_group_skill_names?: string[];
unresolved_access_group_ids?: string[] | null;
access_group_details?: TeamAccessGroupModelGrant[];
router_settings?: Record<string, any>;
guardrails?: string[];
@ -1334,6 +1336,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
info.access_group_details,
(grant) => grant.agent_ids,
);
const inheritedSkills = computeInheritedGrants(
info.access_group_skill_names,
info.access_group_details,
(grant) => grant.skill_names,
);
const initialKillSwitchOn = info.metadata?.disable_global_guardrails === true;
@ -1444,6 +1451,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
objectPermission={info.object_permission}
inheritedMcpServers={inheritedMcpServers}
inheritedAgents={inheritedAgents}
inheritedSkills={inheritedSkills}
unresolvedAccessGroupIds={info.unresolved_access_group_ids}
variant="card"
accessToken={accessToken}
/>
@ -2450,6 +2459,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
objectPermission={info.object_permission}
inheritedMcpServers={inheritedMcpServers}
inheritedAgents={inheritedAgents}
inheritedSkills={inheritedSkills}
unresolvedAccessGroupIds={info.unresolved_access_group_ids}
variant="inline"
className="pt-4 border-t border-border"
accessToken={accessToken}

View file

@ -7,6 +7,7 @@ export interface TeamAccessGroupModelGrant {
models: string[];
mcp_server_ids?: string[];
agent_ids?: string[];
skill_names?: string[];
}
export type TeamModelBadgeKind = "all-proxy" | "no-default" | "direct" | "access-group";

View file

@ -2386,7 +2386,7 @@ export interface paths {
*
* Without `key` the catalog holds the enabled (public) plugins. With `?key=sk-...`
* the key is authenticated and the catalog also holds the disabled plugins granted
* to it through `object_permission.skills` on the key or its team.
* to it through `object_permission.skills` or an access group on the key or its team.
*
* Returns:
* Marketplace catalog with list of available plugins and their git sources.
@ -25105,6 +25105,8 @@ export interface components {
access_mcp_server_ids?: string[] | null;
/** Access Model Names */
access_model_names?: string[] | null;
/** Access Skill Names */
access_skill_names?: string[] | null;
/** Assigned Key Ids */
assigned_key_ids?: string[] | null;
/** Assigned Team Ids */
@ -25154,6 +25156,11 @@ export interface components {
access_mcp_servers: components["schemas"]["AccessGroupResource"][];
/** Access Model Names */
access_model_names: string[];
/**
* Access Skill Names
* @default []
*/
access_skill_names: string[];
/** Assigned Key Ids */
assigned_key_ids: string[];
/** Assigned Keys */
@ -25193,6 +25200,8 @@ export interface components {
access_mcp_server_ids?: string[] | null;
/** Access Model Names */
access_model_names?: string[] | null;
/** Access Skill Names */
access_skill_names?: string[] | null;
/** Assigned Key Ids */
assigned_key_ids?: string[] | null;
/** Assigned Team Ids */
@ -45869,6 +45878,8 @@ export interface components {
access_group_mcp_server_ids?: string[] | null;
/** Access Group Models */
access_group_models?: string[] | null;
/** Access Group Skill Names */
access_group_skill_names?: string[] | null;
/**
* Admins
* @default []