mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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.
This commit is contained in:
parent
25823cba7f
commit
2a85db9a91
3 changed files with 48 additions and 47 deletions
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue