mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
178 lines
5.3 KiB
Python
178 lines
5.3 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any, Callable, Coroutine, Dict, List, Type, TypeVar
|
|
|
|
from fastapi import HTTPException, Request, Response, status
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.routing import APIRoute
|
|
from scim2_models import (
|
|
Bulk,
|
|
ChangePassword,
|
|
Context,
|
|
Error,
|
|
Filter,
|
|
Group,
|
|
ListResponse,
|
|
Patch,
|
|
PatchOp,
|
|
Resource,
|
|
ResourceType,
|
|
Schema,
|
|
ServiceProviderConfig,
|
|
Sort,
|
|
User,
|
|
)
|
|
|
|
R = TypeVar("R", bound=Resource)
|
|
|
|
|
|
def scim_error(status_code: int, detail: str) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=status_code,
|
|
content=Error(status=str(status_code), detail=detail).model_dump(),
|
|
)
|
|
|
|
|
|
class ScimErrorRoute(APIRoute):
|
|
"""Render authentication failures with the SCIM Error schema (RFC 7644)."""
|
|
|
|
def get_route_handler( # type: ignore[override]
|
|
self,
|
|
) -> Callable[[Request], Coroutine[Any, Any, Response]]:
|
|
handler = super().get_route_handler()
|
|
|
|
async def scim_handler(request: Request) -> Response:
|
|
try:
|
|
return await handler(request)
|
|
except HTTPException as exc:
|
|
if exc.status_code not in (
|
|
status.HTTP_401_UNAUTHORIZED,
|
|
status.HTTP_403_FORBIDDEN,
|
|
):
|
|
raise
|
|
response = scim_error(exc.status_code, str(exc.detail))
|
|
if exc.headers:
|
|
response.headers.update(exc.headers)
|
|
return response
|
|
|
|
return scim_handler
|
|
|
|
|
|
def parse_resource(body: Any, model: Type[R]) -> R:
|
|
return model.model_validate(body, scim_ctx=Context.RESOURCE_CREATION_REQUEST)
|
|
|
|
|
|
def _set_path(data: Dict[str, Any], path: str, value: Any) -> None:
|
|
keys = path.split(".")
|
|
node = data
|
|
for key in keys[:-1]:
|
|
child = node.get(key)
|
|
if not isinstance(child, dict):
|
|
child = {}
|
|
node[key] = child
|
|
node = child
|
|
node[keys[-1]] = value
|
|
|
|
|
|
def _remove_path(data: Dict[str, Any], path: str) -> None:
|
|
keys = path.split(".")
|
|
node = data
|
|
for key in keys[:-1]:
|
|
child = node.get(key)
|
|
if not isinstance(child, dict):
|
|
return
|
|
node = child
|
|
node.pop(keys[-1], None)
|
|
|
|
|
|
def _targets_read_only_id(op: Any) -> bool:
|
|
if op.path is not None:
|
|
return op.path.split(".")[0].strip().lower() == "id"
|
|
return isinstance(op.value, dict) and any(str(k).lower() == "id" for k in op.value)
|
|
|
|
|
|
def apply_patch(resource: R, body: Any) -> R:
|
|
patch = PatchOp[type(resource)].model_validate(body)
|
|
data: Dict[str, Any] = resource.model_dump()
|
|
for op in patch.operations:
|
|
action = op.op.value if hasattr(op.op, "value") else str(op.op)
|
|
if op.path is not None and ("[" in op.path or "]" in op.path):
|
|
raise ValueError(f"unsupported SCIM patch path filter: {op.path}")
|
|
if _targets_read_only_id(op):
|
|
raise ValueError("the SCIM id attribute is read-only")
|
|
if action == "remove":
|
|
if op.path:
|
|
_remove_path(data, op.path)
|
|
continue
|
|
if op.path is None and isinstance(op.value, dict):
|
|
data.update(op.value)
|
|
elif op.path is not None:
|
|
_set_path(data, op.path, op.value)
|
|
return type(resource).model_validate(data)
|
|
|
|
|
|
def creation_response(resource: Resource) -> Dict[str, Any]:
|
|
return resource.model_dump(scim_ctx=Context.RESOURCE_CREATION_RESPONSE)
|
|
|
|
|
|
def query_response(resource: Resource) -> Dict[str, Any]:
|
|
return resource.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
|
|
|
|
|
|
def patch_response(resource: Resource) -> Dict[str, Any]:
|
|
return resource.model_dump(scim_ctx=Context.RESOURCE_PATCH_RESPONSE)
|
|
|
|
|
|
def list_response(model: Type[R], items: List[R]) -> Dict[str, Any]:
|
|
listing = ListResponse[model](
|
|
total_results=len(items),
|
|
start_index=1,
|
|
items_per_page=len(items),
|
|
resources=items or None,
|
|
)
|
|
return listing.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
|
|
|
|
|
|
def service_provider_config() -> Dict[str, Any]:
|
|
return ServiceProviderConfig(
|
|
patch=Patch(supported=True),
|
|
bulk=Bulk(supported=False, max_operations=0, max_payload_size=0),
|
|
filter=Filter(supported=False, max_results=0),
|
|
change_password=ChangePassword(supported=False),
|
|
sort=Sort(supported=False),
|
|
etag=None,
|
|
authentication_schemes=[],
|
|
).model_dump()
|
|
|
|
|
|
def resource_types() -> Dict[str, Any]:
|
|
types = [
|
|
ResourceType(
|
|
id="User",
|
|
name="User",
|
|
endpoint="/Users",
|
|
schema="urn:ietf:params:scim:schemas:core:2.0:User",
|
|
),
|
|
ResourceType(
|
|
id="Group",
|
|
name="Group",
|
|
endpoint="/Groups",
|
|
schema="urn:ietf:params:scim:schemas:core:2.0:Group",
|
|
),
|
|
]
|
|
return ListResponse[ResourceType](
|
|
total_results=len(types),
|
|
start_index=1,
|
|
items_per_page=len(types),
|
|
resources=types,
|
|
).model_dump()
|
|
|
|
|
|
def schemas() -> Dict[str, Any]:
|
|
resources = [User.to_schema(), Group.to_schema()]
|
|
return ListResponse[Schema](
|
|
total_results=len(resources),
|
|
start_index=1,
|
|
items_per_page=len(resources),
|
|
resources=resources,
|
|
).model_dump()
|