Skip to content

Commit 43ae6c9

Browse files
committed
fix: replace keeps a write-only attribute left out of the replacement
RFC 7644 §3.5.1 only lets an omitted readWrite attribute be cleared. A client that retrieved the resource and revised it never got a write-only value such as password back, so a PUT built from a GET used to erase it. An explicit null still clears it.
1 parent 5ea0dae commit 43ae6c9

4 files changed

Lines changed: 79 additions & 1 deletion

File tree

‎doc/changelog.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ Fixed
2626
<7643#section-3.1>` declares it, so filters and orders on it respect the case.
2727
- The :class:`~scim2_models.Meta` sub-attributes are read-only, and ``resourceType`` and
2828
``version`` are case-exact, as :rfc:`RFC7643 §3.1 <7643#section-3.1>` declares them.
29+
- :meth:`Resource.replace <scim2_models.Resource.replace>` keeps a write-only attribute, such
30+
as ``password``, that the replacement leaves out, and clears it on an explicit null only. A
31+
client never gets that value back, so a PUT built from a GET used to erase it.
2932

3033
[0.8.1] - 2026-09-25
3134
--------------------

‎scim2_models/base.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,8 @@ def _apply_replace_constraints(self, original: Self) -> None:
701701
- ``readOnly`` fields are copied from *original* unconditionally.
702702
- ``immutable`` fields are copied from *original* when absent from
703703
``self``; a MutabilityException is raised when the value differs.
704+
- ``writeOnly`` fields left out of ``self`` are copied from *original*,
705+
and only an explicit null clears them.
704706
705707
Recursively applies to nested complex attributes, and to the entries of
706708
a multi-valued one whose ``value`` designates a single entry on both
@@ -727,6 +729,14 @@ def _apply_replace_constraints(self, original: Self) -> None:
727729
raise MutabilityException(
728730
attribute=field_name, mutability="immutable"
729731
)
732+
elif (
733+
mutability == Mutability.write_only
734+
and field_name not in self.model_fields_set
735+
):
736+
# RFC 7644 §3.5.1 only lets an omitted "readWrite" attribute be
737+
# cleared: a client that retrieved the resource and revised it
738+
# never got the write-only value back, and cannot resend it.
739+
self.__dict__[field_name] = original_val
730740

731741
complex_and_extensions = self.__scim_info__.complex_fields.union(
732742
self.__scim_info__.extensions

‎scim2_models/resources/resource.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,9 @@ def replace(self, original: Self) -> None:
192192
193193
``readOnly`` fields are copied from *original*.
194194
``immutable`` fields are preserved from *original* when absent,
195-
or checked for equality when present.
195+
or checked for equality when present. ``writeOnly`` fields, such as
196+
``password``, are preserved from *original* when left out, and cleared
197+
by an explicit null only.
196198
197199
The same applies to the sub-attributes of a complex attribute, and to
198200
those of an entry a multi-valued one keeps. Entries are matched on their

‎tests/test_model_validation.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,69 @@ def test_replace_preserves_immutable_when_absent():
490490
assert replacement.immutable == "y"
491491

492492

493+
def test_replace_preserves_a_write_only_field_left_out():
494+
"""RFC 7644 §3.5.1 only clears omitted readWrite attributes: a write-only value is never returned to resend."""
495+
original = MutResource(write_only="secret")
496+
replacement = MutResource(read_write="x")
497+
replacement.replace(original)
498+
assert replacement.write_only == "secret"
499+
assert "write_only" not in replacement.model_fields_set
500+
501+
502+
def test_replace_clears_a_write_only_field_set_to_null():
503+
"""An explicit null is how RFC 7644 §3.5.1 lets a client clear a value."""
504+
original = MutResource(write_only="secret")
505+
replacement = MutResource(write_only=None)
506+
replacement.replace(original)
507+
assert replacement.write_only is None
508+
509+
510+
def test_replace_takes_a_provided_write_only_value():
511+
"""RFC 7644 §3.5.1: write-only values provided "SHALL replace the existing" ones."""
512+
original = MutResource(write_only="secret")
513+
replacement = MutResource(write_only="new")
514+
replacement.replace(original)
515+
assert replacement.write_only == "new"
516+
517+
518+
@pytest.mark.parametrize(
519+
("payload", "expected"),
520+
[({}, "secret"), ({"writeOnly": None}, None), ({"writeOnly": "new"}, "new")],
521+
ids=["omitted", "null", "provided"],
522+
)
523+
def test_replace_reads_a_write_only_field_from_a_replacement_request(payload, expected):
524+
"""A payload tells an omitted write-only attribute apart from a null one."""
525+
original = MutResource(write_only="secret")
526+
replacement = MutResource.model_validate(
527+
{"schemas": ["urn:example:MutResource"], **payload},
528+
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
529+
)
530+
replacement.replace(original)
531+
assert replacement.write_only == expected
532+
533+
534+
def test_replace_preserves_a_write_only_sub_attribute_left_out():
535+
"""The rule applies to the sub-attributes of complex and multi-valued attributes."""
536+
537+
class Sub(ComplexAttribute):
538+
value: str | None = None
539+
write_only: Annotated[str | None, Mutability.write_only] = None
540+
541+
class Holder(Resource):
542+
__schema__ = URN("urn:example:Holder")
543+
sub: Sub | None = None
544+
subs: list[Sub] | None = None
545+
546+
original = Holder(
547+
sub=Sub(value="a", write_only="secret"),
548+
subs=[Sub(value="a", write_only="secret")],
549+
)
550+
replacement = Holder(sub=Sub(value="b"), subs=[Sub(value="a")])
551+
replacement.replace(original)
552+
assert replacement.sub.write_only == "secret"
553+
assert replacement.subs[0].write_only == "secret"
554+
555+
493556
def test_replace_does_not_assert_the_fields_it_copies():
494557
"""The fields replace copies from the original are not reported as set.
495558

0 commit comments

Comments
 (0)