mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
feat(proxy): auth_v2 slice 6 - keys/users/orgs resources, manage action, resource grouping
Expands control-plane governance to keys, users, and organizations (read/write/ delete), mirroring models and teams. Membership changes (team/member_add, organization/member_add, etc.) become the `manage` action on their resource. Adds casbin resource grouping (g2) so a policy can grant on a named group of resource ids rather than one row per id; this is where litellm access groups map in. The matcher prefers a group match and falls back to keyMatch, so direct/wildcard object matching is unchanged when no g2 rules exist. The policy store now splits g2 rows out from g groupings and the enforcer loads them under the g2 role manager. Tests cover grouped vs ungrouped access, preserved direct matching, the g2/g split, and governance of the new resources and the manage action.
This commit is contained in:
parent
a1978bc707
commit
98aa7622a6
8 changed files with 117 additions and 29 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue