mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
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:
parent
7cf35ccc0a
commit
e309003c84
8 changed files with 131 additions and 15 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
23
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue