scim2-server 0.8.2__tar.gz → 0.8.3__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 (31) hide show
  1. {scim2_server-0.8.2 → scim2_server-0.8.3}/PKG-INFO +1 -1
  2. {scim2_server-0.8.2 → scim2_server-0.8.3}/pyproject.toml +1 -1
  3. {scim2_server-0.8.2 → scim2_server-0.8.3}/pyproject.toml.orig +1 -1
  4. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/memory.py +196 -54
  5. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/testing.py +19 -0
  6. {scim2_server-0.8.2 → scim2_server-0.8.3}/LICENSE +0 -0
  7. {scim2_server-0.8.2 → scim2_server-0.8.3}/README.md +0 -0
  8. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/__init__.py +0 -0
  9. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/applications/__init__.py +0 -0
  10. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/applications/asgi.py +0 -0
  11. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/applications/base.py +0 -0
  12. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/applications/wsgi.py +0 -0
  13. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/bulk.py +0 -0
  14. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/conditions.py +0 -0
  15. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/cursor.py +0 -0
  16. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/errors.py +0 -0
  17. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/handler.py +0 -0
  18. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/py.typed +0 -0
  19. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/requests.py +0 -0
  20. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/resources/default-resource-types.json +0 -0
  21. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/resources/default-schemas.json +0 -0
  22. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/resources/default-service-provider-config.json +0 -0
  23. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/responses.py +0 -0
  24. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/routing.py +0 -0
  25. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/service.py +0 -0
  26. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/storage.py +0 -0
  27. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/testserver/__init__.py +0 -0
  28. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/testserver/application.py +0 -0
  29. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/testserver/cli.py +0 -0
  30. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/testserver/precis.py +0 -0
  31. {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scim2-server
3
- Version: 0.8.2
3
+ Version: 0.8.3
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
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.8.2"
7
+ version = "0.8.3"
8
8
  description = "SCIM server core for any storage and web framework"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.8.2"
7
+ version = "0.8.3"
8
8
  description = "SCIM server core for any storage and web framework"
9
9
  authors = [
10
10
  {name="Yaal Coop", email="contact@yaal.coop"},
@@ -22,6 +22,8 @@ from scim2_models import ScimFilter
22
22
  from scim2_models import SearchRequest
23
23
  from scim2_models import Uniqueness
24
24
  from scim2_models import UniquenessException
25
+ from scim2_models.path import CompareOperator
26
+ from scim2_models.path import Comparison
25
27
 
26
28
  from scim2_server.storage import AsyncScimStorage
27
29
  from scim2_server.storage import ScimStorage
@@ -142,11 +144,59 @@ def _decode(value: Any) -> Any:
142
144
  raise InvalidCursorException from None
143
145
 
144
146
 
147
+ Key = tuple[str | None, str]
148
+
149
+
150
+ def _key(resource: Resource[Any]) -> Key:
151
+ """Return the resource type and the identifier a resource is stored under.
152
+
153
+ Per RFC 7643 §3.1, meta.resourceType holds the name of the resource
154
+ type, which may differ from its id.
155
+ """
156
+ assert resource.meta is not None
157
+ return resource.meta.resource_type, cast(str, resource.id)
158
+
159
+
160
+ def _equality(scim_filter: ScimFilter[Any] | None) -> AttributeBinding | None:
161
+ """Return the attribute a filter compares to a string with eq, if it holds a single string."""
162
+ if scim_filter is None or scim_filter.model is None:
163
+ return None
164
+ node = scim_filter.ast
165
+ if (
166
+ not isinstance(node, Comparison)
167
+ or node.op != CompareOperator.eq
168
+ or not isinstance(node.value, str)
169
+ ):
170
+ return None
171
+ binding = scim_filter.resolve_comparison(node.attr_path, strict=False)
172
+ if binding is None or binding.is_multivalued or binding.target_type is not str:
173
+ return None
174
+ return binding
175
+
176
+
177
+ def _hashable(value: Any) -> Any:
178
+ """Return a form of a value that a dict can hold as a key.
179
+
180
+ Lists and models cannot be hashed, so they are held by their
181
+ representation.
182
+ """
183
+ try:
184
+ hash(value)
185
+ except TypeError:
186
+ return repr(value)
187
+ return value
188
+
189
+
145
190
  class InMemoryStorage(ScimStorage):
146
191
  """A storage keeping the resources in memory, for tests, debugging and demos.
147
192
 
148
- It is not optimized for performance: a search walks every resource. A
149
- lock serializes every access, so it can serve a threaded server.
193
+ It indexes the resources by identifier and the unique values. A search
194
+ walks every resource of the searched types, unless it compares a single
195
+ string attribute to a string with ``eq``. A lock serializes every access,
196
+ so it can serve a threaded server.
197
+
198
+ It indexes the values under the policy in effect when they were written,
199
+ so it serves the resources of a single policy.
150
200
 
151
201
  :param clock: Return the date of a write, for ``meta.created`` and
152
202
  ``meta.lastModified``. Pass a fixed clock to get predictable dates.
@@ -155,11 +205,36 @@ class InMemoryStorage(ScimStorage):
155
205
  supports_cursors = True
156
206
 
157
207
  def __init__(self, clock: Callable[[], datetime.datetime] = utcnow) -> None:
158
- self.resources: list[Resource[Any]] = []
208
+ self._resources: dict[str | None, dict[str, Resource[Any]]] = {}
209
+ self._owners: dict[str, dict[Any, Key]] = {}
210
+ self._unique_values: dict[Key, dict[str, Any]] = {}
211
+ self._equalities: dict[
212
+ str | None, dict[AttributeBinding, dict[Any, set[str]]]
213
+ ] = {}
214
+ self._equal_values: dict[Key, dict[AttributeBinding, Any]] = {}
159
215
  self.clock = clock
160
216
  self.lock = RLock()
161
217
  self.versions = itertools.count(1)
162
218
 
219
+ @property
220
+ def resources(self) -> list[Resource[Any]]:
221
+ """The stored resources."""
222
+ return [
223
+ resource
224
+ for resources in self._resources.values()
225
+ for resource in resources.values()
226
+ ]
227
+
228
+ @resources.setter
229
+ def resources(self, resources: list[Resource[Any]]) -> None:
230
+ self._resources = {}
231
+ self._owners = {}
232
+ self._unique_values = {}
233
+ self._equalities = {}
234
+ self._equal_values = {}
235
+ for resource in resources:
236
+ self._store(resource, self._values_of(resource))
237
+
163
238
  def generate_id(self, resource_type: ResourceType, resource: Resource[Any]) -> str:
164
239
  """Return the identifier of a new resource.
165
240
 
@@ -189,9 +264,7 @@ class InMemoryStorage(ScimStorage):
189
264
  response_parameters: ResponseParameters[Any] | None = None,
190
265
  ) -> Resource[Any]:
191
266
  with self.lock:
192
- return self.resources[self._index(resource_type, resource_id)].model_copy(
193
- deep=True
194
- )
267
+ return self._stored(resource_type, resource_id).model_copy(deep=True)
195
268
 
196
269
  def search(
197
270
  self,
@@ -201,19 +274,20 @@ class InMemoryStorage(ScimStorage):
201
274
  position: Any = None,
202
275
  ) -> SearchPage:
203
276
  with self.lock:
204
- candidates = [
205
- resource.model_copy(deep=True)
206
- for resource in self.resources
207
- if any(self._is_of_type(resource, rt) for rt in resource_types)
208
- ]
277
+ names = list(dict.fromkeys(rt.name for rt in resource_types))
278
+ scim_filter = search_request.filter
279
+ candidates = self._candidates(names, scim_filter)
209
280
 
210
- scim_filter = search_request.filter
211
- if scim_filter is not None and not scim_filter.models and candidates:
212
- models = tuple(dict.fromkeys(type(r) for r in candidates))
213
- scim_filter = parametrize(ScimFilter, Union[models])(str(scim_filter)) # noqa: UP007
281
+ if scim_filter is not None and not scim_filter.models and candidates:
282
+ models = tuple(dict.fromkeys(type(r) for r in candidates))
283
+ scim_filter = parametrize(ScimFilter, Union[models])(str(scim_filter)) # noqa: UP007
214
284
 
215
- found = [r for r in candidates if scim_filter is None or scim_filter.match(r)]
216
- return _page(search_request, position, found)
285
+ found = [
286
+ r for r in candidates if scim_filter is None or scim_filter.match(r)
287
+ ]
288
+ page = _page(search_request, position, found)
289
+ page.resources = [r.model_copy(deep=True) for r in page.resources]
290
+ return page
217
291
 
218
292
  def create(
219
293
  self, resource_type: ResourceType, resource: Resource[Any]
@@ -228,8 +302,9 @@ class InMemoryStorage(ScimStorage):
228
302
  last_modified=now,
229
303
  version=self.next_version(),
230
304
  )
231
- self._check_uniqueness(resource)
232
- self.resources.append(resource)
305
+ values = self._values_of(resource)
306
+ self._check_uniqueness(resource, values)
307
+ self._store(resource, values)
233
308
  return resource.model_copy(deep=True)
234
309
 
235
310
  def update(
@@ -240,8 +315,7 @@ class InMemoryStorage(ScimStorage):
240
315
  expected_version: str | None = None,
241
316
  ) -> Resource[Any]:
242
317
  with self.lock:
243
- index = self._index(resource_type, resource.id)
244
- stored = self.resources[index]
318
+ stored = self._stored(resource_type, resource.id)
245
319
  self._check_version(stored, expected_version)
246
320
 
247
321
  updated = type(resource).model_validate(resource.model_dump())
@@ -253,8 +327,9 @@ class InMemoryStorage(ScimStorage):
253
327
  "version": self.next_version(),
254
328
  }
255
329
  )
256
- self._check_uniqueness(updated)
257
- self.resources[index] = updated
330
+ values = self._values_of(updated)
331
+ self._check_uniqueness(updated, values)
332
+ self._store(updated, values)
258
333
  return updated.model_copy(deep=True)
259
334
 
260
335
  def delete(
@@ -265,26 +340,83 @@ class InMemoryStorage(ScimStorage):
265
340
  expected_version: str | None = None,
266
341
  ) -> None:
267
342
  with self.lock:
268
- index = self._index(resource_type, resource_id)
269
- self._check_version(self.resources[index], expected_version)
270
- del self.resources[index]
343
+ stored = self._stored(resource_type, resource_id)
344
+ self._check_version(stored, expected_version)
345
+ self._remove(_key(stored))
271
346
 
272
- def _is_of_type(self, resource: Resource[Any], resource_type: ResourceType) -> bool:
273
- """Tell whether a resource belongs to a resource type.
347
+ def _candidates(
348
+ self, names: list[str | None], scim_filter: ScimFilter[Any] | None
349
+ ) -> list[Resource[Any]]:
350
+ """Return the resources a search walks.
274
351
 
275
- Per RFC 7643 §3.1, meta.resourceType holds the name of the resource
276
- type, which may differ from its id.
352
+ A search on a single resource type, comparing a single string
353
+ attribute to a string with eq, walks only the resources holding an
354
+ equal value. The values are indexed on the first such search.
277
355
  """
278
- assert resource.meta is not None
279
- return resource.meta.resource_type == resource_type.name
280
-
281
- def _index(self, resource_type: ResourceType, resource_id: str | None) -> int:
282
- for index, resource in enumerate(self.resources):
283
- if self._is_of_type(resource, resource_type) and resource.id == resource_id:
284
- return index
285
- raise NotFoundException(
286
- detail=f"{resource_type.name} {resource_id!r} not found"
287
- )
356
+ binding = _equality(scim_filter) if len(names) == 1 else None
357
+ if binding is None:
358
+ return [
359
+ resource
360
+ for name in names
361
+ for resource in self._resources.get(name, {}).values()
362
+ ]
363
+
364
+ node = cast(Comparison, cast(ScimFilter[Any], scim_filter).ast)
365
+ try:
366
+ value = binding.comparable(node.value)
367
+ except ValueError:
368
+ return []
369
+ resources = self._resources.get(names[0], {})
370
+ ids = self._equality_index(names[0], binding).get(value, set())
371
+ return [resources[resource_id] for resource_id in ids]
372
+
373
+ def _equality_index(
374
+ self, name: str | None, binding: AttributeBinding
375
+ ) -> dict[Any, set[str]]:
376
+ """Return the identifiers of the resources of a type, by the value of an attribute."""
377
+ indexes = self._equalities.setdefault(name, {})
378
+ if binding not in indexes:
379
+ indexes[binding] = {}
380
+ for resource in self._resources.get(name, {}).values():
381
+ self._index_equality(resource, binding)
382
+ return indexes[binding]
383
+
384
+ def _index_equality(
385
+ self, resource: Resource[Any], binding: AttributeBinding
386
+ ) -> None:
387
+ key = _key(resource)
388
+ value = self._comparable_value(resource, binding)
389
+ self._equal_values.setdefault(key, {})[binding] = value
390
+ self._equalities[key[0]][binding].setdefault(value, set()).add(key[1])
391
+
392
+ def _store(self, resource: Resource[Any], values: dict[str, Any]) -> None:
393
+ """Keep a resource under its resource type and its identifier, and index its values."""
394
+ key = _key(resource)
395
+ self._remove(key)
396
+ self._resources.setdefault(key[0], {})[key[1]] = resource
397
+ self._unique_values[key] = values
398
+ for urn, value in values.items():
399
+ self._owners.setdefault(urn, {})[value] = key
400
+ for binding in self._equalities.get(key[0], {}):
401
+ self._index_equality(resource, binding)
402
+
403
+ def _remove(self, key: Key) -> None:
404
+ """Forget a resource and its indexed values."""
405
+ self._resources.get(key[0], {}).pop(key[1], None)
406
+ for urn, value in self._unique_values.pop(key, {}).items():
407
+ del self._owners[urn][value]
408
+ for binding, value in self._equal_values.pop(key, {}).items():
409
+ self._equalities[key[0]][binding][value].discard(key[1])
410
+
411
+ def _stored(
412
+ self, resource_type: ResourceType, resource_id: str | None
413
+ ) -> Resource[Any]:
414
+ try:
415
+ return self._resources[resource_type.name][cast(str, resource_id)]
416
+ except KeyError:
417
+ raise NotFoundException(
418
+ detail=f"{resource_type.name} {resource_id!r} not found"
419
+ ) from None
288
420
 
289
421
  @staticmethod
290
422
  def _check_version(resource: Resource[Any], expected_version: str | None) -> None:
@@ -292,32 +424,42 @@ class InMemoryStorage(ScimStorage):
292
424
  if expected_version is not None and resource.meta.version != expected_version:
293
425
  raise PreconditionFailedException
294
426
 
295
- def _check_uniqueness(self, resource: Resource[Any]) -> None:
296
- """Refuse a resource sharing a unique value with another one of the same schema.
427
+ def _check_uniqueness(
428
+ self, resource: Resource[Any], values: dict[str, Any]
429
+ ) -> None:
430
+ """Refuse a resource sharing a unique value with another resource."""
431
+ key = _key(resource)
432
+ for urn, value in values.items():
433
+ if self._owners.get(urn, {}).get(value, key) != key:
434
+ raise UniquenessException()
435
+
436
+ def _values_of(self, resource: Resource[Any]) -> dict[str, Any]:
437
+ """Return the unique values of a resource, by the URN of their attribute.
297
438
 
298
439
  Per RFC 7643 erratum 8279, the uniqueness applies to the resources
299
440
  using the schema that declares the attribute, whatever their resource
300
- type. A missing value never clashes, as a SQL NULL does not.
441
+ type. The URN of an attribute holds its schema, so two resources clash
442
+ when they hold the same value under the same URN. A missing value
443
+ never clashes, as a SQL NULL does not.
444
+
445
+ The values are compared under the policy of the write that stored
446
+ them, so a storage serves the resources of a single policy.
301
447
  """
302
448
  unique_paths = parametrize(Path, type(resource)).iter_paths(
303
449
  include_subattributes=False,
304
450
  uniqueness=[Uniqueness.server, Uniqueness.global_],
305
451
  )
452
+ values = {}
306
453
  for path in unique_paths:
307
454
  attribute = cast(AttributeBinding, path.resolve())
308
- value = self._unique_value(resource, attribute)
309
- if value is None:
310
- continue
311
- for existing_resource in self.resources:
312
- if (
313
- existing_resource.id != resource.id
314
- and self._unique_value(existing_resource, attribute) == value
315
- ):
316
- raise UniquenessException()
455
+ value = self._comparable_value(resource, attribute)
456
+ if value is not None:
457
+ values[attribute.urn] = _hashable(value)
458
+ return values
317
459
 
318
460
  @staticmethod
319
- def _unique_value(resource: Resource[Any], attribute: AttributeBinding) -> Any:
320
- """Return the value a resource holds for a unique attribute, in the form it is compared in.
461
+ def _comparable_value(resource: Resource[Any], attribute: AttributeBinding) -> Any:
462
+ """Return the value a resource holds for an attribute, in the form it is compared in.
321
463
 
322
464
  A resource whose schemas do not declare the attribute holds no value.
323
465
  A string the policy cannot compare is equal to no other value, so it
@@ -501,6 +501,25 @@ class ScimStorageContract:
501
501
  storage.update(user_type, user)
502
502
  assert storage.get(user_type, user.id).user_name == "jsmith"
503
503
 
504
+ def test_update_frees_the_former_unique_values(
505
+ self, storage: Any, user_type: ResourceType, user_model: Any
506
+ ) -> None:
507
+ """A userName a resource gave up can be taken by another resource."""
508
+ (user,) = self.create_users(storage, user_type, user_model, "bjensen")
509
+ user.user_name = "babs"
510
+ storage.update(user_type, user)
511
+
512
+ self.create_users(storage, user_type, user_model, "bjensen")
513
+
514
+ def test_delete_frees_the_unique_values(
515
+ self, storage: Any, user_type: ResourceType, user_model: Any
516
+ ) -> None:
517
+ """The userName of a deleted resource can be taken by a new resource."""
518
+ (user,) = self.create_users(storage, user_type, user_model, "bjensen")
519
+ storage.delete(user_type, user.id)
520
+
521
+ self.create_users(storage, user_type, user_model, "bjensen")
522
+
504
523
  def test_search_a_resource_type(
505
524
  self,
506
525
  storage: Any,
File without changes
File without changes