diff --git a/litellm/proxy/auth/v2/route_map.py b/litellm/proxy/auth/v2/route_map.py index 145ad366e13..b744fde50c3 100644 --- a/litellm/proxy/auth/v2/route_map.py +++ b/litellm/proxy/auth/v2/route_map.py @@ -87,6 +87,16 @@ _GOVERNED: Dict[str, GovernedRoute] = { "/customer/list": GovernedRoute("customer", "read"), "/customer/block": GovernedRoute("customer", "write", _CUSTOMER_ID_FIELDS), "/customer/unblock": GovernedRoute("customer", "write", _CUSTOMER_ID_FIELDS), + # MCP server and guardrail admin surfaces expose collection-level operations + # (register/list/health/submissions), so objects stay at ":*"; the + # runtime verbs (apply_guardrail, test_custom_code) are not management and are + # intentionally left to the data/runtime path. + "/v1/mcp/server/register": GovernedRoute("mcp_server", "write"), + "/v1/mcp/server/health": GovernedRoute("mcp_server", "read"), + "/v1/mcp/server/submissions": GovernedRoute("mcp_server", "read"), + "/guardrails/register": GovernedRoute("guardrail", "write"), + "/guardrails/list": GovernedRoute("guardrail", "read"), + "/guardrails/submissions": GovernedRoute("guardrail", "read"), } diff --git a/tests/test_litellm/proxy/auth/v2/test_route_map.py b/tests/test_litellm/proxy/auth/v2/test_route_map.py index 05077a67aa8..aa3727cb76a 100644 --- a/tests/test_litellm/proxy/auth/v2/test_route_map.py +++ b/tests/test_litellm/proxy/auth/v2/test_route_map.py @@ -98,12 +98,23 @@ def test_customer_resource_is_governed(): assert match_route("/customer/info").id_fields == ["user_id"] -def test_deferred_nonuniform_surfaces_are_still_loud_open(): - # Guardrails/MCP/credentials have non-CRUD verbs; deliberately not governed yet. +def test_mcp_server_and_guardrail_admin_surfaces_are_governed(): + assert match_route("/v1/mcp/server/register").resource == "mcp_server" + assert match_route("/v1/mcp/server/register").action == "write" + assert match_route("/v1/mcp/server/health").action == "read" + assert match_route("/guardrails/register").resource == "guardrail" + assert match_route("/guardrails/register").action == "write" + assert match_route("/guardrails/list").action == "read" + # Collection-level operations carry no per-id field. + assert match_route("/v1/mcp/server/register").id_fields == [] + + +def test_runtime_guardrail_verbs_stay_loud_open(): + # Applying/testing a guardrail is runtime, not management; deliberately not + # governed by the control-plane RBAC map. for route in ( "/guardrails/apply_guardrail", - "/guardrails/register", - "/v1/mcp/server/register", + "/guardrails/test_custom_code", ): assert match_route(route) is None