mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
0d5e14fcd1
commit
c596af04a4
4 changed files with 106 additions and 7 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
42
tests/test_litellm/proxy/auth/v2/test_authenticators.py
Normal file
42
tests/test_litellm/proxy/auth/v2/test_authenticators.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue