feat(auth_v2): back RBAC with Casbin

Replace the hand-rolled has_any_role set check with a Casbin-backed RbacEngine
(per design 03 §4). The engine wraps casbin.Enforcer over an embedded RBAC model
(request sub/obj/act, g role hierarchy, keyMatch2 on obj, regexMatch on act) and a
default in-code policy: platform_admin inherits org_admin/team_admin/platform_viewer,
org_admin inherits org_viewer, team_admin inherits team_member; grants platform_admin
/* .*, platform_admin /scim/v2/* .*, platform_viewer /* GET. Operators can replace the
whole policy with a CSV via AuthConfig.casbin_policy_path (FileAdapter); no DB adapter
yet.

require_roles now honors the hierarchy through the enforcer's grouping
(get_implicit_roles_for_user) instead of exact-match membership, so a platform_admin
passes a require_roles(ORG_ADMIN) gate; signature and 403 semantics are unchanged. New
require_permission(obj, act) dependency runs get_current_principal then RbacEngine.enforce
and 403s on deny. The engine is built in install_auth and injectable for tests via a new
rbac kwarg. Scope checks stay plain SecurityScopes (a token property, not policy).

Adds casbin to the proxy extra (pure python, no native deps).
This commit is contained in:
Yassin Kortam 2026-06-10 18:21:14 -07:00
parent 7cf35ccc0a
commit e309003c84
8 changed files with 131 additions and 15 deletions

View file

@ -1,11 +1,17 @@
from .config import AuthConfig
from .models import Principal
from .security import get_current_principal, install_auth, require_roles
from .security import (
get_current_principal,
install_auth,
require_permission,
require_roles,
)
__all__ = [
"Principal",
"AuthConfig",
"get_current_principal",
"require_roles",
"require_permission",
"install_auth",
]

View file

@ -256,12 +256,19 @@ class OAuth2Authenticator:
async def _introspect(self, token: str) -> Credential:
config = self._introspection
assert config is not None
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
str(config.introspection_endpoint),
data={"token": token},
auth=(config.client_id, config.client_secret.get_secret_value()),
)
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.llms.custom_http import httpxSpecialProvider
basic = base64.b64encode(
f"{config.client_id}:{config.client_secret.get_secret_value()}".encode()
).decode()
client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
response = await client.post(
str(config.introspection_endpoint),
data={"token": token},
headers={"Authorization": f"Basic {basic}"},
timeout=10.0,
)
if response.status_code != 200:
raise errors.invalid_token("introspection failed")
body = response.json()

View file

@ -104,3 +104,4 @@ class AuthConfig(BaseModel):
mutual_tls: MutualTlsConfig = Field(default_factory=MutualTlsConfig)
network: TrustedProxyConfig = Field(default_factory=TrustedProxyConfig)
saml: Optional[SamlConfig] = None
casbin_policy_path: Optional[str] = None

View file

@ -44,3 +44,7 @@ def insufficient_scope() -> AuthError:
def forbidden_role() -> AuthError:
return AuthError(403, "Insufficient role")
def forbidden_permission() -> AuthError:
return AuthError(403, "Forbidden")

View file

@ -1,8 +1,9 @@
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING, Tuple
from typing import TYPE_CHECKING, List, Optional, Tuple
import casbin
from fastapi.security import SecurityScopes
if TYPE_CHECKING:
@ -24,5 +25,62 @@ def has_required_scopes(
return set(security_scopes.scopes).issubset(set(principal.scopes))
def has_any_role(principal: "Principal", allowed: Tuple[Role, ...]) -> bool:
return any(role in allowed for role in principal.roles)
_MODEL_TEXT = """
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && regexMatch(r.act, p.act)
"""
_DEFAULT_GROUPING: List[Tuple[str, str]] = [
(Role.PLATFORM_ADMIN.value, Role.PLATFORM_VIEWER.value),
(Role.PLATFORM_ADMIN.value, Role.ORG_ADMIN.value),
(Role.PLATFORM_ADMIN.value, Role.TEAM_ADMIN.value),
(Role.ORG_ADMIN.value, Role.ORG_VIEWER.value),
(Role.TEAM_ADMIN.value, Role.TEAM_MEMBER.value),
]
_DEFAULT_POLICY: List[Tuple[str, str, str]] = [
(Role.PLATFORM_ADMIN.value, "/*", ".*"),
(Role.PLATFORM_ADMIN.value, "/scim/v2/*", ".*"),
(Role.PLATFORM_VIEWER.value, "/*", "GET"),
]
class RbacEngine:
def __init__(self, policy_path: Optional[str] = None) -> None:
model = casbin.Model()
model.load_model_from_text(_MODEL_TEXT)
if policy_path:
self._enforcer = casbin.Enforcer(model, casbin.FileAdapter(policy_path))
return
self._enforcer = casbin.Enforcer(model)
for sub, inherits in _DEFAULT_GROUPING:
self._enforcer.add_grouping_policy(sub, inherits)
for rule in _DEFAULT_POLICY:
self._enforcer.add_policy(*rule)
def enforce(self, principal: "Principal", obj: str, act: str) -> bool:
return any(
self._enforcer.enforce(role.value, obj, act) for role in principal.roles
)
def has_role(self, principal: "Principal", allowed: Tuple[Role, ...]) -> bool:
allowed_values = {role.value for role in allowed}
for role in principal.roles:
if role.value in allowed_values:
return True
implicit = set(self._enforcer.get_implicit_roles_for_user(role.value))
if allowed_values & implicit:
return True
return False

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated, Callable, List
from dataclasses import dataclass, field
from typing import Annotated, Callable, List, Optional
from fastapi import FastAPI, Request, Security
from fastapi.security import SecurityScopes
@ -11,7 +11,7 @@ from .authenticators import Authenticator, build_authenticators
from .config import AuthConfig
from .models import Principal
from .network import resolve_network_context
from .rbac import Role, has_any_role, has_required_scopes
from .rbac import RbacEngine, Role, has_required_scopes
from .resolver import IdentityResolver
@ -20,6 +20,7 @@ class AuthContext:
config: AuthConfig
authenticators: List[Authenticator]
resolver: IdentityResolver
rbac: RbacEngine = field(default_factory=RbacEngine)
def install_auth(
@ -27,6 +28,7 @@ def install_auth(
config: AuthConfig,
resolver: IdentityResolver,
*,
rbac: Optional[RbacEngine] = None,
mount_scim: bool = True,
mount_oidc: bool = True,
mount_saml: bool = True,
@ -40,7 +42,8 @@ def install_auth(
this module resolve the client IP, or leave ``trusted_proxy_cidrs`` empty and
rely on uvicorn's own ``--forwarded-allow-ips``. Do not enable both.
"""
ctx = AuthContext(config, build_authenticators(config), resolver)
engine = rbac if rbac is not None else RbacEngine(config.casbin_policy_path)
ctx = AuthContext(config, build_authenticators(config), resolver, engine)
app.state.auth_v2 = ctx
if mount_scim:
from .scim import build_scim_router
@ -96,10 +99,23 @@ async def get_current_principal(
def require_roles(*allowed: Role) -> Callable[..., object]:
async def dependency(
request: Request,
principal: Annotated[Principal, Security(get_current_principal)],
) -> Principal:
if not has_any_role(principal, allowed):
if not _ctx(request).rbac.has_role(principal, allowed):
raise errors.forbidden_role()
return principal
return dependency
def require_permission(obj: str, act: str) -> Callable[..., object]:
async def dependency(
request: Request,
principal: Annotated[Principal, Security(get_current_principal)],
) -> Principal:
if not _ctx(request).rbac.enforce(principal, obj, act):
raise errors.forbidden_permission()
return principal
return dependency

View file

@ -56,6 +56,7 @@ proxy = [
"PyJWT[crypto]>=2.13.0,<3.0",
"Authlib>=1.6.0,<2.0",
"scim2-models>=0.6.0,<1.0",
"casbin>=1.36.0,<2.0",
"pysaml2>=7.5.0,<8.0",
# pysaml2 pulls pyOpenSSL transitively without pinning it; force a floor that
# supports cryptography 46 (older pyOpenSSL caps cryptography below 46 and

23
uv.lock generated
View file

@ -668,6 +668,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/0f/f897abe4ea0a8c408ae65c8c83bffab4936ad65d6032d4fb4cd35bbdc3ee/cachetools-7.1.1-py3-none-any.whl", hash = "sha256:0335cd7a0952d2b22327441fb0628139e234c565559eeb91a8a4ac7551c5353d", size = 16775, upload-time = "2026-05-03T20:00:27.857Z" },
]
[[package]]
name = "casbin"
version = "1.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "simpleeval" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/df/ff2aa55cf0d7c14622ce4f9252cdc34c828c81d4213965d73207ac5434ae/casbin-1.43.0.tar.gz", hash = "sha256:d2e90ce8e72f912877851e94d37999f32c558c6ba7aba0437d483275262e86e0", size = 425727, upload-time = "2025-05-10T06:57:18.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/07/facef6abd81378e6153b757dc1848621675d971fbc88ebb5d182ebc1c37f/casbin-1.43.0-py3-none-any.whl", hash = "sha256:63a3d1228870250e859ccd94133fe478821093f71dd37f05e0baa0c6fea26623", size = 475059, upload-time = "2025-05-10T06:57:16.89Z" },
]
[[package]]
name = "certifi"
version = "2026.4.22"
@ -3371,6 +3383,7 @@ proxy = [
{ name = "azure-storage-blob" },
{ name = "backoff" },
{ name = "boto3" },
{ name = "casbin" },
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "fastapi-sso" },
@ -3533,6 +3546,7 @@ requires-dist = [
{ name = "azure-storage-file-datalake", marker = "extra == 'proxy-runtime'", specifier = ">=12.20.0,<13.0" },
{ name = "backoff", marker = "extra == 'proxy'", specifier = ">=2.2.1,<3.0" },
{ name = "boto3", marker = "extra == 'proxy'", specifier = ">=1.43.1,<2.0" },
{ name = "casbin", marker = "extra == 'proxy'", specifier = ">=1.36.0,<2.0" },
{ name = "click", specifier = ">=8.0.0,<9.0" },
{ name = "cryptography", marker = "extra == 'proxy'", specifier = ">=46.0.7,<47.0" },
{ name = "ddtrace", marker = "extra == 'proxy-runtime'", specifier = ">=2.19.0,<3.0" },
@ -7160,6 +7174,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "simpleeval"
version = "1.0.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/9d/e7c9309940794dd3073cba2e5101df5874d84243595ce63b1e1c8f9b9c76/simpleeval-1.0.7.tar.gz", hash = "sha256:1e10e5f9fec597814444e20c0892ed15162fa214c8a88f434b5b077cf2fef85b", size = 30250, upload-time = "2026-03-16T10:53:03.464Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/2f/f32aa85591882378bb43caa09363f3ed97df399369a5144c7f19f2275bc0/simpleeval-1.0.7-py3-none-any.whl", hash = "sha256:97ac271bfd8f2af9e7b9a36ceea67617f26fa873f9d5ae1922f64d4c1442534b", size = 18792, upload-time = "2026-03-16T10:53:02.103Z" },
]
[[package]]
name = "six"
version = "1.17.0"