Skip to content

Commit e6824b0

Browse files
committed
feat: add Resource.replace() for PUT immutability verification
Extract public replace() method on Resource to verify that immutable fields have not been modified, per RFC 7644 §3.5.1. Deprecate the 'original' parameter of model_validate().
1 parent 7fa466d commit e6824b0

File tree

12 files changed

+257
-131
lines changed

12 files changed

+257
-131
lines changed

doc/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ Changelog
44
[0.6.8] - Unreleased
55
--------------------
66

7+
Added
8+
^^^^^
9+
- :class:`~scim2_models.MutabilityException` handler in framework integration examples (FastAPI, Flask, Django).
10+
11+
Deprecated
12+
^^^^^^^^^^
13+
- The ``original`` parameter of :meth:`~scim2_models.base.BaseModel.model_validate` is deprecated. Use :meth:`~scim2_models.Resource.replace` on the validated instance instead. Will be removed in 0.8.0.
14+
715
Fixed
816
^^^^^
917
- PATCH operations on :attr:`~scim2_models.Mutability.immutable` fields are now validated at runtime per :rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`: ``add`` is only allowed when the field has no previous value, ``replace`` is only allowed with the same value, and ``remove`` is only allowed on unset fields.

doc/guides/_examples/django_example.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from scim2_models import Context
1414
from scim2_models import Error
1515
from scim2_models import ListResponse
16+
from scim2_models import MutabilityException
1617
from scim2_models import PatchOp
1718
from scim2_models import ResourceType
1819
from scim2_models import ResponseParameters
@@ -152,10 +153,13 @@ def put(self, request, app_record):
152153
replacement = User.model_validate(
153154
json.loads(request.body),
154155
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
155-
original=existing_user,
156156
)
157+
replacement.replace(existing_user)
157158
except ValidationError as error:
158159
return scim_validation_error(error)
160+
except MutabilityException as error:
161+
scim_error = error.to_error()
162+
return scim_response(scim_error.model_dump_json(), scim_error.status)
159163

160164
replacement.id = existing_user.id
161165
updated_record = from_scim_user(replacement)

doc/guides/_examples/fastapi_example.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from scim2_models import Context
1212
from scim2_models import Error
1313
from scim2_models import ListResponse
14+
from scim2_models import MutabilityException
1415
from scim2_models import PatchOp
1516
from scim2_models import ResourceType
1617
from scim2_models import ResponseParameters
@@ -93,6 +94,13 @@ async def handle_precondition_failed(request, error):
9394
return Response(
9495
scim_error.model_dump_json(), status_code=HTTPStatus.PRECONDITION_FAILED
9596
)
97+
98+
99+
@app.exception_handler(MutabilityException)
100+
async def handle_mutability_error(request, error):
101+
"""Turn mutability violations into SCIM error responses."""
102+
scim_error = error.to_error()
103+
return Response(scim_error.model_dump_json(), status_code=scim_error.status)
96104
# -- error-handlers-end --
97105
# -- refinements-end --
98106

@@ -151,8 +159,8 @@ async def replace_user(request: Request, app_record: dict = Depends(resolve_user
151159
replacement = User.model_validate(
152160
await request.json(),
153161
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
154-
original=existing_user,
155162
)
163+
replacement.replace(existing_user)
156164

157165
replacement.id = existing_user.id
158166
updated_record = from_scim_user(replacement)

doc/guides/_examples/flask_example.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from scim2_models import Context
1212
from scim2_models import Error
1313
from scim2_models import ListResponse
14+
from scim2_models import MutabilityException
1415
from scim2_models import PatchOp
1516
from scim2_models import ResourceType
1617
from scim2_models import ResponseParameters
@@ -99,6 +100,13 @@ def handle_precondition_failed(error):
99100
"""Turn ETag mismatches into SCIM 412 responses."""
100101
scim_error = Error(status=412, detail="ETag mismatch")
101102
return scim_error.model_dump_json(), HTTPStatus.PRECONDITION_FAILED
103+
104+
105+
@bp.errorhandler(MutabilityException)
106+
def handle_mutability_error(error):
107+
"""Turn mutability violations into SCIM error responses."""
108+
scim_error = error.to_error()
109+
return scim_error.model_dump_json(), scim_error.status
102110
# -- error-handlers-end --
103111
# -- refinements-end --
104112

@@ -156,8 +164,8 @@ def replace_user(app_record):
156164
replacement = User.model_validate(
157165
request.get_json(),
158166
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
159-
original=existing_user,
160167
)
168+
replacement.replace(existing_user)
161169

162170
replacement.id = existing_user.id
163171
updated_record = from_scim_user(replacement)

doc/guides/django.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,9 @@ For ``GET``, parse query parameters with :class:`~scim2_models.ResponseParameter
121121
SCIM resource, and serialize with :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`.
122122
For ``DELETE``, remove the record and return an empty 204 response.
123123
For ``PUT``, validate the full replacement payload with
124-
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, passing the ``original`` resource
125-
so that immutable attributes are checked for unintended modifications.
124+
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, then call
125+
:meth:`~scim2_models.Resource.replace` to verify that immutable attributes
126+
have not been modified.
126127
Convert back to native and persist, then serialize with
127128
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RESPONSE`.
128129
For ``PATCH``, validate the payload with :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`,

doc/guides/fastapi.rst

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,9 @@ PUT /Users/<id>
129129
^^^^^^^^^^^^^^^
130130

131131
Validate the full replacement payload with
132-
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, passing the ``original`` resource
133-
so that immutable attributes are checked for unintended modifications.
134-
Convert back to native and persist, then serialize the result with
135-
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RESPONSE`.
132+
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, then call
133+
:meth:`~scim2_models.Resource.replace` to verify that immutable attributes
134+
have not been modified.
136135

