diff --git a/litellm/proxy/auth/v2/route_map.py b/litellm/proxy/auth/v2/route_map.py index 9b0e893097c..145ad366e13 100644 --- a/litellm/proxy/auth/v2/route_map.py +++ b/litellm/proxy/auth/v2/route_map.py @@ -12,15 +12,18 @@ class GovernedRoute: id_fields: List[str] = field(default_factory=list) -# Governs the model and team management planes. Every other route is -# intentionally left ungoverned (loud-open) until later slices wire it in. Team -# membership/permission routes (member_add, etc.) are deferred with the recursive -# `manage` action. +# Control-plane management resources governed by RBAC. Every route not listed +# here (and not an inference route) is loud-open until a later slice wires it in; +# non-uniform surfaces (guardrails, mcp servers, credentials) are deferred +# because their verbs don't map cleanly onto read/write/delete/manage. _MODEL_ID_FIELDS = ["model_id", "id"] _TEAM_ID_FIELDS = ["team_id", "id"] _KEY_ID_FIELDS = ["key", "token", "key_name"] _USER_ID_FIELDS = ["user_id"] _ORG_ID_FIELDS = ["organization_id"] +_VECTOR_STORE_ID_FIELDS = ["vector_store_id", "id"] +_BUDGET_ID_FIELDS = ["budget_id", "id"] +_CUSTOMER_ID_FIELDS = ["user_id"] _GOVERNED: Dict[str, GovernedRoute] = { "/model/new": GovernedRoute("model", "write"), @@ -60,11 +63,35 @@ _GOVERNED: Dict[str, GovernedRoute] = { "/auth/v2/policy/assignment/add": GovernedRoute("policy", "write"), "/auth/v2/policy/assignment/remove": GovernedRoute("policy", "delete"), "/auth/v2/policy/list": GovernedRoute("policy", "read"), + "/vector_store/new": GovernedRoute("vector_store", "write"), + "/vector_store/update": GovernedRoute( + "vector_store", "write", _VECTOR_STORE_ID_FIELDS + ), + "/vector_store/delete": GovernedRoute( + "vector_store", "delete", _VECTOR_STORE_ID_FIELDS + ), + "/vector_store/info": GovernedRoute( + "vector_store", "read", _VECTOR_STORE_ID_FIELDS + ), + "/vector_store/list": GovernedRoute("vector_store", "read"), + "/budget/new": GovernedRoute("budget", "write"), + "/budget/update": GovernedRoute("budget", "write", _BUDGET_ID_FIELDS), + "/budget/delete": GovernedRoute("budget", "delete", _BUDGET_ID_FIELDS), + "/budget/info": GovernedRoute("budget", "read", _BUDGET_ID_FIELDS), + "/budget/list": GovernedRoute("budget", "read"), + "/budget/settings": GovernedRoute("budget", "read"), + "/customer/new": GovernedRoute("customer", "write"), + "/customer/update": GovernedRoute("customer", "write", _CUSTOMER_ID_FIELDS), + "/customer/delete": GovernedRoute("customer", "delete", _CUSTOMER_ID_FIELDS), + "/customer/info": GovernedRoute("customer", "read", _CUSTOMER_ID_FIELDS), + "/customer/list": GovernedRoute("customer", "read"), + "/customer/block": GovernedRoute("customer", "write", _CUSTOMER_ID_FIELDS), + "/customer/unblock": GovernedRoute("customer", "write", _CUSTOMER_ID_FIELDS), } # Inference routes carry a `model` in the body and are authorized on the data -# plane (casbin ABAC over the principal's allowed-model attribute), not the +# plane (a plain allowed-model predicate over the principal's key), not the # control-plane RBAC map. _INFERENCE_ROUTES = { "/chat/completions", 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 e2618b0a3cf..05077a67aa8 100644 --- a/tests/test_litellm/proxy/auth/v2/test_route_map.py +++ b/tests/test_litellm/proxy/auth/v2/test_route_map.py @@ -70,6 +70,44 @@ def test_membership_changes_are_the_manage_action(): assert match_route("/organization/member_add").action == "manage" +def test_vector_store_resource_is_governed(): + assert match_route("/vector_store/new").resource == "vector_store" + assert match_route("/vector_store/new").action == "write" + assert match_route("/vector_store/delete").action == "delete" + assert match_route("/vector_store/info").action == "read" + assert match_route("/vector_store/list").action == "read" + assert match_route("/vector_store/delete").id_fields == ["vector_store_id", "id"] + + +def test_budget_resource_is_governed(): + assert match_route("/budget/new").resource == "budget" + assert match_route("/budget/update").action == "write" + assert match_route("/budget/delete").action == "delete" + assert match_route("/budget/info").action == "read" + assert match_route("/budget/settings").action == "read" + assert match_route("/budget/delete").id_fields == ["budget_id", "id"] + + +def test_customer_resource_is_governed(): + assert match_route("/customer/new").resource == "customer" + assert match_route("/customer/delete").action == "delete" + assert match_route("/customer/info").action == "read" + # block/unblock are state writes, not their own action. + assert match_route("/customer/block").action == "write" + assert match_route("/customer/unblock").action == "write" + 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. + for route in ( + "/guardrails/apply_guardrail", + "/guardrails/register", + "/v1/mcp/server/register", + ): + assert match_route(route) is None + + def test_ungoverned_routes_return_none(): # Genuinely not yet owned by v2: loud-open. for route in ("/v1/models", "/health", "/"):