fix(proxy): address greptile review - JWKS cache, model-less inference, policy-admin auth
Some checks failed
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Has been cancelled
Unit Tests: Security / security (push) Has been cancelled
Unit Tests: Proxy DB Operations / auth-checks (push) Has been cancelled
Unit Tests: Proxy DB Operations / budgets (push) Has been cancelled
Unit Tests: Proxy DB Operations / custom-logging (push) Has been cancelled
Unit Tests: Proxy DB Operations / db-and-spend (push) Has been cancelled
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Has been cancelled
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Has been cancelled
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Has been cancelled
Unit Tests: Proxy DB Operations / key-generation (push) Has been cancelled
Unit Tests: Proxy DB Operations / logging-misc (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-runtime (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-server-core (push) Has been cancelled
Unit Tests: Proxy DB Operations / schema-migration (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-utils (push) Has been cancelled

Three P1 defects from the auth_v2 review, plus the policy-admin flag guard:

- JWKS cache was dead: JWKSProvider was rebuilt per request, so its TTL cache
  never survived and every JWT auth refetched the JWKS over the network. Providers
  are now cached per jwks_uri at module level.
- Inference with no model bypassed casbin: a body omitting "model" skipped the
  call check entirely, so a subject with no grants could reach inference. Now a
  model-less inference request is denied up front (before any enrichment/budget).
- Policy-admin endpoints used a v1 _require_admin role check that rejected
  JWT/OAuth2 admins casbin had already authorized. Removed it - the routes are in
  the casbin route map (policy read/write/delete), so user_api_key_auth enforces
  them - and added a per-request flag guard so the unconditionally-registered
  router 404s when auth_v2 is off.

Regression tests for each. mypy clean on 23 files, 143 tests green.
This commit is contained in:
ryan-crabbe-berri 2026-06-06 23:31:13 -07:00
parent 121d46051f
commit 7f84db33ab
6 changed files with 103 additions and 64 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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]}

View file

@ -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"

View file

@ -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

View file

@ -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: