mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
refactor: extract user_is_scim_deactivated as one shared predicate
The SCIM-deactivation check was spelled out inline at five call sites (the standard auth builder, both MCP admission arms, and both bridge-refresh revalidation paths). Each one re-derived the same three conditions, and cache warming, which resolves a user through get_user_object exactly like those five do, shipped without the check at all; a sixth caller getting it wrong is what a copy-pasted predicate guarantees eventually. The predicate now lives beside get_user_object in auth_checks.py, since the resolver is what hands back a live-looking row for a deactivated user and every caller owes the check afterwards. It returns a bool rather than raising, so each caller keeps its own reaction: the standard builder raises, the MCP arms return 401, the bridge returns a status, and the warming refresher raises a ProxyException before it can spend against a deactivated owner's key. Only an explicit scim_active of False deactivates, so a missing user or absent metadata still fails open exactly as before at every site.
This commit is contained in:
parent
16bf7efd41
commit
bd8511dd51
6 changed files with 54 additions and 13 deletions
|
|
@ -834,7 +834,11 @@ class MCPRequestHandler:
|
|||
missing user and a real outage look identical (the cause survives only as ``__context__``).
|
||||
``_raise_503_if_db_unavailable`` walks the cause chain so an outage stays a retryable 503 while any
|
||||
other failure fails closed as 401, not an opaque 500; the object-permission load shares that boundary."""
|
||||
from litellm.proxy.auth.auth_checks import get_object_permission, get_user_object
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
get_object_permission,
|
||||
get_user_object,
|
||||
user_is_scim_deactivated,
|
||||
)
|
||||
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
|
||||
|
||||
if prisma_client is None:
|
||||
|
|
@ -863,7 +867,7 @@ class MCPRequestHandler:
|
|||
raise HTTPException(status_code=401, detail="Invalid or expired credential") from None
|
||||
if user_object is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired credential")
|
||||
if isinstance(user_object.metadata, dict) and user_object.metadata.get("scim_active") is False:
|
||||
if user_is_scim_deactivated(user_object):
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired credential")
|
||||
admitted = UserAPIKeyAuth(
|
||||
user_id=user_object.user_id,
|
||||
|
|
@ -1004,7 +1008,7 @@ class MCPRequestHandler:
|
|||
keeping parity with how the standard pipeline treats the same lookup failure."""
|
||||
if key_object.user_id is None:
|
||||
return
|
||||
from litellm.proxy.auth.auth_checks import get_user_object
|
||||
from litellm.proxy.auth.auth_checks import get_user_object, user_is_scim_deactivated
|
||||
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
|
||||
|
||||
try:
|
||||
|
|
@ -1017,9 +1021,7 @@ class MCPRequestHandler:
|
|||
except Exception as e: # noqa: BLE001 # mirror the builder's fail-open user lookup; DB errors are of any type
|
||||
verbose_logger.debug(f"bridge admission: user lookup failed, skipping SCIM gate: {e}")
|
||||
user_object = None
|
||||
if user_object is None or not isinstance(user_object.metadata, dict):
|
||||
return
|
||||
if user_object.metadata.get("scim_active") is False:
|
||||
if user_is_scim_deactivated(user_object):
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired credential")
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ async def _reload_active_user_by_id(user_id: str) -> "_KeyResolutionFailure | No
|
|||
)
|
||||
from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
get_user_object,
|
||||
user_is_scim_deactivated,
|
||||
)
|
||||
from litellm.proxy.db.exception_handler import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
PrismaDBExceptionHandler,
|
||||
|
|
@ -224,7 +225,7 @@ async def _reload_active_user_by_id(user_id: str) -> "_KeyResolutionFailure | No
|
|||
return "no_active_key"
|
||||
if user_object is None:
|
||||
return "no_active_key"
|
||||
if isinstance(user_object.metadata, dict) and user_object.metadata.get("scim_active") is False:
|
||||
if user_is_scim_deactivated(user_object):
|
||||
return "no_active_key"
|
||||
return None
|
||||
|
||||
|
|
@ -239,6 +240,7 @@ async def _key_owner_scim_deactivated(key: "UserAPIKeyAuth") -> bool:
|
|||
return False
|
||||
from litellm.proxy.auth.auth_checks import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
get_user_object,
|
||||
user_is_scim_deactivated,
|
||||
)
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
prisma_client,
|
||||
|
|
@ -257,7 +259,7 @@ async def _key_owner_scim_deactivated(key: "UserAPIKeyAuth") -> bool:
|
|||
except Exception as exc: # noqa: BLE001 # fail open: a missing owner (get_user_object's wrapped ValueError) or a DB blip must not revoke a live key
|
||||
verbose_logger.debug("refresh: key-owner SCIM lookup failed, not revoking (%s)", type(exc).__name__)
|
||||
return False
|
||||
return owner is not None and isinstance(owner.metadata, dict) and owner.metadata.get("scim_active") is False
|
||||
return user_is_scim_deactivated(owner)
|
||||
|
||||
|
||||
async def _revalidate_active_subject(identity: "EnvelopeIdentity") -> "_KeyResolutionFailure | None":
|
||||
|
|
|
|||
|
|
@ -1614,6 +1614,22 @@ async def _get_fuzzy_user_object(
|
|||
return response
|
||||
|
||||
|
||||
def user_is_scim_deactivated(user_object: Optional[LiteLLM_UserTable]) -> bool:
|
||||
"""Whether a user has been explicitly deactivated via SCIM, so keys they own must not be usable.
|
||||
|
||||
Lives beside get_user_object because the resolver returns a live-looking row for a deactivated user and
|
||||
every caller owes this check afterwards; it was previously spelled out inline at five call sites, which is
|
||||
how a sixth caller (background cache warming) ended up without it. Returns a value rather than raising so
|
||||
each caller keeps its own reaction: the primary auth path raises, the MCP paths return 401, the bridge
|
||||
returns a status. Only an explicit False deactivates -- a missing user or absent metadata is not a
|
||||
deactivation, so a lookup failure cannot revoke a live key."""
|
||||
return (
|
||||
user_object is not None
|
||||
and isinstance(user_object.metadata, dict)
|
||||
and user_object.metadata.get("scim_active") is False
|
||||
)
|
||||
|
||||
|
||||
@log_db_metrics
|
||||
async def get_user_object(
|
||||
user_id: Optional[str],
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ from litellm.proxy.auth.auth_checks import (
|
|||
get_user_object,
|
||||
is_valid_fallback_model,
|
||||
resolve_and_validate_end_user_id,
|
||||
user_is_scim_deactivated,
|
||||
)
|
||||
from litellm.proxy.auth.auth_exception_handler import UserAPIKeyAuthExceptionHandler
|
||||
from litellm.proxy.auth.auth_utils import (
|
||||
|
|
@ -1745,11 +1746,7 @@ async def _user_api_key_auth_builder(
|
|||
)
|
||||
user_obj = None
|
||||
|
||||
if (
|
||||
user_obj is not None
|
||||
and isinstance(user_obj.metadata, dict)
|
||||
and user_obj.metadata.get("scim_active") is False
|
||||
):
|
||||
if user_is_scim_deactivated(user_obj):
|
||||
raise Exception(
|
||||
f"User={valid_token.user_id} has been deactivated via SCIM. Keys owned by this user cannot be used."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -733,11 +733,13 @@ class CacheWarmingRefresher:
|
|||
those gates unenforced while the cost callback still charged the same scopes. These stay cache-first, so
|
||||
they carry the same in-memory staleness a real request on another pod carries; only the key, which is
|
||||
the credential warming spends against, is read past the local tier."""
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
get_end_user_object,
|
||||
get_project_object,
|
||||
get_team_object,
|
||||
get_user_object,
|
||||
user_is_scim_deactivated,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import (
|
||||
_reserve_budget_after_common_checks, # pyright: ignore[reportPrivateUsage] # the flow owner; reserve_budget_for_request alone drops the operator settings
|
||||
|
|
@ -758,6 +760,13 @@ class CacheWarmingRefresher:
|
|||
if principal.user_id is not None
|
||||
else None
|
||||
)
|
||||
if user_is_scim_deactivated(user):
|
||||
raise ProxyException(
|
||||
message=f"User={principal.user_id} has been deactivated via SCIM. Keys owned by this user cannot be used.",
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param="user_id",
|
||||
code=401,
|
||||
)
|
||||
end_user = (
|
||||
await get_end_user_object(
|
||||
end_user_id=principal.end_user_id, prisma_client=prisma_client, user_api_key_cache=cache
|
||||
|
|
|
|||
|
|
@ -381,3 +381,18 @@ async def test_a_replay_never_falls_back_to_a_group_warming_did_not_validate():
|
|||
await tick(llm_router)
|
||||
assert llm_router.completion_calls, "expected a replay"
|
||||
assert all(call["disable_fallbacks"] is True for call in llm_router.completion_calls)
|
||||
|
||||
|
||||
def test_scim_deactivation_is_one_predicate_shared_with_the_auth_paths():
|
||||
"""The gate sat inline at five call sites and warming became the sixth caller without it, so it lives
|
||||
beside get_user_object now and every path calls the same predicate."""
|
||||
from litellm.proxy._types import LiteLLM_UserTable
|
||||
from litellm.proxy.auth.auth_checks import user_is_scim_deactivated
|
||||
|
||||
def user(metadata):
|
||||
return LiteLLM_UserTable(user_id="u1", max_budget=None, spend=0.0, metadata=metadata)
|
||||
|
||||
assert user_is_scim_deactivated(user({"scim_active": False})) is True
|
||||
assert user_is_scim_deactivated(user({"scim_active": True})) is False
|
||||
assert user_is_scim_deactivated(user({})) is False
|
||||
assert user_is_scim_deactivated(None) is False
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue