mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
Merge bfe921760d into d7b28840eb
This commit is contained in:
commit
24a9cd668b
28 changed files with 1706 additions and 0 deletions
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -466,6 +466,7 @@ jobs:
|
|||
- shard: proxy-auth
|
||||
test-path: |-
|
||||
tests/unit/proxy/auth
|
||||
tests/unit/proxy/control_plane
|
||||
--ignore=tests/unit/proxy/auth/test_auth_checks.py
|
||||
--ignore=tests/unit/proxy/auth/test_user_api_key_auth.py
|
||||
--ignore=tests/unit/proxy/auth/test_default_end_user_budget_simple.py
|
||||
|
|
|
|||
0
litellm/proxy/control_plane/__init__.py
Normal file
0
litellm/proxy/control_plane/__init__.py
Normal file
42
litellm/proxy/control_plane/app.py
Normal file
42
litellm/proxy/control_plane/app.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""App factory for the LiteLLM global control plane. Enterprise only: refuses to build without a valid license."""
|
||||
|
||||
from typing import Final, Protocol
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from litellm.proxy.control_plane.auth.dependencies import get_token_verifier
|
||||
from litellm.proxy.control_plane.auth.router import get_login_service
|
||||
from litellm.proxy.control_plane.auth.router import router as auth_router
|
||||
from litellm.proxy.control_plane.common.exceptions import install_exception_handlers
|
||||
from litellm.proxy.control_plane.container import Container
|
||||
from litellm.proxy.control_plane.health.router import router as health_router
|
||||
|
||||
TITLE: Final = "LiteLLM Control Plane"
|
||||
|
||||
|
||||
class License(Protocol):
|
||||
def is_premium(self) -> bool: ...
|
||||
|
||||
|
||||
class LicenseRequired(RuntimeError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"The LiteLLM control plane is an enterprise feature. Set LITELLM_LICENSE to a valid license key."
|
||||
)
|
||||
|
||||
|
||||
def create_app(container: Container, license_check: License) -> FastAPI:
|
||||
if not license_check.is_premium():
|
||||
raise LicenseRequired()
|
||||
app: Final = FastAPI(
|
||||
title=TITLE,
|
||||
version="1",
|
||||
docs_url=f"{container.settings.api_prefix}/docs",
|
||||
openapi_url=f"{container.settings.api_prefix}/openapi.json",
|
||||
)
|
||||
app.dependency_overrides[get_token_verifier] = lambda: container.token_verifier
|
||||
app.dependency_overrides[get_login_service] = lambda: container.login_service
|
||||
install_exception_handlers(app)
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router, prefix=container.settings.api_prefix)
|
||||
return app
|
||||
0
litellm/proxy/control_plane/auth/__init__.py
Normal file
0
litellm/proxy/control_plane/auth/__init__.py
Normal file
41
litellm/proxy/control_plane/auth/dependencies.py
Normal file
41
litellm/proxy/control_plane/auth/dependencies.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""FastAPI glue: bearer header -> Principal, scope checks via Security."""
|
||||
|
||||
from typing import Annotated, Final
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from litellm.proxy.control_plane.auth.jwt import Rejected, TokenVerifier
|
||||
from litellm.proxy.control_plane.auth.rbac import Principal, Scope
|
||||
from litellm.proxy.control_plane.common.exceptions import ControlPlaneException, forbidden, unauthorized
|
||||
|
||||
bearer_scheme: Final = HTTPBearer(auto_error=False, scheme_name="ControlPlaneBearer")
|
||||
|
||||
|
||||
def get_token_verifier() -> TokenVerifier:
|
||||
"""Placeholder bound in create_app via dependency_overrides, the FastAPI way to inject a per-process singleton."""
|
||||
raise RuntimeError("get_token_verifier is not bound, build the app with create_app")
|
||||
|
||||
|
||||
async def get_principal(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
|
||||
verifier: Annotated[TokenVerifier, Depends(get_token_verifier)],
|
||||
) -> Principal:
|
||||
if credentials is None:
|
||||
raise ControlPlaneException(unauthorized("Missing bearer credentials."))
|
||||
verified: Final = await run_in_threadpool(verifier.verify, credentials.credentials)
|
||||
if isinstance(verified, Rejected):
|
||||
raise ControlPlaneException(unauthorized(verified.reason))
|
||||
return verified
|
||||
|
||||
|
||||
async def require_scopes(
|
||||
security_scopes: SecurityScopes,
|
||||
principal: Annotated[Principal, Depends(get_principal)],
|
||||
) -> Principal:
|
||||
required: Final = frozenset(Scope(scope) for scope in security_scopes.scopes)
|
||||
missing: Final = principal.missing_scopes(required)
|
||||
if missing:
|
||||
raise ControlPlaneException(forbidden(f"Missing scope: {', '.join(sorted(scope.value for scope in missing))}."))
|
||||
return principal
|
||||
255
litellm/proxy/control_plane/auth/jwt.py
Normal file
255
litellm/proxy/control_plane/auth/jwt.py
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
"""Token verification, independent of FastAPI. Session JWTs are HS256 under the master key, OIDC JWTs are
|
||||
verified against the IdP's JWKS. Failures are values so the HTTP layer decides the status code."""
|
||||
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Final, Literal, Protocol, TypeAlias
|
||||
|
||||
import jwt
|
||||
from jwt import PyJWK, PyJWKClient
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm.proxy.control_plane.auth.rbac import (
|
||||
ADMIN_ROLE,
|
||||
ALL_SCOPES,
|
||||
DataplaneBinding,
|
||||
Principal,
|
||||
RolePolicy,
|
||||
build_principal,
|
||||
parse_scopes,
|
||||
)
|
||||
from litellm.proxy.control_plane.settings import ControlPlaneSettings, OidcSettings
|
||||
|
||||
SESSION_ALGORITHM: Final = "HS256"
|
||||
SESSION_AUDIENCE: Final = "litellm-control-plane-api"
|
||||
OIDC_ALGORITHMS: Final = ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512")
|
||||
|
||||
_STRING_LIST: Final = TypeAdapter(list[str])
|
||||
_STRING: Final = TypeAdapter(str)
|
||||
_CLAIMS: Final = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rejected:
|
||||
reason: str
|
||||
|
||||
|
||||
VerifiedToken: TypeAlias = Principal | Rejected
|
||||
|
||||
|
||||
SigningKey: TypeAlias = PyJWK | str
|
||||
JWKS_FETCH_TIMEOUT_SECONDS: Final = 10
|
||||
|
||||
|
||||
class JwksKeyLookup(Protocol):
|
||||
def signing_key(self, token: str) -> SigningKey | Rejected: ...
|
||||
|
||||
|
||||
class PyJwkClientLookup:
|
||||
def __init__(self, jwks_url: str) -> None:
|
||||
self._client = PyJWKClient(jwks_url, cache_keys=False, timeout=JWKS_FETCH_TIMEOUT_SECONDS)
|
||||
|
||||
def signing_key(self, token: str) -> SigningKey | Rejected:
|
||||
try:
|
||||
return self._client.get_signing_key_from_jwt(token)
|
||||
except jwt.PyJWKClientError as error:
|
||||
return Rejected(f"jwks lookup failed: {error}")
|
||||
|
||||
|
||||
def _as_string_tuple(value: object) -> tuple[str, ...]:
|
||||
if isinstance(value, str):
|
||||
return tuple(value.split())
|
||||
try:
|
||||
return tuple(_STRING_LIST.validate_python(value))
|
||||
except ValidationError:
|
||||
return ()
|
||||
|
||||
|
||||
def _as_optional_string(value: object) -> str | None:
|
||||
try:
|
||||
return _STRING.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _decode(
|
||||
token: str, key: SigningKey, algorithms: tuple[str, ...], audience: str | None, issuer: str, leeway: int
|
||||
) -> Mapping[str, object] | Rejected:
|
||||
try:
|
||||
raw: Final = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=list(algorithms),
|
||||
audience=audience,
|
||||
issuer=issuer,
|
||||
leeway=leeway,
|
||||
options={"require": ["exp", "sub"]},
|
||||
)
|
||||
return _CLAIMS.validate_python(raw)
|
||||
except jwt.PyJWTError as error:
|
||||
return Rejected(f"invalid token: {error}")
|
||||
except ValidationError:
|
||||
return Rejected("invalid token: claims are not an object")
|
||||
|
||||
|
||||
class SessionTokenCodec:
|
||||
"""Mints and verifies the control plane's own session JWTs. The audience pins a token to this API, so a
|
||||
token minted under the same master key by another component never authorizes a control plane route."""
|
||||
|
||||
def __init__(
|
||||
self, master_key: str, issuer: str, ttl: timedelta, policy: RolePolicy, audience: str = SESSION_AUDIENCE
|
||||
) -> None:
|
||||
self._master_key = master_key
|
||||
self._issuer = issuer
|
||||
self._audience = audience
|
||||
self._ttl = ttl
|
||||
self._policy = policy
|
||||
|
||||
def mint(self, principal: Principal, now: datetime | None = None) -> str:
|
||||
issued_at: Final = now or datetime.now(timezone.utc)
|
||||
claims: Final = {
|
||||
"iss": self._issuer,
|
||||
"aud": self._audience,
|
||||
"sub": principal.subject,
|
||||
"email": principal.email,
|
||||
"roles": sorted(principal.roles),
|
||||
"scope": " ".join(sorted(scope.value for scope in principal.scopes)),
|
||||
"litellm_dataplanes": ["*"] if principal.dataplanes.all else sorted(principal.dataplanes.ids),
|
||||
"iat": int(issued_at.timestamp()),
|
||||
"exp": int((issued_at + self._ttl).timestamp()),
|
||||
"jti": secrets.token_urlsafe(16),
|
||||
}
|
||||
return jwt.encode(claims, self._master_key, algorithm=SESSION_ALGORITHM)
|
||||
|
||||
def verify(self, token: str) -> VerifiedToken:
|
||||
claims: Final = _decode(token, self._master_key, (SESSION_ALGORITHM,), self._audience, self._issuer, 0)
|
||||
if isinstance(claims, Rejected):
|
||||
return claims
|
||||
return _principal_from_claims(self._policy, claims, "roles", "scope", "litellm_dataplanes", "email", "session")
|
||||
|
||||
|
||||
class OidcTokenVerifier:
|
||||
def __init__(self, settings: OidcSettings, keys: JwksKeyLookup, policy: RolePolicy) -> None:
|
||||
self._settings = settings
|
||||
self._keys = keys
|
||||
self._policy = policy
|
||||
|
||||
@property
|
||||
def issuer(self) -> str:
|
||||
return self._settings.issuer
|
||||
|
||||
def verify(self, token: str) -> VerifiedToken:
|
||||
key: Final = self._keys.signing_key(token)
|
||||
if isinstance(key, Rejected):
|
||||
return key
|
||||
claims: Final = _decode(
|
||||
token, key, OIDC_ALGORITHMS, self._settings.audience, self._settings.issuer, self._settings.leeway_seconds
|
||||
)
|
||||
if isinstance(claims, Rejected):
|
||||
return claims
|
||||
return _principal_from_claims(
|
||||
self._policy,
|
||||
claims,
|
||||
self._settings.roles_claim,
|
||||
self._settings.scope_claim,
|
||||
self._settings.dataplanes_claim,
|
||||
self._settings.email_claim,
|
||||
"oidc",
|
||||
)
|
||||
|
||||
|
||||
def _principal_from_claims(
|
||||
policy: RolePolicy,
|
||||
claims: Mapping[str, object],
|
||||
roles_claim: str,
|
||||
scope_claim: str,
|
||||
dataplanes_claim: str,
|
||||
email_claim: str,
|
||||
token_kind: Literal["session", "oidc"],
|
||||
) -> VerifiedToken:
|
||||
subject: Final = _as_optional_string(claims.get("sub"))
|
||||
if subject is None:
|
||||
return Rejected("invalid token: sub is not a string")
|
||||
roles: Final = policy.known(_as_string_tuple(claims.get(roles_claim)))
|
||||
if not roles:
|
||||
return Rejected("token carries no control plane role")
|
||||
raw_scopes: Final = claims.get(scope_claim)
|
||||
return build_principal(
|
||||
policy=policy,
|
||||
subject=subject,
|
||||
email=_as_optional_string(claims.get(email_claim)),
|
||||
roles=roles,
|
||||
claimed_scopes=None if raw_scopes is None else parse_scopes(_as_string_tuple(raw_scopes)),
|
||||
dataplane_ids=frozenset(_as_string_tuple(claims.get(dataplanes_claim))),
|
||||
token_kind=token_kind,
|
||||
)
|
||||
|
||||
|
||||
MASTER_KEY_SUBJECT: Final = "master_key"
|
||||
|
||||
|
||||
def master_key_principal() -> Principal:
|
||||
"""The master key is the break-glass credential and holds every scope even when operators drop the default
|
||||
``admin`` role from the policy."""
|
||||
return Principal(
|
||||
subject=MASTER_KEY_SUBJECT,
|
||||
email=None,
|
||||
roles=frozenset({ADMIN_ROLE}),
|
||||
scopes=ALL_SCOPES,
|
||||
dataplanes=DataplaneBinding(all=True, ids=frozenset()),
|
||||
token_kind="master_key",
|
||||
)
|
||||
|
||||
|
||||
class TokenVerifier:
|
||||
"""Resolves a bearer credential of any supported kind to a Principal."""
|
||||
|
||||
def __init__(
|
||||
self, master_key: str, sessions: SessionTokenCodec, oidc: OidcTokenVerifier | None, policy: RolePolicy
|
||||
) -> None:
|
||||
self._master_key = master_key
|
||||
self._sessions = sessions
|
||||
self._oidc = oidc
|
||||
self._policy = policy
|
||||
|
||||
def verify(self, credential: str) -> VerifiedToken:
|
||||
if secrets.compare_digest(credential, self._master_key):
|
||||
return master_key_principal()
|
||||
if not _looks_like_jwt(credential):
|
||||
return Rejected("credential is neither the master key nor a JWT")
|
||||
if self._oidc is not None and _issuer_of(credential) == self._oidc.issuer:
|
||||
return self._oidc.verify(credential)
|
||||
return self._sessions.verify(credential)
|
||||
|
||||
|
||||
def _looks_like_jwt(credential: str) -> bool:
|
||||
return credential.count(".") == 2
|
||||
|
||||
|
||||
def _issuer_of(credential: str) -> str | None:
|
||||
try:
|
||||
unverified: Final = jwt.decode(credential, options={"verify_signature": False})
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
return _as_optional_string(unverified.get("iss"))
|
||||
|
||||
|
||||
def build_token_verifier(
|
||||
settings: ControlPlaneSettings, policy: RolePolicy, keys: JwksKeyLookup | None = None
|
||||
) -> TokenVerifier:
|
||||
master_key: Final = settings.master_key.get_secret_value()
|
||||
sessions: Final = SessionTokenCodec(
|
||||
master_key=master_key,
|
||||
issuer=settings.session_issuer,
|
||||
audience=settings.session_audience,
|
||||
ttl=timedelta(seconds=settings.session_ttl_seconds),
|
||||
policy=policy,
|
||||
)
|
||||
oidc_settings: Final = settings.oidc
|
||||
if oidc_settings is None:
|
||||
return TokenVerifier(master_key, sessions, None, policy)
|
||||
jwks_url: Final = oidc_settings.jwks_url or f"{oidc_settings.issuer.rstrip('/')}/.well-known/jwks.json"
|
||||
lookup: Final = keys or PyJwkClientLookup(jwks_url)
|
||||
return TokenVerifier(master_key, sessions, OidcTokenVerifier(oidc_settings, lookup, policy), policy)
|
||||
105
litellm/proxy/control_plane/auth/login.py
Normal file
105
litellm/proxy/control_plane/auth/login.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Username and password login for the admin configured through UI_USERNAME / UI_PASSWORD."""
|
||||
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from math import ceil
|
||||
from typing import Final, TypeAlias
|
||||
|
||||
from litellm.proxy.control_plane.auth.jwt import SessionTokenCodec
|
||||
from litellm.proxy.control_plane.auth.rbac import ADMIN_ROLE, RolePolicy, build_principal
|
||||
from litellm.types.proxy.control_plane.auth import SessionTokenResponse
|
||||
|
||||
LOCKOUT_FAILURES: Final = 5
|
||||
LOCKOUT_SECONDS: Final = 300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InvalidCredentials:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Throttled:
|
||||
retry_after_seconds: int
|
||||
|
||||
|
||||
LoginResult: TypeAlias = SessionTokenResponse | InvalidCredentials | Throttled
|
||||
|
||||
|
||||
class LoginThrottle:
|
||||
"""Locks an account after ``failures`` misses inside ``lockout_seconds``. Source address limits belong to the load
|
||||
balancer in front of the service, which sees the real peer. Expired keys are dropped on every miss, so memory is
|
||||
bounded by the misses inside one window."""
|
||||
|
||||
def __init__(
|
||||
self, now: Callable[[], float], failures: int = LOCKOUT_FAILURES, lockout_seconds: float = LOCKOUT_SECONDS
|
||||
) -> None:
|
||||
self._now = now
|
||||
self._failures = failures
|
||||
self._lockout_seconds = lockout_seconds
|
||||
self._misses: dict[str, tuple[float, ...]] = {} # mutable-ok: per-account failure timestamps
|
||||
|
||||
def _recent(self, key: str) -> tuple[float, ...]:
|
||||
cutoff: Final = self._now() - self._lockout_seconds
|
||||
return tuple(at for at in self._misses.get(key, ()) if at > cutoff)
|
||||
|
||||
def locked_for(self, key: str) -> float:
|
||||
recent: Final = self._recent(key)
|
||||
if len(recent) < self._failures:
|
||||
return 0.0
|
||||
return max(0.0, recent[-1] + self._lockout_seconds - self._now())
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
live: Final = {key: recent for key, recent in ((key, self._recent(key)) for key in self._misses) if recent}
|
||||
self._misses = {**live, key: (*live.get(key, ()), self._now())}
|
||||
|
||||
def tracked(self) -> int:
|
||||
return len(self._misses)
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
self._misses.pop(key, None)
|
||||
|
||||
|
||||
class LoginService:
|
||||
def __init__(
|
||||
self,
|
||||
sessions: SessionTokenCodec,
|
||||
policy: RolePolicy,
|
||||
ttl_seconds: int,
|
||||
admin_username: str | None,
|
||||
admin_password: str | None,
|
||||
throttle: LoginThrottle,
|
||||
) -> None:
|
||||
self._sessions = sessions
|
||||
self._policy = policy
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._admin_username = admin_username
|
||||
self._admin_password = admin_password
|
||||
self._throttle = throttle
|
||||
|
||||
def login(self, username: str, password: str) -> LoginResult:
|
||||
locked: Final = self._throttle.locked_for(username)
|
||||
if locked > 0:
|
||||
return Throttled(retry_after_seconds=max(1, ceil(locked)))
|
||||
if not self._credentials_match(username, password):
|
||||
self._throttle.record_failure(username)
|
||||
return InvalidCredentials()
|
||||
self._throttle.reset(username)
|
||||
principal: Final = build_principal(
|
||||
policy=self._policy,
|
||||
subject=username,
|
||||
email=None,
|
||||
roles=frozenset({ADMIN_ROLE}),
|
||||
claimed_scopes=None,
|
||||
dataplane_ids=frozenset(),
|
||||
token_kind="session",
|
||||
)
|
||||
return SessionTokenResponse(access_token=self._sessions.mint(principal), expires_in=self._ttl_seconds)
|
||||
|
||||
def _credentials_match(self, username: str, password: str) -> bool:
|
||||
if self._admin_username is None or self._admin_password is None:
|
||||
return False
|
||||
username_ok: Final = secrets.compare_digest(username, self._admin_username)
|
||||
password_ok: Final = secrets.compare_digest(password, self._admin_password)
|
||||
return username_ok and password_ok
|
||||
191
litellm/proxy/control_plane/auth/rbac.py
Normal file
191
litellm/proxy/control_plane/auth/rbac.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""Role and scope policy for the control plane. Roles grant scopes, scopes gate routes, the dataplane binding
|
||||
on the token decides where the scopes apply (``*`` or no binding is fleet-wide)."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
|
||||
class Scope(str, Enum):
|
||||
DATAPLANES_READ = "dataplanes:read"
|
||||
DATAPLANES_WRITE = "dataplanes:write"
|
||||
USAGE_READ = "usage:read"
|
||||
FLEET_READ = "fleet:read"
|
||||
PROXY_READ = "proxy:read"
|
||||
PROXY_WRITE = "proxy:write"
|
||||
AUDIT_READ = "audit:read"
|
||||
ROLES_WRITE = "roles:write"
|
||||
|
||||
|
||||
ALL_SCOPES: Final = frozenset(Scope)
|
||||
READ_SCOPES: Final = frozenset({Scope.DATAPLANES_READ, Scope.USAGE_READ, Scope.FLEET_READ, Scope.PROXY_READ})
|
||||
|
||||
ADMIN_ROLE: Final = "admin"
|
||||
EDITOR_ROLE: Final = "editor"
|
||||
VIEWER_ROLE: Final = "viewer"
|
||||
USAGE_VIEWER_ROLE: Final = "usage_viewer"
|
||||
|
||||
DEFAULT_ROLES: Final[Mapping[str, frozenset[Scope]]] = MappingProxyType(
|
||||
{
|
||||
ADMIN_ROLE: ALL_SCOPES,
|
||||
EDITOR_ROLE: READ_SCOPES | {Scope.PROXY_WRITE},
|
||||
VIEWER_ROLE: READ_SCOPES,
|
||||
USAGE_VIEWER_ROLE: frozenset({Scope.USAGE_READ}),
|
||||
}
|
||||
)
|
||||
|
||||
FLEET_WIDE_BINDING: Final = "*"
|
||||
|
||||
TokenKind: TypeAlias = Literal["session", "oidc", "master_key"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoleDefinition:
|
||||
scopes: frozenset[Scope]
|
||||
inherits: frozenset[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnknownScope:
|
||||
role: str
|
||||
scope: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnknownParent:
|
||||
role: str
|
||||
parent: str
|
||||
|
||||
|
||||
RolePolicyError: TypeAlias = UnknownScope | UnknownParent
|
||||
|
||||
|
||||
class RolePolicy:
|
||||
"""Resolved role -> scopes table. Built once from defaults plus operator-defined roles."""
|
||||
|
||||
def __init__(self, roles: Mapping[str, frozenset[Scope]]) -> None:
|
||||
self._roles: Final = MappingProxyType(dict(roles))
|
||||
|
||||
@property
|
||||
def names(self) -> frozenset[str]:
|
||||
return frozenset(self._roles)
|
||||
|
||||
def scopes_for(self, role: str) -> frozenset[Scope]:
|
||||
return self._roles.get(role, frozenset())
|
||||
|
||||
def known(self, raw: tuple[str, ...]) -> frozenset[str]:
|
||||
return frozenset(name for name in raw if name in self._roles)
|
||||
|
||||
def grant(self, roles: frozenset[str]) -> frozenset[Scope]:
|
||||
return frozenset[Scope]().union(*(self.scopes_for(role) for role in roles))
|
||||
|
||||
|
||||
def default_policy() -> RolePolicy:
|
||||
return RolePolicy(DEFAULT_ROLES)
|
||||
|
||||
|
||||
def _resolve(name: str, definitions: Mapping[str, RoleDefinition], seen: frozenset[str]) -> frozenset[Scope]:
|
||||
definition: Final = definitions[name]
|
||||
parents: Final = definition.inherits - seen
|
||||
return definition.scopes.union(*(_resolve(parent, definitions, seen | {name}) for parent in parents))
|
||||
|
||||
|
||||
def build_policy(custom: Mapping[str, RoleDefinition], include_defaults: bool = True) -> RolePolicy | RolePolicyError:
|
||||
"""Operator roles override same-named defaults. Inheritance may reference defaults or other custom roles."""
|
||||
base: Final[Mapping[str, RoleDefinition]] = (
|
||||
{name: RoleDefinition(scopes=scopes, inherits=frozenset()) for name, scopes in DEFAULT_ROLES.items()}
|
||||
if include_defaults
|
||||
else {}
|
||||
)
|
||||
definitions: Final = {**base, **custom}
|
||||
unknown_parents: Final = tuple(
|
||||
UnknownParent(role=name, parent=parent)
|
||||
for name, definition in custom.items()
|
||||
for parent in sorted(definition.inherits - frozenset(definitions))
|
||||
) # comprehension-ok: flattening (role, parent) pairs
|
||||
if unknown_parents:
|
||||
return unknown_parents[0]
|
||||
return RolePolicy({name: _resolve(name, definitions, frozenset()) for name in definitions})
|
||||
|
||||
|
||||
def parse_role_definition(
|
||||
role: str, scopes: tuple[str, ...], inherits: tuple[str, ...]
|
||||
) -> RoleDefinition | UnknownScope:
|
||||
known: Final = frozenset(scope.value for scope in Scope)
|
||||
unknown: Final = tuple(scope for scope in scopes if scope not in known)
|
||||
if unknown:
|
||||
return UnknownScope(role=role, scope=unknown[0])
|
||||
return RoleDefinition(scopes=frozenset(Scope(scope) for scope in scopes), inherits=frozenset(inherits))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DataplaneBinding:
|
||||
"""Which dataplanes a principal may touch. ``all`` is fleet-wide, ``ids`` is an explicit set."""
|
||||
|
||||
all: bool
|
||||
ids: frozenset[str]
|
||||
|
||||
def allows(self, dataplane_id: str) -> bool:
|
||||
return self.all or dataplane_id in self.ids
|
||||
|
||||
def restrict(self, requested: frozenset[str] | None) -> frozenset[str] | None:
|
||||
"""Intersect a caller's requested dataplane filter with the binding. ``None`` means every bound dataplane."""
|
||||
if requested is None:
|
||||
return None if self.all else self.ids
|
||||
return requested if self.all else requested & self.ids
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Principal:
|
||||
subject: str
|
||||
email: str | None
|
||||
roles: frozenset[str]
|
||||
scopes: frozenset[Scope]
|
||||
dataplanes: DataplaneBinding
|
||||
token_kind: TokenKind
|
||||
|
||||
def has_scope(self, scope: Scope) -> bool:
|
||||
return scope in self.scopes
|
||||
|
||||
def missing_scopes(self, required: frozenset[Scope]) -> frozenset[Scope]:
|
||||
return required - self.scopes
|
||||
|
||||
|
||||
def clamp_scopes(granted: frozenset[Scope], claimed: frozenset[Scope] | None) -> frozenset[Scope]:
|
||||
"""A token may narrow what its roles grant, never widen it. ``None`` is a token without a scope claim and
|
||||
keeps the full role grant. A claim that names only unknown scopes narrows the grant to nothing."""
|
||||
return granted if claimed is None else granted & claimed
|
||||
|
||||
|
||||
def binding_for(dataplane_ids: frozenset[str]) -> DataplaneBinding:
|
||||
fleet_wide: Final = not dataplane_ids or FLEET_WIDE_BINDING in dataplane_ids
|
||||
return DataplaneBinding(all=fleet_wide, ids=frozenset() if fleet_wide else dataplane_ids)
|
||||
|
||||
|
||||
def parse_scopes(raw: tuple[str, ...] | None) -> frozenset[Scope] | None:
|
||||
"""Unknown scope names (``openid``, ``profile``) are dropped. Only an absent claim is ``None``."""
|
||||
if raw is None:
|
||||
return None
|
||||
known: Final = frozenset(scope.value for scope in Scope)
|
||||
return frozenset(Scope(value) for value in raw if value in known)
|
||||
|
||||
|
||||
def build_principal(
|
||||
policy: RolePolicy,
|
||||
subject: str,
|
||||
email: str | None,
|
||||
roles: frozenset[str],
|
||||
claimed_scopes: frozenset[Scope] | None,
|
||||
dataplane_ids: frozenset[str],
|
||||
token_kind: TokenKind,
|
||||
) -> Principal:
|
||||
return Principal(
|
||||
subject=subject,
|
||||
email=email,
|
||||
roles=roles,
|
||||
scopes=clamp_scopes(policy.grant(roles), claimed_scopes),
|
||||
dataplanes=binding_for(dataplane_ids),
|
||||
token_kind=token_kind,
|
||||
)
|
||||
45
litellm/proxy/control_plane/auth/router.py
Normal file
45
litellm/proxy/control_plane/auth/router.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from typing import Annotated, Final
|
||||
|
||||
from fastapi import APIRouter, Depends, Security
|
||||
|
||||
from litellm.proxy.control_plane.auth.dependencies import require_scopes
|
||||
from litellm.proxy.control_plane.auth.login import InvalidCredentials, LoginService, Throttled
|
||||
from litellm.proxy.control_plane.auth.rbac import Principal
|
||||
from litellm.proxy.control_plane.common.exceptions import ControlPlaneException, too_many_requests, unauthorized
|
||||
from litellm.types.proxy.control_plane.auth import LoginRequest, PrincipalResponse, SessionTokenResponse
|
||||
|
||||
router: Final = APIRouter(prefix="/auth", tags=["control plane: auth"])
|
||||
|
||||
|
||||
def get_login_service() -> LoginService:
|
||||
raise RuntimeError("get_login_service is not bound, build the app with create_app")
|
||||
|
||||
|
||||
def principal_response(principal: Principal) -> PrincipalResponse:
|
||||
return PrincipalResponse(
|
||||
subject=principal.subject,
|
||||
email=principal.email,
|
||||
roles=tuple(sorted(principal.roles)),
|
||||
scopes=tuple(sorted(scope.value for scope in principal.scopes)),
|
||||
dataplanes=None if principal.dataplanes.all else tuple(sorted(principal.dataplanes.ids)),
|
||||
token_kind=principal.token_kind,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=SessionTokenResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
service: Annotated[LoginService, Depends(get_login_service)],
|
||||
) -> SessionTokenResponse:
|
||||
match service.login(body.username, body.password):
|
||||
case SessionTokenResponse() as token:
|
||||
return token
|
||||
case Throttled(retry_after_seconds=seconds):
|
||||
raise ControlPlaneException(too_many_requests(f"Too many failed logins, retry in {seconds} seconds."))
|
||||
case InvalidCredentials():
|
||||
raise ControlPlaneException(unauthorized("Invalid username or password."))
|
||||
|
||||
|
||||
@router.get("/me", response_model=PrincipalResponse)
|
||||
async def me(principal: Annotated[Principal, Security(require_scopes)]) -> PrincipalResponse:
|
||||
return principal_response(principal)
|
||||
0
litellm/proxy/control_plane/common/__init__.py
Normal file
0
litellm/proxy/control_plane/common/__init__.py
Normal file
67
litellm/proxy/control_plane/common/exceptions.py
Normal file
67
litellm/proxy/control_plane/common/exceptions.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""RFC 9457 problem documents for the control plane, same contract as /management/v1."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from litellm.proxy.common_utils.validation_error_body import public_validation_errors
|
||||
from litellm.proxy.list_api.common import (
|
||||
PROBLEM_TYPE_BASE,
|
||||
ManagementProblem,
|
||||
problem_response,
|
||||
request_validation_problem,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail
|
||||
|
||||
WWW_AUTHENTICATE: Final = "WWW-Authenticate"
|
||||
BEARER_CHALLENGE: Final = 'Bearer realm="litellm-control-plane"'
|
||||
|
||||
|
||||
def problem(slug: str, title: str, status: int, detail: str) -> ProblemDetail:
|
||||
return ProblemDetail(type=f"{PROBLEM_TYPE_BASE}{slug}", title=title, status=status, detail=detail)
|
||||
|
||||
|
||||
def unauthorized(detail: str) -> ProblemDetail:
|
||||
return problem("unauthorized", "Unauthorized", 401, detail)
|
||||
|
||||
|
||||
def forbidden(detail: str) -> ProblemDetail:
|
||||
return problem("forbidden", "Forbidden", 403, detail)
|
||||
|
||||
|
||||
def not_found(detail: str) -> ProblemDetail:
|
||||
return problem("not-found", "Not Found", 404, detail)
|
||||
|
||||
|
||||
def too_many_requests(detail: str) -> ProblemDetail:
|
||||
return problem("too-many-requests", "Too Many Requests", 429, detail)
|
||||
|
||||
|
||||
class ControlPlaneException(ManagementProblem):
|
||||
"""A problem raised from a dependency or handler, rendered by the app-level handler below."""
|
||||
|
||||
|
||||
def _render(problem_detail: ProblemDetail) -> JSONResponse:
|
||||
response: Final = problem_response(problem_detail)
|
||||
if problem_detail.status == 401:
|
||||
response.headers[WWW_AUTHENTICATE] = BEARER_CHALLENGE
|
||||
return response
|
||||
|
||||
|
||||
async def _handle_exception(_request: Request, exc: Exception) -> JSONResponse:
|
||||
if not isinstance(exc, ManagementProblem):
|
||||
raise exc
|
||||
return _render(exc.problem)
|
||||
|
||||
|
||||
async def _handle_validation(_request: Request, exc: Exception) -> JSONResponse:
|
||||
if not isinstance(exc, RequestValidationError):
|
||||
raise exc
|
||||
return _render(request_validation_problem(public_validation_errors(exc.errors())))
|
||||
|
||||
|
||||
def install_exception_handlers(app: FastAPI) -> None:
|
||||
app.add_exception_handler(ManagementProblem, _handle_exception)
|
||||
app.add_exception_handler(RequestValidationError, _handle_validation)
|
||||
83
litellm/proxy/control_plane/container.py
Normal file
83
litellm/proxy/control_plane/container.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Long-lived objects built once per process and handed to the app. Tests build their own."""
|
||||
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from litellm.proxy.control_plane.auth.jwt import JwksKeyLookup, SessionTokenCodec, TokenVerifier, build_token_verifier
|
||||
from litellm.proxy.control_plane.auth.login import LoginService, LoginThrottle
|
||||
from litellm.proxy.control_plane.auth.rbac import (
|
||||
RoleDefinition,
|
||||
RolePolicy,
|
||||
RolePolicyError,
|
||||
UnknownParent,
|
||||
UnknownScope,
|
||||
build_policy,
|
||||
parse_role_definition,
|
||||
)
|
||||
from litellm.proxy.control_plane.settings import ControlPlaneSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Container:
|
||||
settings: ControlPlaneSettings
|
||||
policy: RolePolicy
|
||||
token_verifier: TokenVerifier
|
||||
login_service: LoginService
|
||||
|
||||
|
||||
class InvalidRolePolicy(ValueError):
|
||||
def __init__(self, error: RolePolicyError) -> None:
|
||||
match error:
|
||||
case UnknownScope(role=role, scope=scope):
|
||||
super().__init__(f"role {role!r} grants unknown scope {scope!r}")
|
||||
case UnknownParent(role=role, parent=parent):
|
||||
super().__init__(f"role {role!r} inherits unknown role {parent!r}")
|
||||
|
||||
|
||||
def _parse_roles(settings: ControlPlaneSettings) -> Mapping[str, RoleDefinition]:
|
||||
definitions: Final = MappingProxyType(
|
||||
{name: parse_role_definition(name, spec.scopes, spec.inherits) for name, spec in settings.roles.items()}
|
||||
)
|
||||
for error in definitions.values():
|
||||
if isinstance(error, UnknownScope):
|
||||
raise InvalidRolePolicy(error)
|
||||
return MappingProxyType(
|
||||
{name: definition for name, definition in definitions.items() if isinstance(definition, RoleDefinition)}
|
||||
)
|
||||
|
||||
|
||||
def build_role_policy(settings: ControlPlaneSettings) -> RolePolicy:
|
||||
policy: Final = build_policy(_parse_roles(settings), include_defaults=settings.include_default_roles)
|
||||
if isinstance(policy, RolePolicy):
|
||||
return policy
|
||||
raise InvalidRolePolicy(policy)
|
||||
|
||||
|
||||
def build_container(settings: ControlPlaneSettings, jwks: JwksKeyLookup | None = None) -> Container:
|
||||
master_key: Final = settings.master_key.get_secret_value()
|
||||
policy: Final = build_role_policy(settings)
|
||||
sessions: Final = SessionTokenCodec(
|
||||
master_key=master_key,
|
||||
issuer=settings.session_issuer,
|
||||
audience=settings.session_audience,
|
||||
ttl=timedelta(seconds=settings.session_ttl_seconds),
|
||||
policy=policy,
|
||||
)
|
||||
password: Final = settings.ui_password
|
||||
return Container(
|
||||
settings=settings,
|
||||
policy=policy,
|
||||
token_verifier=build_token_verifier(settings, policy, jwks),
|
||||
login_service=LoginService(
|
||||
sessions=sessions,
|
||||
policy=policy,
|
||||
ttl_seconds=settings.session_ttl_seconds,
|
||||
admin_username=settings.ui_username,
|
||||
admin_password=None if password is None else password.get_secret_value(),
|
||||
throttle=LoginThrottle(time.monotonic),
|
||||
),
|
||||
)
|
||||
0
litellm/proxy/control_plane/health/__init__.py
Normal file
0
litellm/proxy/control_plane/health/__init__.py
Normal file
17
litellm/proxy/control_plane/health/router.py
Normal file
17
litellm/proxy/control_plane/health/router.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from typing import Final
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from litellm.types.proxy.control_plane.health import HealthResponse
|
||||
|
||||
router: Final = APIRouter(tags=["control plane: health"])
|
||||
|
||||
|
||||
@router.get("/healthz", response_model=HealthResponse)
|
||||
async def healthz() -> HealthResponse:
|
||||
return HealthResponse(status="ok")
|
||||
|
||||
|
||||
@router.get("/readyz", response_model=HealthResponse)
|
||||
async def readyz() -> HealthResponse:
|
||||
return HealthResponse(status="ok")
|
||||
79
litellm/proxy/control_plane/settings.py
Normal file
79
litellm/proxy/control_plane/settings.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Process-level configuration for the control plane, read once at startup."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from litellm.types.proxy.control_plane.auth import PASSWORD_MAX_LENGTH, USERNAME_MAX_LENGTH
|
||||
|
||||
DEFAULT_SESSION_TTL_SECONDS: Final = 12 * 60 * 60
|
||||
DEFAULT_DATAPLANE_TIMEOUT_SECONDS: Final = 10.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OidcSettings:
|
||||
"""External IdP used to verify caller JWTs and, later, to exchange them for dataplane tokens."""
|
||||
|
||||
issuer: str
|
||||
audience: str
|
||||
jwks_url: str | None
|
||||
roles_claim: str
|
||||
scope_claim: str
|
||||
dataplanes_claim: str
|
||||
email_claim: str
|
||||
leeway_seconds: int
|
||||
|
||||
|
||||
class RoleSpec(BaseModel):
|
||||
"""An operator-defined role, e.g. CONTROL_PLANE_ROLES='{"finops": {"scopes": ["usage:read", "audit:read"]}}'."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
scopes: tuple[str, ...] = ()
|
||||
inherits: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class ControlPlaneSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(frozen=True, env_prefix="CONTROL_PLANE_", extra="ignore")
|
||||
|
||||
master_key: SecretStr = Field(validation_alias="LITELLM_MASTER_KEY")
|
||||
ui_username: str | None = Field(default=None, max_length=USERNAME_MAX_LENGTH, validation_alias="UI_USERNAME")
|
||||
ui_password: SecretStr | None = Field(default=None, max_length=PASSWORD_MAX_LENGTH, validation_alias="UI_PASSWORD")
|
||||
session_ttl_seconds: int = DEFAULT_SESSION_TTL_SECONDS
|
||||
session_issuer: str = "litellm-control-plane"
|
||||
session_audience: str = "litellm-control-plane-api"
|
||||
roles: Mapping[str, RoleSpec] = Field(default_factory=dict)
|
||||
include_default_roles: bool = True
|
||||
oidc_issuer: str | None = None
|
||||
oidc_audience: str | None = None
|
||||
oidc_jwks_url: str | None = None
|
||||
oidc_roles_claim: str = "roles"
|
||||
oidc_scope_claim: str = "scope"
|
||||
oidc_dataplanes_claim: str = "litellm_dataplanes"
|
||||
oidc_email_claim: str = "email"
|
||||
oidc_leeway_seconds: int = 30
|
||||
dataplane_timeout_seconds: float = DEFAULT_DATAPLANE_TIMEOUT_SECONDS
|
||||
fanout_concurrency: int = 16
|
||||
api_prefix: str = "/api/v1"
|
||||
|
||||
@property
|
||||
def oidc(self) -> OidcSettings | None:
|
||||
if self.oidc_issuer is None or self.oidc_audience is None:
|
||||
return None
|
||||
return OidcSettings(
|
||||
issuer=self.oidc_issuer,
|
||||
audience=self.oidc_audience,
|
||||
jwks_url=self.oidc_jwks_url,
|
||||
roles_claim=self.oidc_roles_claim,
|
||||
scope_claim=self.oidc_scope_claim,
|
||||
dataplanes_claim=self.oidc_dataplanes_claim,
|
||||
email_claim=self.oidc_email_claim,
|
||||
leeway_seconds=self.oidc_leeway_seconds,
|
||||
)
|
||||
|
||||
|
||||
def load_settings() -> ControlPlaneSettings:
|
||||
return ControlPlaneSettings() # pyright: ignore[reportCallIssue] # pydantic-settings fills fields from env
|
||||
0
litellm/types/proxy/control_plane/__init__.py
Normal file
0
litellm/types/proxy/control_plane/__init__.py
Normal file
34
litellm/types/proxy/control_plane/auth.py
Normal file
34
litellm/types/proxy/control_plane/auth.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from typing import Final
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
|
||||
USERNAME_MAX_LENGTH: Final = 256
|
||||
PASSWORD_MAX_LENGTH: Final = 1024
|
||||
|
||||
|
||||
class LoginRequest(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
username: str = Field(min_length=1, max_length=USERNAME_MAX_LENGTH)
|
||||
password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH)
|
||||
|
||||
|
||||
class SessionTokenResponse(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class PrincipalResponse(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
subject: str
|
||||
email: str | None
|
||||
roles: tuple[str, ...]
|
||||
scopes: tuple[str, ...]
|
||||
dataplanes: tuple[str, ...] | None
|
||||
token_kind: str
|
||||
11
litellm/types/proxy/control_plane/health.py
Normal file
11
litellm/types/proxy/control_plane/health.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from typing import Literal
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
|
||||
|
||||
class HealthResponse(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
status: Literal["ok"]
|
||||
0
tests/unit/proxy/control_plane/__init__.py
Normal file
0
tests/unit/proxy/control_plane/__init__.py
Normal file
0
tests/unit/proxy/control_plane/auth/__init__.py
Normal file
0
tests/unit/proxy/control_plane/auth/__init__.py
Normal file
92
tests/unit/proxy/control_plane/auth/test_dependencies.py
Normal file
92
tests/unit/proxy/control_plane/auth/test_dependencies.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import asyncio
|
||||
import threading
|
||||
from datetime import timedelta
|
||||
from typing import Annotated, Final
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, Security
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from litellm.proxy.control_plane.auth.dependencies import get_principal, get_token_verifier, require_scopes
|
||||
from litellm.proxy.control_plane.auth.jwt import Rejected, SessionTokenCodec, TokenVerifier
|
||||
from litellm.proxy.control_plane.auth.rbac import ADMIN_ROLE, VIEWER_ROLE, Principal, build_principal, default_policy
|
||||
from litellm.proxy.control_plane.common.exceptions import install_exception_handlers
|
||||
|
||||
MASTER_KEY: Final = "sk-master"
|
||||
POLICY: Final = default_policy()
|
||||
|
||||
|
||||
def _app() -> tuple[TestClient, SessionTokenCodec]:
|
||||
codec: Final = SessionTokenCodec(MASTER_KEY, "cp", timedelta(hours=1), POLICY)
|
||||
app: Final = FastAPI()
|
||||
verifier: Final = TokenVerifier(MASTER_KEY, codec, None, POLICY)
|
||||
app.dependency_overrides[get_token_verifier] = lambda: verifier
|
||||
install_exception_handlers(app)
|
||||
|
||||
@app.get("/usage")
|
||||
async def usage(principal: Annotated[Principal, Security(require_scopes, scopes=["usage:read"])]) -> str:
|
||||
return principal.subject
|
||||
|
||||
@app.post("/dataplanes")
|
||||
async def write(principal: Annotated[Principal, Security(require_scopes, scopes=["dataplanes:write"])]) -> str:
|
||||
return principal.subject
|
||||
|
||||
return TestClient(app), codec
|
||||
|
||||
|
||||
def _token(codec: SessionTokenCodec, role: str) -> dict[str, str]:
|
||||
principal: Final = build_principal(POLICY, "u", None, frozenset({role}), None, frozenset(), "session")
|
||||
return {"Authorization": f"Bearer {codec.mint(principal)}"}
|
||||
|
||||
|
||||
def test_viewer_reads_usage_but_cannot_write_dataplanes() -> None:
|
||||
client, codec = _app()
|
||||
assert client.get("/usage", headers=_token(codec, VIEWER_ROLE)).status_code == 200
|
||||
denied = client.post("/dataplanes", headers=_token(codec, VIEWER_ROLE))
|
||||
assert denied.status_code == 403
|
||||
assert denied.json()["detail"] == "Missing scope: dataplanes:write."
|
||||
assert denied.headers["content-type"] == "application/problem+json"
|
||||
|
||||
|
||||
def test_admin_passes_both_scope_gates() -> None:
|
||||
client, codec = _app()
|
||||
assert client.post("/dataplanes", headers=_token(codec, ADMIN_ROLE)).status_code == 200
|
||||
|
||||
|
||||
class _GatedVerifier:
|
||||
"""Blocks the slow credential on an Event and records whether that Event was set before the wait gave up."""
|
||||
|
||||
def __init__(self, gate: threading.Event) -> None:
|
||||
self._gate = gate
|
||||
self.slow_saw_the_gate_open: bool | None = None
|
||||
|
||||
def verify(self, credential: str) -> Principal | Rejected:
|
||||
if credential == "slow":
|
||||
self.slow_saw_the_gate_open = self._gate.wait(timeout=5)
|
||||
return build_principal(POLICY, credential, None, frozenset({VIEWER_ROLE}), None, frozenset(), "session")
|
||||
|
||||
|
||||
async def test_a_slow_token_verification_does_not_block_other_requests() -> None:
|
||||
gate: Final = threading.Event()
|
||||
verifier: Final = _GatedVerifier(gate)
|
||||
app: Final = FastAPI()
|
||||
app.dependency_overrides[get_token_verifier] = lambda: verifier
|
||||
|
||||
async def me(principal: Annotated[Principal, Depends(get_principal)]) -> str:
|
||||
return principal.subject
|
||||
|
||||
app.add_api_route("/me", me)
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://cp") as client:
|
||||
slow: Final = asyncio.create_task(client.get("/me", headers={"Authorization": "Bearer slow"}))
|
||||
await asyncio.sleep(0)
|
||||
fast: Final = await client.get("/me", headers={"Authorization": "Bearer fast"})
|
||||
gate.set()
|
||||
assert (await slow).json() == "slow"
|
||||
assert fast.json() == "fast"
|
||||
assert verifier.slow_saw_the_gate_open is True, "the fast request ran while the slow JWKS lookup was still waiting"
|
||||
|
||||
|
||||
def test_security_scopes_show_up_in_openapi() -> None:
|
||||
client, _ = _app()
|
||||
security: Final = client.get("/openapi.json").json()["paths"]["/usage"]["get"]["security"]
|
||||
assert security == [{"ControlPlaneBearer": ["usage:read"]}]
|
||||
258
tests/unit/proxy/control_plane/auth/test_jwt.py
Normal file
258
tests/unit/proxy/control_plane/auth/test_jwt.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Final
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from litellm.proxy.control_plane.auth.jwt import (
|
||||
OidcTokenVerifier,
|
||||
Rejected,
|
||||
SESSION_AUDIENCE,
|
||||
SessionTokenCodec,
|
||||
TokenVerifier,
|
||||
master_key_principal,
|
||||
)
|
||||
from litellm.proxy.control_plane.auth.rbac import (
|
||||
ADMIN_ROLE,
|
||||
ALL_SCOPES,
|
||||
VIEWER_ROLE,
|
||||
Principal,
|
||||
RoleDefinition,
|
||||
RolePolicy,
|
||||
Scope,
|
||||
build_policy,
|
||||
build_principal,
|
||||
default_policy,
|
||||
)
|
||||
from litellm.proxy.control_plane.settings import OidcSettings
|
||||
|
||||
MASTER_KEY: Final = "sk-master-test"
|
||||
ISSUER: Final = "https://idp.example.test/"
|
||||
AUDIENCE: Final = "litellm-control-plane"
|
||||
|
||||
_PRIVATE_KEY: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
_PUBLIC_PEM: Final = (
|
||||
_PRIVATE_KEY.public_key()
|
||||
.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
.decode()
|
||||
)
|
||||
_PRIVATE_PEM: Final = _PRIVATE_KEY.private_bytes(
|
||||
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
|
||||
).decode()
|
||||
|
||||
|
||||
class StaticKeys:
|
||||
def __init__(self, key: str | Rejected) -> None:
|
||||
self._key = key
|
||||
|
||||
def signing_key(self, token: str) -> str | Rejected: # str is a valid SigningKey
|
||||
return self._key
|
||||
|
||||
|
||||
def _oidc_settings(**overrides: str) -> OidcSettings:
|
||||
return OidcSettings(
|
||||
issuer=ISSUER,
|
||||
audience=AUDIENCE,
|
||||
jwks_url=None,
|
||||
roles_claim=overrides.get("roles_claim", "roles"),
|
||||
scope_claim="scope",
|
||||
dataplanes_claim="litellm_dataplanes",
|
||||
email_claim="email",
|
||||
leeway_seconds=0,
|
||||
)
|
||||
|
||||
|
||||
def _oidc_token(claims: dict[str, object], exp_delta: timedelta = timedelta(hours=1)) -> str:
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
return jwt.encode(
|
||||
{"iss": ISSUER, "aud": AUDIENCE, "sub": "user-1", "exp": now + exp_delta, "iat": now, **claims},
|
||||
_PRIVATE_PEM,
|
||||
algorithm="RS256",
|
||||
)
|
||||
|
||||
|
||||
POLICY: Final = default_policy()
|
||||
|
||||
|
||||
def _sessions(
|
||||
ttl: timedelta = timedelta(hours=1),
|
||||
master_key: str = MASTER_KEY,
|
||||
issuer: str = "cp",
|
||||
audience: str = SESSION_AUDIENCE,
|
||||
) -> SessionTokenCodec:
|
||||
return SessionTokenCodec(master_key=master_key, issuer=issuer, ttl=ttl, policy=POLICY, audience=audience)
|
||||
|
||||
|
||||
def _viewer(ids: frozenset[str]) -> Principal:
|
||||
return build_principal(POLICY, "alice", "a@x.test", frozenset({VIEWER_ROLE}), None, ids, "session")
|
||||
|
||||
|
||||
def _oidc_verifier(settings: OidcSettings, key: str | Rejected) -> OidcTokenVerifier:
|
||||
return OidcTokenVerifier(settings, StaticKeys(key), POLICY)
|
||||
|
||||
|
||||
def test_session_round_trip_preserves_subject_roles_scopes_and_binding() -> None:
|
||||
codec = _sessions()
|
||||
original = _viewer(frozenset({"eu", "us"}))
|
||||
verified = codec.verify(codec.mint(original))
|
||||
assert isinstance(verified, Principal)
|
||||
assert (verified.subject, verified.email, verified.roles) == ("alice", "a@x.test", original.roles)
|
||||
assert verified.scopes == original.scopes
|
||||
assert verified.dataplanes == original.dataplanes
|
||||
assert verified.token_kind == "session"
|
||||
|
||||
|
||||
def test_fleet_wide_session_round_trips_as_fleet_wide() -> None:
|
||||
codec = _sessions()
|
||||
admin = build_principal(POLICY, "root", None, frozenset({ADMIN_ROLE}), None, frozenset(), "session")
|
||||
verified = codec.verify(codec.mint(admin))
|
||||
assert isinstance(verified, Principal) and verified.dataplanes.all
|
||||
|
||||
|
||||
def test_expired_session_is_rejected() -> None:
|
||||
codec = _sessions(ttl=timedelta(seconds=-1))
|
||||
assert isinstance(codec.verify(codec.mint(_viewer(frozenset()))), Rejected)
|
||||
|
||||
|
||||
def test_session_signed_with_another_key_is_rejected() -> None:
|
||||
other = _sessions(master_key="sk-other")
|
||||
assert isinstance(_sessions().verify(other.mint(_viewer(frozenset()))), Rejected)
|
||||
|
||||
|
||||
def test_session_from_another_issuer_is_rejected() -> None:
|
||||
other = _sessions(issuer="not-cp")
|
||||
assert isinstance(_sessions().verify(other.mint(_viewer(frozenset()))), Rejected)
|
||||
|
||||
|
||||
def test_session_minted_for_another_audience_is_rejected() -> None:
|
||||
"""Same master key and issuer, but a token for another component never opens the control plane."""
|
||||
other = _sessions(audience="litellm-proxy-ui")
|
||||
assert isinstance(_sessions().verify(other.mint(_viewer(frozenset()))), Rejected)
|
||||
|
||||
|
||||
def test_session_without_an_audience_claim_is_rejected() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
no_aud = jwt.encode(
|
||||
{"iss": "cp", "sub": "alice", "roles": ["viewer"], "exp": now + timedelta(hours=1)},
|
||||
MASTER_KEY,
|
||||
algorithm="HS256",
|
||||
)
|
||||
assert isinstance(_sessions().verify(no_aud), Rejected)
|
||||
with_aud = jwt.encode(
|
||||
{"iss": "cp", "aud": SESSION_AUDIENCE, "sub": "alice", "roles": ["viewer"], "exp": now + timedelta(hours=1)},
|
||||
MASTER_KEY,
|
||||
algorithm="HS256",
|
||||
)
|
||||
assert isinstance(_sessions().verify(with_aud), Principal)
|
||||
|
||||
|
||||
def test_oidc_scope_claim_with_only_foreign_scopes_grants_no_control_plane_scope() -> None:
|
||||
verifier = _oidc_verifier(_oidc_settings(), _PUBLIC_PEM)
|
||||
scoped = verifier.verify(_oidc_token({"roles": ["admin"], "scope": "openid profile email"}))
|
||||
assert isinstance(scoped, Principal) and scoped.scopes == frozenset()
|
||||
unscoped = verifier.verify(_oidc_token({"roles": ["admin"]}))
|
||||
assert isinstance(unscoped, Principal) and unscoped.scopes == POLICY.scopes_for(ADMIN_ROLE)
|
||||
|
||||
|
||||
def test_oidc_token_maps_claims_to_a_clamped_principal() -> None:
|
||||
verifier = _oidc_verifier(_oidc_settings(), _PUBLIC_PEM)
|
||||
token = _oidc_token(
|
||||
{
|
||||
"roles": ["viewer"],
|
||||
"scope": "openid usage:read proxy:write",
|
||||
"litellm_dataplanes": ["eu"],
|
||||
"email": "u@x.test",
|
||||
}
|
||||
)
|
||||
principal = verifier.verify(token)
|
||||
assert isinstance(principal, Principal)
|
||||
assert principal.roles == frozenset({VIEWER_ROLE})
|
||||
assert principal.scopes == frozenset({Scope.USAGE_READ})
|
||||
assert principal.dataplanes.ids == frozenset({"eu"}) and not principal.dataplanes.all
|
||||
assert (principal.email, principal.token_kind) == ("u@x.test", "oidc")
|
||||
|
||||
|
||||
def test_oidc_roles_claim_name_is_configurable_and_space_delimited_roles_parse() -> None:
|
||||
verifier = _oidc_verifier(_oidc_settings(roles_claim="groups"), _PUBLIC_PEM)
|
||||
principal = verifier.verify(_oidc_token({"groups": "viewer other"}))
|
||||
assert isinstance(principal, Principal) and principal.roles == frozenset({VIEWER_ROLE})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"claims",
|
||||
[
|
||||
{"roles": ["admin"], "aud": "someone-else"},
|
||||
{"roles": ["admin"], "iss": "https://other.test/"},
|
||||
{"roles": ["proxy_admin"]},
|
||||
{},
|
||||
],
|
||||
)
|
||||
def test_oidc_token_with_wrong_audience_issuer_or_no_role_is_rejected(claims: dict[str, object]) -> None:
|
||||
verifier = _oidc_verifier(_oidc_settings(), _PUBLIC_PEM)
|
||||
assert isinstance(verifier.verify(_oidc_token(claims)), Rejected)
|
||||
|
||||
|
||||
def test_oidc_token_signed_by_an_unknown_key_is_rejected() -> None:
|
||||
other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
other_pem = (
|
||||
other_key.public_key()
|
||||
.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
.decode()
|
||||
)
|
||||
verifier = _oidc_verifier(_oidc_settings(), other_pem)
|
||||
assert isinstance(verifier.verify(_oidc_token({"roles": ["admin"]})), Rejected)
|
||||
|
||||
|
||||
def test_oidc_token_is_rejected_when_the_jwks_lookup_fails() -> None:
|
||||
verifier = _oidc_verifier(_oidc_settings(), Rejected("jwks down"))
|
||||
assert verifier.verify(_oidc_token({"roles": ["admin"]})) == Rejected("jwks down")
|
||||
|
||||
|
||||
def test_hs256_token_claiming_the_oidc_issuer_cannot_pass_as_oidc() -> None:
|
||||
verifier = _oidc_verifier(_oidc_settings(), _PUBLIC_PEM)
|
||||
forged = jwt.encode(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"aud": AUDIENCE,
|
||||
"sub": "x",
|
||||
"roles": ["admin"],
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
},
|
||||
"a" * 32,
|
||||
algorithm="HS256",
|
||||
)
|
||||
assert isinstance(verifier.verify(forged), Rejected)
|
||||
|
||||
|
||||
def test_token_verifier_routes_master_key_session_and_oidc_credentials() -> None:
|
||||
sessions = _sessions()
|
||||
verifier = TokenVerifier(MASTER_KEY, sessions, _oidc_verifier(_oidc_settings(), _PUBLIC_PEM), POLICY)
|
||||
assert verifier.verify(MASTER_KEY) == master_key_principal()
|
||||
session = verifier.verify(sessions.mint(_viewer(frozenset({"eu"}))))
|
||||
assert isinstance(session, Principal) and session.token_kind == "session"
|
||||
oidc = verifier.verify(_oidc_token({"roles": ["viewer"]}))
|
||||
assert isinstance(oidc, Principal) and oidc.token_kind == "oidc"
|
||||
assert isinstance(verifier.verify("not-a-token"), Rejected)
|
||||
assert isinstance(verifier.verify("sk-master-tes"), Rejected)
|
||||
|
||||
|
||||
def test_oidc_issued_token_is_not_accepted_as_a_session_when_oidc_is_off() -> None:
|
||||
verifier = TokenVerifier(MASTER_KEY, _sessions(), None, POLICY)
|
||||
assert isinstance(verifier.verify(_oidc_token({"roles": ["admin"]})), Rejected)
|
||||
|
||||
|
||||
def test_master_key_principal_is_a_fleet_wide_admin() -> None:
|
||||
principal = master_key_principal()
|
||||
assert principal.roles == frozenset({ADMIN_ROLE}) and principal.dataplanes.all
|
||||
assert principal.token_kind == "master_key"
|
||||
assert principal.scopes == ALL_SCOPES
|
||||
|
||||
|
||||
def test_master_key_keeps_every_scope_when_the_default_roles_are_switched_off() -> None:
|
||||
custom_only = build_policy({"finops": RoleDefinition(frozenset({Scope.USAGE_READ}), frozenset())}, False)
|
||||
assert isinstance(custom_only, RolePolicy) and custom_only.scopes_for(ADMIN_ROLE) == frozenset()
|
||||
verifier = TokenVerifier(MASTER_KEY, _sessions(), None, custom_only)
|
||||
verified = verifier.verify(MASTER_KEY)
|
||||
assert isinstance(verified, Principal) and verified.scopes == ALL_SCOPES
|
||||
93
tests/unit/proxy/control_plane/auth/test_login.py
Normal file
93
tests/unit/proxy/control_plane/auth/test_login.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.control_plane.auth.jwt import SessionTokenCodec
|
||||
from litellm.proxy.control_plane.auth.login import (
|
||||
LOCKOUT_FAILURES,
|
||||
LOCKOUT_SECONDS,
|
||||
InvalidCredentials,
|
||||
LoginService,
|
||||
LoginThrottle,
|
||||
Throttled,
|
||||
)
|
||||
from litellm.proxy.control_plane.auth.rbac import default_policy
|
||||
from litellm.types.proxy.control_plane.auth import SessionTokenResponse
|
||||
|
||||
|
||||
class Clock: # mutable-ok: the test advances it
|
||||
def __init__(self) -> None:
|
||||
self.now = 1000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
def _service(clock: Clock) -> LoginService:
|
||||
policy = default_policy()
|
||||
sessions = SessionTokenCodec(
|
||||
master_key="sk-master", issuer="cp", audience="cp-api", ttl=timedelta(minutes=5), policy=policy
|
||||
)
|
||||
return LoginService(
|
||||
sessions=sessions,
|
||||
policy=policy,
|
||||
ttl_seconds=300,
|
||||
admin_username="admin",
|
||||
admin_password="hunter2",
|
||||
throttle=LoginThrottle(clock),
|
||||
)
|
||||
|
||||
|
||||
def test_account_locks_after_repeated_misses_and_unlocks_after_the_window() -> None:
|
||||
clock = Clock()
|
||||
service = _service(clock)
|
||||
misses = [service.login("admin", "wrong") for i in range(LOCKOUT_FAILURES)]
|
||||
assert all(isinstance(miss, InvalidCredentials) for miss in misses)
|
||||
locked = service.login("admin", "hunter2")
|
||||
assert locked == Throttled(retry_after_seconds=int(LOCKOUT_SECONDS))
|
||||
clock.now += LOCKOUT_SECONDS / 2
|
||||
assert service.login("admin", "hunter2") == Throttled(retry_after_seconds=int(LOCKOUT_SECONDS / 2))
|
||||
clock.now += LOCKOUT_SECONDS / 2 + 1
|
||||
assert isinstance(service.login("admin", "hunter2"), SessionTokenResponse)
|
||||
|
||||
|
||||
def test_expired_misses_are_pruned_so_distinct_usernames_cannot_grow_the_store_forever() -> None:
|
||||
clock = Clock()
|
||||
throttle = LoginThrottle(clock)
|
||||
for i in range(100):
|
||||
throttle.record_failure(f"user{i}")
|
||||
clock.now += LOCKOUT_SECONDS / 10
|
||||
assert throttle.tracked() <= 10
|
||||
clock.now += LOCKOUT_SECONDS + 1
|
||||
throttle.record_failure("last")
|
||||
assert throttle.tracked() == 1
|
||||
|
||||
|
||||
def test_a_success_clears_the_accounts_misses() -> None:
|
||||
clock = Clock()
|
||||
service = _service(clock)
|
||||
for _ in range(LOCKOUT_FAILURES - 1):
|
||||
service.login("admin", "wrong")
|
||||
assert isinstance(service.login("admin", "hunter2"), SessionTokenResponse)
|
||||
for _ in range(LOCKOUT_FAILURES - 1):
|
||||
assert isinstance(service.login("admin", "wrong"), InvalidCredentials)
|
||||
assert isinstance(service.login("admin", "hunter2"), SessionTokenResponse)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("username", ["admin", "nobody"])
|
||||
def test_no_configured_admin_rejects_everyone(username: str) -> None:
|
||||
sessions = SessionTokenCodec(
|
||||
master_key="sk-master", issuer="cp", audience="cp-api", ttl=timedelta(minutes=5), policy=default_policy()
|
||||
)
|
||||
service = LoginService(sessions, default_policy(), 300, None, None, LoginThrottle(Clock()))
|
||||
assert isinstance(service.login(username, "hunter2"), InvalidCredentials)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("username", ["", "u" * 257])
|
||||
def test_login_request_bounds_the_username(username: str) -> None:
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.types.proxy.control_plane.auth import LoginRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
LoginRequest(username=username, password="x")
|
||||
131
tests/unit/proxy/control_plane/auth/test_rbac.py
Normal file
131
tests/unit/proxy/control_plane/auth/test_rbac.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import pytest
|
||||
|
||||
from litellm.proxy.control_plane.auth.rbac import (
|
||||
ADMIN_ROLE,
|
||||
DEFAULT_ROLES,
|
||||
EDITOR_ROLE,
|
||||
USAGE_VIEWER_ROLE,
|
||||
VIEWER_ROLE,
|
||||
DataplaneBinding,
|
||||
RoleDefinition,
|
||||
RolePolicy,
|
||||
Scope,
|
||||
UnknownParent,
|
||||
UnknownScope,
|
||||
build_policy,
|
||||
build_principal,
|
||||
clamp_scopes,
|
||||
default_policy,
|
||||
parse_role_definition,
|
||||
parse_scopes,
|
||||
)
|
||||
|
||||
|
||||
def _principal(policy: RolePolicy, roles: frozenset[str], ids: frozenset[str] = frozenset()):
|
||||
return build_principal(policy, "u", None, roles, None, ids, "oidc")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", list(DEFAULT_ROLES))
|
||||
@pytest.mark.parametrize("scope", list(Scope))
|
||||
def test_every_default_role_scope_cell_matches_the_policy_table(role: str, scope: Scope) -> None:
|
||||
assert _principal(default_policy(), frozenset({role})).has_scope(scope) is (scope in DEFAULT_ROLES[role])
|
||||
|
||||
|
||||
def test_default_tiers_only_admin_manages_access_only_editor_writes_and_usage_viewer_sees_only_usage() -> None:
|
||||
assert DEFAULT_ROLES[ADMIN_ROLE] == frozenset(Scope)
|
||||
assert {Scope.DATAPLANES_WRITE, Scope.ROLES_WRITE, Scope.AUDIT_READ}.isdisjoint(DEFAULT_ROLES[EDITOR_ROLE])
|
||||
assert Scope.PROXY_WRITE in DEFAULT_ROLES[EDITOR_ROLE] and Scope.PROXY_WRITE not in DEFAULT_ROLES[VIEWER_ROLE]
|
||||
assert DEFAULT_ROLES[USAGE_VIEWER_ROLE] == frozenset({Scope.USAGE_READ})
|
||||
|
||||
|
||||
def test_roles_union_their_grants() -> None:
|
||||
principal = _principal(default_policy(), frozenset({USAGE_VIEWER_ROLE, VIEWER_ROLE}))
|
||||
assert principal.scopes == DEFAULT_ROLES[VIEWER_ROLE]
|
||||
|
||||
|
||||
def test_claimed_scopes_narrow_but_never_widen_the_role_grant() -> None:
|
||||
granted = DEFAULT_ROLES[VIEWER_ROLE]
|
||||
assert clamp_scopes(granted, frozenset({Scope.USAGE_READ, Scope.PROXY_WRITE})) == frozenset({Scope.USAGE_READ})
|
||||
assert clamp_scopes(granted, None) == granted
|
||||
|
||||
|
||||
def test_a_scope_claim_naming_only_unknown_scopes_grants_nothing() -> None:
|
||||
"""A present claim is always honored, so a token scoped to foreign scopes cannot fall back to the role grant."""
|
||||
assert clamp_scopes(DEFAULT_ROLES[ADMIN_ROLE], frozenset()) == frozenset()
|
||||
assert clamp_scopes(DEFAULT_ROLES[ADMIN_ROLE], parse_scopes(("openid", "profile"))) == frozenset()
|
||||
|
||||
|
||||
def test_custom_role_overrides_a_default_and_can_inherit() -> None:
|
||||
policy = build_policy(
|
||||
{
|
||||
"viewer": RoleDefinition(scopes=frozenset({Scope.FLEET_READ}), inherits=frozenset()),
|
||||
"finops": RoleDefinition(scopes=frozenset({Scope.AUDIT_READ}), inherits=frozenset({"usage_viewer"})),
|
||||
"lead": RoleDefinition(scopes=frozenset(), inherits=frozenset({"finops", "editor"})),
|
||||
}
|
||||
)
|
||||
assert isinstance(policy, RolePolicy)
|
||||
assert policy.scopes_for("viewer") == frozenset({Scope.FLEET_READ})
|
||||
assert policy.scopes_for("finops") == frozenset({Scope.AUDIT_READ, Scope.USAGE_READ})
|
||||
assert policy.scopes_for("lead") == DEFAULT_ROLES[EDITOR_ROLE] | {Scope.AUDIT_READ, Scope.USAGE_READ}
|
||||
assert policy.scopes_for("admin") == frozenset(Scope)
|
||||
|
||||
|
||||
def test_defaults_can_be_switched_off_so_only_custom_roles_exist() -> None:
|
||||
policy = build_policy({"ops": RoleDefinition(frozenset({Scope.USAGE_READ}), frozenset())}, include_defaults=False)
|
||||
assert isinstance(policy, RolePolicy)
|
||||
assert policy.names == frozenset({"ops"})
|
||||
assert policy.known(("admin", "ops")) == frozenset({"ops"})
|
||||
|
||||
|
||||
def test_inheriting_an_unknown_role_is_an_error_value() -> None:
|
||||
policy = build_policy({"x": RoleDefinition(frozenset(), frozenset({"nope"}))})
|
||||
assert policy == UnknownParent(role="x", parent="nope")
|
||||
|
||||
|
||||
def test_inheritance_cycles_terminate() -> None:
|
||||
policy = build_policy(
|
||||
{
|
||||
"a": RoleDefinition(frozenset({Scope.USAGE_READ}), frozenset({"b"})),
|
||||
"b": RoleDefinition(frozenset({Scope.FLEET_READ}), frozenset({"a"})),
|
||||
}
|
||||
)
|
||||
assert isinstance(policy, RolePolicy)
|
||||
assert policy.scopes_for("a") == policy.scopes_for("b") == frozenset({Scope.USAGE_READ, Scope.FLEET_READ})
|
||||
|
||||
|
||||
def test_role_definition_rejects_unknown_scopes() -> None:
|
||||
assert parse_role_definition("r", ("usage:read", "nope"), ()) == UnknownScope(role="r", scope="nope")
|
||||
parsed = parse_role_definition("r", ("usage:read",), ("viewer",))
|
||||
assert parsed == RoleDefinition(scopes=frozenset({Scope.USAGE_READ}), inherits=frozenset({"viewer"}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ids", [frozenset(), frozenset({"*"}), frozenset({"*", "eu"})])
|
||||
def test_no_binding_or_star_is_fleet_wide(ids: frozenset[str]) -> None:
|
||||
principal = _principal(default_policy(), frozenset({VIEWER_ROLE}), ids)
|
||||
assert principal.dataplanes == DataplaneBinding(all=True, ids=frozenset())
|
||||
assert principal.dataplanes.allows("anything")
|
||||
|
||||
|
||||
def test_explicit_binding_limits_dataplanes_regardless_of_role() -> None:
|
||||
principal = _principal(default_policy(), frozenset({ADMIN_ROLE}), frozenset({"eu"}))
|
||||
assert principal.dataplanes.allows("eu") and not principal.dataplanes.allows("us")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("binding", "requested", "expected"),
|
||||
[
|
||||
(DataplaneBinding(True, frozenset()), None, None),
|
||||
(DataplaneBinding(True, frozenset()), frozenset({"a"}), frozenset({"a"})),
|
||||
(DataplaneBinding(False, frozenset({"a", "b"})), None, frozenset({"a", "b"})),
|
||||
(DataplaneBinding(False, frozenset({"a", "b"})), frozenset({"b", "c"}), frozenset({"b"})),
|
||||
],
|
||||
)
|
||||
def test_restrict_intersects_the_request_with_the_binding(
|
||||
binding: DataplaneBinding, requested: frozenset[str] | None, expected: frozenset[str] | None
|
||||
) -> None:
|
||||
assert binding.restrict(requested) == expected
|
||||
|
||||
|
||||
def test_unknown_scopes_are_dropped_not_raised() -> None:
|
||||
assert parse_scopes(("usage:read", "openid", "profile")) == frozenset({Scope.USAGE_READ})
|
||||
assert parse_scopes(None) is None
|
||||
0
tests/unit/proxy/control_plane/common/__init__.py
Normal file
0
tests/unit/proxy/control_plane/common/__init__.py
Normal file
0
tests/unit/proxy/control_plane/health/__init__.py
Normal file
0
tests/unit/proxy/control_plane/health/__init__.py
Normal file
134
tests/unit/proxy/control_plane/test_app.py
Normal file
134
tests/unit/proxy/control_plane/test_app.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from litellm.proxy.control_plane.app import LicenseRequired, create_app
|
||||
from litellm.proxy.control_plane.auth.jwt import SessionTokenCodec
|
||||
from litellm.proxy.control_plane.auth.rbac import VIEWER_ROLE, build_principal
|
||||
from litellm.proxy.control_plane.container import InvalidRolePolicy, build_container
|
||||
from litellm.proxy.control_plane.settings import ControlPlaneSettings
|
||||
|
||||
MASTER_KEY: Final = "sk-cp-master"
|
||||
|
||||
|
||||
class Licensed:
|
||||
def is_premium(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class Unlicensed:
|
||||
def is_premium(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _settings(**overrides: object) -> ControlPlaneSettings:
|
||||
return ControlPlaneSettings.model_validate(
|
||||
{"LITELLM_MASTER_KEY": MASTER_KEY, "UI_USERNAME": "admin", "UI_PASSWORD": "pw", **overrides}
|
||||
)
|
||||
|
||||
|
||||
def _client(settings: ControlPlaneSettings | None = None) -> TestClient:
|
||||
return TestClient(create_app(build_container(settings or _settings()), Licensed()))
|
||||
|
||||
|
||||
def _bearer(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_app_refuses_to_build_without_a_valid_license() -> None:
|
||||
with pytest.raises(LicenseRequired):
|
||||
create_app(build_container(_settings()), Unlicensed())
|
||||
|
||||
|
||||
def test_health_routes_need_no_credentials() -> None:
|
||||
client = _client()
|
||||
assert client.get("/healthz").json() == {"status": "ok"}
|
||||
assert client.get("/readyz").json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_missing_bearer_is_401_problem_json_with_a_challenge() -> None:
|
||||
response = _client().get("/api/v1/auth/me")
|
||||
assert response.status_code == 401
|
||||
assert response.headers["content-type"] == "application/problem+json"
|
||||
assert response.headers["www-authenticate"].startswith("Bearer ")
|
||||
assert response.json()["type"] == "urn:litellm:error:unauthorized"
|
||||
|
||||
|
||||
def test_garbage_bearer_is_401() -> None:
|
||||
assert _client().get("/api/v1/auth/me", headers=_bearer("nope")).status_code == 401
|
||||
|
||||
|
||||
def test_master_key_is_a_fleet_wide_cp_admin() -> None:
|
||||
body = _client().get("/api/v1/auth/me", headers=_bearer(MASTER_KEY)).json()
|
||||
assert body["roles"] == ["admin"]
|
||||
assert body["dataplanes"] is None
|
||||
assert body["token_kind"] == "master_key"
|
||||
assert "dataplanes:write" in body["scopes"]
|
||||
|
||||
|
||||
def test_login_mints_a_session_that_me_accepts() -> None:
|
||||
client = _client()
|
||||
login = client.post("/api/v1/auth/login", json={"username": "admin", "password": "pw"})
|
||||
assert login.status_code == 200, login.text
|
||||
token = login.json()
|
||||
assert token["token_type"] == "bearer" and token["expires_in"] == _settings().session_ttl_seconds
|
||||
me = client.get("/api/v1/auth/me", headers=_bearer(token["access_token"])).json()
|
||||
assert (me["subject"], me["roles"], me["token_kind"]) == ("admin", ["admin"], "session")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [{"username": "admin", "password": "wrong"}, {"username": "root", "password": "pw"}])
|
||||
def test_wrong_credentials_are_401(body: dict[str, str]) -> None:
|
||||
response = _client().post("/api/v1/auth/login", json=body)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["type"] == "urn:litellm:error:unauthorized"
|
||||
|
||||
|
||||
def test_login_is_disabled_when_no_admin_user_is_configured() -> None:
|
||||
settings = ControlPlaneSettings.model_validate({"LITELLM_MASTER_KEY": MASTER_KEY})
|
||||
response = _client(settings).post("/api/v1/auth/login", json={"username": "", "password": ""})
|
||||
assert response.status_code == 422
|
||||
assert _client(settings).post("/api/v1/auth/login", json={"username": "a", "password": "b"}).status_code == 401
|
||||
|
||||
|
||||
def test_invalid_login_body_is_422_problem_json() -> None:
|
||||
response = _client().post("/api/v1/auth/login", json={"username": "admin"})
|
||||
assert response.status_code == 422
|
||||
assert response.json()["type"] == "urn:litellm:error:invalid-request-body"
|
||||
|
||||
|
||||
def test_session_scopes_are_reported_and_clamped_to_the_role() -> None:
|
||||
settings = _settings()
|
||||
container = build_container(settings)
|
||||
codec = SessionTokenCodec(MASTER_KEY, settings.session_issuer, timedelta(hours=1), container.policy)
|
||||
viewer = build_principal(container.policy, "v", None, frozenset({VIEWER_ROLE}), None, frozenset({"eu"}), "session")
|
||||
body = _client(settings).get("/api/v1/auth/me", headers=_bearer(codec.mint(viewer))).json()
|
||||
assert body["dataplanes"] == ["eu"]
|
||||
assert "proxy:write" not in body["scopes"] and "usage:read" in body["scopes"]
|
||||
|
||||
|
||||
def test_master_key_setting_is_a_secret() -> None:
|
||||
assert isinstance(_settings().master_key, SecretStr)
|
||||
assert MASTER_KEY not in repr(_settings())
|
||||
|
||||
|
||||
def test_custom_roles_from_settings_are_honoured_on_me() -> None:
|
||||
settings = _settings(roles={"finops": {"scopes": ["usage:read"], "inherits": ["usage_viewer"]}})
|
||||
container = build_container(settings)
|
||||
codec = SessionTokenCodec(MASTER_KEY, settings.session_issuer, timedelta(hours=1), container.policy)
|
||||
finops = build_principal(container.policy, "f", None, frozenset({"finops"}), None, frozenset(), "session")
|
||||
client = TestClient(create_app(container, Licensed()))
|
||||
body = client.get("/api/v1/auth/me", headers=_bearer(codec.mint(finops))).json()
|
||||
assert (body["roles"], body["scopes"]) == (["finops"], ["usage:read"])
|
||||
|
||||
|
||||
def test_a_role_granting_an_unknown_scope_fails_startup() -> None:
|
||||
with pytest.raises(InvalidRolePolicy, match="unknown scope 'nope'"):
|
||||
build_container(_settings(roles={"x": {"scopes": ["nope"]}}))
|
||||
|
||||
|
||||
def test_a_role_inheriting_an_unknown_role_fails_startup() -> None:
|
||||
with pytest.raises(InvalidRolePolicy, match="inherits unknown role 'ghost'"):
|
||||
build_container(_settings(roles={"x": {"inherits": ["ghost"]}}))
|
||||
27
tests/unit/proxy/control_plane/test_settings.py
Normal file
27
tests/unit/proxy/control_plane/test_settings.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.proxy.control_plane.settings import ControlPlaneSettings
|
||||
from litellm.types.proxy.control_plane.auth import PASSWORD_MAX_LENGTH, USERNAME_MAX_LENGTH
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("username", "password"),
|
||||
[("u" * (USERNAME_MAX_LENGTH + 1), "p"), ("u", "p" * (PASSWORD_MAX_LENGTH + 1))],
|
||||
)
|
||||
def test_configured_admin_credentials_share_the_login_request_bounds(username: str, password: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ControlPlaneSettings.model_validate(
|
||||
{"LITELLM_MASTER_KEY": "sk-cp", "UI_USERNAME": username, "UI_PASSWORD": password}
|
||||
)
|
||||
|
||||
|
||||
def test_credentials_at_the_bound_are_accepted() -> None:
|
||||
settings = ControlPlaneSettings.model_validate(
|
||||
{
|
||||
"LITELLM_MASTER_KEY": "sk-cp",
|
||||
"UI_USERNAME": "u" * USERNAME_MAX_LENGTH,
|
||||
"UI_PASSWORD": "p" * PASSWORD_MAX_LENGTH,
|
||||
}
|
||||
)
|
||||
assert settings.ui_username is not None and len(settings.ui_username) == USERNAME_MAX_LENGTH
|
||||
Loading…
Add table
Reference in a new issue