Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ Changelog
[0.6.8] - Unreleased
--------------------

Added
^^^^^
- :class:`~scim2_models.MutabilityException` handler in framework integration examples (FastAPI, Flask, Django).

Deprecated
^^^^^^^^^^
- 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.

Fixed
^^^^^
- 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.
Expand Down
6 changes: 5 additions & 1 deletion doc/guides/_examples/django_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from scim2_models import Context
from scim2_models import Error
from scim2_models import ListResponse
from scim2_models import MutabilityException
from scim2_models import PatchOp
from scim2_models import ResourceType
from scim2_models import ResponseParameters
Expand Down Expand Up @@ -152,10 +153,13 @@ def put(self, request, app_record):
replacement = User.model_validate(
json.loads(request.body),
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
original=existing_user,
)
replacement.replace(existing_user)
except ValidationError as error:
return scim_validation_error(error)
except MutabilityException as error:
scim_error = error.to_error()
return scim_response(scim_error.model_dump_json(), scim_error.status)

replacement.id = existing_user.id
updated_record = from_scim_user(replacement)
Expand Down
10 changes: 9 additions & 1 deletion doc/guides/_examples/fastapi_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from scim2_models import Context
from scim2_models import Error
from scim2_models import ListResponse
from scim2_models import MutabilityException
from scim2_models import PatchOp
from scim2_models import ResourceType
from scim2_models import ResponseParameters
Expand Down Expand Up @@ -93,6 +94,13 @@ async def handle_precondition_failed(request, error):
return Response(
scim_error.model_dump_json(), status_code=HTTPStatus.PRECONDITION_FAILED
)


@app.exception_handler(MutabilityException)
async def handle_mutability_error(request, error):
"""Turn mutability violations into SCIM error responses."""
scim_error = error.to_error()
return Response(scim_error.model_dump_json(), status_code=scim_error.status)
# -- error-handlers-end --
# -- refinements-end --

Expand Down Expand Up @@ -151,8 +159,8 @@ async def replace_user(request: Request, app_record: dict = Depends(resolve_user
replacement = User.model_validate(
await request.json(),
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
original=existing_user,
)
replacement.replace(existing_user)

replacement.id = existing_user.id
updated_record = from_scim_user(replacement)
Expand Down
10 changes: 9 additions & 1 deletion doc/guides/_examples/flask_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from scim2_models import Context
from scim2_models import Error
from scim2_models import ListResponse
from scim2_models import MutabilityException
from scim2_models import PatchOp
from scim2_models import ResourceType
from scim2_models import ResponseParameters
Expand Down Expand Up @@ -99,6 +100,13 @@ def handle_precondition_failed(error):
"""Turn ETag mismatches into SCIM 412 responses."""
scim_error = Error(status=412, detail="ETag mismatch")
return scim_error.model_dump_json(), HTTPStatus.PRECONDITION_FAILED


@bp.errorhandler(MutabilityException)
def handle_mutability_error(error):
"""Turn mutability violations into SCIM error responses."""
scim_error = error.to_error()
return scim_error.model_dump_json(), scim_error.status
# -- error-handlers-end --
# -- refinements-end --

Expand Down Expand Up @@ -156,8 +164,8 @@ def replace_user(app_record):
replacement = User.model_validate(
request.get_json(),
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
original=existing_user,
)
replacement.replace(existing_user)

replacement.id = existing_user.id
updated_record = from_scim_user(replacement)
Expand Down
5 changes: 3 additions & 2 deletions doc/guides/django.rst
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ For ``GET``, parse query parameters with :class:`~scim2_models.ResponseParameter
SCIM resource, and serialize with :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`.
For ``DELETE``, remove the record and return an empty 204 response.
For ``PUT``, validate the full replacement payload with
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, passing the ``original`` resource
so that immutable attributes are checked for unintended modifications.
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, then call
:meth:`~scim2_models.Resource.replace` to verify that immutable attributes
have not been modified.
Convert back to native and persist, then serialize with
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RESPONSE`.
For ``PATCH``, validate the payload with :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`,
Expand Down
7 changes: 3 additions & 4 deletions doc/guides/fastapi.rst
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,9 @@ PUT /Users/<id>
^^^^^^^^^^^^^^^

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

.. literalinclude:: _examples/fastapi_example.py
:language: python
Expand Down
7 changes: 3 additions & 4 deletions doc/guides/flask.rst
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,9 @@ PUT /Users/<id>
^^^^^^^^^^^^^^^

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

.. literalinclude:: _examples/flask_example.py
:language: python
Expand Down
30 changes: 23 additions & 7 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,6 @@ fields with unexpected values will raise :class:`~pydantic.ValidationError`:
... except pydantic.ValidationError:
... obj = Error(...)

.. note::

With the :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` context,
:meth:`~scim2_models.BaseModel.model_validate` takes an additional
:paramref:`~scim2_models.BaseModel.model_validate.original` argument that is used to compare
:attr:`~scim2_models.Mutability.immutable` attributes, and raise an exception when they have mutated.

Attributes inclusions and exclusions
====================================

Expand Down Expand Up @@ -479,6 +472,29 @@ Client applications can use this to dynamically discover server resources by bro
:language: json
:caption: schema-group.json

Replace operations
==================

When handling a ``PUT`` request, validate the incoming payload with the
:attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` context, then call
:meth:`~scim2_models.Resource.replace` against the existing resource to
verify that :attr:`~scim2_models.Mutability.immutable` attributes have not been
modified.

.. doctest::

>>> from scim2_models import User, Context
>>> from scim2_models.exceptions import MutabilityException
>>> existing = User(user_name="bjensen")
>>> replacement = User.model_validate(
... {"userName": "bjensen"},
... scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
... )
>>> replacement.replace(existing)

If an immutable attribute differs, a :class:`~scim2_models.MutabilityException`
is raised.

Patch operations
================

Expand Down
60 changes: 34 additions & 26 deletions scim2_models/base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import warnings
from inspect import isclass
from typing import Any
from typing import Optional
Expand All @@ -23,6 +24,7 @@
from scim2_models.annotations import Required
from scim2_models.annotations import Returned
from scim2_models.context import Context
from scim2_models.exceptions import MutabilityException
from scim2_models.utils import UNION_TYPES
from scim2_models.utils import _find_field_name
from scim2_models.utils import _normalize_attribute_name
Expand Down Expand Up @@ -410,7 +412,10 @@ def check_replacement_request_mutability(
and issubclass(cls, Resource)
and original is not None
):
cls._check_mutability_issues(original, obj)
try:
obj._check_immutable_fields(original)
except MutabilityException as exc:
raise exc.as_pydantic_error() from exc
return obj

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

return self

@classmethod
def _check_mutability_issues(
cls, original: "BaseModel", replacement: "BaseModel"
) -> None:
"""Compare two instances, and check for differences of values on the fields marked as immutable."""
def _check_immutable_fields(self, original: Self) -> None:
"""Check that immutable fields have not been modified compared to *original*.

Recursively checks nested single-valued complex attributes.
"""
from .attributes import is_complex_attribute

model = replacement.__class__
for field_name in model.model_fields:
mutability = model.get_field_annotation(field_name, Mutability)
for field_name in type(self).model_fields:
mutability = type(self).get_field_annotation(field_name, Mutability)
if mutability == Mutability.immutable and getattr(
original, field_name
) != getattr(replacement, field_name):
raise PydanticCustomError(
"mutability_error",
"Field '{field_name}' is immutable but the request value is different than the original value.",
{"field_name": field_name},
)
) != getattr(self, field_name):
raise MutabilityException(attribute=field_name, mutability="immutable")

attr_type = model.get_field_root_type(field_name)
attr_type = type(self).get_field_root_type(field_name)
if (
attr_type
and is_complex_attribute(attr_type)
and not model.get_field_multiplicity(field_name)
and not type(self).get_field_multiplicity(field_name)
):
original_val = getattr(original, field_name)
replacement_value = getattr(replacement, field_name)
if original_val is not None and replacement_value is not None:
cls._check_mutability_issues(original_val, replacement_value)
replacement_val = getattr(self, field_name)
if original_val is not None and replacement_val is not None:
replacement_val._check_immutable_fields(original_val)

def _set_complex_attribute_urns(self) -> None:
"""Navigate through attributes and sub-attributes of type ComplexAttribute, and mark them with a '_attribute_urn' attribute.
Expand Down Expand Up @@ -611,22 +611,30 @@ def model_validate(
original: Optional["BaseModel"] = None,
**kwargs: Any,
) -> Self:
"""Validate SCIM payloads and generate model representation by using Pydantic :code:`BaseModel.model_validate`.
"""Validate SCIM payloads and generate model representation by using Pydantic :meth:`~pydantic.BaseModel.model_validate`.

:param scim_ctx: The SCIM :class:`~scim2_models.Context` in which the validation happens.
:param original: If this parameter is set during :attr:`~Context.RESOURCE_REPLACEMENT_REQUEST`,
:attr:`~scim2_models.Mutability.immutable` parameters will be compared against the *original* model value.
An exception is raised if values are different.

.. deprecated:: 0.6.7
Use :meth:`replace` on the validated instance instead.
Will be removed in 0.8.0.
"""
if original is not None:
warnings.warn(
"The 'original' parameter is deprecated, "
"use the 'replace' method on the validated instance instead. "
"Will be removed in 0.8.0.",
DeprecationWarning,
stacklevel=2,
)

context = kwargs.setdefault("context", {})
context.setdefault("scim", scim_ctx)
context.setdefault("original", original)

if scim_ctx == Context.RESOURCE_REPLACEMENT_REQUEST and original is None:
raise ValueError(
"Resource queries replacement validation must compare to an original resource"
)

return super().model_validate(*args, **kwargs)

def get_attribute_urn(self, field_name: str) -> str:
Expand Down
14 changes: 14 additions & 0 deletions scim2_models/resources/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,20 @@ class Resource(ScimObject, Generic[AnyExtension]):
meta: Annotated[Meta | None, Mutability.read_only, Returned.default] = None
"""A complex attribute containing resource metadata."""

def replace(self, original: Self) -> None:
"""Verify that no immutable field has been modified compared to *original*.

Intended to be called after parsing a PUT request body, to enforce
:rfc:`RFC 7644 §3.5.1 <7644#section-3.5.1>`: if one or more values
are already set for an immutable attribute, the input values MUST match.

Recursively checks nested single-valued complex attributes.

:param original: The original resource state to compare against.
:raises MutabilityException: If an immutable field value differs.
"""
self._check_immutable_fields(original)

@classmethod
def __class_getitem__(cls, item: Any) -> type["Resource[Any]"]:
"""Create a Resource class with extension fields dynamically added."""
Expand Down
Loading
Loading