diff --git a/litellm/auth_v2/__init__.py b/litellm/auth_v2/__init__.py index 7373bc860c4..0e76c806a6b 100644 --- a/litellm/auth_v2/__init__.py +++ b/litellm/auth_v2/__init__.py @@ -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", ] diff --git a/litellm/auth_v2/authenticators.py b/litellm/auth_v2/authenticators.py index 54bd9c15a6f..6f22bf63991 100644 --- a/litellm/auth_v2/authenticators.py +++ b/litellm/auth_v2/authenticators.py @@ -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() diff --git a/litellm/auth_v2/config.py b/litellm/auth_v2/config.py index 3e2acc7a78b..4da9447b9a3 100644 --- a/litellm/auth_v2/config.py +++ b/litellm/auth_v2/config.py @@ -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 diff --git a/litellm/auth_v2/errors.py b/litellm/auth_v2/errors.py index 8c0dbbdd1bf..82953124496 100644 --- a/litellm/auth_v2/errors.py +++ b/litellm/auth_v2/errors.py @@ -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") diff --git a/litellm/auth_v2/rbac.py b/litellm/auth_v2/rbac.py index ee094cac500..2290fd671dd 100644 --- a/litellm/auth_v2/rbac.py +++ b/litellm/auth_v2/rbac.py @@ -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 diff --git a/litellm/auth_v2/security.py b/litellm/auth_v2/security.py index 7dd2721a3fb..7d709955381 100644 --- a/litellm/auth_v2/security.py +++ b/litellm/auth_v2/security.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index d388c29ab33..478390dee9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/uv.lock b/uv.lock index 4f3e18d573b..040b3d65b4a 100644 --- a/uv.lock +++ b/uv.lock @@ -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"