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:
Yassin Kortam 2026-06-10 18:52:58 -07:00
parent 6f3fc5eba4
commit 450349965c

View file

@ -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