From c99295564811f00f600ec1d22faeb98274000c04 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Fri, 5 Jun 2026 12:29:45 -0700 Subject: [PATCH] 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. --- .../migration.sql | 4 + .../litellm_proxy_extras/schema.prisma | 2 + litellm/proxy/_types.py | 5 ++ litellm/proxy/auth/auth_checks.py | 77 +++++++++++++++++- litellm/proxy/auth/route_checks.py | 20 ++--- .../access_group_endpoints.py | 6 ++ litellm/proxy/schema.prisma | 2 + litellm/types/access_group.py | 6 ++ schema.prisma | 2 + .../proxy/auth/test_auth_checks.py | 81 +++++++++++++++++++ .../proxy/auth/test_route_checks.py | 57 +++++++++++++ .../test_access_group_endpoints.py | 50 ++++++++++++ .../accessGroups/useAccessGroups.test.ts | 2 + .../hooks/accessGroups/useAccessGroups.ts | 2 + .../accessGroups/useCreateAccessGroup.ts | 2 + .../hooks/accessGroups/useEditAccessGroup.ts | 2 + .../AccessGroupsDetailsPage.test.tsx | 2 + .../AccessGroups/AccessGroupsDetailsPage.tsx | 66 ++++++++++++++- .../AccessGroupsModal/AccessGroupBaseForm.tsx | 51 +++++++++++- .../AccessGroupCreateModal.tsx | 6 +- .../AccessGroupEditModal.tsx | 8 +- .../AccessGroups/AccessGroupsPage.test.tsx | 4 + 22 files changed, 435 insertions(+), 22 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260605121233_add_passthrough_routes_and_vector_stores_to_access_group/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260605121233_add_passthrough_routes_and_vector_stores_to_access_group/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260605121233_add_passthrough_routes_and_vector_stores_to_access_group/migration.sql new file mode 100644 index 00000000000..81d2fe0bb6b --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260605121233_add_passthrough_routes_and_vector_stores_to_access_group/migration.sql @@ -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[]; + diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 6715f80464d..2999df6e20e 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -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([]) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 5059a6f2e55..11c4bdd2545 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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 diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 94ae3f5eacc..ecfaee3d65e 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -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 ), diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 4be2a185ce4..094fd9e88ac 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -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: diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py index 62a770f46ae..81f15165cce 100644 --- a/litellm/proxy/management_endpoints/access_group_endpoints.py +++ b/litellm/proxy/management_endpoints/access_group_endpoints.py @@ -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 ): diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 6715f80464d..2999df6e20e 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -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([]) diff --git a/litellm/types/access_group.py b/litellm/types/access_group.py index e26ebe00625..4fed36def49 100644 --- a/litellm/types/access_group.py +++ b/litellm/types/access_group.py @@ -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 diff --git a/schema.prisma b/schema.prisma index 6715f80464d..2999df6e20e 100644 --- a/schema.prisma +++ b/schema.prisma @@ -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([]) diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 42c76c4671d..eb6d115381b 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -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 diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 63b61954cf6..57c3678b11b 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -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", [ diff --git a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py index 016e10859b6..11201efabc6 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py @@ -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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts index b15ea4491e9..8518c6121d8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts @@ -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", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts index 9f306c21459..cad049221f6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts @@ -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; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts index 5efa2da6557..42563845f58 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts @@ -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[]; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts index 7dd85ae93dc..99de61a5325 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts @@ -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[]; } diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx index ee8a8d0ffc5..68cf2bb6af2 100644 --- a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx @@ -51,6 +51,8 @@ const createMockAccessGroup = (overrides: Partial = {}): 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", diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx index ae0cd8cd61b..8adaeb2c30c 100644 --- a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx @@ -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 ), }, + { + key: "passthrough", + label: ( + + + Pass Through Routes + {passthroughRoutes?.length} + + ), + children: + passthroughRoutes?.length > 0 ? ( + ( + + + {route} + + + )} + /> + ) : ( + + ), + }, + { + key: "vector_stores", + label: ( + + + Vector Stores + {vectorStoreIds?.length} + + ), + children: + vectorStoreIds?.length > 0 ? ( + ( + + + {id} + + + )} + /> + ) : ( + + ), + }, ]; return ( diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx index 72b89e34301..eaa86ab2336 100644 --- a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx @@ -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; + 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 ), }, + { + key: "5", + label: ( + + + Pass Through Routes + + ), + children: ( +
+ + form.setFieldsValue({ passthroughRoutes: values })} + /> + +
+ ), + }, + { + key: "6", + label: ( + + + Vector Stores + + ), + children: ( +
+ + form.setFieldsValue({ vectorStoreIds: values })} + /> + +
+ ), + }, ]; return ( @@ -139,6 +184,8 @@ export function AccessGroupBaseForm({ form, isNameDisabled = false }: AccessGrou modelIds: [], mcpServerIds: [], agentIds: [], + passthroughRoutes: [], + vectorStoreIds: [], }} > diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupCreateModal.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupCreateModal.tsx index d0a15dbe975..f8ef0a9b3fd 100644 --- a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupCreateModal.tsx +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupCreateModal.tsx @@ -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(); 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 > - + ); } diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupEditModal.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupEditModal.tsx index 875dd75489a..1c271e723fd 100644 --- a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupEditModal.tsx +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupEditModal.tsx @@ -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(); 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 > - + ); } diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx index d50811949f5..523dc49f145 100644 --- a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx @@ -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",