Skip to content

Commit 8d3d82f

Browse files
committed
doc: ETags in framework integrations
1 parent 4ba3355 commit 8d3d82f

7 files changed

Lines changed: 209 additions & 10 deletions

File tree

doc/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"python": ("https://docs.python.org/3", None),
4545
"pydantic": ("https://docs.pydantic.dev/latest/", None),
4646
"flask": ("https://flask.palletsprojects.com/en/stable/", None),
47+
"sqlalchemy": ("https://docs.sqlalchemy.org/en/20/", None),
4748
}
4849

4950
# -- Options for HTML output ----------------------------------------------

doc/guides/_examples/django_example.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from scim2_models import UniquenessException
2121
from scim2_models import User
2222

23+
from .integrations import check_etag
2324
from .integrations import delete_record
2425
from .integrations import from_scim_user
2526
from .integrations import get_record
@@ -28,6 +29,8 @@
2829
from .integrations import get_schema
2930
from .integrations import get_schemas
3031
from .integrations import list_records
32+
from .integrations import make_etag
33+
from .integrations import PreconditionFailed
3134
from .integrations import save_record
3235
from .integrations import service_provider_config
3336
from .integrations import to_scim_user
@@ -78,6 +81,14 @@ def scim_uniqueness_error(error):
7881
# -- uniqueness-helper-end --
7982

8083

84+
# -- precondition-helper-start --
85+
def scim_precondition_error():
86+
"""Turn ETag mismatches into a SCIM 412 response."""
87+
scim_error = Error(status=412, detail="ETag mismatch")
88+
return scim_response(scim_error.model_dump_json(), HTTPStatus.PRECONDITION_FAILED)
89+
# -- precondition-helper-end --
90+
91+
8192
# -- error-handler-start --
8293
def handler404(request, exception):
8394
"""Turn Django 404 errors into SCIM error responses."""
@@ -99,20 +110,35 @@ def get(self, request, app_record):
99110
except ValidationError as error:
100111
return scim_validation_error(error)
101112

113+
etag = make_etag(app_record)
114+
if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
115+
if if_none_match and etag in [t.strip() for t in if_none_match.split(",")]:
116+
return HttpResponse(status=HTTPStatus.NOT_MODIFIED)
117+
102118
scim_user = to_scim_user(app_record)
103-
return scim_response(
119+
resp = scim_response(
104120
scim_user.model_dump_json(
105121
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
106122
attributes=req.attributes,
107123
excluded_attributes=req.excluded_attributes,
108124
)
109125
)
126+
resp["ETag"] = etag
127+
return resp
110128

111129
def delete(self, request, app_record):
130+
try:
131+
check_etag(app_record, request.META.get("HTTP_IF_MATCH"))
132+
except PreconditionFailed:
133+
return scim_precondition_error()
112134
delete_record(app_record["id"])
113135
return scim_response("", HTTPStatus.NO_CONTENT)
114136

115137
def put(self, request, app_record):
138+
try:
139+
check_etag(app_record, request.META.get("HTTP_IF_MATCH"))
140+
except PreconditionFailed:
141+
return scim_precondition_error()
116142
existing_user = to_scim_user(app_record)
117143
try:
118144
replacement = User.model_validate(
@@ -131,13 +157,19 @@ def put(self, request, app_record):
131157
return scim_uniqueness_error(error)
132158

133159
response_user = to_scim_user(updated_record)
134-
return scim_response(
160+
resp = scim_response(
135161
response_user.model_dump_json(
136162
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE
137163
)
138164
)
165+
resp["ETag"] = make_etag(updated_record)
166+
return resp
139167

140168
def patch(self, request, app_record):
169+
try:
170+
check_etag(app_record, request.META.get("HTTP_IF_MATCH"))
171+
except PreconditionFailed:
172+
return scim_precondition_error()
141173
try:
142174
patch = PatchOp[User].model_validate(
143175
json.loads(request.body),
@@ -155,9 +187,11 @@ def patch(self, request, app_record):
155187
except ValueError as error:
156188
return scim_uniqueness_error(error)
157189

158-
return scim_response(
190+
resp = scim_response(
159191
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE)
160192
)
193+
resp["ETag"] = make_etag(updated_record)
194+
return resp
161195
# -- single-resource-end --
162196

163197

@@ -204,10 +238,12 @@ def post(self, request):
204238
return scim_uniqueness_error(error)
205239

206240
response_user = to_scim_user(app_record)
207-
return scim_response(
241+
resp = scim_response(
208242
response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE),
209243
HTTPStatus.CREATED,
210244
)
245+
resp["ETag"] = make_etag(app_record)
246+
return resp
211247

212248

213249
urlpatterns = [

doc/guides/_examples/flask_example.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from http import HTTPStatus
22

33
from flask import Blueprint
4+
from flask import make_response
45
from flask import request
56
from pydantic import ValidationError
67
from werkzeug.routing import BaseConverter
@@ -17,6 +18,7 @@
1718
from scim2_models import UniquenessException
1819
from scim2_models import User
1920

21+
from .integrations import check_etag
2022
from .integrations import delete_record
2123
from .integrations import from_scim_user
2224
from .integrations import get_record
@@ -25,6 +27,8 @@
2527
from .integrations import get_schema
2628
from .integrations import get_schemas
2729
from .integrations import list_records
30+
from .integrations import make_etag
31+
from .integrations import PreconditionFailed
2832
from .integrations import save_record
2933
from .integrations import service_provider_config
3034
from .integrations import to_scim_user
@@ -82,6 +86,13 @@ def handle_value_error(error):
8286
"""Turn uniqueness errors into SCIM 409 responses."""
8387
scim_error = UniquenessException(detail=str(error)).to_error()
8488
return scim_error.model_dump_json(), HTTPStatus.CONFLICT
89+
90+
91+
@bp.errorhandler(PreconditionFailed)
92+
def handle_precondition_failed(error):
93+
"""Turn ETag mismatches into SCIM 412 responses."""
94+
scim_error = Error(status=412, detail="ETag mismatch")
95+
return scim_error.model_dump_json(), HTTPStatus.PRECONDITION_FAILED
8596
# -- error-handlers-end --
8697
# -- refinements-end --
8798

@@ -94,21 +105,24 @@ def get_user(app_record):
94105
"""Return one SCIM user."""
95106
req = ResponseParameters.model_validate(request.args.to_dict())
96107
scim_user = to_scim_user(app_record)
97-
return (
108+
resp = make_response(
98109
scim_user.model_dump_json(
99110
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
100111
attributes=req.attributes,
101112
excluded_attributes=req.excluded_attributes,
102-
),
103-
HTTPStatus.OK,
113+
)
104114
)
115+
resp.headers["ETag"] = make_etag(app_record)
116+
resp.make_conditional(request)
117+
return resp
105118
# -- get-user-end --
106119

107120

108121
# -- patch-user-start --
109122
@bp.patch("/Users/<user:app_record>")
110123
def patch_user(app_record):
111124
"""Apply a SCIM PatchOp to an existing user."""
125+
check_etag(app_record, request.headers.get("If-Match"))
112126
scim_user = to_scim_user(app_record)
113127
patch = PatchOp[User].model_validate(
114128
request.get_json(),
@@ -122,6 +136,7 @@ def patch_user(app_record):
122136
return (
123137
scim_user.model_dump_json(scim_ctx=Context.RESOURCE_PATCH_RESPONSE),
124138
HTTPStatus.OK,
139+
{"ETag": make_etag(updated_record)},
125140
)
126141
# -- patch-user-end --
127142

@@ -130,6 +145,7 @@ def patch_user(app_record):
130145
@bp.put("/Users/<user:app_record>")
131146
def replace_user(app_record):
132147
"""Replace an existing user with a full SCIM resource."""
148+
check_etag(app_record, request.headers.get("If-Match"))
133149
existing_user = to_scim_user(app_record)
134150
replacement = User.model_validate(
135151
request.get_json(),
@@ -147,6 +163,7 @@ def replace_user(app_record):
147163
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE
148164
),
149165
HTTPStatus.OK,
166+
{"ETag": make_etag(updated_record)},
150167
)
151168
# -- put-user-end --
152169

@@ -155,6 +172,7 @@ def replace_user(app_record):
155172
@bp.delete("/Users/<user:app_record>")
156173
def delete_user(app_record):
157174
"""Delete an existing user."""
175+
check_etag(app_record, request.headers.get("If-Match"))
158176
delete_record(app_record["id"])
159177
return "", HTTPStatus.NO_CONTENT
160178
# -- delete-user-end --
@@ -201,6 +219,7 @@ def create_user():
201219
return (
202220
response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE),
203221
HTTPStatus.CREATED,
222+
{"ETag": make_etag(app_record)},
204223
)
205224
# -- create-user-end --
206225
# -- collection-end --

doc/guides/_examples/integrations.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Framework-agnostic storage and mapping layer shared by the integration examples."""
22

3+
import hashlib
4+
from datetime import datetime
5+
from datetime import timezone
36
from uuid import uuid4
47

58
from scim2_models import AuthenticationScheme
@@ -38,9 +41,14 @@ def list_records(start=None, stop=None):
3841

3942
def save_record(record):
4043
"""Persist *record*, raising ValueError if its userName is already taken."""
44+
if not record.get("id"):
45+
record["id"] = str(uuid4())
4146
for existing in records.values():
4247
if existing["id"] != record["id"] and existing["user_name"] == record["user_name"]:
4348
raise ValueError(f"userName {record['user_name']!r} is already taken")
49+
now = datetime.now(timezone.utc)
50+
record.setdefault("created_at", now)
51+
record["updated_at"] = now
4452
records[record["id"]] = record
4553

4654

@@ -59,14 +67,19 @@ def to_scim_user(record):
5967
display_name=record.get("display_name"),
6068
active=record.get("active", True),
6169
emails=[User.Emails(value=record["email"])] if record.get("email") else None,
62-
meta=Meta(resource_type="User"),
70+
meta=Meta(
71+
resource_type="User",
72+
version=make_etag(record),
73+
created=record["created_at"],
74+
last_modified=record["updated_at"],
75+
),
6376
)
6477

6578

6679
def from_scim_user(scim_user):
6780
"""Convert a validated SCIM payload into the application shape."""
6881
return {
69-
"id": scim_user.id or str(uuid4()),
82+
"id": scim_user.id,
7083
"user_name": scim_user.user_name,
7184
"display_name": scim_user.display_name,
7285
"active": True if scim_user.active is None else scim_user.active,
@@ -75,6 +88,35 @@ def from_scim_user(scim_user):
7588
# -- mapping-end --
7689

7790

91+
# -- etag-start --
92+
class PreconditionFailed(Exception):
93+
"""Raised when an ``If-Match`` ETag check fails."""
94+
95+
96+
def make_etag(record):
97+
"""Compute a weak ETag from a record's content."""
98+
digest = hashlib.sha256(str(sorted(record.items())).encode()).hexdigest()[:16]
99+
return f'W/"{digest}"'
100+
101+
102+
def check_etag(record, if_match):
103+
"""Compare the record's ETag against an ``If-Match`` header value.
104+
105+
:param record: The application record.
106+
:param if_match: Raw ``If-Match`` header value, or :data:`None`.
107+
:raises PreconditionFailed: If the header is present and does not match.
108+
"""
109+
if not if_match:
110+
return
111+
if if_match.strip() == "*":
112+
return
113+
etag = make_etag(record)
114+
tags = [t.strip() for t in if_match.split(",")]
115+
if etag not in tags:
116+
raise PreconditionFailed()
117+
# -- etag-end --
118+
119+
78120
# -- discovery-start --
79121
RESOURCE_MODELS = [User]
80122

@@ -125,7 +167,7 @@ def get_resource_type(resource_type_id):
125167
filter=Filter(supported=False, max_results=0),
126168
change_password=ChangePassword(supported=False),
127169
sort=Sort(supported=False),
128-
etag=ETag(supported=False),
170+
etag=ETag(supported=True),
129171
authentication_schemes=[
130172
AuthenticationScheme(
131173
type=AuthenticationScheme.Type.httpbasic,

doc/guides/django.rst

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ Uniqueness error helper
7878
:start-after: # -- uniqueness-helper-start --
7979
:end-before: # -- uniqueness-helper-end --
8080

81+
Precondition error helper
82+
^^^^^^^^^^^^^^^^^^^^^^^^^
83+
84+
``scim_precondition_error`` catches the
85+
:class:`~doc.guides._examples.integrations.PreconditionFailed` errors raised by the
86+
:ref:`ETag helpers <etag-helpers>` and returns a 412.
87+
88+
.. literalinclude:: _examples/django_example.py
89+
:language: python
90+
:start-after: # -- precondition-helper-start --
91+
:end-before: # -- precondition-helper-end --
92+
8193
Error handler
8294
^^^^^^^^^^^^^
8395

@@ -144,6 +156,41 @@ The ``urlpatterns`` list wires both views to their routes.
144156
:start-after: # -- collection-start --
145157
:end-before: # -- collection-end --
146158

159+
Resource versioning (ETags)
160+
===========================
161+
162+
SCIM supports resource versioning through HTTP ETags
163+
(:rfc:`RFC 7644 §3.14 <7644#section-3.14>`).
164+
The shared :ref:`ETag helpers <etag-helpers>` compute a weak ETag from each record and
165+
populate :attr:`~scim2_models.Meta.version`.
166+
167+
On ``GET`` single-resource responses, the ``ETag`` header is set and the ``If-None-Match``
168+
request header is checked manually to return a ``304 Not Modified`` when the client already
169+
has the current version.
170+
171+
On write operations (``PUT``, ``PATCH``, ``DELETE``), the ``If-Match`` header is checked
172+
before processing.
173+
If the client's ETag does not match, a ``412 Precondition Failed`` SCIM error is returned.
174+
``POST`` and ``PUT``/``PATCH`` responses include the ``ETag`` header for the newly created or
175+
updated resource.
176+
177+
.. tip::
178+
179+
With Django ORM, a :class:`~uuid.UUIDField` regenerated on every
180+
:meth:`~django.db.models.Model.save` provides a collision-free ETag value
181+
without relying on clock precision::
182+
183+
import uuid
184+
185+
from django.db import models
186+
187+
class UserModel(models.Model):
188+
version = models.UUIDField(default=uuid.uuid4)
189+
190+
def save(self, **kwargs):
191+
self.version = uuid.uuid4()
192+
super().save(**kwargs)
193+
147194
Discovery endpoints
148195
===================
149196

0 commit comments

Comments
 (0)