Skip to content

Commit 76fb72f

Browse files
committed
feat: replace() now copies readOnly and preserves immutable fields
Enforce RFC 7644 §3.5.1 PUT semantics: readOnly fields are copied from the original resource, and omitted immutable fields are preserved instead of being nulled out.
1 parent e0637c5 commit 76fb72f

File tree

7 files changed

+86
-23
lines changed

7 files changed

+86
-23
lines changed

doc/changelog.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
Changelog
22
=========
33

4+
[0.6.10] - Unreleased
5+
---------------------
6+
7+
Fixed
8+
^^^^^
9+
- replace copies readOnly and preserves immutable fields
10+
411
[0.6.9] - 2026-04-07
512
--------------------
613

doc/guides/_examples/django_example.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,6 @@ def put(self, request, app_record):
176176
except SCIMException as error:
177177
return scim_exception_error(error)
178178

179-
replacement.id = existing_user.id
180179
updated_record = from_scim_user(replacement)
181180
try:
182181
save_record(updated_record)

doc/guides/_examples/fastapi_example.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,6 @@ async def replace_user(
186186
existing_user = to_scim_user(app_record, resource_location(request, app_record))
187187
replacement.replace(existing_user)
188188

189-
replacement.id = existing_user.id
190189
updated_record = from_scim_user(replacement)
191190
save_record(updated_record)
192191

doc/guides/_examples/flask_example.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,6 @@ def replace_user(app_record):
177177
)
178178
replacement.replace(existing_user)
179179

180-
replacement.id = existing_user.id
181180
updated_record = from_scim_user(replacement)
182181
save_record(updated_record)
183182

scim2_models/base.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ def check_replacement_request_mutability(
413413
and original is not None
414414
):
415415
try:
416-
obj._check_immutable_fields(original)
416+
obj._apply_replace_constraints(original)
417417
except MutabilityException as exc:
418418
raise exc.as_pydantic_error() from exc
419419
return obj
@@ -461,30 +461,47 @@ def check_primary_attribute_uniqueness(self, info: ValidationInfo) -> Self:
461461

462462
return self
463463

464-
def _check_immutable_fields(self, original: Self) -> None:
465-
"""Check that immutable fields have not been modified compared to *original*.
464+
def _apply_replace_constraints(self, original: Self) -> None:
465+
"""Enforce RFC 7644 §3.5.1 replace (PUT) semantics.
466466
467-
Recursively checks nested single-valued complex attributes.
467+
- ``readOnly`` fields are copied from *original* unconditionally.
468+
- ``immutable`` fields are copied from *original* when absent from
469+
``self``; a :class:`~scim2_models.MutabilityException` is raised
470+
when the value differs.
471+
472+
Recursively applies to nested single-valued complex attributes.
468473
"""
469474
from .attributes import is_complex_attribute
470475

471476
for field_name in type(self).model_fields:
472477
mutability = type(self).get_field_annotation(field_name, Mutability)
473-
if mutability == Mutability.immutable and getattr(
474-
original, field_name
475-
) != getattr(self, field_name):
476-
raise MutabilityException(attribute=field_name, mutability="immutable")
478+
original_val = getattr(original, field_name)
479+
480+
if mutability == Mutability.read_only:
481+
# RFC 7644 §3.5.1: "readOnly" values provided SHALL be ignored.
482+
setattr(self, field_name, original_val)
483+
elif mutability == Mutability.immutable:
484+
self_val = getattr(self, field_name)
485+
if self_val is None and original_val is not None:
486+
# RFC 7643 §7: "SHALL NOT be updated" — omitting an
487+
# immutable field is not a request to clear it.
488+
setattr(self, field_name, original_val)
489+
elif self_val != original_val:
490+
# RFC 7644 §3.5.1: input values MUST match.
491+
raise MutabilityException(
492+
attribute=field_name, mutability="immutable"
493+
)
477494

478495
attr_type = type(self).get_field_root_type(field_name)
479496
if (
480497
attr_type
481498
and is_complex_attribute(attr_type)
482499
and not type(self).get_field_multiplicity(field_name)
483500
):
484-
original_val = getattr(original, field_name)
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)
501+
original_sub = getattr(original, field_name)
502+
replacement_sub = getattr(self, field_name)
503+
if original_sub is not None and replacement_sub is not None:
504+
replacement_sub._apply_replace_constraints(original_sub)
488505

489506
def _set_complex_attribute_urns(self) -> None:
490507
"""Navigate through attributes and sub-attributes of type ComplexAttribute, and mark them with a '_attribute_urn' attribute.

scim2_models/resources/resource.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,18 +154,16 @@ class Resource(ScimObject, Generic[AnyExtension]):
154154
"""A complex attribute containing resource metadata."""
155155

156156
def replace(self, original: Self) -> None:
157-
"""Verify that no immutable field has been modified compared to *original*.
157+
"""Apply :rfc:`RFC 7644 §3.5.1 <7644#section-3.5.1>` replace (PUT) semantics.
158158
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.
159+
``readOnly`` fields are copied from *original*.
160+
``immutable`` fields are preserved from *original* when absent,
161+
or checked for equality when present.
164162
165163
:param original: The original resource state to compare against.
166164
:raises MutabilityException: If an immutable field value differs.
167165
"""
168-
self._check_immutable_fields(original)
166+
self._apply_replace_constraints(original)
169167

170168
@classmethod
171169
def __class_getitem__(cls, item: Any) -> type["Resource[Any]"]:

tests/test_model_validation.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ def test_validate_replacement_request_mutability():
145145
146146
Attributes marked as:
147147
- Mutability.immutable raise a ValidationError if different than the 'original' item.
148-
- Mutability.read_only are ignored
148+
- Mutability.read_only are copied from the original
149149
"""
150150
original = MutResource(read_only="y", read_write="y", write_only="y", immutable="y")
151151
with pytest.warns(DeprecationWarning, match="original"):
@@ -160,6 +160,7 @@ def test_validate_replacement_request_mutability():
160160
original=original,
161161
) == MutResource(
162162
schemas=["org:example:MutResource"],
163+
read_only="y",
163164
readWrite="x",
164165
writeOnly="x",
165166
immutable="y",
@@ -286,6 +287,49 @@ def test_replace_ignores_readwrite_changes():
286287
original = MutResource(read_write="y")
287288
replacement = MutResource(read_write="x")
288289
replacement.replace(original)
290+
assert replacement.read_write == "x"
291+
292+
293+
def test_replace_copies_read_only_from_original():
294+
"""Replace copies readOnly fields from the original resource."""
295+
original = MutResource(read_only="server-value")
296+
replacement = MutResource(read_only="client-value")
297+
replacement.replace(original)
298+
assert replacement.read_only == "server-value"
299+
300+
301+
def test_replace_copies_read_only_none_from_original():
302+
"""Replace copies readOnly fields even when the original value is None."""
303+
original = MutResource(read_only=None)
304+
replacement = MutResource(read_only="client-value")
305+
replacement.replace(original)
306+
assert replacement.read_only is None
307+
308+
309+
def test_replace_preserves_immutable_when_absent():
310+
"""Replace copies immutable fields from original when absent in replacement."""
311+
original = MutResource(immutable="y")
312+
replacement = MutResource(immutable=None)
313+
replacement.replace(original)
314+
assert replacement.immutable == "y"
315+
316+
317+
def test_replace_copies_read_only_in_nested_complex_attribute():
318+
"""Replace copies readOnly sub-attributes from original in nested complex attributes."""
319+
320+
class Sub(ComplexAttribute):
321+
read_only: Annotated[str | None, Mutability.read_only] = None
322+
read_write: Annotated[str | None, Mutability.read_write] = None
323+
324+
class Super(Resource):
325+
schemas: Annotated[list[str], Required.true] = ["org:example:Super"]
326+
sub: Sub | None = None
327+
328+
original = Super(sub=Sub(read_only="server", read_write="old"))
329+
replacement = Super(sub=Sub(read_only="client", read_write="new"))
330+
replacement.replace(original)
331+
assert replacement.sub.read_only == "server"
332+
assert replacement.sub.read_write == "new"
289333

290334

291335
def test_original_parameter_emits_deprecation_warning():

0 commit comments

Comments
 (0)