test(auth_v2): cover Casbin-backed RBAC hierarchy and permissions

RBAC moved to an embedded Casbin enforcer: require_roles now honors the role
hierarchy and require_permission gates object/action against the policy.

- rbac: RbacEngine.has_role inherits down the g-rules (platform_admin satisfies
  an org_admin/team_member gate, org_admin satisfies org_viewer, team_admin
  satisfies team_member) and never climbs (team_member fails an org_admin gate);
  enforce honors the default policy (platform_admin any obj/act incl keyMatch2
  on /scim/v2/*, platform_viewer read-only, org_viewer no write) and an operator
  CSV fully replaces the in-code defaults
- security: require_roles passes a higher role through a lower-role gate via the
  hierarchy; require_permission allows platform_admin, denies a viewer on write
  with detail "Forbidden", and 401s when unauthenticated; an RbacEngine injected
  onto the AuthContext overrides the default policy (operator CSV path)

Replaces the removed has_any_role coverage. Mutation-checked: dropping the
hierarchy lookup or short-circuiting enforce fails these.
This commit is contained in:
Yassin Kortam 2026-06-10 18:26:19 -07:00
parent 3dc660a135
commit 55a332bb91
2 changed files with 171 additions and 10 deletions

View file

@ -1,9 +1,10 @@
from __future__ import annotations
import pytest
from fastapi.security import SecurityScopes
from litellm.auth_v2.models import AuthMethod, Principal, PrincipalType
from litellm.auth_v2.rbac import Role, has_any_role, has_required_scopes
from litellm.auth_v2.rbac import RbacEngine, Role, has_required_scopes
def _principal(*, scopes=None, roles=None) -> Principal:
@ -16,6 +17,11 @@ def _principal(*, scopes=None, roles=None) -> Principal:
)
# --------------------------------------------------------------------------- #
# Scopes stay a plain SecurityScopes subset check (not Casbin)
# --------------------------------------------------------------------------- #
def test_required_scopes_is_subset_check():
principal = _principal(scopes=["models:read", "chat:write", "scim:write"])
assert has_required_scopes(SecurityScopes(["models:read"]), principal)
@ -31,15 +37,92 @@ def test_empty_required_scopes_always_passes():
assert has_required_scopes(SecurityScopes([]), _principal())
def test_has_any_role_matches_one_of_allowed():
principal = _principal(roles=[Role.TEAM_MEMBER, Role.ORG_VIEWER])
assert has_any_role(principal, (Role.ORG_VIEWER, Role.PLATFORM_ADMIN))
# --------------------------------------------------------------------------- #
# RbacEngine.has_role honors the role hierarchy (Casbin g-rules)
# --------------------------------------------------------------------------- #
def test_has_any_role_rejects_when_no_overlap():
principal = _principal(roles=[Role.TEAM_MEMBER])
assert not has_any_role(principal, (Role.PLATFORM_ADMIN, Role.ORG_ADMIN))
@pytest.fixture
def engine() -> RbacEngine:
return RbacEngine()
def test_has_any_role_false_when_principal_has_no_roles():
assert not has_any_role(_principal(), (Role.PLATFORM_ADMIN,))
@pytest.mark.parametrize(
"held,gate",
[
(Role.PLATFORM_ADMIN, Role.ORG_ADMIN),
(Role.PLATFORM_ADMIN, Role.ORG_VIEWER),
(Role.PLATFORM_ADMIN, Role.TEAM_ADMIN),
(Role.PLATFORM_ADMIN, Role.TEAM_MEMBER),
(Role.PLATFORM_ADMIN, Role.PLATFORM_VIEWER),
(Role.ORG_ADMIN, Role.ORG_VIEWER),
(Role.ORG_ADMIN, Role.ORG_ADMIN), # exact match
(Role.TEAM_ADMIN, Role.TEAM_MEMBER),
],
)
def test_has_role_inherits_down_the_hierarchy(engine, held, gate):
assert engine.has_role(_principal(roles=[held]), (gate,))
@pytest.mark.parametrize(
"held,gate",
[
(Role.ORG_ADMIN, Role.TEAM_MEMBER), # sideways, no inheritance edge
(Role.TEAM_MEMBER, Role.ORG_ADMIN), # lower cannot reach higher
(Role.ORG_VIEWER, Role.ORG_ADMIN),
],
)
def test_has_role_does_not_climb_the_hierarchy(engine, held, gate):
assert not engine.has_role(_principal(roles=[held]), (gate,))
def test_has_role_false_without_roles(engine):
assert not engine.has_role(_principal(), (Role.TEAM_MEMBER,))
# --------------------------------------------------------------------------- #
# RbacEngine.enforce against the default policy
# --------------------------------------------------------------------------- #
def test_platform_admin_enforces_any_object_and_action(engine):
assert engine.enforce(_principal(roles=[Role.PLATFORM_ADMIN]), "/anything", "POST")
# keyMatch2: /scim/v2/* covers /scim/v2/Users
assert engine.enforce(
_principal(roles=[Role.PLATFORM_ADMIN]), "/scim/v2/Users", "DELETE"
)
def test_platform_viewer_is_read_only(engine):
viewer = _principal(roles=[Role.PLATFORM_VIEWER])
assert engine.enforce(viewer, "/anything", "GET")
assert not engine.enforce(viewer, "/anything", "POST")
def test_org_viewer_has_no_write_grant(engine):
assert not engine.enforce(_principal(roles=[Role.ORG_VIEWER]), "/widgets", "POST")
def test_enforce_false_without_roles(engine):
assert not engine.enforce(_principal(), "/anything", "GET")
# --------------------------------------------------------------------------- #
# Operator CSV policy fully replaces the in-code defaults
# --------------------------------------------------------------------------- #
def test_csv_policy_overrides_defaults(tmp_path):
policy = tmp_path / "policy.csv"
policy.write_text("p, platform_viewer, /reports, POST\n")
engine = RbacEngine(policy_path=str(policy))
# the operator rule is honored
assert engine.enforce(_principal(roles=[Role.PLATFORM_VIEWER]), "/reports", "POST")
# the built-in platform_admin "/*" grant is gone, not merged
assert not engine.enforce(
_principal(roles=[Role.PLATFORM_ADMIN]), "/reports", "POST"
)
assert not engine.enforce(
_principal(roles=[Role.PLATFORM_ADMIN]), "/anything", "GET"
)

View file

@ -18,11 +18,12 @@ from litellm.auth_v2.config import (
OidcProviderConfig,
)
from litellm.auth_v2.models import AuthMethod, Principal, PrincipalType
from litellm.auth_v2.rbac import Role
from litellm.auth_v2.rbac import RbacEngine, Role
from litellm.auth_v2.resolver import InMemoryIdentityStore, _hash_api_key
from litellm.auth_v2.security import (
AuthContext,
get_current_principal,
require_permission,
require_roles,
)
@ -31,6 +32,8 @@ from auth_v2_helpers import TEST_AUDIENCE, TEST_ISSUER, FakeJwksClient
ADMIN_KEY = "sk-admin-key"
READER_KEY = "sk-reader-key"
NOSCOPE_KEY = "sk-noscope-key"
PLATFORM_ADMIN_KEY = "sk-platform-admin-key"
PLATFORM_VIEWER_KEY = "sk-platform-viewer-key"
def _principal(subject: str, *, scopes=None, roles=None) -> Principal:
@ -61,6 +64,12 @@ def _build_app(public_key: Any) -> Tuple[FastAPI, InMemoryIdentityStore]:
"reader-principal", scopes=["models:read"]
),
_hash_api_key(NOSCOPE_KEY): _principal("noscope-principal"),
_hash_api_key(PLATFORM_ADMIN_KEY): _principal(
"platform-admin-principal", roles=[Role.PLATFORM_ADMIN]
),
_hash_api_key(PLATFORM_VIEWER_KEY): _principal(
"platform-viewer-principal", roles=[Role.PLATFORM_VIEWER]
),
}
)
ctx = AuthContext(AuthConfig(), authenticators, resolver)
@ -92,6 +101,14 @@ def _build_app(public_key: Any) -> Tuple[FastAPI, InMemoryIdentityStore]:
):
return {"subject": principal.subject}
@app.post("/perm-widgets")
async def widgets_route(
principal: Annotated[
Principal, Security(require_permission("/widgets", "POST"))
],
):
return {"subject": principal.subject}
return app, resolver
@ -201,3 +218,64 @@ def test_required_role_present_returns_200(client):
def test_required_role_missing_returns_403(client):
response = client.get("/admin", headers={"x-litellm-api-key": READER_KEY})
assert response.status_code == 403
def test_required_role_honors_hierarchy(client):
# platform_admin inherits org_admin via the Casbin g-rules, so it passes a
# require_roles(ORG_ADMIN) gate without holding org_admin explicitly
response = client.get("/admin", headers={"x-litellm-api-key": PLATFORM_ADMIN_KEY})
assert response.status_code == 200
assert response.json()["subject"] == "platform-admin-principal"
# --------------------------------------------------------------------------- #
# Permission enforcement (require_permission -> RbacEngine.enforce)
# --------------------------------------------------------------------------- #
def test_require_permission_allows_platform_admin(client):
response = client.post(
"/perm-widgets", headers={"x-litellm-api-key": PLATFORM_ADMIN_KEY}
)
assert response.status_code == 200
def test_require_permission_denies_viewer_on_write(client):
# platform_viewer is GET-only in the default policy -> POST /widgets is denied
response = client.post(
"/perm-widgets", headers={"x-litellm-api-key": PLATFORM_VIEWER_KEY}
)
assert response.status_code == 403
assert response.json()["detail"] == "Forbidden"
def test_require_permission_unauthenticated_returns_401(client):
response = client.post("/perm-widgets")
assert response.status_code == 401
assert "WWW-Authenticate" in response.headers
def test_injected_rbac_engine_overrides_default_policy(rsa_keypair, tmp_path):
# operator CSV grants only platform_viewer POST /widgets and drops the
# built-in platform_admin "/*" grant; the injected engine governs enforce
policy = tmp_path / "policy.csv"
policy.write_text("p, platform_viewer, /widgets, POST\n")
_, public_key = rsa_keypair
app, _ = _build_app(public_key)
app.state.auth_v2.rbac = RbacEngine(policy_path=str(policy))
client = TestClient(app)
# viewer now passes, platform_admin (default grant removed) now fails
assert (
client.post(
"/perm-widgets", headers={"x-litellm-api-key": PLATFORM_VIEWER_KEY}
).status_code
== 200
)
assert (
client.post(
"/perm-widgets", headers={"x-litellm-api-key": PLATFORM_ADMIN_KEY}
).status_code
== 403
)