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.
This commit is contained in:
ryan-crabbe-berri 2026-06-04 20:23:03 -07:00
parent 0d5e14fcd1
commit c596af04a4
4 changed files with 106 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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