diff --git a/litellm/proxy/auth/v2/README.md b/litellm/proxy/auth/v2/README.md index 73d387e1e84..62f67249f4f 100644 --- a/litellm/proxy/auth/v2/README.md +++ b/litellm/proxy/auth/v2/README.md @@ -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:` 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 diff --git a/litellm/proxy/auth/v2/data_plane.py b/litellm/proxy/auth/v2/data_plane.py deleted file mode 100644 index ae9aaa014b0..00000000000 --- a/litellm/proxy/auth/v2/data_plane.py +++ /dev/null @@ -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) diff --git a/litellm/proxy/auth/v2/entry.py b/litellm/proxy/auth/v2/entry.py index 2bc700ac1a7..f6a336496f8 100644 --- a/litellm/proxy/auth/v2/entry.py +++ b/litellm/proxy/auth/v2/entry.py @@ -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:` 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 diff --git a/litellm/proxy/auth/v2/policy_admin.py b/litellm/proxy/auth/v2/policy_admin.py index 772b2f0c0f5..c19599b8996 100644 --- a/litellm/proxy/auth/v2/policy_admin.py +++ b/litellm/proxy/auth/v2/policy_admin.py @@ -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"} diff --git a/litellm/proxy/auth/v2/route_map.py b/litellm/proxy/auth/v2/route_map.py index 90fcab4ad7f..517c072ee7d 100644 --- a/litellm/proxy/auth/v2/route_map.py +++ b/litellm/proxy/auth/v2/route_map.py @@ -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:` object. They are matched +# here (by body, not path) rather than in the RPC route map above. _INFERENCE_ROUTES = { "/chat/completions", "/v1/chat/completions", diff --git a/tests/test_litellm/proxy/auth/v2/test_data_plane.py b/tests/test_litellm/proxy/auth/v2/test_data_plane.py deleted file mode 100644 index 8837bd54db7..00000000000 --- a/tests/test_litellm/proxy/auth/v2/test_data_plane.py +++ /dev/null @@ -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 diff --git a/tests/test_litellm/proxy/auth/v2/test_enforcer.py b/tests/test_litellm/proxy/auth/v2/test_enforcer.py index 96364f6bbb5..822b9376a8e 100644 --- a/tests/test_litellm/proxy/auth/v2/test_enforcer.py +++ b/tests/test_litellm/proxy/auth/v2/test_enforcer.py @@ -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:`, 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"]],