scim2-server 0.6.0__tar.gz → 0.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {scim2_server-0.6.0 → scim2_server-0.6.2}/PKG-INFO +2 -2
  2. {scim2_server-0.6.0 → scim2_server-0.6.2}/pyproject.toml +2 -2
  3. {scim2_server-0.6.0 → scim2_server-0.6.2}/pyproject.toml.orig +2 -2
  4. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/applications/asgi.py +1 -5
  5. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/applications/wsgi.py +1 -5
  6. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/conditions.py +5 -2
  7. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/handler.py +18 -16
  8. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/service.py +186 -56
  9. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/storage.py +3 -1
  10. {scim2_server-0.6.0 → scim2_server-0.6.2}/LICENSE +0 -0
  11. {scim2_server-0.6.0 → scim2_server-0.6.2}/README.md +0 -0
  12. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/__init__.py +0 -0
  13. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/applications/__init__.py +0 -0
  14. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/applications/base.py +0 -0
  15. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/bulk.py +0 -0
  16. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/errors.py +0 -0
  17. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/memory.py +0 -0
  18. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/py.typed +0 -0
  19. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/requests.py +0 -0
  20. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/resources/default-resource-types.json +0 -0
  21. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/resources/default-schemas.json +0 -0
  22. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/resources/default-service-provider-config.json +0 -0
  23. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/responses.py +0 -0
  24. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/routing.py +0 -0
  25. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/testing.py +0 -0
  26. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/testserver/__init__.py +0 -0
  27. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/testserver/application.py +0 -0
  28. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/testserver/cli.py +0 -0
  29. {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scim2-server
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: SCIM server core for any storage and web framework
5
5
  Keywords: scim,scim2,provisioning,rfc7643,rfc7644
6
6
  Author: Yaal Coop, Christian Friedrich Coors
@@ -217,7 +217,7 @@ Classifier: License :: OSI Approved :: Apache Software License
217
217
  Classifier: Environment :: Web Environment
218
218
  Classifier: Programming Language :: Python
219
219
  Classifier: Operating System :: OS Independent
220
- Requires-Dist: scim2-models>=0.10.3
220
+ Requires-Dist: scim2-models>=0.10.6
221
221
  Requires-Dist: pytest>=9.1.1 ; extra == 'testing'
222
222
  Requires-Python: >=3.11
223
223
  Project-URL: repository, https://github.com/python-scim/scim2-server
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.6.0"
7
+ version = "0.6.2"
8
8
  description = "SCIM server core for any storage and web framework"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -28,7 +28,7 @@ classifiers = [
28
28
  "Operating System :: OS Independent",
29
29
  ]
30
30
  requires-python = ">= 3.11"
31
- dependencies = ["scim2-models>=0.10.3"]
31
+ dependencies = ["scim2-models>=0.10.6"]
32
32
 
33
33
  [[project.authors]]
34
34
  name = "Yaal Coop"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.6.0"
7
+ version = "0.6.2"
8
8
  description = "SCIM server core for any storage and web framework"
9
9
  authors = [
10
10
  {name="Yaal Coop", email="contact@yaal.coop"},
@@ -29,7 +29,7 @@ classifiers = [
29
29
 
30
30
  requires-python = ">= 3.11"
31
31
  dependencies = [
32
- "scim2-models>=0.10.3",
32
+ "scim2-models>=0.10.6",
33
33
  ]
34
34
 
35
35
  [project.optional-dependencies]
@@ -6,7 +6,6 @@ from collections.abc import Callable
6
6
  from typing import Any
7
7
  from urllib.parse import parse_qsl
8
8
 
9
- from scim2_models import SCIMException
10
9
  from scim2_models import ScimProvider
11
10
 
12
11
  from scim2_server.applications.base import VERSION_PREFIX
@@ -79,10 +78,7 @@ class ASGIApplication(BaseApplication):
79
78
  (name.decode(), value.decode()) for name, value in scope["headers"]
80
79
  ],
81
80
  )
82
- try:
83
- limit = self.service.max_body_size(request)
84
- except SCIMException:
85
- limit = None
81
+ limit = self.service.max_body_size(request)
86
82
  body = b""
87
83
  more_body = True
88
84
  while more_body:
@@ -10,7 +10,6 @@ from urllib.parse import parse_qsl
10
10
  from wsgiref.util import application_uri
11
11
 
12
12
  from scim2_models import NotFoundException
13
- from scim2_models import SCIMException
14
13
  from scim2_models import ScimProvider
15
14
 
16
15
  from scim2_server.applications.base import VERSION_PREFIX
@@ -94,10 +93,7 @@ class WSGIApplication(BaseApplication):
94
93
  A body without :mdn:`Content-Length` is read up to its end, when the
95
94
  server marks its input as terminated.
96
95
  """
97
- try:
98
- limit = self.service.max_body_size(request)
99
- except SCIMException:
100
- limit = None
96
+ limit = self.service.max_body_size(request)
101
97
  if environ.get("CONTENT_LENGTH"):
102
98
  size = int(environ["CONTENT_LENGTH"])
103
99
  elif environ.get("wsgi.input_terminated"):
@@ -50,8 +50,11 @@ class EntityTags:
50
50
  def matches(self, version: str | None) -> bool:
51
51
  """Tell whether a resource version matches the header, with the weak comparison.
52
52
 
53
- Both :mdn:`If-Match` and :mdn:`If-None-Match` use the weak comparison
54
- (:rfc:`RFC 9110 §8.8.3.2 <9110#section-8.8.3.2>`).
53
+ Both :mdn:`If-Match` and :mdn:`If-None-Match` use the weak comparison.
54
+ Per :rfc:`RFC 9110 §13.1.1 <9110#section-13.1.1>`, ``If-Match`` uses the
55
+ strong comparison. But SCIM prefers weak ETags
56
+ (:rfc:`RFC 7644 §3.14 <7644#section-3.14>`), and the strong comparison
57
+ never matches them.
55
58
 
56
59
  :param version: The version of the resource, or :data:`None` when the
57
60
  service does not version its resources.
@@ -245,18 +245,19 @@ class ScimHandler:
245
245
  ) -> tuple[dict[str, Any], Resource[Any] | None]:
