mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-26 06:03:19 +00:00
* 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>
338 lines
13 KiB
Python
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,
|
|
)
|