Skip to content

Commit f9aa332

Browse files
committed
fix: enforce immutable mutability checks at runtime in PATCH operations
Move immutable field validation from parse-time to runtime in patch(), where the resource instance is available. This aligns with RFC 7644 §3.5.2 which allows adding a value to an immutable attribute only if it had no previous value, and tolerates no-op operations (remove on unset fields, replace with identical value).
1 parent 34d2dd0 commit f9aa332

File tree

4 files changed

+166
-31
lines changed

4 files changed

+166
-31
lines changed

doc/changelog.rst

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

4+
[0.6.8] - Unreleased
5+
--------------------
6+
7+
Fixed
8+
^^^^^
9+
- PATCH operations on :attr:`~scim2_models.Mutability.read_only` fields now correctly reject ``remove`` in addition to ``add`` and ``replace``.
10+
- 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.
11+
412
[0.6.7] - 2026-04-02
513
--------------------
614

scim2_models/messages/patch_op.py

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,28 +52,23 @@ class Op(str, Enum):
5252
def _validate_mutability(
5353
self, resource_class: type[Resource[Any]], field_name: str
5454
) -> None:
55-
"""Validate mutability constraints."""
56-
# RFC 7644 Section 3.5.2: "Servers should be tolerant of schema extensions"
55+
"""Validate mutability constraints at parse-time.
56+
57+
Only :attr:`~scim2_models.Mutability.read_only` is validated here.
58+
:attr:`~scim2_models.Mutability.immutable` validation requires access
59+
to the resource instance and is enforced at runtime in
60+
:meth:`PatchOp._check_immutable`.
61+
"""
5762
if field_name not in resource_class.model_fields:
5863
return
5964

6065
mutability = resource_class.get_field_annotation(field_name, Mutability)
6166

62-
# RFC 7643 Section 7: "Attributes with mutability 'readOnly' SHALL NOT be modified"
63-
if mutability == Mutability.read_only and self.op in (
64-
PatchOperation.Op.add,
65-
PatchOperation.Op.replace_,
66-
):
67+
if mutability == Mutability.read_only:
6768
raise MutabilityException(
6869
attribute=field_name, mutability="readOnly", operation=self.op.value
6970
).as_pydantic_error()
7071

71-
# RFC 7643 Section 7: "Attributes with mutability 'immutable' SHALL NOT be updated"
72-
if mutability == Mutability.immutable and self.op == PatchOperation.Op.replace_:
73-
raise MutabilityException(
74-
attribute=field_name, mutability="immutable", operation=self.op.value
75-
).as_pydantic_error()
76-
7772
def _validate_required_attribute(
7873
self, resource_class: type[Resource[Any]], field_name: str
7974
) -> None:
@@ -284,14 +279,65 @@ def _apply_operation(
284279
"""Apply a single patch operation to a resource.
285280
286281
:return: :data:`True` if the resource was modified, else :data:`False`.
282+
:raises MutabilityException: If the operation would modify an
283+
immutable attribute.
287284
"""
285+
if operation.path is not None:
286+
self._check_immutable(resource, operation)
287+
288288
if operation.op in (PatchOperation.Op.add, PatchOperation.Op.replace_):
289289
return self._apply_add_replace(resource, operation)
290290
if operation.op == PatchOperation.Op.remove:
291291
return self._apply_remove(resource, operation)
292292

293293
raise InvalidValueException(detail=f"unsupported operation: {operation.op}")
294294

295+
def _check_immutable(
296+
self, resource: Resource[Any], operation: PatchOperation[ResourceT]
297+
) -> None:
298+
"""Validate immutable constraints at runtime.
299+
300+
:rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`:
301+
302+
*"A client MUST NOT modify an attribute that has mutability
303+
"readOnly" or "immutable". However, a client MAY "add" a value
304+
to an "immutable" attribute if the attribute had no previous
305+
value."*
306+
307+
An operation is considered a no-op (and thus allowed) when it would
308+
not effectively change the resource state: ``remove`` on an unset
309+
field, or ``replace`` with the current value.
310+
"""
311+
resource_class = type(resource)
312+
assert operation.path is not None
313+
field_name = operation.path.parts[0] if operation.path.parts else None
314+
if field_name is None or field_name not in resource_class.model_fields:
315+
return
316+
317+
mutability = resource_class.get_field_annotation(field_name, Mutability)
318+
if mutability != Mutability.immutable:
319+
return
320+
321+
current_value = getattr(resource, field_name, None)
322+
323+
if operation.op == PatchOperation.Op.add and current_value is None:
324+
return
325+
326+
if operation.op == PatchOperation.Op.remove and current_value is None:
327+
return
328+
329+
if (
330+
operation.op == PatchOperation.Op.replace_
331+
and operation.value == current_value
332+
):
333+
return
334+
335+
raise MutabilityException(
336+
attribute=field_name,
337+
mutability="immutable",
338+
operation=operation.op.value,
339+
)
340+
295341
def _apply_add_replace(
296342
self, resource: Resource[Any], operation: PatchOperation[ResourceT]
297343
) -> bool:

tests/test_patch_op_replace.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from typing import Annotated
22

33
import pytest
4-
from pydantic import ValidationError
54

65
from scim2_models import URN
6+
from scim2_models import MutabilityException
77
from scim2_models import PatchOp
88
from scim2_models import PatchOperation
99
from scim2_models import User
@@ -205,21 +205,23 @@ def test_replace_operation_with_non_dict_value_no_path():
205205

206206

207207
def test_immutable_field():
208-
"""Test that replace operations on immutable fields raise validation errors."""
208+
"""Test that replace operations on immutable fields raise mutability errors."""
209209

210210
class Dummy(Resource):
211211
__schema__ = URN("urn:test:TestResource")
212212

213213
immutable: Annotated[str, Mutability.immutable]
214214

215-
with pytest.raises(ValidationError, match="mutability"):
216-
PatchOp[Dummy](
217-
operations=[
218-
PatchOperation[Dummy](
219-
op=PatchOperation.Op.replace_, path="immutable", value="new_value"
220-
)
221-
]
222-
)
215+
resource = Dummy.model_construct(immutable="original")
216+
patch = PatchOp[Dummy](
217+
operations=[
218+
PatchOperation[Dummy](
219+
op=PatchOperation.Op.replace_, path="immutable", value="new_value"
220+
)
221+
]
222+
)
223+
with pytest.raises(MutabilityException):
224+
patch.patch(resource)
223225

224226

225227
def test_primary_auto_exclusion_on_add():

tests/test_patch_op_validation.py

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import Annotated
12
from typing import TypeVar
23

34
import pytest
@@ -6,13 +7,19 @@
67
from scim2_models import Group
78
from scim2_models import InvalidPathException
89
from scim2_models import InvalidValueException
10+
from scim2_models import Mutability
11+
from scim2_models import MutabilityException
912
from scim2_models import PatchOp
1013
from scim2_models import PatchOperation
1114
from scim2_models import User
1215
from scim2_models.base import Context
1316
from scim2_models.resources.resource import Resource
1417

1518

19+
class ImmutableFieldResource(Resource):
20+
locked: Annotated[str | None, Mutability.immutable] = None
21+
22+
1623
def test_patch_op_add_invalid_extension_path():
1724
user = User(user_name="john")
1825
patch_op = PatchOp[User](
@@ -257,9 +264,8 @@ def test_validate_mutability_readonly_error():
257264
)
258265

259266

260-
def test_validate_mutability_immutable_error():
261-
"""Test mutability validation error for immutable attributes."""
262-
# Test replace operation on immutable field within groups complex attribute
267+
def test_validate_mutability_readonly_replace_via_complex_path():
268+
"""Replacing a readOnly complex attribute path is rejected."""
263269
with pytest.raises(ValidationError, match="mutability"):
264270
PatchOp[User].model_validate(
265271
{
@@ -275,9 +281,83 @@ def test_validate_mutability_immutable_error():
275281
)
276282

277283

284+
def test_patch_remove_on_immutable_field_with_value_is_rejected():
285+
"""Removing an existing immutable attribute via PATCH is rejected."""
286+
resource = ImmutableFieldResource.model_construct(locked="existing")
287+
patch_op = PatchOp[ImmutableFieldResource].model_validate(
288+
{"operations": [{"op": "remove", "path": "locked"}]},
289+
context={"scim": Context.RESOURCE_PATCH_REQUEST},
290+
)
291+
with pytest.raises(MutabilityException):
292+
patch_op.patch(resource)
293+
294+
295+
def test_patch_remove_on_immutable_field_without_value_is_allowed():
296+
"""Removing an unset immutable attribute is a no-op and is allowed."""
297+
resource = ImmutableFieldResource.model_construct()
298+
patch_op = PatchOp[ImmutableFieldResource].model_validate(
299+
{"operations": [{"op": "remove", "path": "locked"}]},
300+
context={"scim": Context.RESOURCE_PATCH_REQUEST},
301+
)
302+
patch_op.patch(resource)
303+
assert resource.locked is None
304+
305+
306+
def test_patch_add_on_immutable_field_with_existing_value_is_rejected():
307+
"""Adding to an immutable attribute that already has a value is rejected."""
308+
resource = ImmutableFieldResource.model_construct(locked="existing")
309+
patch_op = PatchOp[ImmutableFieldResource].model_validate(
310+
{"operations": [{"op": "add", "path": "locked", "value": "new"}]},
311+
context={"scim": Context.RESOURCE_PATCH_REQUEST},
312+
)
313+
with pytest.raises(MutabilityException):
314+
patch_op.patch(resource)
315+
316+
317+
def test_patch_add_on_immutable_field_without_value_is_allowed():
318+
"""Adding to an immutable attribute with no previous value is allowed per RFC 7644."""
319+
resource = ImmutableFieldResource.model_construct()
320+
patch_op = PatchOp[ImmutableFieldResource].model_validate(
321+
{"operations": [{"op": "add", "path": "locked", "value": "initial"}]},
322+
context={"scim": Context.RESOURCE_PATCH_REQUEST},
323+
)
324+
patch_op.patch(resource)
325+
assert resource.locked == "initial"
326+
327+
328+
def test_patch_replace_on_immutable_field_with_different_value_is_rejected():
329+
"""Replacing an immutable attribute with a different value is rejected."""
330+
resource = ImmutableFieldResource.model_construct(locked="existing")
331+
patch_op = PatchOp[ImmutableFieldResource].model_validate(
332+
{"operations": [{"op": "replace", "path": "locked", "value": "other"}]},
333+
context={"scim": Context.RESOURCE_PATCH_REQUEST},
334+
)
335+
with pytest.raises(MutabilityException):
336+
patch_op.patch(resource)
337+
338+
339+
def test_patch_replace_on_immutable_field_with_same_value_is_allowed():
340+
"""Replacing an immutable attribute with its current value is a no-op and is allowed."""
341+
resource = ImmutableFieldResource.model_construct(locked="existing")
342+
patch_op = PatchOp[ImmutableFieldResource].model_validate(
343+
{"operations": [{"op": "replace", "path": "locked", "value": "existing"}]},
344+
context={"scim": Context.RESOURCE_PATCH_REQUEST},
345+
)
346+
patch_op.patch(resource)
347+
assert resource.locked == "existing"
348+
349+
350+
def test_patch_remove_on_readonly_field_is_rejected():
351+
"""Removing a readOnly attribute via PATCH is rejected per RFC 7643 §7."""
352+
with pytest.raises(ValidationError, match="mutability"):
353+
PatchOp[User].model_validate(
354+
{"operations": [{"op": "remove", "path": "id"}]},
355+
context={"scim": Context.RESOURCE_PATCH_REQUEST},
356+
)
357+
358+
278359
def test_patch_validation_allows_unknown_fields():
279-
"""Test that patch validation allows unknown fields in operations."""
280-
# This should not raise an error even though 'unknownField' doesn't exist on User
360+
"""Patch operations on unknown fields pass without mutability checks."""
281361
patch_op = PatchOp[User].model_validate(
282362
{
283363
"operations": [
@@ -290,9 +370,8 @@ def test_patch_validation_allows_unknown_fields():
290370
assert patch_op.operations[0].path == "unknownField"
291371

292372

293-
def test_non_replace_operations_on_immutable_fields_allowed():
294-
"""Test that non-replace operations on immutable fields are allowed."""
295-
# Test with non-immutable fields since groups.value is immutable
373+
def test_patch_operations_on_readwrite_fields_allowed():
374+
"""All patch operations are allowed on readWrite fields."""
296375
patch_op = PatchOp[User].model_validate(
297376
{
298377
"operations": [

0 commit comments

Comments
 (0)