mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
fix(auth_v2): honor nested SCIM patch paths, align /Schemas, unshadow filter
PATCH now applies dotted attribute paths like name.givenName instead of silently dropping them, and rejects unsupported value-filter paths (emails[type eq "work"].value) with a 400 SCIM Error so behavior matches the advertised patch support. /Schemas now uses the ListResponse envelope like the other discovery endpoints, and the list route's query parameter no longer shadows the builtin while keeping the RFC 7644 ?filter= wire contract.
This commit is contained in:
parent
6f3fc5eba4
commit
450349965c
1 changed files with 59 additions and 25 deletions
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, Optional, Type, TypeVar
|
from typing import Any, Dict, Optional, Type, TypeVar
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Response, Security, status
|
from fastapi import APIRouter, Query, Request, Response, Security, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from scim2_models import (
|
from scim2_models import (
|
||||||
|
|
@ -17,6 +17,7 @@ from scim2_models import (
|
||||||
PatchOp,
|
PatchOp,
|
||||||
Resource,
|
Resource,
|
||||||
ResourceType,
|
ResourceType,
|
||||||
|
Schema,
|
||||||
ServiceProviderConfig,
|
ServiceProviderConfig,
|
||||||
Sort,
|
Sort,
|
||||||
User,
|
User,
|
||||||
|
|
@ -44,18 +45,43 @@ async def _parse(request: Request, model: Type[R]) -> R:
|
||||||
return model.model_validate(body, scim_ctx=Context.RESOURCE_CREATION_REQUEST)
|
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 _apply_patch(resource: R, patch: PatchOp) -> R:
|
def _apply_patch(resource: R, patch: PatchOp) -> R:
|
||||||
data: Dict[str, Any] = resource.model_dump()
|
data: Dict[str, Any] = resource.model_dump()
|
||||||
for op in patch.operations:
|
for op in patch.operations:
|
||||||
action = op.op.value if hasattr(op.op, "value") else str(op.op)
|
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 action == "remove":
|
if action == "remove":
|
||||||
if op.path:
|
if op.path:
|
||||||
data.pop(op.path, None)
|
_remove_path(data, op.path)
|
||||||
continue
|
continue
|
||||||
if op.path is None and isinstance(op.value, dict):
|
if op.path is None and isinstance(op.value, dict):
|
||||||
data.update(op.value)
|
data.update(op.value)
|
||||||
elif op.path is not None:
|
elif op.path is not None:
|
||||||
data[op.path] = op.value
|
_set_path(data, op.path, op.value)
|
||||||
return type(resource).model_validate(data)
|
return type(resource).model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -105,24 +131,19 @@ def _build_discovery_router() -> APIRouter:
|
||||||
|
|
||||||
@router.get("/Schemas")
|
@router.get("/Schemas")
|
||||||
async def schemas() -> Response:
|
async def schemas() -> Response:
|
||||||
return JSONResponse(
|
resources = [User.to_schema(), Group.to_schema()]
|
||||||
content={
|
listing: ListResponse[Schema] = ListResponse[Schema](
|
||||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
total_results=len(resources),
|
||||||
"totalResults": 2,
|
start_index=1,
|
||||||
"startIndex": 1,
|
items_per_page=len(resources),
|
||||||
"itemsPerPage": 2,
|
resources=resources,
|
||||||
"Resources": [
|
|
||||||
User.to_schema().model_dump(),
|
|
||||||
Group.to_schema().model_dump(),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
return JSONResponse(content=listing.model_dump())
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
def build_scim_router() -> APIRouter:
|
def _build_protected_router() -> APIRouter:
|
||||||
router = APIRouter(prefix="/scim/v2", tags=["scim"])
|
|
||||||
protected = APIRouter(
|
protected = APIRouter(
|
||||||
dependencies=[Security(get_current_principal, scopes=["scim:write"])],
|
dependencies=[Security(get_current_principal, scopes=["scim:write"])],
|
||||||
)
|
)
|
||||||
|
|
@ -154,9 +175,10 @@ def build_scim_router() -> APIRouter:
|
||||||
return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found")
|
return _error(status.HTTP_404_NOT_FOUND, f"User {resource_id} not found")
|
||||||
try:
|
try:
|
||||||
patch = PatchOp[User].model_validate(await request.json())
|
patch = PatchOp[User].model_validate(await request.json())
|
||||||
except ValidationError as exc:
|
patched = _apply_patch(user, patch)
|
||||||
|
except (ValidationError, ValueError) as exc:
|
||||||
return _error(status.HTTP_400_BAD_REQUEST, str(exc))
|
return _error(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||||
updated = await store.upsert_user(_apply_patch(user, patch))
|
updated = await store.upsert_user(patched)
|
||||||
return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE))
|
return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE))
|
||||||
|
|
||||||
@protected.delete("/Users/{resource_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@protected.delete("/Users/{resource_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
@ -168,8 +190,11 @@ def build_scim_router() -> APIRouter:
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@protected.get("/Users")
|
@protected.get("/Users")
|
||||||
async def list_users(request: Request, filter: Optional[str] = None) -> Response:
|
async def list_users(
|
||||||
users = await _store(request).list_users(filter)
|
request: Request,
|
||||||
|
filter_expr: Optional[str] = Query(default=None, alias="filter"),
|
||||||
|
) -> Response:
|
||||||
|
users = await _store(request).list_users(filter_expr)
|
||||||
listing: ListResponse[User] = ListResponse[User](
|
listing: ListResponse[User] = ListResponse[User](
|
||||||
total_results=len(users),
|
total_results=len(users),
|
||||||
start_index=1,
|
start_index=1,
|
||||||
|
|
@ -205,9 +230,10 @@ def build_scim_router() -> APIRouter:
|
||||||
return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found")
|
return _error(status.HTTP_404_NOT_FOUND, f"Group {resource_id} not found")
|
||||||
try:
|
try:
|
||||||
patch = PatchOp[Group].model_validate(await request.json())
|
patch = PatchOp[Group].model_validate(await request.json())
|
||||||
except ValidationError as exc:
|
patched = _apply_patch(group, patch)
|
||||||
|
except (ValidationError, ValueError) as exc:
|
||||||
return _error(status.HTTP_400_BAD_REQUEST, str(exc))
|
return _error(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||||
updated = await store.upsert_group(_apply_patch(group, patch))
|
updated = await store.upsert_group(patched)
|
||||||
return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE))
|
return JSONResponse(content=_dump(updated, Context.RESOURCE_PATCH_RESPONSE))
|
||||||
|
|
||||||
@protected.delete("/Groups/{resource_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@protected.delete("/Groups/{resource_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
@ -219,8 +245,11 @@ def build_scim_router() -> APIRouter:
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@protected.get("/Groups")
|
@protected.get("/Groups")
|
||||||
async def list_groups(request: Request, filter: Optional[str] = None) -> Response:
|
async def list_groups(
|
||||||
groups = await _store(request).list_groups(filter)
|
request: Request,
|
||||||
|
filter_expr: Optional[str] = Query(default=None, alias="filter"),
|
||||||
|
) -> Response:
|
||||||
|
groups = await _store(request).list_groups(filter_expr)
|
||||||
listing: ListResponse[Group] = ListResponse[Group](
|
listing: ListResponse[Group] = ListResponse[Group](
|
||||||
total_results=len(groups),
|
total_results=len(groups),
|
||||||
start_index=1,
|
start_index=1,
|
||||||
|
|
@ -229,6 +258,11 @@ def build_scim_router() -> APIRouter:
|
||||||
)
|
)
|
||||||
return JSONResponse(content=_dump(listing, Context.RESOURCE_QUERY_RESPONSE))
|
return JSONResponse(content=_dump(listing, Context.RESOURCE_QUERY_RESPONSE))
|
||||||
|
|
||||||
router.include_router(protected)
|
return protected
|
||||||
|
|
||||||
|
|
||||||
|
def build_scim_router() -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/scim/v2", tags=["scim"])
|
||||||
|
router.include_router(_build_protected_router())
|
||||||
router.include_router(_build_discovery_router())
|
router.include_router(_build_discovery_router())
|
||||||
return router
|
return router
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue