diff --git a/litellm/proxy/auth/v2/enforcer.py b/litellm/proxy/auth/v2/enforcer.py index 84a6cf2833c..b4667fb7fda 100644 --- a/litellm/proxy/auth/v2/enforcer.py +++ b/litellm/proxy/auth/v2/enforcer.py @@ -1,5 +1,5 @@ import os -from typing import List, Sequence +from typing import List, Optional, Sequence import casbin @@ -17,13 +17,20 @@ class CasbinEnforcer: litellm imports so the decision logic is testable in isolation. """ - def __init__(self, policies: List[Rule], groupings: List[Rule]): + def __init__( + self, + policies: List[Rule], + groupings: List[Rule], + resource_groupings: Optional[List[Rule]] = None, + ): self._enforcer = casbin.Enforcer(_MODEL_PATH) self._enforcer.enable_auto_save(False) for rule in policies: self._enforcer.add_policy(*rule) for rule in groupings: self._enforcer.add_named_grouping_policy("g", *rule) + for rule in resource_groupings or []: + self._enforcer.add_named_grouping_policy("g2", *rule) def enforce(self, subject: str, domain: str, obj: str, action: str) -> bool: return self._enforcer.enforce(subject, domain, obj, action) diff --git a/litellm/proxy/auth/v2/entry.py b/litellm/proxy/auth/v2/entry.py index 71febe46e51..fece757eca3 100644 --- a/litellm/proxy/auth/v2/entry.py +++ b/litellm/proxy/auth/v2/entry.py @@ -58,8 +58,12 @@ async def user_api_key_auth_v2( request_data = await _read_request_body(request=request) principal = build_principal(identity) - policies, groupings = await load_policy_snapshot(prisma_client) - enforcer = CasbinEnforcer(policies, groupings + principal.groupings) + policies, groupings, resource_groupings = await load_policy_snapshot( + prisma_client + ) + enforcer = CasbinEnforcer( + policies, groupings + principal.groupings, resource_groupings + ) try: authorize(principal, route, request_data, enforcer) diff --git a/litellm/proxy/auth/v2/model.conf b/litellm/proxy/auth/v2/model.conf index 7271443642d..b12fdc68a85 100644 --- a/litellm/proxy/auth/v2/model.conf +++ b/litellm/proxy/auth/v2/model.conf @@ -6,9 +6,10 @@ p = sub, dom, obj, act, eft [role_definition] g = _, _ +g2 = _, _ [policy_effect] e = some(where (p.eft == allow)) && !some(where (p.eft == deny)) [matchers] -m = g(r.sub, p.sub) && (p.dom == "*" || r.dom == p.dom) && keyMatch(r.obj, p.obj) && (p.act == "*" || r.act == p.act) +m = g(r.sub, p.sub) && (p.dom == "*" || r.dom == p.dom) && (g2(r.obj, p.obj) || keyMatch(r.obj, p.obj)) && (p.act == "*" || r.act == p.act) diff --git a/litellm/proxy/auth/v2/policy_store.py b/litellm/proxy/auth/v2/policy_store.py index b63a768cbea..f7d15ad2b6e 100644 --- a/litellm/proxy/auth/v2/policy_store.py +++ b/litellm/proxy/auth/v2/policy_store.py @@ -12,7 +12,9 @@ DEFAULT_POLICIES: List[List[str]] = [ # to absorb bursts while keeping policy edits visible within seconds across pods. _CACHE_TTL_SECONDS = 5.0 -_cache: Optional[Tuple[float, List[List[str]], List[List[str]]]] = None +_cache: Optional[ + Tuple[float, List[List[str]], List[List[str]], List[List[str]]] +] = None def _row_values(row: Any) -> List[str]: @@ -27,17 +29,22 @@ def _row_values(row: Any) -> List[str]: return [v for v in values if v is not None and v != ""] -def _split_rules(rows: List[Any]) -> Tuple[List[List[str]], List[List[str]]]: +def _split_rules( + rows: List[Any], +) -> Tuple[List[List[str]], List[List[str]], List[List[str]]]: policies: List[List[str]] = [list(p) for p in DEFAULT_POLICIES] groupings: List[List[str]] = [] + resource_groupings: List[List[str]] = [] for row in rows: ptype = getattr(row, "ptype", None) values = _row_values(row) if ptype == "p": policies.append(values) + elif ptype == "g2": + resource_groupings.append(values) elif ptype is not None and ptype.startswith("g"): groupings.append(values) - return policies, groupings + return policies, groupings, resource_groupings def reset_cache() -> None: @@ -47,8 +54,9 @@ def reset_cache() -> None: async def load_policy_snapshot( prisma_client: Any, -) -> Tuple[List[List[str]], List[List[str]]]: - """Load (policies, groupings) from LiteLLM_CasbinRule, with a short TTL cache. +) -> Tuple[List[List[str]], List[List[str]], List[List[str]]]: + """Load (policies, groupings, resource_groupings) from LiteLLM_CasbinRule, + with a short TTL cache. Returns only the bootstrap defaults when no DB is connected, so the engine is always constructible. @@ -56,12 +64,12 @@ async def load_policy_snapshot( global _cache now = time.monotonic() if _cache is not None and (now - _cache[0]) < _CACHE_TTL_SECONDS: - return _cache[1], _cache[2] + return _cache[1], _cache[2], _cache[3] rows: List[Any] = [] if prisma_client is not None: rows = await prisma_client.db.litellm_casbinrule.find_many() - policies, groupings = _split_rules(rows) - _cache = (now, policies, groupings) - return policies, groupings + policies, groupings, resource_groupings = _split_rules(rows) + _cache = (now, policies, groupings, resource_groupings) + return policies, groupings, resource_groupings diff --git a/litellm/proxy/auth/v2/route_map.py b/litellm/proxy/auth/v2/route_map.py index cb81237d2f5..9b0e893097c 100644 --- a/litellm/proxy/auth/v2/route_map.py +++ b/litellm/proxy/auth/v2/route_map.py @@ -18,6 +18,9 @@ class GovernedRoute: # `manage` action. _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"] _GOVERNED: Dict[str, GovernedRoute] = { "/model/new": GovernedRoute("model", "write"), @@ -28,6 +31,28 @@ _GOVERNED: Dict[str, GovernedRoute] = { "/team/update": GovernedRoute("team", "write", _TEAM_ID_FIELDS), "/team/delete": GovernedRoute("team", "delete", _TEAM_ID_FIELDS), "/team/info": GovernedRoute("team", "read", _TEAM_ID_FIELDS), + # Membership changes are the `manage` action on the team resource. + "/team/member_add": GovernedRoute("team", "manage", _TEAM_ID_FIELDS), + "/team/member_update": GovernedRoute("team", "manage", _TEAM_ID_FIELDS), + "/team/member_delete": GovernedRoute("team", "manage", _TEAM_ID_FIELDS), + "/key/generate": GovernedRoute("key", "write"), + "/key/update": GovernedRoute("key", "write", _KEY_ID_FIELDS), + "/key/delete": GovernedRoute("key", "delete", _KEY_ID_FIELDS), + "/key/info": GovernedRoute("key", "read", _KEY_ID_FIELDS), + "/key/list": GovernedRoute("key", "read"), + "/key/block": GovernedRoute("key", "write", _KEY_ID_FIELDS), + "/key/unblock": GovernedRoute("key", "write", _KEY_ID_FIELDS), + "/user/new": GovernedRoute("user", "write"), + "/user/update": GovernedRoute("user", "write", _USER_ID_FIELDS), + "/user/delete": GovernedRoute("user", "delete", _USER_ID_FIELDS), + "/user/info": GovernedRoute("user", "read", _USER_ID_FIELDS), + "/user/list": GovernedRoute("user", "read"), + "/organization/new": GovernedRoute("organization", "write"), + "/organization/update": GovernedRoute("organization", "write", _ORG_ID_FIELDS), + "/organization/delete": GovernedRoute("organization", "delete", _ORG_ID_FIELDS), + "/organization/info": GovernedRoute("organization", "read", _ORG_ID_FIELDS), + "/organization/list": GovernedRoute("organization", "read"), + "/organization/member_add": GovernedRoute("organization", "manage", _ORG_ID_FIELDS), # The policy-admin surface governs itself: only a role permitted to manage the # "policy" resource (the bootstrap proxy_admin role does) may edit policies. "/auth/v2/policy/permission/add": GovernedRoute("policy", "write"), diff --git a/tests/test_litellm/proxy/auth/v2/test_enforcer.py b/tests/test_litellm/proxy/auth/v2/test_enforcer.py index ae3244862e0..85b4923ed87 100644 --- a/tests/test_litellm/proxy/auth/v2/test_enforcer.py +++ b/tests/test_litellm/proxy/auth/v2/test_enforcer.py @@ -59,3 +59,25 @@ def test_explicit_deny_overrides_allow(): def test_empty_policy_denies_everything(): e = _enforcer([], []) assert e.enforce("user:anyone", "*", "model:gpt4", "read") is False + + +def test_resource_grouping_grants_access_to_a_named_group(): + e = CasbinEnforcer( + policies=[["role:grp_mgr", "*", "group:prod", "write", "allow"]], + groupings=[["user:al", "role:grp_mgr"]], + resource_groupings=[ + ["model:gpt-4o", "group:prod"], + ["model:claude", "group:prod"], + ], + ) + assert e.enforce("user:al", "*", "model:gpt-4o", "write") is True + assert e.enforce("user:al", "*", "model:claude", "write") is True + # A model not in the group is denied even with the same action. + assert e.enforce("user:al", "*", "model:o1", "write") is False + + +def test_direct_object_matching_still_works_with_grouping_enabled(): + # No g2 rules: keyMatch / exact behavior must be unchanged. + e = _enforcer([READER_POLICY], [["user:alice", "role:model_reader"]]) + assert e.enforce("user:alice", "*", "model:gpt4", "read") is True + assert e.enforce("user:alice", "*", "model:gpt4", "write") is False diff --git a/tests/test_litellm/proxy/auth/v2/test_policy_store.py b/tests/test_litellm/proxy/auth/v2/test_policy_store.py index ebb934f2fb3..b7e45cf6def 100644 --- a/tests/test_litellm/proxy/auth/v2/test_policy_store.py +++ b/tests/test_litellm/proxy/auth/v2/test_policy_store.py @@ -37,23 +37,27 @@ def _clear_cache(): def test_bootstrap_admin_policy_always_present(): - policies, _ = policy_store._split_rules([]) + policies, _, _ = policy_store._split_rules([]) assert ["role:proxy_admin", "*", "*", "*", "allow"] in policies assert DEFAULT_POLICIES[0] == ["role:proxy_admin", "*", "*", "*", "allow"] -def test_p_rows_become_policies_and_g_rows_become_groupings(): +def test_rows_split_by_ptype(): rows = [ _Row("p", "role:model_reader", "*", "model:*", "read", "allow"), _Row("g", "user:alice", "role:model_reader"), + _Row("g2", "model:gpt-4o", "group:prod"), ] - policies, groupings = policy_store._split_rules(rows) + policies, groupings, resource_groupings = policy_store._split_rules(rows) assert ["role:model_reader", "*", "model:*", "read", "allow"] in policies assert ["user:alice", "role:model_reader"] in groupings + # g2 must NOT leak into the g groupings. + assert ["model:gpt-4o", "group:prod"] in resource_groupings + assert ["model:gpt-4o", "group:prod"] not in groupings def test_empty_trailing_columns_are_trimmed(): - policies, _ = policy_store._split_rules( + policies, _, _ = policy_store._split_rules( [_Row("p", "role:x", "*", "model:*", "read", "allow")] ) assert policies[-1] == ["role:x", "*", "model:*", "read", "allow"] @@ -61,9 +65,12 @@ def test_empty_trailing_columns_are_trimmed(): @pytest.mark.asyncio async def test_load_snapshot_without_db_returns_only_bootstrap(): - policies, groupings = await load_policy_snapshot(prisma_client=None) + policies, groupings, resource_groupings = await load_policy_snapshot( + prisma_client=None + ) assert policies == [list(p) for p in DEFAULT_POLICIES] assert groupings == [] + assert resource_groupings == [] @pytest.mark.asyncio @@ -72,8 +79,12 @@ async def test_load_snapshot_reads_db_rows(): [ _Row("p", "role:model_reader", "*", "model:*", "read", "allow"), _Row("g", "user:alice", "role:model_reader"), + _Row("g2", "model:gpt-4o", "group:prod"), ] ) - policies, groupings = await load_policy_snapshot(prisma_client=prisma) + policies, groupings, resource_groupings = await load_policy_snapshot( + prisma_client=prisma + ) assert ["role:model_reader", "*", "model:*", "read", "allow"] in policies assert ["user:alice", "role:model_reader"] in groupings + assert ["model:gpt-4o", "group:prod"] in resource_groupings 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 99539f4185d..e2618b0a3cf 100644 --- a/tests/test_litellm/proxy/auth/v2/test_route_map.py +++ b/tests/test_litellm/proxy/auth/v2/test_route_map.py @@ -53,14 +53,24 @@ def test_inference_routes_are_not_control_plane_governed(): assert match_route("/chat/completions") is None +def test_key_user_org_resources_are_governed(): + assert match_route("/key/generate") == match_route("/key/generate") + assert match_route("/key/generate").resource == "key" + assert match_route("/key/delete").action == "delete" + assert match_route("/key/info").action == "read" + assert match_route("/user/new").resource == "user" + assert match_route("/user/delete").action == "delete" + assert match_route("/organization/update").resource == "organization" + + +def test_membership_changes_are_the_manage_action(): + assert match_route("/team/member_add").resource == "team" + assert match_route("/team/member_add").action == "manage" + assert match_route("/team/member_delete").action == "manage" + assert match_route("/organization/member_add").action == "manage" + + def test_ungoverned_routes_return_none(): - # These are loud-open in this slice and must not be governed yet. - # /team/member_add is deferred with the recursive `manage` action. - for route in ( - "/chat/completions", - "/key/generate", - "/team/member_add", - "/v1/models", - "/", - ): + # Genuinely not yet owned by v2: loud-open. + for route in ("/v1/models", "/health", "/"): assert match_route(route) is None