From 77c22a4c015c84ed78e33835d86c9b022bf591c9 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:49:26 -0700 Subject: [PATCH] feat(proxy): add agent_access_default_deny for least-privilege agent access (#43979) * feat(proxy): add require_key_agent_access_defined for least-privilege agent access Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): align A2A model visibility assertions with supported routes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): keep A2A model entries on their existing routes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): point the listing model at the harness upstream Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): coerce require_key_agent_access_defined like the settings validator Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): inject agent access lookups instead of patching the handler Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(proxy): rename require_key_agent_access_defined to agent_access_default_deny Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: ryan Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 7 + .../auth/agent_permission_handler.py | 51 ++- litellm/proxy/proxy_server.py | 1 + .../test_agent_least_privilege.py | 338 ++++++++++++++++++ .../auth/test_agent_permission_handler.py | 180 ++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 6 + 6 files changed, 576 insertions(+), 7 deletions(-) create mode 100644 tests/integration/authorization/test_agent_least_privilege.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c3bb76e0f28..e4a0a5242b4 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3027,6 +3027,13 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): "(see GitHub issue #12905)." ), ) + agent_access_default_deny: bool = Field( + False, + description=( + "When true, a key with no agent grants on itself or its team can reach no agents instead of all agents. " + "Proxy admins are exempt." + ), + ) user_url_validation: bool | None = Field( None, description=( diff --git a/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py b/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py index f671e1f9098..48213625c4f 100644 --- a/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py +++ b/litellm/proxy/agent_endpoints/auth/agent_permission_handler.py @@ -9,9 +9,10 @@ import asyncio from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from types import MappingProxyType -from typing import Final, TypeAlias +from typing import Final, Protocol, TypeAlias from fastapi import HTTPException +from pydantic import TypeAdapter, ValidationError from litellm._logging import verbose_logger from litellm.proxy._experimental.mcp_server.ui_session_utils import build_effective_auth_contexts @@ -47,6 +48,29 @@ class RestrictedAgentAccess: AgentAccess: TypeAlias = UnrestrictedAgentAccess | RestrictedAgentAccess +class AgentAccessLookup(Protocol): + def __call__(self, user_api_key_auth: UserAPIKeyAuth | None, /, *, strict: bool) -> Awaitable[AgentAccess]: ... + + +_DEFAULT_DENY_FLAG: Final = TypeAdapter(bool) + + +def agent_access_default_deny() -> bool: + from litellm.proxy.proxy_server import general_settings_view + + try: + return _DEFAULT_DENY_FLAG.validate_python(general_settings_view().get("agent_access_default_deny", False)) + except ValidationError: + return True + + +def _is_proxy_admin(user_api_key_auth: UserAPIKeyAuth | None) -> bool: + return user_api_key_auth is not None and user_api_key_auth.user_role in ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN.value, + ) + + def _to_stable_ids(agent_ids: frozenset[str]) -> frozenset[str]: from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry @@ -80,7 +104,7 @@ class AgentRequestHandler: - If team has restrictions and key has restrictions: use intersection - If team has restrictions and key has none: inherit from team - If team has no restrictions: use key restrictions - - If no restrictions: allow all agents + - If no restrictions: allow all agents unless agent_access_default_deny is enabled for a non-admin key """ @staticmethod @@ -89,15 +113,22 @@ class AgentRequestHandler: resolve_ceiling: CeilingResolver = resolve_agent_access_group_ceiling, *, strict: bool = False, + default_deny: Callable[[], bool] = agent_access_default_deny, + lookup_key_access: AgentAccessLookup | None = None, + lookup_team_access: AgentAccessLookup | None = None, ) -> AgentAccess: """Agents the key may reach: key and team grants, intersected with the agent's access group ceiling and, for an agent key acting on behalf of an invoking user, with that user's team grants.""" if managed_agent_policy(user_api_key_auth) is not None: return await _managed_actor_agent_access(user_api_key_auth) key_team_access: Final = await AgentRequestHandler.resolve_key_team_agent_access( - user_api_key_auth, strict=strict + user_api_key_auth, + strict=strict, + lookup_key_access=lookup_key_access, + lookup_team_access=lookup_team_access, ) - if strict and isinstance(key_team_access, UnrestrictedAgentAccess): + deny_undefined: Final = strict or (not _is_proxy_admin(user_api_key_auth) and default_deny()) + if deny_undefined and isinstance(key_team_access, UnrestrictedAgentAccess): return RestrictedAgentAccess(frozenset()) caller_access: Final = await AgentRequestHandler.agent_caller_access(user_api_key_auth, strict=strict) own_access: Final = _intersect_agent_access(key_team_access, caller_access) @@ -122,12 +153,18 @@ class AgentRequestHandler: user_api_key_auth: UserAPIKeyAuth | None, *, strict: bool = False, + lookup_key_access: AgentAccessLookup | None = None, + lookup_team_access: AgentAccessLookup | None = None, ) -> AgentAccess: try: - key_access: Final = await AgentRequestHandler.get_allowed_agents_for_key(user_api_key_auth, strict=strict) - team_access: Final = await AgentRequestHandler._get_allowed_agents_for_team( - user_api_key_auth, strict=strict + key_lookup: Final = ( + AgentRequestHandler.get_allowed_agents_for_key if lookup_key_access is None else lookup_key_access ) + team_lookup: Final = ( + AgentRequestHandler._get_allowed_agents_for_team if lookup_team_access is None else lookup_team_access + ) + key_access: Final = await key_lookup(user_api_key_auth, strict=strict) + team_access: Final = await team_lookup(user_api_key_auth, strict=strict) except Exception as e: if strict: raise HTTPException(503, "Agent invocation policy is unavailable") from e diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index e5bceb25ef3..60842648d16 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -18824,6 +18824,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro "cancel_on_disconnect": "Boolean", "disable_auto_add_proxy_admin_to_teams": "Boolean", "apply_user_budget_to_team_keys": "Boolean", + "agent_access_default_deny": "Boolean", "user_api_key_cache_max_size": "Integer", "transcribe_media_buckets": "List", } diff --git a/tests/integration/authorization/test_agent_least_privilege.py b/tests/integration/authorization/test_agent_least_privilege.py new file mode 100644 index 00000000000..8a7f1596379 --- /dev/null +++ b/tests/integration/authorization/test_agent_least_privilege.py @@ -0,0 +1,338 @@ +import json +import uuid +from contextlib import ExitStack +from pathlib import Path +from typing import Final + +import httpx +import pytest +import yaml +from integration._support.client import Gateway, eventually, object_value, string_value +from integration._support.database import read_rows +from integration._support.process import owned_proxy +from integration._support.wire import Reply, Request, Wire, wire_server + + +def _write_access_config(directory: Path, *, default_deny: bool, model_name: str, api_base: str) -> Path: + source: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + config: Final = { + **source, + "model_list": [ + { + "model_name": model_name, + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "integration-provider-key", + "api_base": api_base, + }, + } + ], + "general_settings": { + **source["general_settings"], + "agent_access_default_deny": default_deny, + }, + } + path: Final = directory / f"agent-access-{uuid.uuid4().hex}.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + +def _peer_response(request: Request, wire: Wire, agent_name: str) -> Reply: + if request.method == "GET": + assert request.target in ("/.well-known/agent-card.json", "/.well-known/agent.json") + return Reply( + body=json.dumps( + { + "protocolVersion": "0.3", + "name": agent_name, + "description": "Synthetic permission peer", + "version": "1.0.0", + "url": wire.url + "/", + "capabilities": {"streaming": False}, + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "skills": [], + } + ).encode() + ) + assert request.method == "POST" and request.target == "/" + body: Final = json.loads(request.body) + assert body["jsonrpc"] == "2.0" and body["method"] == "message/send" + message: Final = body["params"]["message"] + assert message["role"] == "user" + assert message["kind"] == "message" + assert message["parts"] in ( + [{"kind": "text", "text": "synthetic ping"}], + [{"kind": "text", "text": "user: synthetic ping"}], + ) + return Reply( + body=json.dumps( + { + "jsonrpc": "2.0", + "id": body["id"], + "result": { + "kind": "message", + "role": "agent", + "messageId": message["messageId"] + "-out", + "parts": [{"kind": "text", "text": "synthetic pong"}], + }, + } + ).encode() + ) + + +def _create_agent(candidate: Gateway, wire: Wire, agent_name: str, cleanups: ExitStack) -> str: + created: Final = candidate.post( + "/v1/agents", + { + "agent_name": agent_name, + "agent_card_params": { + "protocolVersion": "0.3", + "name": agent_name, + "description": "Synthetic permission peer", + "version": "1.0.0", + "url": wire.url + "/", + "capabilities": {"streaming": False}, + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "skills": [], + }, + "litellm_params": {"make_public": False}, + }, + ) + agent_id: Final = string_value(created["agent_id"]) + cleanups.callback(_delete_agent, candidate, agent_id) + return agent_id + + +def _delete_agent(candidate: Gateway, agent_id: str) -> None: + deleted: Final = candidate.request("DELETE", f"/v1/agents/{agent_id}") + assert deleted.status_code == 200, deleted.text + assert read_rows('SELECT agent_id FROM "LiteLLM_AgentsTable" WHERE agent_id=%s', (agent_id,)) == [] + + +def _card(candidate: Gateway, agent_id: str, key: str) -> httpx.Response: + return candidate.request( + "GET", + f"/a2a/{agent_id}/.well-known/agent-card.json", + key=key, + headers={"Connection": "close"}, + ) + + +def _send(candidate: Gateway, agent_id: str, key: str, marker: str) -> httpx.Response: + return candidate.request( + "POST", + f"/a2a/{agent_id}", + { + "jsonrpc": "2.0", + "id": marker, + "method": "message/send", + "params": { + "message": { + "kind": "message", + "role": "user", + "messageId": marker + "-in", + "parts": [{"kind": "text", "text": "synthetic ping"}], + } + }, + }, + key=key, + headers={"Connection": "close"}, + ) + + +def _chat_completion(candidate: Gateway, agent_name: str, key: str) -> httpx.Response: + return candidate.request( + "POST", + "/v1/chat/completions", + { + "model": f"a2a/{agent_name}", + "messages": [{"role": "user", "content": "synthetic ping"}], + }, + key=key, + headers={"Connection": "close"}, + ) + + +def _model_names(candidate: Gateway, path: str, key: str) -> frozenset[str]: + response: Final = candidate.request("GET", path, key=key, headers={"Connection": "close"}) + assert response.status_code == 200, response.text + payload: Final = response.json() + raw_entries: Final = payload if isinstance(payload, list) else payload["data"] + entries: Final = tuple(object_value(entry) for entry in raw_entries) + if path == "/model_group/info": + return frozenset(string_value(entry["model_group"]) for entry in entries) + return frozenset(string_value(entry["model_name"]) for entry in entries) + + +def _listed_agent_ids(candidate: Gateway, key: str) -> frozenset[str]: + response: Final = candidate.request("GET", "/v1/agents", key=key, headers={"Connection": "close"}) + assert response.status_code == 200, response.text + return frozenset(string_value(object_value(agent)["agent_id"]) for agent in response.json()) + + +def _assert_successful_send(response: httpx.Response, marker: str) -> None: + assert response.status_code == 200, response.text + body: Final = response.json() + assert body["jsonrpc"] == "2.0" and body["id"] == marker and "error" not in body, response.text + assert body["result"]["messageId"] == marker + "-in-out", response.text + assert body["result"]["parts"] == [{"kind": "text", "text": "synthetic pong"}], response.text + + +def _assert_denial(response: httpx.Response, agent_id: str) -> None: + assert response.status_code == 403, response.text + assert response.json() == { + "detail": f"Agent '{agent_id}' is not allowed for your key/team. Contact proxy admin for access." + }, response.text + + +@pytest.mark.timeout(180) +def test_agent_access_default_deny_denies_ungranted_key_but_keeps_explicit_grants( + gateway: Gateway, tmp_path: Path +) -> None: + agent_name: Final = "least-privilege-" + uuid.uuid4().hex + v2_model: Final = "listing-" + agent_name + config: Final = _write_access_config( + tmp_path, default_deny=True, model_name=v2_model, api_base=gateway.upstream_url + "/v1" + ) + + def upstream(request: Request) -> Reply: + return _peer_response(request, wire, agent_name) + + with ( + wire_server(upstream) as wire, + owned_proxy(gateway, tmp_path, {}, config=config, workers=2) as candidate, + candidate.scenario() as scenario, + ): + agent_id: Final = _create_agent(candidate, wire, agent_name, scenario.cleanups) + ungranted_team: Final = scenario.team() + key_a: Final = scenario.key(team_id=ungranted_team) + key_b: Final = scenario.key(object_permission={"agents": [agent_id]}) + granted_team: Final = scenario.team(object_permission={"agents": [agent_id]}) + key_c: Final = scenario.key(team_id=granted_team) + wire.drain() + + denied_cards: Final = tuple(_card(candidate, agent_id, key_a) for _ in range(8)) + _assert_denial(denied_cards[0], agent_id) + assert all(response.json() == denied_cards[0].json() for response in denied_cards), [ + response.text for response in denied_cards + ] + + denied_sends: Final = tuple( + _send(candidate, agent_id, key_a, f"denied-{index}-{uuid.uuid4().hex}") for index in range(8) + ) + _assert_denial(denied_sends[0], agent_id) + assert all(response.json() == denied_sends[0].json() for response in denied_sends), [ + response.text for response in denied_sends + ] + denied_completion: Final = _chat_completion(candidate, agent_name, key_a) + assert denied_completion.status_code == 403, denied_completion.text + assert denied_completion.json() == { + "error": { + "message": f"Agent '{agent_name}' is not allowed for your key/team. Contact proxy admin for access.", + "type": "permission_error", + "param": None, + "code": "403", + } + }, denied_completion.text + denied_agents: Final = tuple( + candidate.request("GET", f"/v1/agents/{agent_id}", key=key_a, headers={"Connection": "close"}) + for _ in range(8) + ) + _assert_denial(denied_agents[0], agent_id) + assert all(response.json() == denied_agents[0].json() for response in denied_agents), [ + response.text for response in denied_agents + ] + assert v2_model in _model_names(candidate, "/v2/model/info", candidate.key) + assert _listed_agent_ids(candidate, key_a) == frozenset() + assert f"a2a/{agent_name}" not in _model_names(candidate, "/v2/model/info", key_a) + assert f"a2a/{agent_name}" not in _model_names(candidate, "/model_group/info", key_a) + assert tuple(item for item in wire.drain() if item.method == "POST") == () + + for key in (key_b, key_c, candidate.key): + cards: Final = tuple(_card(candidate, agent_id, key) for _ in range(8)) + assert all(response.status_code == 200 for response in cards), [response.text for response in cards] + completion: Final = _chat_completion(candidate, agent_name, key) + assert completion.status_code == 200, completion.text + assert completion.json()["choices"][0]["message"]["content"] == "synthetic pong", completion.text + wire.drain() + for index in range(8): + marker: Final = f"granted-{index}-{uuid.uuid4().hex}" + _assert_successful_send(_send(candidate, agent_id, key, marker), marker) + posts: Final = tuple(item for item in wire.drain() if item.method == "POST") + assert len(posts) == 1, posts + if key != candidate.key: + assert agent_id in _listed_agent_ids(candidate, key) + assert f"a2a/{agent_name}" in _model_names(candidate, "/v2/model/info", key) + + +@pytest.mark.timeout(180) +@pytest.mark.parametrize("workers", [2, 4]) +def test_agent_access_default_deny_toggles_at_runtime_through_config_api( + gateway: Gateway, tmp_path: Path, workers: int +) -> None: + agent_name: Final = "runtime-agent-" + uuid.uuid4().hex + + def upstream(request: Request) -> Reply: + return _peer_response(request, wire, agent_name) + + with ( + wire_server(upstream) as wire, + owned_proxy( + gateway, + tmp_path, + {}, + config=Path("tests/integration/proxy_config.yaml"), + workers=workers, + ) as candidate, + candidate.scenario() as scenario, + ): + agent_id: Final = _create_agent(candidate, wire, agent_name, scenario.cleanups) + ungranted_key: Final = scenario.key() + wire.drain() + assert _card(candidate, agent_id, ungranted_key).status_code == 200 + + enabled: Final = candidate.request( + "POST", + "/config/field/update", + { + "config_type": "general_settings", + "field_name": "agent_access_default_deny", + "field_value": "true", + }, + ) + assert enabled.status_code == 200, enabled.text + config_fields: Final = candidate.request( + "GET", + "/config/list", + params={"config_type": "general_settings"}, + ) + assert config_fields.status_code == 200, config_fields.text + setting: Final = next( + object_value(field) + for field in config_fields.json() + if object_value(field)["field_name"] == "agent_access_default_deny" + ) + assert setting["field_type"] == "Boolean" and setting["field_value"] == "true", setting + eventually( + lambda: tuple(_card(candidate, agent_id, ungranted_key) for _ in range(8)), + lambda responses: all(response.status_code == 403 for response in responses), + seconds=40, + ) + + disabled: Final = candidate.request( + "POST", + "/config/field/update", + { + "config_type": "general_settings", + "field_name": "agent_access_default_deny", + "field_value": False, + }, + ) + assert disabled.status_code == 200, disabled.text + eventually( + lambda: tuple(_card(candidate, agent_id, ungranted_key) for _ in range(8)), + lambda responses: all(response.status_code == 200 for response in responses), + seconds=40, + ) diff --git a/tests/unit/proxy/agent_endpoints/auth/test_agent_permission_handler.py b/tests/unit/proxy/agent_endpoints/auth/test_agent_permission_handler.py index b5fa1cbc53d..45d3d7fb22e 100644 --- a/tests/unit/proxy/agent_endpoints/auth/test_agent_permission_handler.py +++ b/tests/unit/proxy/agent_endpoints/auth/test_agent_permission_handler.py @@ -15,6 +15,7 @@ from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry from litellm.proxy.agent_endpoints.auth.agent_access_groups import AgentAccessGroupCeiling, CeilingResolver from litellm.proxy.agent_endpoints.auth.agent_permission_handler import ( AgentAccess, + AgentAccessLookup, AgentRequestHandler, RestrictedAgentAccess, UnrestrictedAgentAccess, @@ -43,6 +44,15 @@ def _agent_id(registry: AgentRegistry, agent_name: str) -> str: return agent.agent_id +def _lookup(result: AgentAccess | Exception) -> AgentAccessLookup: + async def lookup(_user_api_key_auth: UserAPIKeyAuth | None, /, *, strict: bool) -> AgentAccess: + if isinstance(result, Exception): + raise result + return result + + return lookup + + async def _single_context(user_api_key_auth: UserAPIKeyAuth) -> list[UserAPIKeyAuth]: return [user_api_key_auth] @@ -133,6 +143,176 @@ class TestAgentRequestHandler: ) assert result == UnrestrictedAgentAccess() + async def test_agent_access_default_deny_is_opt_in(self): + auth: Final = UserAPIKeyAuth(api_key="test-key", user_id="test-user", team_id="test-team") + key_lookup: Final = _lookup(UnrestrictedAgentAccess()) + team_lookup: Final = _lookup(UnrestrictedAgentAccess()) + + assert ( + await AgentRequestHandler.resolve_agent_access( + auth, + default_deny=lambda: False, + lookup_key_access=key_lookup, + lookup_team_access=team_lookup, + ) + == UnrestrictedAgentAccess() + ) + assert await AgentRequestHandler.resolve_agent_access( + auth, + default_deny=lambda: True, + lookup_key_access=key_lookup, + lookup_team_access=team_lookup, + ) == RestrictedAgentAccess(frozenset()) + assert await AgentRequestHandler.resolve_agent_access( + None, + default_deny=lambda: True, + lookup_key_access=key_lookup, + lookup_team_access=team_lookup, + ) == RestrictedAgentAccess(frozenset()) + + async def test_explicit_key_and_team_grants_work_with_key_agent_access_required(self): + auth: Final = UserAPIKeyAuth(api_key="test-key", user_id="test-user", team_id="test-team") + unrestricted_lookup: Final = _lookup(UnrestrictedAgentAccess()) + + assert await AgentRequestHandler.resolve_agent_access( + auth, + default_deny=lambda: True, + lookup_key_access=unrestricted_lookup, + lookup_team_access=_lookup(RestrictedAgentAccess(frozenset({"team-agent"}))), + ) == RestrictedAgentAccess(frozenset({"team-agent"})) + + assert await AgentRequestHandler.resolve_agent_access( + auth, + default_deny=lambda: True, + lookup_key_access=_lookup(RestrictedAgentAccess(frozenset({"key-agent"}))), + lookup_team_access=unrestricted_lookup, + ) == RestrictedAgentAccess(frozenset({"key-agent"})) + + async def test_proxy_admin_is_exempt_from_key_agent_access_requirement(self): + admin: Final = UserAPIKeyAuth( + api_key="admin-key", + user_id="admin", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + string_admin: Final = admin.model_copy(update={"user_role": LitellmUserRoles.PROXY_ADMIN.value}) + key_lookup: Final = _lookup(UnrestrictedAgentAccess()) + team_lookup: Final = _lookup(UnrestrictedAgentAccess()) + + assert ( + await AgentRequestHandler.resolve_agent_access( + admin, + default_deny=lambda: True, + lookup_key_access=key_lookup, + lookup_team_access=team_lookup, + ) + == UnrestrictedAgentAccess() + ) + assert ( + await AgentRequestHandler.resolve_agent_access( + string_admin, + default_deny=lambda: True, + lookup_key_access=key_lookup, + lookup_team_access=team_lookup, + ) + == UnrestrictedAgentAccess() + ) + + async def test_key_agent_access_requirement_precedes_agent_access_group_ceiling(self): + agent_key: Final = UserAPIKeyAuth( + api_key="test-key", + user_id="test-user", + agent_id="caller-agent", + ) + resolve, _ = self._ceiling_resolver(frozenset({"agent-beta"})) + unrestricted_lookup: Final = _lookup(UnrestrictedAgentAccess()) + + assert await AgentRequestHandler.resolve_agent_access( + agent_key, + resolve, + default_deny=lambda: True, + lookup_key_access=unrestricted_lookup, + lookup_team_access=unrestricted_lookup, + ) == RestrictedAgentAccess(frozenset()) + + async def test_key_agent_access_requirement_fails_closed_on_lookup_errors(self): + auth: Final = UserAPIKeyAuth(api_key="test-key", user_id="test-user", team_id="test-team") + unrestricted_lookup: Final = _lookup(UnrestrictedAgentAccess()) + key_error_lookup: Final = _lookup(Exception("DB Error")) + + assert await AgentRequestHandler.resolve_agent_access( + auth, + default_deny=lambda: True, + lookup_key_access=key_error_lookup, + lookup_team_access=unrestricted_lookup, + ) == RestrictedAgentAccess(frozenset()) + assert ( + await AgentRequestHandler.resolve_agent_access( + auth, + default_deny=lambda: False, + lookup_key_access=key_error_lookup, + lookup_team_access=unrestricted_lookup, + ) + == UnrestrictedAgentAccess() + ) + + team_error_lookup: Final = _lookup(Exception("DB Error")) + key_grant_lookup: Final = _lookup(RestrictedAgentAccess(frozenset({"key-agent"}))) + assert await AgentRequestHandler.resolve_agent_access( + auth, + default_deny=lambda: True, + lookup_key_access=key_grant_lookup, + lookup_team_access=team_error_lookup, + ) == RestrictedAgentAccess(frozenset()) + assert ( + await AgentRequestHandler.resolve_agent_access( + auth, + default_deny=lambda: False, + lookup_key_access=key_grant_lookup, + lookup_team_access=team_error_lookup, + ) + == UnrestrictedAgentAccess() + ) + + async def test_agent_access_default_deny_reads_general_settings(self, monkeypatch: pytest.MonkeyPatch): + from litellm.proxy import proxy_server + + auth: Final = UserAPIKeyAuth(api_key="test-key", user_id="test-user") + monkeypatch.setattr(proxy_server, "prisma_client", None) + + with patch.object(proxy_server, "general_settings", {"agent_access_default_deny": True}): + assert await AgentRequestHandler.is_agent_allowed("agent-alpha", auth) is False + + with patch.object(proxy_server, "general_settings", {}): + assert await AgentRequestHandler.is_agent_allowed("agent-alpha", auth) is True + + @pytest.mark.parametrize( + ("settings", "expected_allowed"), + [ + pytest.param({"agent_access_default_deny": True}, False, id="true-bool"), + pytest.param({"agent_access_default_deny": "true"}, False, id="true-lower-string"), + pytest.param({"agent_access_default_deny": "True"}, False, id="true-capitalized-string"), + pytest.param({"agent_access_default_deny": 1}, False, id="one"), + pytest.param({"agent_access_default_deny": False}, True, id="false-bool"), + pytest.param({"agent_access_default_deny": "false"}, True, id="false-string"), + pytest.param({"agent_access_default_deny": 0}, True, id="zero"), + pytest.param({}, True, id="missing"), + pytest.param({"agent_access_default_deny": "not-a-bool"}, False, id="invalid-string"), + ], + ) + async def test_agent_access_default_deny_coerces_general_settings( + self, + monkeypatch: pytest.MonkeyPatch, + settings: dict[str, object], + expected_allowed: bool, + ): + from litellm.proxy import proxy_server + + auth: Final = UserAPIKeyAuth(api_key="test-key", user_id="test-user") + monkeypatch.setattr(proxy_server, "prisma_client", None) + + with patch.object(proxy_server, "general_settings", settings): + assert await AgentRequestHandler.is_agent_allowed("agent-alpha", auth) is expected_allowed + async def test_disjoint_key_and_team_grants_deny_every_agent(self): """LIT-5143: a key restricted to one agent inside a team restricted to another must reach nothing. The empty intersection used to read as "no restrictions", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7abd84c4ad7..6f6eae1f98e 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -29649,6 +29649,12 @@ export interface components { * @default 1 */ admission_queue_timeout_seconds: number; + /** + * Agent Access Default Deny + * @description When true, a key with no agent grants on itself or its team can reach no agents instead of all agents. Proxy admins are exempt. + * @default false + */ + agent_access_default_deny: boolean; /** * Alert To Webhook Url * @description Mapping of alert type to webhook url. e.g. `alert_to_webhook_url: {'budget_alerts': 'https://nothooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'}`