From 2a85db9a9154125a5c93be92510e499b7aef849c Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Fri, 5 Jun 2026 08:54:22 -0700 Subject: [PATCH] refactor(proxy): auth_v2 data-plane model gate is a plain predicate, with wildcard parity The inference-path model check ran through a casbin enforcer whose matcher was a trivial membership test (unrestricted || requested in allowed). On the hot path that is pure overhead and indirection; casbin earns its keep on the control plane (roles, deny-override, domains), not here. Replaces it with a direct predicate and removes data_plane.conf. Also closes a parity gap with v1: the gate now honors wildcard patterns (e.g. bedrock/*, openai/*) via v1's is_model_allowed_by_pattern semantics, where before it only matched exact names and would over-deny wildcard model lists. Access-group expansion remains a tracked follow-up. --- litellm/proxy/auth/v2/data_plane.conf | 11 ---- litellm/proxy/auth/v2/data_plane.py | 62 ++++++++----------- .../proxy/auth/v2/test_data_plane.py | 22 +++++++ 3 files changed, 48 insertions(+), 47 deletions(-) delete mode 100644 litellm/proxy/auth/v2/data_plane.conf diff --git a/litellm/proxy/auth/v2/data_plane.conf b/litellm/proxy/auth/v2/data_plane.conf deleted file mode 100644 index 693e3fbd763..00000000000 --- a/litellm/proxy/auth/v2/data_plane.conf +++ /dev/null @@ -1,11 +0,0 @@ -[request_definition] -r = sub, obj - -[policy_definition] -p = eft - -[policy_effect] -e = some(where (p.eft == allow)) - -[matchers] -m = r.sub.unrestricted || r.obj in r.sub.allowed_models diff --git a/litellm/proxy/auth/v2/data_plane.py b/litellm/proxy/auth/v2/data_plane.py index 04bf3f8a394..0ee599126f0 100644 --- a/litellm/proxy/auth/v2/data_plane.py +++ b/litellm/proxy/auth/v2/data_plane.py @@ -1,47 +1,37 @@ -import os -from dataclasses import dataclass +import re from typing import List, Optional -import casbin - -_MODEL_PATH = os.path.join(os.path.dirname(__file__), "data_plane.conf") - # Sentinels that mean "any model" in the existing key/team model lists. _UNRESTRICTED_SENTINELS = {"*", "all-proxy-models", "all-team-models"} -@dataclass -class ModelAccessSubject: - """Carries the principal's model entitlement as a casbin ABAC attribute. - - The data plane runs on the inference hot path, so access is decided from - attributes already on the loaded key/team (no per-key policy rows, no policy - store read). An empty list means unrestricted, matching existing key - semantics where ``models == []`` allows every model. - """ - - allowed_models: List[str] - - @property - def unrestricted(self) -> bool: - if not self.allowed_models: - return True - return any(model in _UNRESTRICTED_SENTINELS for model in self.allowed_models) +def _is_unrestricted(allowed_models: List[str]) -> bool: + # An empty list means "no restriction" in litellm, matching key/team semantics. + if not allowed_models: + return True + return any(model in _UNRESTRICTED_SENTINELS for model in allowed_models) -_enforcer: Optional[casbin.Enforcer] = None - - -def _get_enforcer() -> casbin.Enforcer: - global _enforcer - if _enforcer is None: - enforcer = casbin.Enforcer(_MODEL_PATH) - enforcer.add_policy("allow") # single gate; the matcher does the deciding - _enforcer = enforcer - return _enforcer +def _matches_pattern(requested_model: str, pattern: str) -> bool: + # Mirrors v1 is_model_allowed_by_pattern: '*' is the only wildcard. + if "*" not in pattern: + return False + return bool(re.match("^" + pattern.replace("*", ".*") + "$", requested_model)) def can_call_model(allowed_models: Optional[List[str]], requested_model: str) -> bool: - """Decide whether a principal with ``allowed_models`` may call ``requested_model``.""" - subject = ModelAccessSubject(allowed_models=list(allowed_models or [])) - return _get_enforcer().enforce(subject, requested_model) + """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). + """ + 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) diff --git a/tests/test_litellm/proxy/auth/v2/test_data_plane.py b/tests/test_litellm/proxy/auth/v2/test_data_plane.py index dec3d951253..7ebd28dcc49 100644 --- a/tests/test_litellm/proxy/auth/v2/test_data_plane.py +++ b/tests/test_litellm/proxy/auth/v2/test_data_plane.py @@ -27,3 +27,25 @@ def test_specific_list_denies_unlisted_model(): def test_wildcard_mixed_with_specific_still_unrestricted(): assert can_call_model(["gpt-4o", "all-proxy-models"], "o1") is True + + +def test_provider_wildcard_pattern_matches(): + # Parity with v1 is_model_allowed_by_pattern: "bedrock/*" admits any bedrock model. + assert can_call_model(["bedrock/*"], "bedrock/anthropic.claude-3") is True + assert can_call_model(["openai/*"], "openai/gpt-4o") is True + + +def test_provider_wildcard_pattern_denies_other_providers(): + assert can_call_model(["bedrock/*"], "openai/gpt-4o") is False + # A prefix that isn't a full segment match must not leak. + assert can_call_model(["bedrock/*"], "bedrockzzz/x") is False + + +def test_partial_wildcard_within_provider(): + assert can_call_model(["bedrock/us.*"], "bedrock/us.amazon.nova") is True + assert can_call_model(["bedrock/us.*"], "bedrock/eu.amazon.nova") is False + + +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