feat(proxy): auth_v2 model-call access is a role permission, not the legacy field

Calling a model is now the `call` action on the `model:<id>` object, decided by
the same casbin role engine as everything else. Grant it via a role (wildcard
objects like model:gpt-*, or g2 groups) or directly to a key/user subject. The
legacy key.models list and access-group expansion are no longer consulted, so
with no grant a key can call nothing (clean-slate default-deny).

Removes the separate data-plane predicate (data_plane.py and its conf/tests);
the inference path now runs the same enforcer the control plane uses, extracted
into a shared _build_enforcer helper. Adds `call` to the valid policy actions.

Note: this puts a casbin check on the inference hot path. It builds the enforcer
per request from the short-TTL policy snapshot; caching a long-lived in-memory
enforcer with cross-pod invalidation is the next step before this is enabled at
real traffic.
This commit is contained in:
ryan-crabbe-berri 2026-06-05 09:37:51 -07:00
parent 08f2e924f3
commit ebb965d4c2
7 changed files with 82 additions and 179 deletions

View file

@ -27,24 +27,23 @@ Authentication is a chain dispatched by credential shape:
- JWT (3-part bearer) -> authlib: JWKS fetch/cache, signature + exp/iss/aud
- opaque token -> authlib RFC 7662 introspection (when configured)
Authorization splits by plane:
Authorization is one role system for everything, management and inference alike:
- control plane (management routes): a casbin engine with RBAC policy rows over
resources `model`, `team`, `key`, `user`, `organization`, `policy`, with
actions `read` / `write` / `delete` / `manage`. Supports per-resource ids,
resource groups (casbin `g2`, mapping to access groups), and roles that are
- management routes: RBAC over resources `model`, `team`, `key`, `user`,
`organization`, `policy`, `vector_store`, `budget`, `customer`, `mcp_server`,
`guardrail`, `credential`, with actions `read` / `write` / `delete` / `manage`.
Supports per-resource ids, resource groups (casbin `g2`), and roles that are
either global or scoped to a domain (casbin `g3`, e.g. "admin within
team:eng").
- data plane (inference: chat/completions, completions, embeddings, responses):
a plain membership/pattern predicate over the principal's allowed-model list,
read from the already-loaded key. It is not a policy engine: on the hot path a
casbin evaluation of a list check is pure overhead. Exact names and wildcard
patterns (`bedrock/*`, `openai/*`, v1 semantics) match; empty list / `*` /
`all-proxy-models` are unrestricted, matching existing key semantics.
- calling a model (inference: chat/completions, completions, embeddings,
responses) is the `call` action on the `model:<id>` object, decided by the
same engine. Grant it with a role (e.g. "call `gpt-*`", via wildcard objects or
`g2` groups) or directly to a key/user. The legacy `key.models` field and
access-group expansion are not consulted; the role system is the only
authority, so with no grant a key calls nothing (clean-slate default-deny).
Control-plane policies live in `LiteLLM_CasbinRule`, loaded on cold routes with a
short TTL cache; the inference path reads no policy store. A bootstrap policy
keeps `proxy_admin` fully authorized.
Policies live in `LiteLLM_CasbinRule`, loaded with a short TTL cache. A bootstrap
policy keeps `proxy_admin` fully authorized.
## Configuration

View file

@ -1,48 +0,0 @@
import re
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"}
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)
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,
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.
``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
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

@ -4,10 +4,9 @@ from fastapi import HTTPException, Request, status
from .authenticators import AuthContext, authenticate
from .authorizer import AuthorizationDenied, authorize
from .data_plane import can_call_model
from .enforcer import CasbinEnforcer
from .policy_store import load_policy_snapshot
from .principal import build_principal
from .principal import Principal, build_principal
from .route_map import is_inference_route, match_route
@ -17,20 +16,24 @@ 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.
async def _build_enforcer(principal: Principal, prisma_client: Any) -> CasbinEnforcer:
"""Build the casbin engine for this principal from the current policy snapshot.
Returns an empty tuple when no router is configured so the caller degrades to
plain name/pattern matching.
The principal's identity-to-role bridges are added on top of the stored
groupings. One engine authorizes both the control plane and model calls.
"""
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 ()
(
policies,
groupings,
resource_groupings,
domain_groupings,
) = await load_policy_snapshot(prisma_client)
return CasbinEnforcer(
policies,
groupings + principal.groupings,
resource_groupings,
domain_groupings,
)
async def _best_effort_identity(api_key: Optional[str], ctx: AuthContext) -> Any:
@ -69,23 +72,11 @@ async def user_api_key_auth_v2(
rule = match_route(route, request.method)
if rule is not None:
# Control plane: RBAC policy rows.
# Control plane: RBAC over management resources.
identity = await authenticate(token, ctx)
request_data = await _read_request_body(request=request)
principal = build_principal(identity)
(
policies,
groupings,
resource_groupings,
domain_groupings,
) = await load_policy_snapshot(prisma_client)
enforcer = CasbinEnforcer(
policies,
groupings + principal.groupings,
resource_groupings,
domain_groupings,
)
enforcer = await _build_enforcer(principal, prisma_client)
try:
authorize(principal, route, request_data, enforcer, request.method)
@ -96,22 +87,24 @@ 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,
# with access-group expansion resolved from the router.
# Model calls are a permission like any other: the `call` action on the
# `model:<id>` object, decided by the same role system. The legacy
# key.models / access-group mechanism is intentionally not consulted.
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,
_model_access_groups(requested_model),
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"auth_v2: not permitted to call model '{requested_model}'",
)
if requested_model:
principal = build_principal(identity)
enforcer = await _build_enforcer(principal, prisma_client)
if not enforcer.enforce(
principal.subject, principal.domain, f"model:{requested_model}", "call"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"auth_v2: not permitted to call model '{requested_model}'",
)
identity.request_route = route
return identity