246
246
  """Apply one step of a bulk request.
247
247
 
248
- The step resolves the references of the operation, locates it and
249
- authorizes it. An operation that failed its validation then keeps its
250
- error.
248
+ The step routes the path of the operation, resolves its references,
249
+ locates it and authorizes it, so that it fails as a single request
250
+ would. An operation that failed its validation then keeps its error.
251
251
 
252
252
  :return: The outcome of the step, and the resource it created or updated.
253
253
  """
254
254
  operation = plan.operation(step)
255
- outcome = self.service.bulk_outcome(operation)
255
+ outcome = self.service.bulk_outcome(request.base_url, operation)
256
256
  try:
257
+ resource_type = self.service.route_bulk_operation(operation)
257
258
  operation = plan.resolve(step)
258
- resource_type = self.service.locate_bulk_operation(
259
- request.base_url, operation, outcome
259
+ self.service.locate_bulk_operation(
260
+ request.base_url, resource_type, operation, outcome
260
261
  )
261
262
  self.service.authorize_bulk_operation(request, operation, resource_type)
262
263
  failure = self.service.bulk_validation_failure(operation, outcome)
@@ -280,12 +281,12 @@ class ScimHandler:
280
281
  The data of the operation is already validated, in the context of the
281
282
  request the operation stands for.
282
283
  """
283
- resource_id = self.service.check_bulk_target(operation)
284
- if resource_id is None:
284
+ if operation.method == BulkOperation.Method.post:
285
285
  return self.storage.create(
286
286
  resource_type, cast(Resource[Any], operation.data)
287
287
  )
288
288
 
289
+ resource_id = cast(str, operation.resource_id)
289
290
  conditions = Conditions(if_match=operation.version)
290
291
  match operation.method:
291
292
  case BulkOperation.Method.put:
@@ -577,18 +578,19 @@ class AsyncScimHandler:
577
578
  ) -> tuple[dict[str, Any], Resource[Any] | None]:
578
579
  """Apply one step of a bulk request.
579
580
 
580
- The step resolves the references of the operation, locates it and
581
- authorizes it. An operation that failed its validation then keeps its
582
- error.
581
+ The step routes the path of the operation, resolves its references,
582
+ locates it and authorizes it, so that it fails as a single request
583
+ would. An operation that failed its validation then keeps its error.
583
584
 
584
585
  :return: The outcome of the step, and the resource it created or updated.
585
586
  """
586
587
  operation = plan.operation(step)
587
- outcome = self.service.bulk_outcome(operation)
588
+ outcome = self.service.bulk_outcome(request.base_url, operation)
588
589
  try:
590
+ resource_type = self.service.route_bulk_operation(operation)
589
591
  operation = plan.resolve(step)
590
- resource_type = self.service.locate_bulk_operation(
591
- request.base_url, operation, outcome
592
+ self.service.locate_bulk_operation(
593
+ request.base_url, resource_type, operation, outcome
592
594
  )
593
595
  self.service.authorize_bulk_operation(request, operation, resource_type)
594
596
  failure = self.service.bulk_validation_failure(operation, outcome)
@@ -612,12 +614,12 @@ class AsyncScimHandler:
612
614
  The data of the operation is already validated, in the context of the
613
615
  request the operation stands for.
614
616
  """
615
- resource_id = self.service.check_bulk_target(operation)
616
- if resource_id is None:
617
+ if operation.method == BulkOperation.Method.post:
617
618
  return await self.storage.create(
618
619
  resource_type, cast(Resource[Any], operation.data)
619
620
  )
620
621
 
622
+ resource_id = cast(str, operation.resource_id)
621
623
  conditions = Conditions(if_match=operation.version)
622
624
  match operation.method:
623
625
  case BulkOperation.Method.put:
@@ -5,6 +5,7 @@ from typing import Any
5
5
  from typing import TypeVar
6
6
  from typing import Union
7
7
  from typing import cast
8
+ from urllib.parse import urlsplit
8
9
 
9
10
  from pydantic import ValidationError
10
11
  from pydantic_core import from_json
@@ -14,8 +15,10 @@ from scim2_models import Bulk
14
15
  from scim2_models import BulkOperation
15
16
  from scim2_models import BulkRequest
16
17
  from scim2_models import BulkResponse
18
+ from scim2_models import ChangePassword
17
19
  from scim2_models import Context
18
20
  from scim2_models import Error
21
+ from scim2_models import ETag
19
22
  from scim2_models import Filter
20
23
  from scim2_models import ForbiddenException
21
24
  from scim2_models import InvalidSyntaxException
@@ -26,7 +29,9 @@ from scim2_models import NotFoundException
26
29
  from scim2_models import NotImplementedException
27
30
  from scim2_models import Patch
28
31
  from scim2_models import PatchOp
32
+ from scim2_models import Path
29
33
  from scim2_models import PayloadTooLargeException
34
+ from scim2_models import Reference
30
35
  from scim2_models import Resource
31
36
  from scim2_models import ResourceType
32
37
  from scim2_models import ResponseParameters
@@ -46,7 +51,6 @@ from scim2_server.responses import ScimResponse
46
51
  from scim2_server.routing import Operation
47
52
  from scim2_server.routing import Target
48
53
  from scim2_server.routing import match
49
- from scim2_server.utils import load_default_service_provider_config
50
54
  from scim2_server.utils import parametrize
51
55
 
52
56
 
@@ -105,6 +109,23 @@ def is_json_media_type(content_type: str | None) -> bool:
105
109
  )
106
110
 
107
111
 
112
+ def patch_only_config() -> ServiceProviderConfig:
113
+ """Return the configuration of a service that only supports PATCH.
114
+
115
+ PATCH only needs the storage to update a resource, so every storage
116
+ supports it. The other capabilities need a storage that implements them.
117
+ """
118
+ return ServiceProviderConfig(
119
+ patch=Patch(supported=True),
120
+ bulk=Bulk(supported=False, max_operations=0, max_payload_size=0),
121
+ filter=Filter(supported=False),
122
+ change_password=ChangePassword(supported=False),
123
+ sort=Sort(supported=False),
124
+ etag=ETag(supported=False),
125
+ authentication_schemes=[],
126
+ )
127
+
128
+
108
129
  class ScimService:
109
130
  """The SCIM protocol, without input or output.
110
131
 
@@ -116,6 +137,9 @@ class ScimService:
116
137
  reads the request, and turns the :class:`~scim2_server.responses.ScimResponse`
117
138
  into a response of the framework.
118
139
 
140
+ A provider without ``config`` announces PATCH, and no other capability.
141
+ The storage must implement each capability the configuration announces.
142
+
119
143
  Every public method can be overridden. ``base_url`` is the root URL of the
120
144
  SCIM endpoints, as the client sees it, such as
121
145
  ``https://example.com/scim/v2``.
@@ -123,7 +147,7 @@ class ScimService:
123
147
 
124
148
  def __init__(self, provider: ScimProvider):
125
149
  self.provider = provider
126
- self.config = provider.config or load_default_service_provider_config()
150
+ self.config = provider.config or patch_only_config()
127
151
 
128
152
  # -- Resource types and models --------------------------------------
129
153
 
@@ -294,13 +318,13 @@ class ScimService:
294
318
  ) -> None:
295
319
  """Authorize a resolved bulk operation with :meth:`authorize`.
296
320
 
297
- An operation without a known resource type or a valid method keeps its
298
- validation error, without authorization.
321
+ An operation without a valid method or path keeps its validation
322
+ error, without authorization.
299
323
  """
300
- if resource_type is None or operation.method is None:
324
+ if resource_type is None:
301
325
  return
302
326
  target = Target(
303
- BULK_OPERATIONS[operation.method],
327
+ BULK_OPERATIONS[cast(BulkOperation.Method, operation.method)],
304
328
  endpoint=self.endpoint_of(resource_type),
305
329
  resource_id=operation.resource_id,
306
330
  )
@@ -344,13 +368,25 @@ class ScimService:
344
368
  def max_body_size(self, request: ScimRequest) -> int | None:
345
369
  """Return the largest body the service accepts for a request, in bytes.
346
370
 
347
- An integration reads at most one byte more, and leaves the 413 answer to
348
- the service. A bulk request is limited by maxPayloadSize
349
- (:rfc:`RFC 7644 §3.7.4 <7644#section-3.7.4>`). Other requests have no limit.
371
+ An integration reads at most one byte more, and passes the body to the
372
+ handler. It must not refuse a larger body itself: the service answers,
373
+ with a 413 or with an error that comes first, such as a 501 when bulk
374
+ is not supported.
375
+
376
+ A bulk request is limited by maxPayloadSize
377
+ (:rfc:`RFC 7644 §3.7.4 <7644#section-3.7.4>`). A request that fails
378
+ whatever its body, such as a request on an unknown path, gets 0.
379
+ Other requests have no limit.
350
380
  """
351
- if self.match(request).operation is Operation.bulk:
352
- return self.bulk_max_payload_size()
353
- return None
381
+ try:
382
+ operation = self.match(request).operation
383
+ except SCIMException:
384
+ return 0
385
+ if operation is not Operation.bulk:
386
+ return None
387
+ if self.config.bulk is None or not self.config.bulk.supported:
388
+ return 0
389
+ return self.bulk_max_payload_size()
354
390
 
355
391
  def resource_type_at(self, endpoint: str) -> ResourceType:
356
392
  """Return the resource type an endpoint serves.
@@ -434,8 +470,13 @@ class ScimService:
434
470
 
435
471
  @staticmethod
436
472
  def ensure_json(content_type: str | None) -> None:
437
- """Refuse with a 415 a request body that is not JSON."""
438
- if not is_json_media_type(content_type):
473
+ """Refuse with a 415 a request body that is not JSON.
474
+
475
+ Per :rfc:`RFC 7644 §3.8 <7644#section-3.8>`, JSON is the default
476
+ format: a body without a :mdn:`Content-Type`, or with an empty one,
477
+ is read as JSON.
478
+ """
479
+ if content_type and not is_json_media_type(content_type):
439
480
  raise UnsupportedMediaTypeException
440
481
 
441
482
  def read_body(
@@ -474,11 +515,28 @@ class ScimService:
474
515
  f"{base_url.rstrip('/')}/{resource_type.endpoint.strip('/')}/{resource_id}"
475
516
  )
476
517
 
518
+ def reference_location(self, base_url: str, reference: str) -> str:
519
+ """Return the URL of a reference to a resource.
520
+
521
+ A reference relative to the SCIM root, such as ``Users/2819c223``,
522
+ becomes the location of the resource
523
+ (:rfc:`RFC 7643 §2.3.7 <7643#section-2.3.7>`). Absolute references
524
+ are kept.
525
+ """
526
+ if urlsplit(reference).scheme or reference.startswith("/"):
527
+ return reference
528
+ endpoint, _, resource_id = reference.rpartition("/")
529
+ resource_type = self.get_resource_type_by_endpoint(endpoint)
530
+ if resource_type is None or not resource_id:
531
+ return f"{base_url.rstrip('/')}/{reference}"
532
+ return self.resource_location(base_url, resource_type, resource_id)
533
+
477
534
  def publish(self, base_url: str, resource: Resource[Any]) -> Resource[Any]:
478
535
  """Return a copy of a stored resource in the form sent to the client.
479
536
 
480
- Its location is set, and its version is left out when the service does
481
- not support ETags.
537
+ Its location is set, its references to other resources become URLs
538
+ with :meth:`reference_location`, and its version is left out when the
539
+ service does not support ETags.
482
540
  """
483
541
  assert resource.meta is not None
484
542
  assert resource.id is not None
@@ -489,9 +547,41 @@ class ScimService:
489
547
  }
490
548
  if not self.etag_supported:
491
549
  update["version"] = None
492
- return resource.model_copy(
493
- update={"meta": resource.meta.model_copy(update=update)}
494
- )
550
+ published = resource.model_copy(deep=True)
551
+ published.meta = resource.meta.model_copy(update=update)
552
+ self._publish_references(base_url, published)
553
+ return published
554
+
555
+ def _publish_references(self, base_url: str, resource: Resource[Any]) -> None:
556
+ """Replace in place the references of a resource to other resources by their URLs."""
557
+
558
+ def locate(reference: Any) -> Any:
559
+ if isinstance(reference, list):
560
+ return [locate(item) for item in reference]
561
+ if reference is None:
562
+ return None
563
+ return self.reference_location(base_url, reference)
564
+
565
+ for path in parametrize(Path, type(resource)).iter_paths(
566
+ target_type=[Reference]
567
+ ):
568
+ binding = path.resolve()
569
+ assert binding is not None
570
+ target_type = cast(type[Reference[Any]], binding.target_type)
571
+ reference_types = set(target_type.__reference_types__)
572
+ if not reference_types or reference_types & {"external", "uri"}:
573
+ continue
574
+
575
+ if binding.sub_field_name is None:
576
+ path.set(resource, locate(path.get(resource)), strict=False)
577
+ continue
578
+
579
+ head = f"{path.schema}:{path.parts[0]}" if path.schema else path.parts[0]
580
+ entries = Path(head).get(resource)
581
+ for entry in entries if isinstance(entries, list) else [entries]:
582
+ if entry is not None:
583
+ reference = getattr(entry, binding.sub_field_name)
584
+ setattr(entry, binding.sub_field_name, locate(reference))
495
585
 
496
586
  @staticmethod
497
587
  def resource_response(
@@ -500,9 +590,15 @@ class ScimService:
500
590
  response_parameters: ResponseParameters[Any] | None = None,
501
591
  status: HTTPStatus = HTTPStatus.OK,
502
592
  ) -> ScimResponse:
503
- """Return the response carrying a published resource, with its ETag."""
504
- assert resource.meta is not None
505
- headers = {"ETag": resource.meta.version} if resource.meta.version else {}
593
+ """Return the response carrying a published resource, with its ETag.
594
+
595
+ Per :rfc:`RFC 7643 §3.1 <7643#section-3.1>`, the
596
+ :mdn:`Content-Location` header holds ``meta.location``.
597
+ """
598
+ assert resource.meta is not None and resource.meta.location is not None
599
+ headers = {"Content-Location": resource.meta.location}
600
+ if resource.meta.version:
601
+ headers["ETag"] = resource.meta.version
506
602
  body = resource.model_dump(
507
603
  scim_ctx=scim_ctx, response_parameters=response_parameters
508
604
  )
@@ -798,28 +894,73 @@ class ScimService:
798
894
  )
799
895
 
800
896
  @staticmethod
801
- def bulk_outcome(operation: BulkOperation[Resource[Any]]) -> dict[str, Any]:
802
- """Start the outcome of a bulk operation, before it runs."""
803
- return {"method": operation.method, "bulk_id": operation.bulk_id}
897
+ def bulk_outcome(
898
+ base_url: str, operation: BulkOperation[Resource[Any]]
899
+ ) -> dict[str, Any]:
900
+ """Start the outcome of a bulk operation, before it runs.
901
+
902
+ :rfc:`RFC 7644 §3.7.3 <7644#section-3.7.3>` requires a location for
903
+ every operation but a failed POST. Until the operation is located, its
904
+ location is the URL of its path, so that an operation with an unknown
905
+ endpoint or an unresolved reference still has one.
906
+ """
907
+ outcome: dict[str, Any] = {
908
+ "method": operation.method,
909
+ "bulk_id": operation.bulk_id,
910
+ }
911
+ if operation.method != BulkOperation.Method.post:
912
+ path = (operation.path or "").lstrip("/")
913
+ outcome["location"] = f"{base_url.rstrip('/')}/{path}"
914
+ return outcome
915
+
916
+ def route_bulk_operation(
917
+ self, operation: BulkOperation[Resource[Any]]
918
+ ) -> ResourceType | None:
919
+ """Return the resource type a bulk operation acts on.
920
+
921
+ The path of the operation is routed as the path of a single request,
922
+ so that the operation fails with the same status
923
+ (:rfc:`RFC 7644 §3.7.3 <7644#section-3.7.3>`). An operation without a
924
+ valid method or path keeps its validation error, and has no resource
925
+ type.
926
+
927
+ :raises ~scim2_models.NotFoundException: When no endpoint has the path
928
+ of the operation, or no resource type is served at it.
929
+ :raises ~scim2_server.errors.MethodNotAllowedException: When the
930
+ endpoint does not support the method of the operation.
931
+ :raises ~scim2_models.InvalidValueException: When the path is not a
932
+ resource type endpoint or a resource, such as ``/Me`` or a search
933
+ (:rfc:`RFC 7644 §3.7 <7644#section-3.7>`).
934
+ """
935
+ if operation.method is None or operation.path is None:
936
+ return None
937
+ target = match(operation.method, operation.path)
938
+ if target.me or target.operation not in BULK_OPERATIONS.values():
939
+ raise InvalidValueException(
940
+ detail="A bulk operation must target a resource type endpoint or a resource"
941
+ )
942
+ return self.resource_type_at(cast(str, target.endpoint))
804
943
 
805
944
  def locate_bulk_operation(
806
945
  self,
807
946
  base_url: str,
947
+ resource_type: ResourceType | None,
808
948
  operation: BulkOperation[Resource[Any]],
809
949
  outcome: dict[str, Any],
810
- ) -> ResourceType | None:
811
- """Return the resource type a resolved bulk operation targets, and locate its resource.
950
+ ) -> None:
951
+ """Locate the resource of a resolved bulk operation.
812
952
 
813
953
  The location is set before the operation runs, so a failure still
814
- knows it. :rfc:`RFC 7644 §3.7.3 <7644#section-3.7.3>` requires it for
815
- every operation but a failed POST.
954
+ knows it. A POST only gets a location when it succeeds.
816
955
  """
817
- resource_type = self.get_resource_type_by_endpoint(operation.endpoint or "")
818
- if resource_type is not None and operation.resource_id:
956
+ if (
957
+ resource_type is not None
958
+ and operation.resource_id
959
+ and operation.method != BulkOperation.Method.post
960
+ ):
819
961
  outcome["location"] = self.resource_location(
820
962
  base_url, resource_type, operation.resource_id
821
963
  )
822
- return resource_type
823
964
 
824
965
  @staticmethod
825
966
  def bulk_validation_failure(
@@ -830,21 +971,6 @@ class ScimService:
830
971
  return None
831
972
  return {**outcome, "status": operation.status, "response": operation.response}
832
973
 
833
- @staticmethod
834
- def check_bulk_target(operation: BulkOperation[Resource[Any]]) -> str | None:
835
- """Check that the path of a bulk operation fits its method.
836
-
837
- :return: The identifier of the targeted resource, or :data:`None` for a POST.
838
- :raises ~scim2_models.InvalidValueException: When a POST targets a
839
- resource, or another method a resource type endpoint.
840
- """
841
- resource_id = operation.resource_id
842
- if (operation.method == BulkOperation.Method.post) == bool(resource_id):
843
- raise InvalidValueException(
844
- detail="A POST path must target a resource type endpoint, other methods a resource"
845
- )
846
- return resource_id
847
-
848
974
  def bulk_failure(
849
975
  self, outcome: dict[str, Any], exception: Exception
850
976
  ) -> dict[str, Any]:
@@ -900,6 +1026,16 @@ class ScimService:
900
1026
  meta = Meta(resource_type=type(resource).__name__, location=location)
901
1027
  return resource.model_copy(update={"meta": meta})
902
1028
 
1029
+ @staticmethod
1030
+ def discovery_response(resource: DiscoveryResourceT) -> ScimResponse:
1031
+ """Return the response carrying a discovery resource, with its :mdn:`Content-Location`."""
1032
+ assert resource.meta is not None and resource.meta.location is not None
1033
+ return ScimResponse(
1034
+ HTTPStatus.OK,
1035
+ resource.model_dump(),
1036
+ {"Content-Location": resource.meta.location},
1037
+ )
1038
+
903
1039
  def service_provider_config(
904
1040
  self, location: str, query: Mapping[str, str]
905
1041
  ) -> ScimResponse:
@@ -908,9 +1044,7 @@ class ScimService:
908
1044
  :param location: The URL of the endpoint.
909
1045
  """
910
1046
  self.forbid_filter(query)
911
- return ScimResponse(
912
- HTTPStatus.OK, self.locate(self.config, location).model_dump()
913
- )
1047
+ return self.discovery_response(self.locate(self.config, location))
914
1048
 
915
1049
  def resource_types(self, location: str, query: Mapping[str, str]) -> ScimResponse:
916
1050
  """Return the list of the resource types."""
@@ -931,9 +1065,7 @@ class ScimService:
931
1065
  self.forbid_filter(query)
932
1066
  for resource_type in self.provider.resource_types:
933
1067
  if resource_type.id == resource_type_id:
934
- return ScimResponse(
935
- HTTPStatus.OK, self.locate(resource_type, location).model_dump()
936
- )
1068
+ return self.discovery_response(self.locate(resource_type, location))
937
1069
  raise NotFoundException(detail=f"Resource type {resource_type_id!r} not found")
938
1070
 
939
1071
  def schemas(self, location: str, query: Mapping[str, str]) -> ScimResponse:
@@ -955,9 +1087,7 @@ class ScimService:
955
1087
  self.forbid_filter(query)
956
1088
  for schema in self.provider.schemas:
957
1089
  if schema.id == schema_id:
958
- return ScimResponse(
959
- HTTPStatus.OK, self.locate(schema, location).model_dump()
960
- )
1090
+ return self.discovery_response(self.locate(schema, location))
961
1091
  raise NotFoundException(detail=f"Schema {schema_id!r} not found")
962
1092
 
963
1093
  # -- Errors ---------------------------------------------------------
@@ -26,7 +26,9 @@ class ScimStorage(ABC):
26
26
  stored resource, and the resources it receives are not changed either.
27
27
  - It fills ``id``, ``meta.resourceType``, ``meta.created``,
28
28
  ``meta.lastModified`` and ``meta.version``. It leaves ``meta.location``
29
- to the server, which knows the URLs.
29
+ to the server, which knows the URLs. For the same reason, a reference
30
+ to another resource, such as ``members.$ref``, can be relative to the
31
+ SCIM root, such as ``Users/2819c223``. The server returns its URL.
30
32
  - The version changes whenever the resource changes. An update that
31
33
  changes nothing may keep it.
32
34
  - A resource that does not exist raises
File without changes
File without changes