litellm/tests/integration/authorization/test_agent_least_privilege.py
devin-ai-integration[bot] 77c22a4c01
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 <ryan@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-15 16:49:26 -07:00

338 lines
13 KiB
Python

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,
)