View file

@ -1,6 +1,6 @@
from typing import List, Optional
VALID_ACTIONS = {"read", "write", "delete", "manage"}
VALID_ACTIONS = {"read", "write", "delete", "manage", "call"}
VALID_EFFECTS = {"allow", "deny"}
VALID_SUBJECT_TYPES = {"user", "team"}

View file

@ -100,9 +100,9 @@ _GOVERNED: Dict[str, GovernedRoute] = {
}
# Inference routes carry a `model` in the body and are authorized on the data
# plane (a plain allowed-model predicate over the principal's key), not the
# control-plane RBAC map.
# Inference routes carry a `model` in the body and are authorized by the same
# role system: the `call` action on the `model:<id>` object. They are matched
# here (by body, not path) rather than in the RPC route map above.
_INFERENCE_ROUTES = {
"/chat/completions",
"/v1/chat/completions",

View file

@ -1,74 +0,0 @@
from litellm.proxy.auth.v2.data_plane import can_call_model
def test_model_in_allowed_list_is_permitted():
assert can_call_model(["gpt-4o", "claude-3"], "gpt-4o") is True
def test_model_not_in_allowed_list_is_denied():
assert can_call_model(["gpt-4o"], "claude-3") is False
def test_empty_list_is_unrestricted_matching_v1():
# In litellm an empty models list means "no restriction", not "no access".
assert can_call_model([], "anything") is True
assert can_call_model(None, "anything") is True
def test_wildcards_are_unrestricted():
assert can_call_model(["*"], "anything") is True
assert can_call_model(["all-proxy-models"], "anything") is True
assert can_call_model(["all-team-models"], "anything") is True
def test_specific_list_denies_unlisted_model():
assert can_call_model(["gpt-4o", "gpt-4o-mini"], "o1") is False
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
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

View file

@ -61,6 +61,39 @@ def test_empty_policy_denies_everything():
assert e.enforce("user:anyone", "*", "model:gpt4", "read") is False
def test_model_call_permission_via_role_with_wildcard():
# Calling a model is the `call` action on `model:<id>`, granted by a role.
e = _enforcer(
[["role:gpt_caller", "*", "model:gpt-*", "call", "allow"]],
[["user:alice", "role:gpt_caller"]],
)
assert e.enforce("user:alice", "*", "model:gpt-4o", "call") is True
assert e.enforce("user:alice", "*", "model:gpt-4o-mini", "call") is True
# A model outside the wildcard is denied.
assert e.enforce("user:alice", "*", "model:claude-3", "call") is False
def test_model_call_can_be_granted_directly_to_a_subject():
# No role needed: grant the `call` permission straight to the key/user.
e = _enforcer(
[["user:svc-key", "*", "model:o1", "call", "allow"]],
[],
)
assert e.enforce("user:svc-key", "*", "model:o1", "call") is True
assert e.enforce("user:svc-key", "*", "model:o3", "call") is False
def test_model_call_denied_without_any_policy():
# Clean slate: with no grant, a key cannot call any model.
e = _enforcer([], [])
assert e.enforce("user:anyone", "*", "model:gpt-4o", "call") is False
def test_proxy_admin_can_call_any_model():
e = _enforcer([ADMIN_POLICY], [["user:root", "role:proxy_admin"]])
assert e.enforce("user:root", "*", "model:anything", "call") is True
def test_resource_grouping_grants_access_to_a_named_group():
e = CasbinEnforcer(
policies=[["role:grp_mgr", "*", "group:prod", "write", "allow"]],