mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
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:
parent
dba46c0513
commit
10e4ecc313
38 changed files with 1018 additions and 50 deletions
|
|
@ -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[];
|
||||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 []))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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, ...]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface AccessGroup {
|
|||
modelIds: string[];
|
||||
mcpServerIds: string[];
|
||||
agentIds: string[];
|
||||
skillNames: string[];
|
||||
keyIds: string[];
|
||||
teamIds: string[];
|
||||
createdAt: string;
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
13
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
13
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 []
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue