scim2-server 0.3.0__tar.gz → 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scim2-server
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Lightweight SCIM2 server prototype
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.9.0
220
+ Requires-Dist: scim2-models>=0.10.0
221
221
  Requires-Dist: werkzeug>=3.0.3
222
222
  Requires-Python: >=3.11
223
223
  Project-URL: repository, https://github.com/python-scim/scim2-server
@@ -239,7 +239,7 @@ they are lost once the process exits.
239
239
  - [x] Unique Constraints
240
240
  - [x] HTTP PATCH (Add/Remove/Replace)
241
241
  - [x] Sorting
242
- - [x] Bulk operations ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7))
242
+ - [x] Bulk operations
243
243
 
244
244
  ## Usage
245
245
 
@@ -13,7 +13,7 @@ they are lost once the process exits.
13
13
  - [x] Unique Constraints
14
14
  - [x] HTTP PATCH (Add/Remove/Replace)
15
15
  - [x] Sorting
16
- - [x] Bulk operations ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7))
16
+ - [x] Bulk operations
17
17
 
18
18
  ## Usage
19
19
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Lightweight SCIM2 server prototype"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -29,7 +29,7 @@ classifiers = [
29
29
  ]
30
30
  requires-python = ">= 3.11"
31
31
  dependencies = [
32
- "scim2-models>=0.9.0",
32
+ "scim2-models>=0.10.0",
33
33
  "werkzeug>=3.0.3",
34
34
  ]
35
35
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Lightweight SCIM2 server prototype"
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.9.0",
32
+ "scim2-models>=0.10.0",
33
33
  "werkzeug>=3.0.3",
34
34
  ]
35
35
 
@@ -1,16 +1,14 @@
1
- import dataclasses
2
1
  import datetime
3
2
  import pickle
4
3
  import uuid
5
- from inspect import isclass
6
4
  from threading import Lock
7
5
  from typing import Any
8
6
  from typing import Union
7
+ from typing import cast
9
8
 
10
- from scim2_models import BaseModel
11
- from scim2_models import CaseExact
12
- from scim2_models import Extension
9
+ from scim2_models import AttributeBinding
13
10
  from scim2_models import Meta
11
+ from scim2_models import Path
14
12
  from scim2_models import Resource
15
13
  from scim2_models import ResourceType
16
14
  from scim2_models import ScimFilter
@@ -118,54 +116,6 @@ class InMemoryBackend(Backend):
118
116
  implementation simple.
119
117
  """
120
118
 
121
- @dataclasses.dataclass(frozen=True)
122
- class UniquenessDescriptor:
123
- """Used to mimic uniqueness constraints e.g. from a SQL database."""
124
-
125
- extension: str | None
126
- field_name: str
127
- case_exact: bool
128
- schema: str
129
-
130
- def is_declared_by(self, resource: Resource) -> bool:
131
- """Tell whether the model of a resource holds the schema of the attribute."""
132
- model = type(resource)
133
- return self.schema == model.__schema__ or (
134
- self.schema in model.get_extension_models()
135
- )
136
-
137
- def get_attribute(self, resource: Resource) -> Any:
138
- holder = getattr(resource, self.extension) if self.extension else resource
139
- value = getattr(holder, self.field_name, None) if holder else None
140
- if isinstance(value, str) and not self.case_exact:
141
- return value.casefold()
142
- return value
143
-
144
- @classmethod
145
- def collect_unique_attrs(
146
- cls, model: type[BaseModel], extension: str | None = None
147
- ) -> list[UniquenessDescriptor]:
148
- """Return the uniqueness constraints the annotations of a model declare.
149
-
150
- The ``id`` is left out: the backend issues it, so it cannot clash.
151
- """
152
- descriptors = [
153
- cls.UniquenessDescriptor(
154
- extension,
155
- field_name,
156
- model.get_field_annotation(field_name, CaseExact) == CaseExact.true,
157
- str(model.__schema__),
158
- )
159
- for field_name in model.model_fields
160
- if field_name != "id"
161
- and model.get_field_annotation(field_name, Uniqueness) != Uniqueness.none
162
- ]
163
- for field_name in model.model_fields:
164
- root_type = model.get_field_root_type(field_name)
165
- if isclass(root_type) and issubclass(root_type, Extension):
166
- descriptors.extend(cls.collect_unique_attrs(root_type, field_name))
167
- return descriptors
168
-
169
119
  def __init__(self):
170
120
  super().__init__()
171
121
  self.resources: list[Resource] = []
@@ -278,18 +228,33 @@ class InMemoryBackend(Backend):
278
228
  schema that declares the attribute, whatever their resource type. A
279
229
  missing value never clashes, as a SQL NULL does not.
280
230
  """
281
- for unique_attribute in self.collect_unique_attrs(type(resource)):
282
- value = unique_attribute.get_attribute(resource)
231
+ unique_paths = Path[type(resource)].iter_paths(
232
+ include_subattributes=False,
233
+ uniqueness=[Uniqueness.server, Uniqueness.global_],
234
+ )
235
+ for path in unique_paths:
236
+ attribute = cast(AttributeBinding, path.resolve())
237
+ value = self._unique_value(resource, attribute)
283
238
  if value is None:
284
239
  continue
285
240
  for existing_resource in self.resources:
286
241
  if (
287
- unique_attribute.is_declared_by(existing_resource)
288
- and existing_resource.id != resource.id
289
- and unique_attribute.get_attribute(existing_resource) == value
242
+ existing_resource.id != resource.id
243
+ and self._unique_value(existing_resource, attribute) == value
290
244
  ):
291
245
  raise UniquenessException()
292
246
 
247
+ @staticmethod
248
+ def _unique_value(resource: Resource, attribute: AttributeBinding) -> Any:
249
+ """Return the value a resource holds for a unique attribute, in the form it is compared in.
250
+
251
+ A resource whose schemas do not declare the attribute holds no value.
252
+ """
253
+ value = Path[type(resource)](attribute.urn).get(resource, strict=False)
254
+ if isinstance(value, str) and not attribute.case_exact:
255
+ return value.casefold()
256
+ return value
257
+
293
258
  @staticmethod
294
259
  def _touch_resource(resource: Resource, last_modified: datetime.datetime):
295
260
  """Touches a resource (updates last_modified and version).
@@ -1,55 +1,71 @@
1
1
  from collections.abc import Callable
2
2
  from typing import Any
3
3
 
4
+ from pydantic import BaseModel
5
+ from scim2_models import BulkOperation
4
6
  from scim2_models import InvalidValueException
5
7
  from scim2_models import Resource
6
8
  from werkzeug.exceptions import Conflict
7
9
 
8
10
  BULK_ID_PREFIX = "bulkId:"
9
11
 
10
- Resolver = Callable[[Any], Any]
11
- """Replaces the bulkId references of a raw operation."""
12
+ Resolver = Callable[[BulkOperation], BulkOperation]
13
+ """Replaces the bulkId references of an operation."""
12
14
 
13
- OperationRunner = Callable[[Any, Resolver], tuple[dict[str, Any], Resource | None]]
14
- """Applies a raw operation once resolved, and returns its outcome and the resource it acted on."""
15
-
16
-
17
- def raw_attribute(payload: Any, name: str) -> Any:
18
- """Return an attribute of a raw payload, whose names are case insensitive."""
19
- if not isinstance(payload, dict):
20
- return None
21
- return next(
22
- (value for key, value in payload.items() if key.casefold() == name.casefold()),
23
- None,
24
- )
15
+ OperationRunner = Callable[
16
+ [BulkOperation, Resolver], tuple[dict[str, Any], Resource | None]
17
+ ]
18
+ """Applies an operation once resolved, and returns its outcome and the resource it acted on."""
25
19
 
26
20
 
27
21
  def replace_bulk_ids(value: Any, replace: Callable[[str], str]) -> Any:
28
- """Replace every "bulkId:" reference of a raw value."""
22
+ """Replace every "bulkId:" reference of a value.
23
+
24
+ A value without reference is returned as it is. Models are copied with
25
+ only the changed fields, so the fields the client set stay the same.
26
+ """
29
27
  if isinstance(value, str) and value.startswith(BULK_ID_PREFIX):
30
28
  return replace(value.removeprefix(BULK_ID_PREFIX))
29
+
31
30
  if isinstance(value, list):
32
- return [replace_bulk_ids(item, replace) for item in value]
31
+ items = [replace_bulk_ids(item, replace) for item in value]
32
+ changed = any(new is not old for new, old in zip(items, value, strict=True))
33
+ return items if changed else value
34
+
33
35
  if isinstance(value, dict):
34
- return {key: replace_bulk_ids(item, replace) for key, item in value.items()}
36
+ entries = {key: replace_bulk_ids(item, replace) for key, item in value.items()}
37
+ changed = any(entries[key] is not item for key, item in value.items())
38
+ return entries if changed else value
39
+
40
+ if isinstance(value, BaseModel):
41
+ updates = {}
42
+ for name in type(value).model_fields:
43
+ field = getattr(value, name)
44
+ replaced = replace_bulk_ids(field, replace)
45
+ if replaced is not field:
46
+ updates[name] = replaced
47
+ return value.model_copy(update=updates) if updates else value
48
+
35
49
  return value
36
50
 
37
51
 
38
- def resolve_operation(payload: Any, replace: Callable[[str], str]) -> Any:
39
- """Replace the "bulkId:" references of the path and the data of a raw bulk operation."""
40
- if not isinstance(payload, dict):
41
- return payload
52
+ def resolve_operation(
53
+ operation: BulkOperation, replace: Callable[[str], str]
54
+ ) -> BulkOperation:
55
+ """Replace the "bulkId:" references of the path and the data of a bulk operation."""
56
+ updates: dict[str, Any] = {}
57
+ if operation.path is not None:
58
+ path = "/".join(
59
+ replace_bulk_ids(segment, replace) for segment in operation.path.split("/")
60
+ )
61
+ if path != operation.path:
62
+ updates["path"] = path
42
63
 
43
- resolved = {}
44
- for key, value in payload.items():
45
- if key.casefold() == "path" and isinstance(value, str):
46
- value = "/".join(
47
- replace_bulk_ids(segment, replace) for segment in value.split("/")
48
- )
49
- elif key.casefold() == "data":
50
- value = replace_bulk_ids(value, replace)
51
- resolved[key] = value
52
- return resolved
64
+ data = replace_bulk_ids(operation.data, replace)
65
+ if data is not operation.data:
66
+ updates["data"] = data
67
+
68
+ return operation.model_copy(update=updates) if updates else operation
53
69
 
54
70
 
55
71
  class BulkJob:
@@ -63,7 +79,7 @@ class BulkJob:
63
79
 
64
80
  def __init__(
65
81
  self,
66
- operations: list[Any],
82
+ operations: list[BulkOperation],
67
83
  fail_on_errors: int | None,
68
84
  run: OperationRunner,
69
85
  ):
@@ -76,10 +92,12 @@ class BulkJob:
76
92
  self.errors = 0
77
93
 
78
94
  self.creations: dict[str, int] = {}
79
- for index, payload in enumerate(operations):
80
- bulk_id = raw_attribute(payload, "bulkId")
81
- if raw_attribute(payload, "method") == "POST" and isinstance(bulk_id, str):
82
- self.creations.setdefault(bulk_id, index)
95
+ for index, operation in enumerate(operations):
96
+ if (
97
+ operation.method == BulkOperation.Method.post
98
+ and operation.bulk_id is not None
99
+ ):
100
+ self.creations.setdefault(operation.bulk_id, index)
83
101
 
84
102
  @property
85
103
  def stopped(self) -> bool:
@@ -105,15 +123,15 @@ class BulkJob:
105
123
  if index in self.results or index in self.running or self.stopped:
106
124
  return
107
125
 
108
- payload = self.operations[index]
126
+ operation = self.operations[index]
109
127
  self.running.add(index)
110
- for bulk_id in self.references(payload):
128
+ for bulk_id in self.references(operation):
111
129
  if bulk_id in self.creations:
112
130
  self.run_operation(self.creations[bulk_id])
113
131
 
114
132
  if not self.stopped:
115
133
  result, resource = self.run_resolved(
116
- payload, lambda payload: self.resolve(index, payload)
134
+ operation, lambda operation: self.resolve(index, operation)
117
135
  )
118
136
  self.results[index] = result
119
137
  if result["status"] >= 400:
@@ -127,7 +145,7 @@ class BulkJob:
127
145
  return bulk_id is not None and self.creations.get(bulk_id) == index
128
146
 
129
147
  @staticmethod
130
- def references(payload: Any) -> list[str]:
148
+ def references(operation: BulkOperation) -> list[str]:
131
149
  """Return the bulkIds an operation references."""
132
150
  bulk_ids: list[str] = []
133
151
 
@@ -135,23 +153,22 @@ class BulkJob:
135
153
  bulk_ids.append(bulk_id)
136
154
  return bulk_id
137
155
 
138
- resolve_operation(payload, collect)
156
+ resolve_operation(operation, collect)
139
157
  return bulk_ids
140
158
 
141
- def resolve(self, index: int, payload: Any) -> Any:
159
+ def resolve(self, index: int, operation: BulkOperation) -> BulkOperation:
142
160
  """Replace the bulkId references of an operation with the identifiers of the created resources.
143
161
 
144
162
  :raises Conflict: When a referenced resource was not created, as
145
163
  RFC 7644 §3.7.1 allows for circular references.
146
164
  """
147
- bulk_id = raw_attribute(payload, "bulkId")
148
165
  if (
149
- raw_attribute(payload, "method") == "POST"
150
- and isinstance(bulk_id, str)
151
- and not self.is_creation(index, bulk_id)
166
+ operation.method == BulkOperation.Method.post
167
+ and operation.bulk_id is not None
168
+ and not self.is_creation(index, operation.bulk_id)
152
169
  ):
153
170
  raise InvalidValueException(
154
- detail=f"The bulkId {bulk_id} is not unique in the request"
171
+ detail=f"The bulkId {operation.bulk_id} is not unique in the request"
155
172
  )
156
173
 
157
174
  def replace(bulk_id: str) -> str:
@@ -161,4 +178,4 @@ class BulkJob:
161
178
  raise Conflict(f"The bulkId {bulk_id} is part of a circular reference")
162
179
  raise Conflict(f"No resource was created with the bulkId {bulk_id}")
163
180
 
164
- return resolve_operation(payload, replace)
181
+ return resolve_operation(operation, replace)
@@ -47,7 +47,6 @@ from werkzeug.routing.exceptions import RequestRedirect
47
47
  from scim2_server.backend import Backend
48
48
  from scim2_server.bulk import BulkJob
49
49
  from scim2_server.bulk import Resolver
50
- from scim2_server.bulk import raw_attribute
51
50
  from scim2_server.utils import load_default_service_provider_config
52
51
 
53
52
  SEARCH_REQUEST_PARAMETERS = (
@@ -251,7 +250,9 @@ class SCIMApplication:
251
250
  replacement = self.get_model(resource_type).model_validate(
252
251
  payload, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST
253
252
  )
254
- replacement.replace(resource)
253
+ # A PUT that changes nothing keeps meta.lastModified and the ETag.
254
+ if not replacement.replace(resource):
255
+ return resource
255
256
  return self.backend.update_resource(resource_type, replacement)
256
257
 
257
258
  def patch(
@@ -514,7 +515,10 @@ class SCIMApplication:
514
515
  f"The payload exceeds the maxPayloadSize ({bulk.max_payload_size} bytes)"
515
516
  )
516
517
 
517
- bulk_request, operations = self.read_bulk_request(request.json)
518
+ bulk_request = BulkRequest[Union[tuple(self.get_models())]].model_validate( # noqa: UP007
519
+ request.json, scim_ctx=Context.BULK_REQUEST
520
+ )
521
+ operations = cast(list[BulkOperation], bulk_request.operations)
518
522
  if bulk.max_operations is not None and len(operations) > bulk.max_operations:
519
523
  raise RequestEntityTooLarge(
520
524
  f"The number of operations exceeds the maxOperations ({bulk.max_operations})"
@@ -523,7 +527,9 @@ class SCIMApplication:
523
527
  results = BulkJob(
524
528
  operations,
525
529
  bulk_request.fail_on_errors,
526
- lambda payload, resolve: self.run_bulk_operation(request, payload, resolve),
530
+ lambda operation, resolve: self.run_bulk_operation(
531
+ request, operation, resolve
532
+ ),
527
533
  ).run()
528
534
  return self.make_response(
529
535
  BulkResponse[Union[tuple(self.get_models())]]( # noqa: UP007
@@ -531,51 +537,38 @@ class SCIMApplication:
531
537
  ).model_dump(scim_ctx=Context.BULK_RESPONSE)
532
538
  )
533
539
 
534
- def read_bulk_request(self, payload: Any) -> tuple[BulkRequest, list[Any]]:
535
- """Validate the envelope of a bulk request, and return it with its raw operations.
536
-
537
- Each operation is validated on its own, so an invalid operation only
538
- fails itself (RFC 7644 §3.7.3).
539
- """
540
- key = (
541
- next((key for key in payload if key.casefold() == "operations"), None)
542
- if isinstance(payload, dict)
543
- else None
544
- )
545
- operations = payload[key] if key else None
546
- envelope = {**payload, key: []} if isinstance(operations, list) else payload
547
- bulk_request = BulkRequest[Union[tuple(self.get_models())]].model_validate( # noqa: UP007
548
- envelope, scim_ctx=Context.BULK_REQUEST
549
- )
550
- return bulk_request, cast(list[Any], operations)
551
-
552
540
  def run_bulk_operation(
553
- self, request: Request, payload: Any, resolve: Resolver
541
+ self, request: Request, operation: BulkOperation, resolve: Resolver
554
542
  ) -> tuple[dict[str, Any], Resource | None]:
555
543
  """Apply one operation of a bulk job.
556
544
 
545
+ An operation that failed its validation keeps its error, once its
546
+ references are resolved to locate it.
547
+
557
548
  :return: The outcome of the operation, and the resource it created or updated.
558
549
  """
559
- method = raw_attribute(payload, "method")
560
- bulk_id = raw_attribute(payload, "bulkId")
561
550
  result: dict[str, Any] = {
562
- "method": method
563
- if method in [member.value for member in BulkOperation.Method]
564
- else None,
565
- "bulk_id": bulk_id if isinstance(bulk_id, str) else None,
551
+ "method": operation.method,
552
+ "bulk_id": operation.bulk_id,
566
553
  }
567
554
 
568
555
  try:
569
- payload = resolve(payload)
570
- resource_type, resource_id = self.get_bulk_target(payload)
571
- if resource_id:
556
+ operation = resolve(operation)
557
+ resource_type = self.get_resource_type_by_endpoint(operation.endpoint or "")
558
+ if resource_type is not None and operation.resource_id:
572
559
  result["location"] = urljoin(
573
- request.url, f"{resource_type.endpoint.strip('/')}/{resource_id}"
560
+ request.url,
561
+ f"{resource_type.endpoint.strip('/')}/{operation.resource_id}",
574
562
  )
575
- operation = BulkOperation[self.get_model(resource_type)].model_validate(
576
- payload, scim_ctx=Context.BULK_REQUEST
563
+ if isinstance(operation.response, Error):
564
+ return {
565
+ **result,
566
+ "status": operation.status,
567
+ "response": operation.response,
568
+ }, None
569
+ resource = self.apply_bulk_operation(
570
+ cast(ResourceType, resource_type), operation
577
571
  )
578
- resource = self.apply_bulk_operation(resource_type, resource_id, operation)
579
572
  except Exception as exception:
580
573
  error = self.error_from(exception)
581
574
  return {**result, "status": error.status, "response": error}, None
@@ -589,34 +582,15 @@ class SCIMApplication:
589
582
  result["version"] = resource.meta.version
590
583
  return result, resource
591
584
 
592
- def get_bulk_target(self, payload: Any) -> tuple[ResourceType, str]:
593
- """Return the resource type and the resource identifier of a bulk operation path.
594
-
595
- :raises NotFound: When the path does not start with a resource type endpoint.
596
- """
597
- path = raw_attribute(payload, "path")
598
- if not isinstance(path, str):
599
- raise InvalidValueException(
600
- detail="path is required for request operations"
601
- )
602
-
603
- endpoint, _, resource_id = path.lstrip("/").partition("/")
604
- resource_type = self.get_resource_type_by_endpoint(endpoint)
605
- if resource_type is None:
606
- raise NotFound
607
- return resource_type, resource_id
608
-
609
585
  def apply_bulk_operation(
610
- self,
611
- resource_type: ResourceType,
612
- resource_id: str,
613
- operation: BulkOperation,
586
+ self, resource_type: ResourceType, operation: BulkOperation
614
587
  ) -> Resource | None:
615
588
  """Apply a validated bulk operation, and return the resource it acted on.
616
589
 
617
590
  The data of the operation is already validated, and the resource
618
591
  operations take it as it is.
619
592
  """
593
+ resource_id = operation.resource_id
620
594
  if (operation.method == BulkOperation.Method.post) == bool(resource_id):
621
595
  raise InvalidValueException(
622
596
  detail="A POST path must target a resource type endpoint, other methods a resource"
File without changes