mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Extend access groups to passthrough routes and vector stores
Adds two resource lists to the unified access group table, access_passthrough_routes and access_vector_store_ids, so a single access group can grant passthrough routes and vector stores alongside models, MCP servers, and agents. Vector store enforcement in vector_store_access_check now unions the stores granted through a key's or team's access groups with the existing object_permission.vector_stores list. Passthrough enforcement resolves access-group routes into access_group_passthrough_routes during common_checks so the synchronous route checks pick them up next to the existing allowed_passthrough_routes metadata; routes match by path with the same exact/prefix semantics. UI: the access group editor gains pass through routes and vector store selectors, and the details page shows both resource lists.
This commit is contained in:
parent
4a5644d51e
commit
c992955648
22 changed files with 435 additions and 22 deletions
|
|
@ -0,0 +1,4 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_AccessGroupTable" ADD COLUMN "access_passthrough_routes" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN "access_vector_store_ids" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
|
|
@ -1211,6 +1211,8 @@ model LiteLLM_AccessGroupTable {
|
|||
access_model_names String[] @default([])
|
||||
access_mcp_server_ids String[] @default([])
|
||||
access_agent_ids String[] @default([])
|
||||
access_passthrough_routes String[] @default([])
|
||||
access_vector_store_ids String[] @default([])
|
||||
|
||||
assigned_team_ids String[] @default([])
|
||||
assigned_key_ids String[] @default([])
|
||||
|
|
|
|||
|
|
@ -2739,6 +2739,9 @@ class UserAPIKeyAuth(
|
|||
# Decoded upstream IdP claims (groups, roles, etc.) propagated by JWT auth machinery
|
||||
# and forwarded into outbound tokens by guardrails such as MCPJWTSigner.
|
||||
jwt_claims: Optional[Dict] = None
|
||||
# Passthrough routes granted to this token through its (and its team's) access
|
||||
# groups, resolved in common_checks so the synchronous route checks can read them.
|
||||
access_group_passthrough_routes: Optional[List[str]] = None
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
|
@ -3196,6 +3199,8 @@ class LiteLLM_AccessGroupTable(LiteLLMPydanticObjectBase):
|
|||
access_model_names: List[str] = []
|
||||
access_mcp_server_ids: List[str] = []
|
||||
access_agent_ids: List[str] = []
|
||||
access_passthrough_routes: List[str] = []
|
||||
access_vector_store_ids: List[str] = []
|
||||
assigned_team_ids: List[str] = []
|
||||
assigned_key_ids: List[str] = []
|
||||
created_at: Optional[datetime] = None
|
||||
|
|
|
|||
|
|
@ -746,6 +746,19 @@ async def common_checks( # noqa: PLR0915
|
|||
user_object=user_object, route=route, request_body=request_body
|
||||
)
|
||||
|
||||
passthrough_access_group_ids = list(
|
||||
{
|
||||
*(valid_token.access_group_ids or []),
|
||||
*((team_object.access_group_ids or []) if team_object is not None else []),
|
||||
}
|
||||
)
|
||||
if passthrough_access_group_ids:
|
||||
valid_token.access_group_passthrough_routes = (
|
||||
await _get_passthrough_routes_from_access_groups(
|
||||
access_group_ids=passthrough_access_group_ids,
|
||||
)
|
||||
)
|
||||
|
||||
_is_route_allowed = _is_api_route_allowed(
|
||||
route=route,
|
||||
request=request,
|
||||
|
|
@ -2816,7 +2829,11 @@ async def get_org_object(
|
|||
async def _get_resources_from_access_groups(
|
||||
access_group_ids: List[str],
|
||||
resource_field: Literal[
|
||||
"access_model_names", "access_mcp_server_ids", "access_agent_ids"
|
||||
"access_model_names",
|
||||
"access_mcp_server_ids",
|
||||
"access_agent_ids",
|
||||
"access_passthrough_routes",
|
||||
"access_vector_store_ids",
|
||||
],
|
||||
prisma_client: Optional[PrismaClient] = None,
|
||||
user_api_key_cache: Optional[UserApiKeyCache] = None,
|
||||
|
|
@ -2832,6 +2849,8 @@ async def _get_resources_from_access_groups(
|
|||
- "access_model_names": model names (for model access checks)
|
||||
- "access_mcp_server_ids": MCP server IDs (for MCP access checks)
|
||||
- "access_agent_ids": agent IDs (for agent access checks)
|
||||
- "access_passthrough_routes": passthrough route paths (for passthrough access checks)
|
||||
- "access_vector_store_ids": vector store IDs (for vector store access checks)
|
||||
prisma_client: Optional PrismaClient (lazy-imported from proxy_server if None)
|
||||
user_api_key_cache: Optional DualCache (lazy-imported from proxy_server if None)
|
||||
proxy_logging_obj: Optional ProxyLogging (lazy-imported from proxy_server if None)
|
||||
|
|
@ -2931,6 +2950,44 @@ async def _get_agent_ids_from_access_groups(
|
|||
)
|
||||
|
||||
|
||||
async def _get_passthrough_routes_from_access_groups(
|
||||
access_group_ids: List[str],
|
||||
prisma_client: Optional[PrismaClient] = None,
|
||||
user_api_key_cache: Optional[UserApiKeyCache] = None,
|
||||
proxy_logging_obj: Optional[ProxyLogging] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Collect passthrough route paths from unified access groups.
|
||||
Routes are matched by path (exact or prefix), same as allowed_passthrough_routes.
|
||||
"""
|
||||
return await _get_resources_from_access_groups(
|
||||
access_group_ids=access_group_ids,
|
||||
resource_field="access_passthrough_routes",
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
|
||||
async def _get_vector_store_ids_from_access_groups(
|
||||
access_group_ids: List[str],
|
||||
prisma_client: Optional[PrismaClient] = None,
|
||||
user_api_key_cache: Optional[UserApiKeyCache] = None,
|
||||
proxy_logging_obj: Optional[ProxyLogging] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Collect vector store IDs from unified access groups.
|
||||
Vector stores are matched by vector_store_id.
|
||||
"""
|
||||
return await _get_resources_from_access_groups(
|
||||
access_group_ids=access_group_ids,
|
||||
resource_field="access_vector_store_ids",
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
|
||||
def _check_model_access_helper(
|
||||
model: str,
|
||||
llm_router: Optional[Router],
|
||||
|
|
@ -4490,6 +4547,9 @@ async def vector_store_access_check(
|
|||
object_type="key",
|
||||
vector_store_ids_to_run=vector_store_ids_to_run,
|
||||
object_permissions=key_object_permission,
|
||||
access_group_vector_store_ids=await _get_vector_store_ids_from_access_groups(
|
||||
access_group_ids=valid_token.access_group_ids or [],
|
||||
),
|
||||
)
|
||||
|
||||
# Check if the team can access the vector store
|
||||
|
|
@ -4504,6 +4564,9 @@ async def vector_store_access_check(
|
|||
object_type="team",
|
||||
vector_store_ids_to_run=vector_store_ids_to_run,
|
||||
object_permissions=team_object_permission,
|
||||
access_group_vector_store_ids=await _get_vector_store_ids_from_access_groups(
|
||||
access_group_ids=team_object.access_group_ids or [],
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
|
|
@ -4512,9 +4575,13 @@ def _can_object_call_vector_stores(
|
|||
object_type: Literal["key", "team", "org"],
|
||||
vector_store_ids_to_run: List[str],
|
||||
object_permissions: Optional[LiteLLM_ObjectPermissionTable],
|
||||
access_group_vector_store_ids: Optional[List[str]] = None,
|
||||
):
|
||||
"""
|
||||
Raises ProxyException if the object (key, team, org) cannot access the specific vector store.
|
||||
|
||||
Access is granted if a requested vector store is in the object's object_permission
|
||||
list OR in a vector store granted through one of the object's access groups.
|
||||
"""
|
||||
if object_permissions is None:
|
||||
return True
|
||||
|
|
@ -4526,10 +4593,14 @@ def _can_object_call_vector_stores(
|
|||
if len(object_permissions.vector_stores) == 0:
|
||||
return True
|
||||
|
||||
allowed_vector_store_ids = set(object_permissions.vector_stores) | set(
|
||||
access_group_vector_store_ids or []
|
||||
)
|
||||
|
||||
for vector_store_id in vector_store_ids_to_run:
|
||||
if vector_store_id not in object_permissions.vector_stores:
|
||||
if vector_store_id not in allowed_vector_store_ids:
|
||||
raise ProxyException(
|
||||
message=f"User not allowed to access vector store. Tried to access {vector_store_id}. Only allowed to access {object_permissions.vector_stores}",
|
||||
message=f"User not allowed to access vector store. Tried to access {vector_store_id}. Only allowed to access {sorted(allowed_vector_store_ids)}",
|
||||
type=ProxyErrorTypes.get_vector_store_access_error_type_for_object(
|
||||
object_type
|
||||
),
|
||||
|
|
|
|||
|
|
@ -730,27 +730,19 @@ class RouteChecks:
|
|||
"""
|
||||
Check if route is a passthrough route.
|
||||
Supports both exact match and prefix match.
|
||||
|
||||
Allowed routes come from `allowed_passthrough_routes` on the key or team
|
||||
metadata, plus any routes granted through the key's or team's access groups
|
||||
(resolved into `access_group_passthrough_routes` during common_checks).
|
||||
"""
|
||||
metadata = user_api_key_dict.metadata
|
||||
metadata = user_api_key_dict.metadata or {}
|
||||
team_metadata = user_api_key_dict.team_metadata or {}
|
||||
if metadata is None and team_metadata is None:
|
||||
return False
|
||||
if (
|
||||
"allowed_passthrough_routes" not in metadata
|
||||
and "allowed_passthrough_routes" not in team_metadata
|
||||
):
|
||||
return False
|
||||
if (
|
||||
metadata.get("allowed_passthrough_routes") is None
|
||||
and team_metadata.get("allowed_passthrough_routes") is None
|
||||
):
|
||||
return False
|
||||
|
||||
allowed_passthrough_routes = (
|
||||
metadata.get("allowed_passthrough_routes")
|
||||
or team_metadata.get("allowed_passthrough_routes")
|
||||
or []
|
||||
)
|
||||
) + (user_api_key_dict.access_group_passthrough_routes or [])
|
||||
|
||||
# Check if route matches any allowed passthrough route (exact or prefix match)
|
||||
for allowed_route in allowed_passthrough_routes:
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ def _record_to_response(record) -> AccessGroupResponse:
|
|||
access_model_names=record.access_model_names,
|
||||
access_mcp_server_ids=record.access_mcp_server_ids,
|
||||
access_agent_ids=record.access_agent_ids,
|
||||
access_passthrough_routes=record.access_passthrough_routes,
|
||||
access_vector_store_ids=record.access_vector_store_ids,
|
||||
assigned_team_ids=record.assigned_team_ids,
|
||||
assigned_key_ids=record.assigned_key_ids,
|
||||
created_at=record.created_at,
|
||||
|
|
@ -330,6 +332,8 @@ async def create_access_group(
|
|||
"access_model_names": data.access_model_names or [],
|
||||
"access_mcp_server_ids": data.access_mcp_server_ids or [],
|
||||
"access_agent_ids": data.access_agent_ids or [],
|
||||
"access_passthrough_routes": data.access_passthrough_routes or [],
|
||||
"access_vector_store_ids": data.access_vector_store_ids 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,
|
||||
|
|
@ -441,6 +445,8 @@ async def update_access_group(
|
|||
"access_model_names",
|
||||
"access_mcp_server_ids",
|
||||
"access_agent_ids",
|
||||
"access_passthrough_routes",
|
||||
"access_vector_store_ids",
|
||||
)
|
||||
and value is None
|
||||
):
|
||||
|
|
|
|||
|
|
@ -1211,6 +1211,8 @@ model LiteLLM_AccessGroupTable {
|
|||
access_model_names String[] @default([])
|
||||
access_mcp_server_ids String[] @default([])
|
||||
access_agent_ids String[] @default([])
|
||||
access_passthrough_routes String[] @default([])
|
||||
access_vector_store_ids String[] @default([])
|
||||
|
||||
assigned_team_ids String[] @default([])
|
||||
assigned_key_ids String[] @default([])
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ class AccessGroupCreateRequest(BaseModel):
|
|||
access_model_names: Optional[List[str]] = None
|
||||
access_mcp_server_ids: Optional[List[str]] = None
|
||||
access_agent_ids: Optional[List[str]] = None
|
||||
access_passthrough_routes: Optional[List[str]] = None
|
||||
access_vector_store_ids: Optional[List[str]] = None
|
||||
assigned_team_ids: Optional[List[str]] = None
|
||||
assigned_key_ids: Optional[List[str]] = None
|
||||
|
||||
|
|
@ -20,6 +22,8 @@ class AccessGroupUpdateRequest(BaseModel):
|
|||
access_model_names: Optional[List[str]] = None
|
||||
access_mcp_server_ids: Optional[List[str]] = None
|
||||
access_agent_ids: Optional[List[str]] = None
|
||||
access_passthrough_routes: Optional[List[str]] = None
|
||||
access_vector_store_ids: Optional[List[str]] = None
|
||||
assigned_team_ids: Optional[List[str]] = None
|
||||
assigned_key_ids: Optional[List[str]] = None
|
||||
|
||||
|
|
@ -31,6 +35,8 @@ class AccessGroupResponse(BaseModel):
|
|||
access_model_names: List[str]
|
||||
access_mcp_server_ids: List[str]
|
||||
access_agent_ids: List[str]
|
||||
access_passthrough_routes: List[str]
|
||||
access_vector_store_ids: List[str]
|
||||
assigned_team_ids: List[str]
|
||||
assigned_key_ids: List[str]
|
||||
created_at: datetime
|
||||
|
|
|
|||
|
|
@ -1211,6 +1211,8 @@ model LiteLLM_AccessGroupTable {
|
|||
access_model_names String[] @default([])
|
||||
access_mcp_server_ids String[] @default([])
|
||||
access_agent_ids String[] @default([])
|
||||
access_passthrough_routes String[] @default([])
|
||||
access_vector_store_ids String[] @default([])
|
||||
|
||||
assigned_team_ids String[] @default([])
|
||||
assigned_key_ids String[] @default([])
|
||||
|
|
|
|||
|
|
@ -879,6 +879,87 @@ async def test_vector_store_access_check_with_team_permissions():
|
|||
assert exc_info.value.type == ProxyErrorTypes.team_vector_store_access_denied
|
||||
|
||||
|
||||
def test_can_object_call_vector_stores_access_group_widens():
|
||||
"""A vector store granted through an access group is allowed even when it is
|
||||
not in the object's object_permission list."""
|
||||
mock_permissions = MagicMock()
|
||||
mock_permissions.vector_stores = ["store-1"]
|
||||
|
||||
# store-2 is not in object_permission but is granted via an access group
|
||||
result = _can_object_call_vector_stores(
|
||||
object_type="key",
|
||||
vector_store_ids_to_run=["store-2"],
|
||||
object_permissions=mock_permissions,
|
||||
access_group_vector_store_ids=["store-2"],
|
||||
)
|
||||
assert result is True
|
||||
|
||||
# store-2 in neither object_permission nor access group -> denied
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
_can_object_call_vector_stores(
|
||||
object_type="key",
|
||||
vector_store_ids_to_run=["store-2"],
|
||||
object_permissions=mock_permissions,
|
||||
access_group_vector_store_ids=["store-9"],
|
||||
)
|
||||
assert exc_info.value.type == ProxyErrorTypes.key_vector_store_access_denied
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vector_store_access_check_access_group_grant():
|
||||
"""vector_store_access_check unions access-group-granted stores with the key's
|
||||
object_permission, so an access-group grant unblocks an otherwise-denied store."""
|
||||
request_body = {"tools": [{"type": "function", "function": {"name": "test"}}]}
|
||||
valid_token = UserAPIKeyAuth(
|
||||
token="ag-test-token",
|
||||
object_permission_id="perm-123",
|
||||
access_group_ids=["ag-1"],
|
||||
)
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_permissions = MagicMock()
|
||||
mock_permissions.vector_stores = ["store-1"]
|
||||
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(
|
||||
return_value=mock_permissions
|
||||
)
|
||||
|
||||
mock_vector_store_registry = MagicMock()
|
||||
mock_vector_store_registry.get_vector_store_ids_to_run.return_value = ["store-2"]
|
||||
|
||||
# Access group grants store-2 -> request for store-2 is allowed
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client),
|
||||
patch("litellm.vector_store_registry", mock_vector_store_registry),
|
||||
patch(
|
||||
"litellm.proxy.auth.auth_checks._get_vector_store_ids_from_access_groups",
|
||||
AsyncMock(return_value=["store-2"]),
|
||||
),
|
||||
):
|
||||
result = await vector_store_access_check(
|
||||
request_body=request_body,
|
||||
team_object=None,
|
||||
valid_token=valid_token,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
# Access group grants nothing -> request for store-2 is denied
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client),
|
||||
patch("litellm.vector_store_registry", mock_vector_store_registry),
|
||||
patch(
|
||||
"litellm.proxy.auth.auth_checks._get_vector_store_ids_from_access_groups",
|
||||
AsyncMock(return_value=[]),
|
||||
),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await vector_store_access_check(
|
||||
request_body=request_body,
|
||||
team_object=None,
|
||||
valid_token=valid_token,
|
||||
)
|
||||
assert exc_info.value.type == ProxyErrorTypes.key_vector_store_access_denied
|
||||
|
||||
|
||||
def test_can_object_call_model_with_alias():
|
||||
"""Test that can_object_call_model works with model aliases"""
|
||||
from litellm import Router
|
||||
|
|
|
|||
|
|
@ -1321,6 +1321,63 @@ def test_check_passthrough_route_access_empty_list():
|
|||
assert result is False
|
||||
|
||||
|
||||
def test_check_passthrough_route_access_access_group_route():
|
||||
"""A passthrough route granted through an access group (resolved into
|
||||
access_group_passthrough_routes) is allowed even with no metadata routes."""
|
||||
valid_token = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
metadata={},
|
||||
access_group_passthrough_routes=["/group-endpoint"],
|
||||
)
|
||||
|
||||
# exact match via access group
|
||||
assert (
|
||||
RouteChecks.check_passthrough_route_access(
|
||||
route="/group-endpoint",
|
||||
user_api_key_dict=valid_token,
|
||||
)
|
||||
is True
|
||||
)
|
||||
# prefix match via access group
|
||||
assert (
|
||||
RouteChecks.check_passthrough_route_access(
|
||||
route="/group-endpoint/v1/chat/completions",
|
||||
user_api_key_dict=valid_token,
|
||||
)
|
||||
is True
|
||||
)
|
||||
# route not granted by the access group is denied
|
||||
assert (
|
||||
RouteChecks.check_passthrough_route_access(
|
||||
route="/other-endpoint",
|
||||
user_api_key_dict=valid_token,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_check_passthrough_route_access_access_group_unions_with_metadata():
|
||||
"""Access-group routes are additive to metadata routes; both sources grant access."""
|
||||
valid_token = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
metadata={"allowed_passthrough_routes": ["/key-endpoint"]},
|
||||
access_group_passthrough_routes=["/group-endpoint"],
|
||||
)
|
||||
|
||||
assert (
|
||||
RouteChecks.check_passthrough_route_access(
|
||||
route="/key-endpoint", user_api_key_dict=valid_token
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
RouteChecks.check_passthrough_route_access(
|
||||
route="/group-endpoint", user_api_key_dict=valid_token
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ 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_passthrough_routes: list | None = None,
|
||||
access_vector_store_ids: list | None = None,
|
||||
assigned_team_ids: list | None = None,
|
||||
assigned_key_ids: list | None = None,
|
||||
created_by: str | None = "admin-user",
|
||||
|
|
@ -46,6 +48,8 @@ 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_passthrough_routes": access_passthrough_routes or [],
|
||||
"access_vector_store_ids": access_vector_store_ids or [],
|
||||
"assigned_team_ids": assigned_team_ids or [],
|
||||
"assigned_key_ids": assigned_key_ids or [],
|
||||
"created_at": created_at_val,
|
||||
|
|
@ -75,6 +79,8 @@ 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_passthrough_routes=data.get("access_passthrough_routes", []),
|
||||
access_vector_store_ids=data.get("access_vector_store_ids", []),
|
||||
assigned_team_ids=data.get("assigned_team_ids", []),
|
||||
assigned_key_ids=data.get("assigned_key_ids", []),
|
||||
created_by=data.get("created_by"),
|
||||
|
|
@ -92,6 +98,8 @@ 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_passthrough_routes=data.get("access_passthrough_routes", []),
|
||||
access_vector_store_ids=data.get("access_vector_store_ids", []),
|
||||
assigned_team_ids=data.get("assigned_team_ids", []),
|
||||
assigned_key_ids=data.get("assigned_key_ids", []),
|
||||
updated_by=data.get("updated_by"),
|
||||
|
|
@ -198,6 +206,48 @@ def test_create_access_group_success(client_and_mocks, base_path, payload):
|
|||
mock_table.create.assert_awaited_once()
|
||||
|
||||
|
||||
def test_create_access_group_persists_passthrough_and_vector_store_fields(
|
||||
client_and_mocks,
|
||||
):
|
||||
"""Passthrough routes and vector store ids round-trip through create."""
|
||||
client, _, mock_table, *_ = client_and_mocks
|
||||
|
||||
payload = {
|
||||
"access_group_name": "group-resources",
|
||||
"access_passthrough_routes": ["/bedrock", "/vllm"],
|
||||
"access_vector_store_ids": ["vs-1", "vs-2"],
|
||||
}
|
||||
resp = client.post("/v1/access_group", json=payload)
|
||||
assert resp.status_code == 201
|
||||
|
||||
body = resp.json()
|
||||
assert body["access_passthrough_routes"] == ["/bedrock", "/vllm"]
|
||||
assert body["access_vector_store_ids"] == ["vs-1", "vs-2"]
|
||||
|
||||
# the new fields are written to the DB row, not silently dropped
|
||||
_, create_kwargs = mock_table.create.call_args
|
||||
create_data = create_kwargs["data"]
|
||||
assert create_data["access_passthrough_routes"] == ["/bedrock", "/vllm"]
|
||||
assert create_data["access_vector_store_ids"] == ["vs-1", "vs-2"]
|
||||
|
||||
|
||||
def test_update_access_group_clears_resource_fields_to_empty_list(client_and_mocks):
|
||||
"""Explicit null for the new list fields is coerced to [] on update."""
|
||||
client, _, mock_table, *_ = client_and_mocks
|
||||
mock_table.find_unique = AsyncMock(return_value=_make_access_group_record())
|
||||
|
||||
resp = client.put(
|
||||
"/v1/access_group/ag-123",
|
||||
json={"access_passthrough_routes": None, "access_vector_store_ids": None},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
_, update_kwargs = mock_table.update.call_args
|
||||
update_data = update_kwargs["data"]
|
||||
assert update_data["access_passthrough_routes"] == []
|
||||
assert update_data["access_vector_store_ids"] == []
|
||||
|
||||
|
||||
def test_create_access_group_duplicate_name_conflict(client_and_mocks):
|
||||
"""Create with duplicate name returns 409."""
|
||||
client, _, mock_table, *_ = client_and_mocks
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ const mockAccessGroups: AccessGroupResponse[] = [
|
|||
access_model_names: [],
|
||||
access_mcp_server_ids: [],
|
||||
access_agent_ids: [],
|
||||
access_passthrough_routes: [],
|
||||
access_vector_store_ids: [],
|
||||
assigned_team_ids: [],
|
||||
assigned_key_ids: [],
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export interface AccessGroupResponse {
|
|||
access_model_names: string[];
|
||||
access_mcp_server_ids: string[];
|
||||
access_agent_ids: string[];
|
||||
access_passthrough_routes: string[];
|
||||
access_vector_store_ids: string[];
|
||||
assigned_team_ids: string[];
|
||||
assigned_key_ids: string[];
|
||||
created_at: string;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export interface AccessGroupCreateParams {
|
|||
access_model_names?: string[];
|
||||
access_mcp_server_ids?: string[];
|
||||
access_agent_ids?: string[];
|
||||
access_passthrough_routes?: string[];
|
||||
access_vector_store_ids?: string[];
|
||||
assigned_team_ids?: string[];
|
||||
assigned_key_ids?: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export interface AccessGroupUpdateParams {
|
|||
access_model_names?: string[];
|
||||
access_mcp_server_ids?: string[];
|
||||
access_agent_ids?: string[];
|
||||
access_passthrough_routes?: string[];
|
||||
access_vector_store_ids?: string[];
|
||||
assigned_team_ids?: string[];
|
||||
assigned_key_ids?: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ const createMockAccessGroup = (overrides: Partial<AccessGroupResponse> = {}): Ac
|
|||
access_model_names: ["model-1", "model-2"],
|
||||
access_mcp_server_ids: ["mcp-1"],
|
||||
access_agent_ids: ["agent-1"],
|
||||
access_passthrough_routes: ["/bedrock"],
|
||||
access_vector_store_ids: ["vs-1"],
|
||||
assigned_team_ids: ["team-1"],
|
||||
assigned_key_ids: ["key-1", "key-2"],
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,17 @@ import {
|
|||
theme,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BotIcon,
|
||||
DatabaseIcon,
|
||||
EditIcon,
|
||||
KeyIcon,
|
||||
LayersIcon,
|
||||
RouteIcon,
|
||||
ServerIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
|
||||
import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal";
|
||||
|
|
@ -69,6 +79,8 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
const modelIds = accessGroup.access_model_names ?? [];
|
||||
const mcpServerIds = accessGroup.access_mcp_server_ids ?? [];
|
||||
const agentIds = accessGroup.access_agent_ids ?? [];
|
||||
const passthroughRoutes = accessGroup.access_passthrough_routes ?? [];
|
||||
const vectorStoreIds = accessGroup.access_vector_store_ids ?? [];
|
||||
const keyIds = accessGroup.assigned_key_ids ?? [];
|
||||
const teamIds = accessGroup.assigned_team_ids ?? [];
|
||||
|
||||
|
|
@ -158,6 +170,58 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
<Empty description="No agents assigned to this group" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "passthrough",
|
||||
label: (
|
||||
<Flex align="center" gap={8}>
|
||||
<RouteIcon size={16} />
|
||||
Pass Through Routes
|
||||
<Tag>{passthroughRoutes?.length}</Tag>
|
||||
</Flex>
|
||||
),
|
||||
children:
|
||||
passthroughRoutes?.length > 0 ? (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
|
||||
dataSource={passthroughRoutes}
|
||||
renderItem={(route) => (
|
||||
<List.Item>
|
||||
<Card size="small">
|
||||
<Text code>{route}</Text>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="No pass through routes assigned to this group" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "vector_stores",
|
||||
label: (
|
||||
<Flex align="center" gap={8}>
|
||||
<DatabaseIcon size={16} />
|
||||
Vector Stores
|
||||
<Tag>{vectorStoreIds?.length}</Tag>
|
||||
</Flex>
|
||||
),
|
||||
children:
|
||||
vectorStoreIds?.length > 0 ? (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
|
||||
dataSource={vectorStoreIds}
|
||||
renderItem={(id) => (
|
||||
<List.Item>
|
||||
<Card size="small">
|
||||
<Text code>{id}</Text>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="No vector stores assigned to this group" />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
|
||||
import PassThroughRoutesSelector from "@/components/common_components/PassThroughRoutesSelector";
|
||||
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
|
||||
import type { FormInstance } from "antd";
|
||||
import { Form, Input, Select, Space, Tabs } from "antd";
|
||||
import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react";
|
||||
import { BotIcon, DatabaseIcon, InfoIcon, LayersIcon, RouteIcon, ServerIcon } from "lucide-react";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
|
|
@ -13,14 +15,17 @@ export interface AccessGroupFormValues {
|
|||
modelIds: string[];
|
||||
mcpServerIds: string[];
|
||||
agentIds: string[];
|
||||
passthroughRoutes: string[];
|
||||
vectorStoreIds: string[];
|
||||
}
|
||||
|
||||
interface AccessGroupBaseFormProps {
|
||||
form: FormInstance<AccessGroupFormValues>;
|
||||
accessToken: string;
|
||||
isNameDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function AccessGroupBaseForm({ form, isNameDisabled = false }: AccessGroupBaseFormProps) {
|
||||
export function AccessGroupBaseForm({ form, accessToken, isNameDisabled = false }: AccessGroupBaseFormProps) {
|
||||
const { data: agentsData } = useAgents();
|
||||
const { data: mcpServersData } = useMCPServers();
|
||||
|
||||
|
|
@ -128,6 +133,46 @@ export function AccessGroupBaseForm({ form, isNameDisabled = false }: AccessGrou
|
|||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "5",
|
||||
label: (
|
||||
<Space align="center" size={4}>
|
||||
<RouteIcon size={16} />
|
||||
Pass Through Routes
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 16 }}>
|
||||
<Form.Item name="passthroughRoutes" label="Allowed Pass Through Routes">
|
||||
<PassThroughRoutesSelector
|
||||
accessToken={accessToken}
|
||||
value={form.getFieldValue("passthroughRoutes") ?? []}
|
||||
onChange={(values) => form.setFieldsValue({ passthroughRoutes: values })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "6",
|
||||
label: (
|
||||
<Space align="center" size={4}>
|
||||
<DatabaseIcon size={16} />
|
||||
Vector Stores
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div style={{ paddingTop: 16 }}>
|
||||
<Form.Item name="vectorStoreIds" label="Allowed Vector Stores">
|
||||
<VectorStoreSelector
|
||||
accessToken={accessToken}
|
||||
value={form.getFieldValue("vectorStoreIds") ?? []}
|
||||
onChange={(values) => form.setFieldsValue({ vectorStoreIds: values })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -139,6 +184,8 @@ export function AccessGroupBaseForm({ form, isNameDisabled = false }: AccessGrou
|
|||
modelIds: [],
|
||||
mcpServerIds: [],
|
||||
agentIds: [],
|
||||
passthroughRoutes: [],
|
||||
vectorStoreIds: [],
|
||||
}}
|
||||
>
|
||||
<Tabs defaultActiveKey="1" items={items} />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
useCreateAccessGroup,
|
||||
AccessGroupCreateParams,
|
||||
} from "@/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
interface AccessGroupCreateModalProps {
|
||||
visible: boolean;
|
||||
|
|
@ -16,6 +17,7 @@ interface AccessGroupCreateModalProps {
|
|||
export function AccessGroupCreateModal({ visible, onCancel, onSuccess }: AccessGroupCreateModalProps) {
|
||||
const [form] = Form.useForm<AccessGroupFormValues>();
|
||||
const createMutation = useCreateAccessGroup();
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
const handleOk = () => {
|
||||
form
|
||||
|
|
@ -27,6 +29,8 @@ export function AccessGroupCreateModal({ visible, onCancel, onSuccess }: AccessG
|
|||
access_model_names: values.modelIds,
|
||||
access_mcp_server_ids: values.mcpServerIds,
|
||||
access_agent_ids: values.agentIds,
|
||||
access_passthrough_routes: values.passthroughRoutes,
|
||||
access_vector_store_ids: values.vectorStoreIds,
|
||||
};
|
||||
|
||||
createMutation.mutate(params, {
|
||||
|
|
@ -55,7 +59,7 @@ export function AccessGroupCreateModal({ visible, onCancel, onSuccess }: AccessG
|
|||
confirmLoading={createMutation.isPending}
|
||||
destroyOnClose
|
||||
>
|
||||
<AccessGroupBaseForm form={form} />
|
||||
<AccessGroupBaseForm form={form} accessToken={accessToken ?? ""} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import MessageManager from "@/components/molecules/message_manager";
|
|||
import { AccessGroupBaseForm, AccessGroupFormValues } from "./AccessGroupBaseForm";
|
||||
import { useEditAccessGroup, AccessGroupUpdateParams } from "@/app/(dashboard)/hooks/accessGroups/useEditAccessGroup";
|
||||
import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
interface AccessGroupEditModalProps {
|
||||
visible: boolean;
|
||||
|
|
@ -15,6 +16,7 @@ interface AccessGroupEditModalProps {
|
|||
export function AccessGroupEditModal({ visible, accessGroup, onCancel, onSuccess }: AccessGroupEditModalProps) {
|
||||
const [form] = Form.useForm<AccessGroupFormValues>();
|
||||
const editMutation = useEditAccessGroup();
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
// Populate the form with initial values whenever the modal opens or the data changes
|
||||
useEffect(() => {
|
||||
|
|
@ -25,6 +27,8 @@ export function AccessGroupEditModal({ visible, accessGroup, onCancel, onSuccess
|
|||
modelIds: accessGroup.access_model_names ?? [],
|
||||
mcpServerIds: accessGroup.access_mcp_server_ids ?? [],
|
||||
agentIds: accessGroup.access_agent_ids ?? [],
|
||||
passthroughRoutes: accessGroup.access_passthrough_routes ?? [],
|
||||
vectorStoreIds: accessGroup.access_vector_store_ids ?? [],
|
||||
});
|
||||
}
|
||||
}, [visible, accessGroup, form]);
|
||||
|
|
@ -39,6 +43,8 @@ export function AccessGroupEditModal({ visible, accessGroup, onCancel, onSuccess
|
|||
access_model_names: values.modelIds,
|
||||
access_mcp_server_ids: values.mcpServerIds,
|
||||
access_agent_ids: values.agentIds,
|
||||
access_passthrough_routes: values.passthroughRoutes,
|
||||
access_vector_store_ids: values.vectorStoreIds,
|
||||
};
|
||||
|
||||
editMutation.mutate(
|
||||
|
|
@ -69,7 +75,7 @@ export function AccessGroupEditModal({ visible, accessGroup, onCancel, onSuccess
|
|||
confirmLoading={editMutation.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<AccessGroupBaseForm form={form} />
|
||||
<AccessGroupBaseForm form={form} accessToken={accessToken ?? ""} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ const mockAccessGroups: AccessGroupResponse[] = [
|
|||
access_model_names: ["m1", "m2"],
|
||||
access_mcp_server_ids: ["s1"],
|
||||
access_agent_ids: ["a1"],
|
||||
access_passthrough_routes: ["/bedrock"],
|
||||
access_vector_store_ids: ["vs-1"],
|
||||
assigned_team_ids: [],
|
||||
assigned_key_ids: [],
|
||||
created_at: "2024-01-15T10:00:00Z",
|
||||
|
|
@ -26,6 +28,8 @@ const mockAccessGroups: AccessGroupResponse[] = [
|
|||
access_model_names: ["m1"],
|
||||
access_mcp_server_ids: [],
|
||||
access_agent_ids: [],
|
||||
access_passthrough_routes: [],
|
||||
access_vector_store_ids: [],
|
||||
assigned_team_ids: [],
|
||||
assigned_key_ids: [],
|
||||
created_at: "2024-01-10T09:00:00Z",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue