mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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
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:
parent
121d46051f
commit
7f84db33ab
6 changed files with 103 additions and 64 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue