mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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:
parent
08f2e924f3
commit
ebb965d4c2
7 changed files with 82 additions and 179 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"]],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue