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.
- {scim2_server-0.8.2 → scim2_server-0.8.3}/PKG-INFO +1 -1
- {scim2_server-0.8.2 → scim2_server-0.8.3}/pyproject.toml +1 -1
- {scim2_server-0.8.2 → scim2_server-0.8.3}/pyproject.toml.orig +1 -1
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/memory.py +196 -54
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/testing.py +19 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/LICENSE +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/README.md +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/__init__.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/applications/__init__.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/applications/asgi.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/applications/base.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/applications/wsgi.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/bulk.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/conditions.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/cursor.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/errors.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/handler.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/py.typed +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/requests.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/resources/default-resource-types.json +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/resources/default-schemas.json +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/resources/default-service-provider-config.json +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/responses.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/routing.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/service.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/storage.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/testserver/__init__.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/testserver/application.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/testserver/cli.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/testserver/precis.py +0 -0
- {scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/utils.py +0 -0
|
@@ -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
|
|
149
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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.
|
|
232
|
-
self.
|
|
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
|
-
|
|
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.
|
|
257
|
-
self.
|
|
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
|
-
|
|
269
|
-
self._check_version(
|
|
270
|
-
|
|
343
|
+
stored = self._stored(resource_type, resource_id)
|
|
344
|
+
self._check_version(stored, expected_version)
|
|
345
|
+
self._remove(_key(stored))
|
|
271
346
|
|
|
272
|
-
def
|
|
273
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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(
|
|
296
|
-
|
|
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.
|
|
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.
|
|
309
|
-
if value is None:
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
320
|
-
"""Return the value a resource holds for
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scim2_server-0.8.2 → scim2_server-0.8.3}/scim2_server/resources/default-resource-types.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|