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:
ryan-crabbe-berri 2026-06-04 20:39:21 -07:00
parent a1978bc707
commit 98aa7622a6
8 changed files with 117 additions and 29 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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"),

View file

@ -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

View file

@ -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

View file

@ -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