diff --git a/litellm/proxy/auth/v2/authn/authenticators.py b/litellm/proxy/auth/v2/authn/authenticators.py index 3698fd4b9bc..6aec5132008 100644 --- a/litellm/proxy/auth/v2/authn/authenticators.py +++ b/litellm/proxy/auth/v2/authn/authenticators.py @@ -16,9 +16,26 @@ if TYPE_CHECKING: from litellm.proxy.utils import PrismaClient, ProxyLogging from .jwt_claims import JWTSettings + from .jwt_verifier import JWKSProvider from .oauth2_introspection import IntrospectionSettings +# JWKS providers are long-lived and keyed by uri so the in-instance TTL cache +# actually survives between requests; a fresh provider per request would refetch +# the JWKS over the network on every JWT authentication. +_jwks_providers: Dict[str, "JWKSProvider"] = {} + + +def _jwks_provider(jwks_uri: str) -> "JWKSProvider": + from .jwt_verifier import JWKSProvider + + provider = _jwks_providers.get(jwks_uri) + if provider is None: + provider = JWKSProvider(jwks_uri) + _jwks_providers[jwks_uri] = provider + return provider + + @dataclass(frozen=True) class AuthResult: """The output of the authenticator chain: the resolved identity and how.""" @@ -155,10 +172,10 @@ class JWTAuthenticator: from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from .jwt_claims import extract_identity - from .jwt_verifier import JWKSProvider, JWTVerificationError, verify + from .jwt_verifier import JWTVerificationError, verify settings = _load_jwt_settings() - key_set = await JWKSProvider(settings.jwks_uri).get_key_set() + key_set = await _jwks_provider(settings.jwks_uri).get_key_set() try: claims = verify(api_key, key_set, settings.issuer, settings.audience) except JWTVerificationError as e: diff --git a/litellm/proxy/auth/v2/entry.py b/litellm/proxy/auth/v2/entry.py index 49dbe3cfcf1..39ca07c63b7 100644 --- a/litellm/proxy/auth/v2/entry.py +++ b/litellm/proxy/auth/v2/entry.py @@ -191,37 +191,44 @@ async def user_api_key_auth_v2( # key.models / access-group mechanism is intentionally not consulted. result = await authenticate(token, ctx) request_data = await _read_request_body(request=request) - if result.method is not AuthMethod.VIRTUAL_KEY: - await _enrich_for_limits(result.identity, ctx) - principal, identity = _establish_context(request, result, route) requested_model = ( request_data.get("model") if isinstance(request_data, dict) else None ) - if requested_model: - enforcer = await _build_enforcer(principal, prisma_client) - obj = f"model:{requested_model}" - start = time.perf_counter() - allowed = enforcer.enforce(principal.subject, principal.domain, obj, "call") - metrics.observe_latency(time.perf_counter() - start) - decision = Decision.ALLOW if allowed else Decision.DENY - metrics.observe_decision(decision, "model", "call") - record( - AuthzDecision( - decision=decision, - subject=principal.subject, - domain=principal.domain, - obj=obj, - action="call", - route=route, - reason="model call", - auth_method=result.method.value, - ) + # Default-deny means a model call must name a model to be authorized. A + # missing/empty model on an inference route can't be authorized, so deny it + # up front -- before any enrichment/budget work -- rather than pass through. + if not requested_model: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="auth_v2: a model must be specified to call an inference route", + ) + if result.method is not AuthMethod.VIRTUAL_KEY: + await _enrich_for_limits(result.identity, ctx) + principal, identity = _establish_context(request, result, route) + enforcer = await _build_enforcer(principal, prisma_client) + obj = f"model:{requested_model}" + start = time.perf_counter() + allowed = enforcer.enforce(principal.subject, principal.domain, obj, "call") + metrics.observe_latency(time.perf_counter() - start) + decision = Decision.ALLOW if allowed else Decision.DENY + metrics.observe_decision(decision, "model", "call") + record( + AuthzDecision( + decision=decision, + subject=principal.subject, + domain=principal.domain, + obj=obj, + action="call", + route=route, + reason="model call", + auth_method=result.method.value, + ) + ) + if not allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"auth_v2: not permitted to call model '{requested_model}'", ) - if not allowed: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"auth_v2: not permitted to call model '{requested_model}'", - ) await _enforce_budgets(identity, route, ctx) await resolve_end_user(request, request_data, dict(request.headers)) seed_request_identity(identity, model=requested_model) diff --git a/litellm/proxy/auth/v2/management_endpoints.py b/litellm/proxy/auth/v2/management_endpoints.py index 26eea1af17e..e6826b434a9 100644 --- a/litellm/proxy/auth/v2/management_endpoints.py +++ b/litellm/proxy/auth/v2/management_endpoints.py @@ -3,7 +3,7 @@ from typing import Dict, List, Optional, cast from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from .authz.policy_admin import ( @@ -14,7 +14,22 @@ from .authz.policy_admin import ( from .authz.policy_store import reset_cache from .protocols import CasbinRuleRow, PolicyAdminDB -router = APIRouter(tags=["auth_v2"]) + +def _require_auth_v2_enabled() -> None: + # These endpoints only exist as a surface when auth_v2 is the active auth path; + # the router is registered unconditionally, so gate it per request (general + # settings aren't loaded at import time). 404 keeps it invisible on v1. + from litellm.proxy.proxy_server import general_settings + + if (general_settings or {}).get("auth_version") != "v2": + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") + + +# Authorization is the casbin "policy" resource: every route below is in the v2 +# route map (policy read/write/delete), so user_api_key_auth enforces it before +# the body runs. No separate v1-role check - that would reject JWT/OAuth2 admins +# casbin already authorized. +router = APIRouter(tags=["auth_v2"], dependencies=[Depends(_require_auth_v2_enabled)]) class PermissionRequest(BaseModel): @@ -52,14 +67,6 @@ def row_to_rule(row: CasbinRuleRow) -> List[str]: return rule -def _require_admin(user_api_key_dict: UserAPIKeyAuth) -> None: - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="auth_v2 policy administration requires proxy admin", - ) - - def _prisma() -> PolicyAdminDB: from litellm.proxy.proxy_server import prisma_client @@ -89,7 +96,6 @@ async def add_permission( body: PermissionRequest, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): - _require_admin(user_api_key_dict) try: rule = make_permission_rule( role=body.role, @@ -110,7 +116,6 @@ async def remove_permission( body: PermissionRequest, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): - _require_admin(user_api_key_dict) try: rule = make_permission_rule( role=body.role, @@ -131,7 +136,6 @@ async def add_assignment( body: AssignmentRequest, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): - _require_admin(user_api_key_dict) try: rule = make_assignment_rule( body.subject_type, body.subject_id, body.role, body.domain @@ -147,7 +151,6 @@ async def remove_assignment( body: AssignmentRequest, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): - _require_admin(user_api_key_dict) try: rule = make_assignment_rule( body.subject_type, body.subject_id, body.role, body.domain @@ -162,6 +165,5 @@ async def remove_assignment( async def list_policies( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): - _require_admin(user_api_key_dict) rows = await _prisma().db.litellm_casbinrule.find_many() return {"rules": [row_to_rule(row) for row in rows]} diff --git a/tests/test_litellm/proxy/auth/v2/authn/test_authenticators.py b/tests/test_litellm/proxy/auth/v2/authn/test_authenticators.py index 9afd829560a..e1fd8fc4184 100644 --- a/tests/test_litellm/proxy/auth/v2/authn/test_authenticators.py +++ b/tests/test_litellm/proxy/auth/v2/authn/test_authenticators.py @@ -1,10 +1,12 @@ import pytest +from litellm.proxy.auth.v2.authn import authenticators from litellm.proxy.auth.v2.authn.authenticators import ( OAuth2IntrospectionAuthenticator, JWTAuthenticator, MasterKeyAuthenticator, VirtualKeyAuthenticator, + _jwks_provider, ) from litellm.proxy.auth.v2.context import AuthMethod @@ -20,6 +22,17 @@ def test_each_authenticator_advertises_its_method(): assert OAuth2IntrospectionAuthenticator().method is AuthMethod.OAUTH2 +def test_jwks_provider_is_reused_per_uri(): + # Regression: a fresh provider per request makes the TTL cache dead and refetches + # the JWKS every JWT auth. The same uri must return the same long-lived provider. + authenticators._jwks_providers.clear() + first = _jwks_provider("https://idp.example/.well-known/jwks.json") + again = _jwks_provider("https://idp.example/.well-known/jwks.json") + other = _jwks_provider("https://other.example/jwks") + assert first is again + assert first is not other + + MASTER = "sk-master-secret-123" diff --git a/tests/test_litellm/proxy/auth/v2/test_integration_flow.py b/tests/test_litellm/proxy/auth/v2/test_integration_flow.py index 1d0b6b133b2..c53a9b8c01a 100644 --- a/tests/test_litellm/proxy/auth/v2/test_integration_flow.py +++ b/tests/test_litellm/proxy/auth/v2/test_integration_flow.py @@ -156,3 +156,11 @@ def test_model_call_requires_call_permission(client): "/chat/completions", headers=_h("sk-user-test"), json={"model": "gpt-4o"} ) assert allowed.status_code == 200 + + +def test_inference_without_a_model_is_denied(client): + # Default-deny: a model call must name a model. Omitting it must not pass + # through (it would let a subject with no grants reach inference). Even the + # master-key admin is denied, since an unnamed model call can't be authorized. + r = client.post("/chat/completions", headers=_h(MASTER_KEY), json={}) + assert r.status_code == 403 diff --git a/tests/test_litellm/proxy/auth/v2/test_management_endpoints.py b/tests/test_litellm/proxy/auth/v2/test_management_endpoints.py index d926b07c0a5..31cfd354d7e 100644 --- a/tests/test_litellm/proxy/auth/v2/test_management_endpoints.py +++ b/tests/test_litellm/proxy/auth/v2/test_management_endpoints.py @@ -1,34 +1,26 @@ -from types import SimpleNamespace - import pytest from fastapi import HTTPException -from litellm.proxy._types import LitellmUserRoles +import litellm.proxy.proxy_server as ps from litellm.proxy.auth.v2.management_endpoints import ( - _require_admin, + _require_auth_v2_enabled, row_to_rule, rule_to_row_data, ) -def test_require_admin_allows_only_proxy_admin(): - # No raise for a full proxy admin. - _require_admin(SimpleNamespace(user_role=LitellmUserRoles.PROXY_ADMIN)) +def test_policy_admin_surface_is_404_when_auth_v2_disabled(monkeypatch): + # The router is registered unconditionally, so the per-request guard must hide + # it on v1 deployments (authz itself is casbin's job once v2 is on). + monkeypatch.setattr(ps, "general_settings", {}, raising=False) + with pytest.raises(HTTPException) as exc: + _require_auth_v2_enabled() + assert exc.value.status_code == 404 -def test_require_admin_blocks_non_admins(): - # Privilege-escalation guard: only PROXY_ADMIN may edit policies. View-only - # admins and every other role (and no role) must be rejected with 403. - for role in ( - LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, - LitellmUserRoles.INTERNAL_USER, - LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, - LitellmUserRoles.TEAM, - None, - ): - with pytest.raises(HTTPException) as exc: - _require_admin(SimpleNamespace(user_role=role)) - assert exc.value.status_code == 403 +def test_policy_admin_surface_is_available_when_auth_v2_enabled(monkeypatch): + monkeypatch.setattr(ps, "general_settings", {"auth_version": "v2"}, raising=False) + _require_auth_v2_enabled() # must not raise class _Row: