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:
ryan-crabbe-berri 2026-06-05 08:54:22 -07:00
parent 25823cba7f
commit 2a85db9a91
3 changed files with 48 additions and 47 deletions

View file

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

View file

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

View file

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