Skip to content

Commit ebe6213

Browse files
committed
Merge branch 'scim-etags' into 'main'
Support SCIM ETags See merge request yaal/canaille!335
2 parents 103c49a + e478ec1 commit ebe6213

7 files changed

Lines changed: 338 additions & 178 deletions

File tree

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Added
55
^^^^^
66
- SCIM ``attributes`` and ``excludedAttributes`` query parameter support.
77
- SCIM ``POST /.search`` endpoint.
8+
- SCIM ETags support. :pr:`335`
89

910
[0.2.3] - 2026-03-24
1011
--------------------

canaille/scim/casting.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
import hashlib
23

34
from flask import url_for
45
from scim2_models import Meta
@@ -11,6 +12,13 @@
1112
from canaille.scim.models import User
1213

1314

15+
def make_etag(model):
16+
"""Compute a weak ETag from a model's identity and modification time."""
17+
raw = f"{model.id}:{model.last_modified}"
18+
digest = hashlib.sha256(raw.encode()).hexdigest()[:16]
19+
return f'W/"{digest}"'
20+
21+
1422
def user_from_canaille_to_scim(user, user_class, enterprise_user_class):
1523
scim_user_class = user_class if user_class != User else User[EnterpriseUser]
1624
scim_user = scim_user_class(
@@ -19,6 +27,7 @@ def user_from_canaille_to_scim(user, user_class, enterprise_user_class):
1927
created=user.created,
2028
last_modified=user.last_modified,
2129
location=url_for("scim.query_user", user=user, _external=True),
30+
version=make_etag(user),
2231
),
2332
user_name=user.user_name,
2433
preferred_language=user.preferred_language,
@@ -157,6 +166,7 @@ def group_from_canaille_to_scim(group, group_class):
157166
created=group.created,
158167
last_modified=group.last_modified,
159168
location=url_for("scim.query_group", group=group, _external=True),
169+
version=make_etag(group),
160170
),
161171
display_name=group.display_name,
162172
)

canaille/scim/endpoints.py

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
)
99
from authlib.oauth2.rfc6750 import BearerTokenValidator
1010
from flask import Blueprint
11-
from flask import Response
1211
from flask import abort
1312
from flask import current_app
1413
from flask import request
@@ -22,13 +21,15 @@
2221
from scim2_models import Schema
2322
from scim2_models import SearchRequest
2423
from werkzeug.exceptions import HTTPException
24+
from werkzeug.exceptions import PreconditionFailed
2525

2626
from canaille.app import models
2727
from canaille.app.flask import csrf
2828
from canaille.backends import Backend
2929

3030
from .casting import group_from_canaille_to_scim_server
3131
from .casting import group_from_scim_to_canaille
32+
from .casting import make_etag
3233
from .casting import user_from_canaille_to_scim_server
3334
from .casting import user_from_scim_to_canaille
3435
from .models import EnterpriseUser
@@ -58,6 +59,34 @@ def add_scim_content_type(response):
5859
return response
5960

6061

62+
@bp.after_request
63+
def set_etag_header(response):
64+
"""Extract ``ETag`` from ``meta.version`` and handle conditional responses."""
65+
data = response.get_json(silent=True)
66+
if meta := (data or {}).get("meta"):
67+
if version := meta.get("version"):
68+
response.headers["ETag"] = version
69+
response.make_conditional(request)
70+
return response
71+
72+
73+
@bp.before_request
74+
def check_etag():
75+
"""Verify ``If-Match`` on write operations."""
76+
if request.method not in ("PUT", "PATCH", "DELETE"):
77+
return
78+
arg = next(iter(request.view_args.values()))
79+
if_match = request.headers.get("If-Match")
80+
if not if_match:
81+
return
82+
if if_match.strip() == "*":
83+
return
84+
etag = make_etag(arg)
85+
tags = [t.strip() for t in if_match.split(",")]
86+
if etag not in tags:
87+
raise PreconditionFailed("ETag mismatch")
88+
89+
6190
@bp.errorhandler(HTTPException)
6291
def http_error_handler(error):
6392
obj = Error(detail=str(error), status=error.code)
@@ -268,6 +297,7 @@ def search():
268297
@csrf.exempt
269298
@require_oauth()
270299
def create_user():
300+
req = ResponseParameters.model_validate(request.args.to_dict())
271301
request_user = User[EnterpriseUser].model_validate(
272302
request.json, scim_ctx=Context.RESOURCE_CREATION_REQUEST
273303
)
@@ -277,14 +307,21 @@ def create_user():
277307
f"SCIM created user {user.id} by client {current_token.client.client_id}"
278308
)
279309
response_user = user_from_canaille_to_scim_server(user)
280-
payload = response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE)
281-
return Response(payload, status=HTTPStatus.CREATED)
310+
return (
311+
response_user.model_dump(
312+
scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
313+
attributes=req.attributes,
314+
excluded_attributes=req.excluded_attributes,
315+
),
316+
HTTPStatus.CREATED,
317+
)
282318

283319

284320
@bp.route("/Groups", methods=["POST"])
285321
@csrf.exempt
286322
@require_oauth()
287323
def create_group():
324+
req = ResponseParameters.model_validate(request.args.to_dict())
288325
request_group = Group.model_validate(
289326
request.json, scim_ctx=Context.RESOURCE_CREATION_REQUEST
290327
)
@@ -294,58 +331,69 @@ def create_group():
294331
f"SCIM created group {group.id} by client {current_token.client.client_id}"
295332
)
296333
response_group = group_from_canaille_to_scim_server(group)
297-
payload = response_group.model_dump_json(
298-
scim_ctx=Context.RESOURCE_CREATION_RESPONSE
334+
return (
335+
response_group.model_dump(
336+
scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
337+
attributes=req.attributes,
338+
excluded_attributes=req.excluded_attributes,
339+
),
340+
HTTPStatus.CREATED,
299341
)
300-
return Response(payload, status=HTTPStatus.CREATED)
301342

302343

303344
@bp.route("/Users/<user:user>", methods=["PUT"])
304345
@csrf.exempt
305346
@require_oauth()
306347
def replace_user(user):
348+
req = ResponseParameters.model_validate(request.args.to_dict())
307349
original_scim_user = user_from_canaille_to_scim_server(user)
308350
request_scim_user = User[EnterpriseUser].model_validate(
309351
request.json,
310352
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
311-
original=original_scim_user,
312353
)
354+
request_scim_user.replace(original_scim_user)
313355
updated_user = user_from_scim_to_canaille(request_scim_user, user)
314356
Backend.instance.save(updated_user)
315357
current_app.logger.security(
316358
f"SCIM replaced user {updated_user.id} by client {current_token.client.client_id}"
317359
)
318360
response_scim_user = user_from_canaille_to_scim_server(updated_user)
319-
payload = response_scim_user.model_dump(
320-
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE
361+
return response_scim_user.model_dump(
362+
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
363+
attributes=req.attributes,
364+
excluded_attributes=req.excluded_attributes,
321365
)
322-
return payload
323366

324367

325368
@bp.route("/Groups/<group:group>", methods=["PUT"])
326369
@csrf.exempt
327370
@require_oauth()
328371
def replace_group(group):
372+
req = ResponseParameters.model_validate(request.args.to_dict())
329373
original_scim_group = group_from_canaille_to_scim_server(group)
330374
request_scim_group = Group.model_validate(
331375
request.json,
332376
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
333-
original=original_scim_group,
334377
)
378+
request_scim_group.replace(original_scim_group)
335379
updated_group = group_from_scim_to_canaille(request_scim_group, group)
336380
Backend.instance.save(updated_group)
337381
current_app.logger.security(
338382
f"SCIM replaced group {updated_group.id} by client {current_token.client.client_id}"
339383
)
340384
response_group = group_from_canaille_to_scim_server(updated_group)
341-
payload = response_group.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE)
342-
return payload
385+
return response_group.model_dump(
386+
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
387+
attributes=req.attributes,
388+
excluded_attributes=req.excluded_attributes,
389+
)
343390

344391

345392
@bp.route("/Users/<user:user>", methods=["PATCH"])
346393
@csrf.exempt
347394
@require_oauth()
348395
def patch_user(user):
396+
req = ResponseParameters.model_validate(request.args.to_dict())
349397
scim_user = user_from_canaille_to_scim_server(user)
350398
patch_op = PatchOp[User[EnterpriseUser]].model_validate(
351399
request.json, scim_ctx=Context.RESOURCE_PATCH_REQUEST
@@ -360,13 +408,18 @@ def patch_user(user):
360408
)
361409
scim_user = user_from_canaille_to_scim_server(updated_user)
362410

363-
return scim_user.model_dump(scim_ctx=Context.RESOURCE_PATCH_RESPONSE)
411+
return scim_user.model_dump(
412+
scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
413+
attributes=req.attributes,
414+
excluded_attributes=req.excluded_attributes,
415+
)
364416

365417

366418
@bp.route("/Groups/<group:group>", methods=["PATCH"])
367419
@csrf.exempt
368420
@require_oauth()
369421
def patch_group(group):
422+
req = ResponseParameters.model_validate(request.args.to_dict())
370423
scim_group = group_from_canaille_to_scim_server(group)
371424
patch_op = PatchOp[Group].model_validate(
372425
request.json, scim_ctx=Context.RESOURCE_PATCH_REQUEST
@@ -381,7 +434,11 @@ def patch_group(group):
381434
)
382435
scim_group = group_from_canaille_to_scim_server(updated_group)
383436

384-
return scim_group.model_dump(scim_ctx=Context.RESOURCE_PATCH_RESPONSE)
437+
return scim_group.model_dump(
438+
scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
439+
attributes=req.attributes,
440+
excluded_attributes=req.excluded_attributes,
441+
)
385442

386443

387444
@bp.route("/Users/<user:user>", methods=["DELETE"])

canaille/scim/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ def get_service_provider_config():
237237
change_password=ChangePassword(supported=True),
238238
filter=Filter(supported=False, max_results=0),
239239
sort=Sort(supported=False),
240-
etag=ETag(supported=False),
240+
etag=ETag(supported=True),
241241
authentication_schemes=[
242242
AuthenticationScheme(
243243
name="OAuth Bearer Token",

doc/development/specifications.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ What's implemented
6868
Endpoints:
6969

7070
- /Users (GET, POST)
71-
- /Users/<user_id> (GET, PUT, DELETE)
71+
- /Users/<user_id> (GET, PUT, PATCH, DELETE)
7272
- /Groups (GET, POST)
73-
- /Groups/<user_id> (GET, PUT, DELETE)
73+
- /Groups/<group_id> (GET, PUT, PATCH, DELETE)
7474
- /ServiceProviderConfig (GET)
7575
- /Schemas (GET)
7676
- /Schemas/<schema_id> (GET)
@@ -80,6 +80,8 @@ Endpoints:
8080
Features:
8181

8282
- :rfc:`pagination <7644#section-3.4.2.4>`
83+
- :rfc:`ETags <7644#section-3.14>`
84+
- :rfc:`attributes selection <7644#section-3.4.2.5>`
8385

8486
.. _scim_unimplemented:
8587

@@ -96,5 +98,3 @@ Features
9698

9799
- :rfc:`filtering <7644#section-3.4.2.2>`
98100
- :rfc:`sorting <7644#section-3.4.2.3>`
99-
- :rfc:`attributes selection <7644#section-3.4.2.5>`
100-
- :rfc:`ETags <7644#section-3.14>`

tests/scim/test_etag.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import json
2+
3+
from werkzeug.test import Client
4+
5+
from canaille.scim.casting import make_etag
6+
7+
8+
def _auth_headers(app, oidc_token):
9+
return {
10+
"Authorization": f"Bearer {oidc_token.access_token}",
11+
"Host": app.config["SERVER_NAME"],
12+
"Content-Type": "application/scim+json",
13+
}
14+
15+
16+
def _get_user_payload(client, app, user, oidc_token):
17+
"""Fetch the SCIM representation of a user to use as PUT payload."""
18+
response = client.get(
19+
f"/scim/v2/Users/{user.id}",
20+
headers=_auth_headers(app, oidc_token),
21+
)
22+
payload = response.get_json()
23+
for key in ("id", "meta", "photos", "groups"):
24+
payload.pop(key, None)
25+
return payload
26+
27+
28+
def test_get_user_returns_etag_header(app, backend, user, oidc_token):
29+
"""GET on a user resource includes an ETag header in the response."""
30+
client = Client(app)
31+
headers = _auth_headers(app, oidc_token)
32+
response = client.get(f"/scim/v2/Users/{user.id}", headers=headers)
33+
assert response.status_code == 200
34+
assert response.headers.get("ETag")
35+
assert response.headers["ETag"] == make_etag(user)
36+
37+
38+
def test_put_user_with_matching_etag(app, backend, user, oidc_token):
39+
"""PUT with a correct If-Match ETag succeeds."""
40+
client = Client(app)
41+
headers = _auth_headers(app, oidc_token)
42+
payload = _get_user_payload(client, app, user, oidc_token)
43+
payload["displayName"] = "Updated Name"
44+
headers["If-Match"] = make_etag(user)
45+
response = client.put(
46+
f"/scim/v2/Users/{user.id}",
47+
data=json.dumps(payload),
48+
headers=headers,
49+
)
50+
assert response.status_code == 200
51+
assert response.get_json()["displayName"] == "Updated Name"
52+
53+
54+
def test_put_user_with_wildcard_if_match(app, backend, user, oidc_token):
55+
"""PUT with If-Match: * bypasses ETag comparison."""
56+
client = Client(app)
57+
headers = _auth_headers(app, oidc_token)
58+
payload = _get_user_payload(client, app, user, oidc_token)
59+
headers["If-Match"] = "*"
60+
response = client.put(
61+
f"/scim/v2/Users/{user.id}",
62+
data=json.dumps(payload),
63+
headers=headers,
64+
)
65+
assert response.status_code == 200
66+
67+
68+
def test_put_user_with_mismatched_etag(app, backend, user, oidc_token):
69+
"""PUT with an incorrect If-Match ETag returns 412 Precondition Failed."""
70+
client = Client(app)
71+
headers = _auth_headers(app, oidc_token)
72+
payload = _get_user_payload(client, app, user, oidc_token)
73+
headers["If-Match"] = 'W/"0000000000000000"'
74+
response = client.put(
75+
f"/scim/v2/Users/{user.id}",
76+
data=json.dumps(payload),
77+
headers=headers,
78+
)
79+
assert response.status_code == 412
80+
81+
82+
def test_put_user_without_if_match(app, backend, user, oidc_token):
83+
"""PUT without If-Match header proceeds without ETag verification."""
84+
client = Client(app)
85+
headers = _auth_headers(app, oidc_token)
86+
payload = _get_user_payload(client, app, user, oidc_token)
87+
response = client.put(
88+
f"/scim/v2/Users/{user.id}",
89+
data=json.dumps(payload),
90+
headers=headers,
91+
)
92+
assert response.status_code == 200

0 commit comments

Comments
 (0)