From c596af04a4fda263c670778f7a06dfc68f3a2a05 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 4 Jun 2026 20:23:03 -0700 Subject: [PATCH] feat(proxy): auth_v2 slice 2 - master-key authenticator + team resource Adds a master-key authenticator ahead of the virtual-key node so the proxy admin can authenticate under auth_v2 (slice 1 was virtual-key only, which locked the master key out). Match is a constant-time exact compare; the raw key never propagates downstream, a stable alias stands in for it. Extends casbin governance to the team management plane (/team/new, /team/update, /team/delete, /team/info) mirroring models. Team membership/permission routes (member_add, etc.) stay loud-open, deferred with the recursive `manage` action. Tests cover the master-key exact-match deny path (near-match, non-string, empty, unconfigured) and team route governance. --- litellm/proxy/auth/v2/authenticators.py | 40 ++++++++++++++++-- litellm/proxy/auth/v2/route_map.py | 11 ++++- .../proxy/auth/v2/test_authenticators.py | 42 +++++++++++++++++++ .../proxy/auth/v2/test_route_map.py | 20 ++++++++- 4 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 tests/test_litellm/proxy/auth/v2/test_authenticators.py diff --git a/litellm/proxy/auth/v2/authenticators.py b/litellm/proxy/auth/v2/authenticators.py index 7627d15ff43..484bc09b2c2 100644 --- a/litellm/proxy/auth/v2/authenticators.py +++ b/litellm/proxy/auth/v2/authenticators.py @@ -1,3 +1,4 @@ +import secrets from typing import Any, List, Optional, Protocol, runtime_checkable from fastapi import HTTPException, status @@ -28,6 +29,36 @@ class AuthContext: self.parent_otel_span = parent_otel_span +class MasterKeyAuthenticator: + """Authenticates the configured master key as the proxy admin. + + Checked before the virtual-key node because the master key also looks like a + ``sk-`` token but is not a row in the key table. The raw key never propagates + downstream; a stable alias stands in for it. + """ + + def can_handle(self, api_key: Optional[str]) -> bool: + from litellm.proxy.proxy_server import master_key + + if not isinstance(api_key, str) or not isinstance(master_key, str): + return False + try: + return secrets.compare_digest(api_key, master_key) + except Exception: + return False + + async def authenticate(self, api_key: str, ctx: AuthContext) -> Any: + from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.proxy_server import litellm_proxy_admin_name + + return UserAPIKeyAuth( + api_key=LITELLM_PROXY_MASTER_KEY_ALIAS, + user_id=litellm_proxy_admin_name, + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + class VirtualKeyAuthenticator: """Resolves a ``sk-`` virtual key to its identity via the existing key store.""" @@ -47,9 +78,12 @@ class VirtualKeyAuthenticator: ) -# Slice 1: virtual keys only. authlib-backed JWT / OAuth2 nodes slot in here next, -# implementing the same interface. -AUTHENTICATORS: List[Authenticator] = [VirtualKeyAuthenticator()] +# Master key is matched first (exact compare), then virtual keys. authlib-backed +# JWT / OAuth2 nodes slot in next, implementing the same interface. +AUTHENTICATORS: List[Authenticator] = [ + MasterKeyAuthenticator(), + VirtualKeyAuthenticator(), +] async def authenticate(api_key: Optional[str], ctx: AuthContext) -> Any: diff --git a/litellm/proxy/auth/v2/route_map.py b/litellm/proxy/auth/v2/route_map.py index 12c5c4ae94f..fdc7788101a 100644 --- a/litellm/proxy/auth/v2/route_map.py +++ b/litellm/proxy/auth/v2/route_map.py @@ -12,15 +12,22 @@ class GovernedRoute: id_fields: List[str] = field(default_factory=list) -# Slice 1 governs only the model-deployment management plane. Every other route -# is intentionally left ungoverned (loud-open) until later slices wire it in. +# Governs the model and team management planes. Every other route is +# intentionally left ungoverned (loud-open) until later slices wire it in. Team +# membership/permission routes (member_add, etc.) are deferred with the recursive +# `manage` action. _MODEL_ID_FIELDS = ["model_id", "id"] +_TEAM_ID_FIELDS = ["team_id", "id"] _GOVERNED: Dict[str, GovernedRoute] = { "/model/new": GovernedRoute("model", "write"), "/model/update": GovernedRoute("model", "write", _MODEL_ID_FIELDS), "/model/delete": GovernedRoute("model", "delete", _MODEL_ID_FIELDS), "/model/info": GovernedRoute("model", "read", _MODEL_ID_FIELDS), + "/team/new": GovernedRoute("team", "write"), + "/team/update": GovernedRoute("team", "write", _TEAM_ID_FIELDS), + "/team/delete": GovernedRoute("team", "delete", _TEAM_ID_FIELDS), + "/team/info": GovernedRoute("team", "read", _TEAM_ID_FIELDS), } diff --git a/tests/test_litellm/proxy/auth/v2/test_authenticators.py b/tests/test_litellm/proxy/auth/v2/test_authenticators.py new file mode 100644 index 00000000000..4747dde5296 --- /dev/null +++ b/tests/test_litellm/proxy/auth/v2/test_authenticators.py @@ -0,0 +1,42 @@ +import pytest + +from litellm.proxy.auth.v2.authenticators import ( + MasterKeyAuthenticator, + VirtualKeyAuthenticator, +) + +MASTER = "sk-master-secret-123" + + +@pytest.fixture +def master_key_set(monkeypatch): + # proxy_server.master_key is a module global; set it for the exact-compare path. + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", MASTER, raising=False) + + +def test_master_key_matches_only_exact(master_key_set): + auth = MasterKeyAuthenticator() + assert auth.can_handle(MASTER) is True + # A prefix / near-match must NOT authenticate as admin (constant-time exact compare). + assert auth.can_handle(MASTER + "x") is False + assert auth.can_handle("sk-master-secret-12") is False + assert auth.can_handle("sk-something-else") is False + + +def test_master_key_rejects_non_strings_and_empty(master_key_set): + auth = MasterKeyAuthenticator() + assert auth.can_handle(None) is False + assert auth.can_handle(b"bytes") is False + assert auth.can_handle("") is False + + +def test_master_key_authenticator_inert_when_unconfigured(monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None, raising=False) + assert MasterKeyAuthenticator().can_handle("sk-anything") is False + + +def test_virtual_key_handles_sk_prefix_but_not_master_first(): + vk = VirtualKeyAuthenticator() + assert vk.can_handle("sk-abc123") is True + assert vk.can_handle("not-a-key") is False + assert vk.can_handle(None) is False diff --git a/tests/test_litellm/proxy/auth/v2/test_route_map.py b/tests/test_litellm/proxy/auth/v2/test_route_map.py index 72820c25517..b798a9f7a54 100644 --- a/tests/test_litellm/proxy/auth/v2/test_route_map.py +++ b/tests/test_litellm/proxy/auth/v2/test_route_map.py @@ -18,11 +18,27 @@ def test_create_has_no_id_field(): assert match_route("/model/new").id_fields == [] +def test_team_routes_map_to_team_resource(): + assert match_route("/team/new").resource == "team" + assert match_route("/team/new").action == "write" + assert match_route("/team/update").action == "write" + assert match_route("/team/delete").action == "delete" + assert match_route("/team/info").action == "read" + assert match_route("/team/delete").id_fields == ["team_id", "id"] + + def test_trailing_slash_is_normalized(): assert match_route("/model/info/").resource == "model" def test_ungoverned_routes_return_none(): - # These are loud-open in slice 1 and must not be governed yet. - for route in ("/chat/completions", "/key/generate", "/team/new", "/v1/models", "/"): + # These are loud-open in this slice and must not be governed yet. + # /team/member_add is deferred with the recursive `manage` action. + for route in ( + "/chat/completions", + "/key/generate", + "/team/member_add", + "/v1/models", + "/", + ): assert match_route(route) is None