feat(proxy): auth_v2 data-plane honors model access groups (v1 parity)

The inference gate now allows a model when the key lists an access-group name
the model belongs to, mirroring v1 model_in_access_group. The group lookup is
resolved from the router in the entry point and injected into can_call_model,
which stays a pure predicate. Closes the access-group parity gap flagged when
the data plane moved off casbin; name, wildcard, and sentinel matching are
unchanged.
This commit is contained in:
ryan-crabbe-berri 2026-06-05 09:13:30 -07:00
parent 037df6ada3
commit 08f2e924f3
3 changed files with 60 additions and 7 deletions

View file

@ -1,5 +1,5 @@
import re
from typing import List, Optional
from typing import Iterable, List, Optional
# Sentinels that mean "any model" in the existing key/team model lists.
_UNRESTRICTED_SENTINELS = {"*", "all-proxy-models", "all-team-models"}
@ -19,19 +19,30 @@ def _matches_pattern(requested_model: str, pattern: str) -> bool:
return bool(re.match("^" + pattern.replace("*", ".*") + "$", requested_model))
def can_call_model(allowed_models: Optional[List[str]], requested_model: str) -> bool:
def can_call_model(
allowed_models: Optional[List[str]],
requested_model: str,
model_access_groups: Optional[Iterable[str]] = None,
) -> bool:
"""Decide whether a principal with ``allowed_models`` may call ``requested_model``.
Data-plane access is a direct membership/pattern predicate, not a policy
engine: it runs on the inference hot path where a casbin evaluation would be
pure overhead for what is a list check. Empty list or a sentinel means
unrestricted; an exact name matches; a wildcard pattern (e.g. ``bedrock/*``)
matches using v1's pattern semantics. Access-group expansion is not yet
honored here (tracked as a parity follow-up).
matches using v1's pattern semantics.
``model_access_groups`` are the access-group names ``requested_model`` belongs
to (from the router); if the key lists any of them the call is allowed,
mirroring v1 ``model_in_access_group``. Injected rather than resolved here so
this stays a pure predicate.
"""
models = list(allowed_models or [])
if _is_unrestricted(models):
return True
if requested_model in models:
return True
return any(_matches_pattern(requested_model, model) for model in models)
if any(_matches_pattern(requested_model, model) for model in models):
return True
groups = model_access_groups or ()
return any(model in groups for model in models)

View file

@ -17,6 +17,22 @@ async def _anonymous_identity(api_key: Optional[str]) -> Any:
return UserAPIKeyAuth(api_key=api_key)
def _model_access_groups(requested_model: str) -> Any:
"""Access-group names the requested model belongs to, via the router.
Returns an empty tuple when no router is configured so the caller degrades to
plain name/pattern matching.
"""
from litellm.proxy.proxy_server import llm_router
if llm_router is None:
return ()
try:
return llm_router.get_model_access_groups(model_name=requested_model)
except Exception:
return ()
async def _best_effort_identity(api_key: Optional[str], ctx: AuthContext) -> Any:
"""On loud-open routes, use the real identity if a usable key is present,
otherwise fall back to an anonymous principal. Never fails the request."""
@ -80,14 +96,17 @@ async def user_api_key_auth_v2(
return identity
if is_inference_route(route):
# Data plane: plain allowed-model predicate over the principal's key.
# Data plane: plain allowed-model predicate over the principal's key,
# with access-group expansion resolved from the router.
identity = await authenticate(token, ctx)
request_data = await _read_request_body(request=request)
requested_model = (
request_data.get("model") if isinstance(request_data, dict) else None
)
if requested_model and not can_call_model(
getattr(identity, "models", None), requested_model
getattr(identity, "models", None),
requested_model,
_model_access_groups(requested_model),
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,

View file

@ -49,3 +49,26 @@ def test_partial_wildcard_within_provider():
def test_exact_name_without_wildcard_does_not_pattern_match():
# No '*' -> exact membership only, never a substring/regex match.
assert can_call_model(["gpt-4o"], "gpt-4o-mini") is False
def test_access_group_membership_grants_access():
# Mirrors v1 model_in_access_group: the requested model belongs to "beta", and
# the key lists the group name, so the call is allowed.
groups = {"beta": ["o1", "o1-mini"]}
assert can_call_model(["beta"], "o1", model_access_groups=groups) is True
def test_unlisted_access_group_is_denied():
groups = {"beta": ["o1"]}
# Model is in group "beta" but the key only lists group "gamma".
assert can_call_model(["gamma"], "o1", model_access_groups=groups) is False
def test_access_groups_accept_any_iterable_of_names():
assert can_call_model(["beta"], "o1", model_access_groups={"beta", "alpha"}) is True
def test_no_access_groups_falls_back_to_name_and_pattern():
# Without groups, only name/pattern matching applies (existing behavior).
assert can_call_model(["beta"], "o1") is False
assert can_call_model(["o1"], "o1", model_access_groups=None) is True