137136
.. literalinclude:: _examples/fastapi_example.py
138137
:language: python

doc/guides/flask.rst

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,9 @@ PUT /Users/<id>
122122
^^^^^^^^^^^^^^^
123123

124124
Validate the full replacement payload with
125-
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, passing the ``original`` resource
126-
so that immutable attributes are checked for unintended modifications.
127-
Convert back to native and persist, then serialize the result with
128-
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RESPONSE`.
125+
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, then call
126+
:meth:`~scim2_models.Resource.replace` to verify that immutable attributes
127+
have not been modified.
129128

130129
.. literalinclude:: _examples/flask_example.py
131130
:language: python

doc/tutorial.rst

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,6 @@ fields with unexpected values will raise :class:`~pydantic.ValidationError`:
124124
... except pydantic.ValidationError:
125125
... obj = Error(...)
126126
127-
.. note::
128-
129-
With the :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` context,
130-
:meth:`~scim2_models.BaseModel.model_validate` takes an additional
131-
:paramref:`~scim2_models.BaseModel.model_validate.original` argument that is used to compare
132-
:attr:`~scim2_models.Mutability.immutable` attributes, and raise an exception when they have mutated.
133-
134127
Attributes inclusions and exclusions
135128
====================================
136129

@@ -479,6 +472,29 @@ Client applications can use this to dynamically discover server resources by bro
479472
:language: json
480473
:caption: schema-group.json
481474
475+
Replace operations
476+
==================
477+
478+
When handling a ``PUT`` request, validate the incoming payload with the
479+
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` context, then call
480+
:meth:`~scim2_models.Resource.replace` against the existing resource to
481+
verify that :attr:`~scim2_models.Mutability.immutable` attributes have not been
482+
modified.
483+
484+
.. doctest::
485+
486+
>>> from scim2_models import User, Context
487+
>>> from scim2_models.exceptions import MutabilityException
488+
>>> existing = User(user_name="bjensen")
489+
>>> replacement = User.model_validate(
490+
... {"userName": "bjensen"},
491+
... scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
492+
... )
493+
>>> replacement.replace(existing)
494+
495+
If an immutable attribute differs, a :class:`~scim2_models.MutabilityException`
496+
is raised.
497+
482498
Patch operations
483499
================
484500

scim2_models/base.py

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import warnings
12
from inspect import isclass
23
from typing import Any
34
from typing import Optional
@@ -23,6 +24,7 @@
2324
from scim2_models.annotations import Required
2425
from scim2_models.annotations import Returned
2526
from scim2_models.context import Context
27+
from scim2_models.exceptions import MutabilityException
2628
from scim2_models.utils import UNION_TYPES
2729
from scim2_models.utils import _find_field_name
2830
from scim2_models.utils import _normalize_attribute_name
@@ -410,7 +412,10 @@ def check_replacement_request_mutability(
410412
and issubclass(cls, Resource)
411413
and original is not None
412414
):
413-
cls._check_mutability_issues(original, obj)
415+
try:
416+
obj._check_immutable_fields(original)
417+
except MutabilityException as exc:
418+
raise exc.as_pydantic_error() from exc
414419
return obj
415420

416421
@model_validator(mode="after")
@@ -456,35 +461,30 @@ def check_primary_attribute_uniqueness(self, info: ValidationInfo) -> Self:
456461

457462
return self
458463

459-
@classmethod
460-
def _check_mutability_issues(
461-
cls, original: "BaseModel", replacement: "BaseModel"
462-
) -> None:
463-
"""Compare two instances, and check for differences of values on the fields marked as immutable."""
464+
def _check_immutable_fields(self, original: Self) -> None:
465+
"""Check that immutable fields have not been modified compared to *original*.
466+
467+
Recursively checks nested single-valued complex attributes.
468+
"""
464469
from .attributes import is_complex_attribute
465470

466-
model = replacement.__class__
467-
for field_name in model.model_fields:
468-
mutability = model.get_field_annotation(field_name, Mutability)
471+
for field_name in type(self).model_fields:
472+
mutability = type(self).get_field_annotation(field_name, Mutability)
469473
if mutability == Mutability.immutable and getattr(
470474
original, field_name
471-
) != getattr(replacement, field_name):
472-
raise PydanticCustomError(
473-
"mutability_error",
474-
"Field '{field_name}' is immutable but the request value is different than the original value.",
475-
{"field_name": field_name},
476-
)
475+
) != getattr(self, field_name):
476+
raise MutabilityException(attribute=field_name, mutability="immutable")
477477

478-
attr_type = model.get_field_root_type(field_name)
478+
attr_type = type(self).get_field_root_type(field_name)
479479
if (
480480
attr_type
481481
and is_complex_attribute(attr_type)
482-
and not model.get_field_multiplicity(field_name)
482+
and not type(self).get_field_multiplicity(field_name)
483483
):
484484
original_val = getattr(original, field_name)
485-
replacement_value = getattr(replacement, field_name)
486-
if original_val is not None and replacement_value is not None:
487-
cls._check_mutability_issues(original_val, replacement_value)
485+
replacement_val = getattr(self, field_name)
486+
if original_val is not None and replacement_val is not None:
487+
replacement_val._check_immutable_fields(original_val)
488488

489489
def _set_complex_attribute_urns(self) -> None:
490490
"""Navigate through attributes and sub-attributes of type ComplexAttribute, and mark them with a '_attribute_urn' attribute.
@@ -611,22 +611,30 @@ def model_validate(
611611
original: Optional["BaseModel"] = None,
612612
**kwargs: Any,
613613
) -> Self:
614-
"""Validate SCIM payloads and generate model representation by using Pydantic :code:`BaseModel.model_validate`.
614+
"""Validate SCIM payloads and generate model representation by using Pydantic :meth:`~pydantic.BaseModel.model_validate`.
615615
616616
:param scim_ctx: The SCIM :class:`~scim2_models.Context` in which the validation happens.
617617
:param original: If this parameter is set during :attr:`~Context.RESOURCE_REPLACEMENT_REQUEST`,
618618
:attr:`~scim2_models.Mutability.immutable` parameters will be compared against the *original* model value.
619619
An exception is raised if values are different.
620+
621+
.. deprecated:: 0.6.7
622+
Use :meth:`replace` on the validated instance instead.
623+
Will be removed in 0.8.0.
620624
"""
625+
if original is not None:
626+
warnings.warn(
627+
"The 'original' parameter is deprecated, "
628+
"use the 'replace' method on the validated instance instead. "
629+
"Will be removed in 0.8.0.",
630+
DeprecationWarning,
631+
stacklevel=2,
632+
)
633+
621634
context = kwargs.setdefault("context", {})
622635
context.setdefault("scim", scim_ctx)
623636
context.setdefault("original", original)
624637

625-
if scim_ctx == Context.RESOURCE_REPLACEMENT_REQUEST and original is None:
626-
raise ValueError(
627-
"Resource queries replacement validation must compare to an original resource"
628-
)
629-
630638
return super().model_validate(*args, **kwargs)
631639

632640
def get_attribute_urn(self, field_name: str) -> str:

scim2_models/resources/resource.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,20 @@ class Resource(ScimObject, Generic[AnyExtension]):
153153
meta: Annotated[Meta | None, Mutability.read_only, Returned.default] = None
154154
"""A complex attribute containing resource metadata."""
155155

156+
def replace(self, original: Self) -> None:
157+
"""Verify that no immutable field has been modified compared to *original*.
158+
159+
Intended to be called after parsing a PUT request body, to enforce
160+
:rfc:`RFC 7644 §3.5.1 <7644#section-3.5.1>`: if one or more values
161+
are already set for an immutable attribute, the input values MUST match.
162+
163+
Recursively checks nested single-valued complex attributes.
164+
165+
:param original: The original resource state to compare against.
166+
:raises MutabilityException: If an immutable field value differs.
167+
"""
168+
self._check_immutable_fields(original)
169+
156170
@classmethod
157171
def __class_getitem__(cls, item: Any) -> type["Resource[Any]"]:
158172
"""Create a Resource class with extension fields dynamically added."""

0 commit comments

Comments
 (0)