scim2-server 0.7.0__tar.gz → 0.8.0__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 (32) hide show
  1. {scim2_server-0.7.0 → scim2_server-0.8.0}/PKG-INFO +4 -2
  2. {scim2_server-0.7.0 → scim2_server-0.8.0}/pyproject.toml +6 -3
  3. {scim2_server-0.7.0 → scim2_server-0.8.0}/pyproject.toml.orig +8 -3
  4. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/applications/asgi.py +3 -1
  5. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/applications/base.py +9 -2
  6. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/applications/wsgi.py +3 -1
  7. scim2_server-0.8.0/scim2_server/cursor.py +107 -0
  8. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/handler.py +50 -16
  9. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/memory.py +148 -15
  10. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/service.py +215 -19
  11. scim2_server-0.8.0/scim2_server/storage.py +356 -0
  12. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/testing.py +346 -33
  13. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/testserver/cli.py +19 -2
  14. scim2_server-0.7.0/scim2_server/storage.py +0 -176
  15. {scim2_server-0.7.0 → scim2_server-0.8.0}/LICENSE +0 -0
  16. {scim2_server-0.7.0 → scim2_server-0.8.0}/README.md +0 -0
  17. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/__init__.py +0 -0
  18. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/applications/__init__.py +0 -0
  19. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/bulk.py +0 -0
  20. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/conditions.py +0 -0
  21. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/errors.py +0 -0
  22. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/py.typed +0 -0
  23. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/requests.py +0 -0
  24. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/resources/default-resource-types.json +0 -0
  25. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/resources/default-schemas.json +0 -0
  26. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/resources/default-service-provider-config.json +0 -0
  27. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/responses.py +0 -0
  28. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/routing.py +0 -0
  29. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/testserver/__init__.py +0 -0
  30. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/testserver/application.py +0 -0
  31. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/testserver/precis.py +0 -0
  32. {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scim2-server
3
- Version: 0.7.0
3
+ Version: 0.8.0
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,12 +217,14 @@ 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.12.0
220
+ Requires-Dist: scim2-models>=0.12.2
221
+ Requires-Dist: cryptography>=50.0.1 ; extra == 'cursor'
221
222
  Requires-Dist: precis-i18n>=1.1.2 ; extra == 'precis'
222
223
  Requires-Dist: pytest>=9.1.1 ; extra == 'testing'
223
224
  Requires-Python: >=3.11
224
225
  Project-URL: repository, https://github.com/python-scim/scim2-server
225
226
  Project-URL: funding, https://github.com/sponsors/python-scim
227
+ Provides-Extra: cursor
226
228
  Provides-Extra: precis
227
229
  Provides-Extra: testing
228
230
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.7.0"
7
+ version = "0.8.0"
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.12.0"]
31
+ dependencies = ["scim2-models>=0.12.2"]
32
32
 
33
33
  [[project.authors]]
34
34
  name = "Yaal Coop"
@@ -44,6 +44,7 @@ file = "LICENSE"
44
44
  [project.optional-dependencies]
45
45
  testing = ["pytest>=9.1.1"]
46
46
  precis = ["precis-i18n>=1.1.2"]
47
+ cursor = ["cryptography>=50.0.1"]
47
48
 
48
49
  [project.urls]
49
50
  repository = "https://github.com/python-scim/scim2-server"
@@ -63,9 +64,10 @@ dev = [
63
64
  "aiosqlite>=0.22.1",
64
65
  "precis-i18n>=1.1.2",
65
66
  "mypy>=2.3.1",
66
- "scim2-client[werkzeug]>=0.11.2",
67
+ "scim2-client>=0.12.0",
67
68
  "scim2-tester>=0.5.1",
68
69
  "werkzeug>=3.0.3",
70
+ "cryptography>=50.0.1",
69
71
  ]
70
72
  doc = [
71
73
  "shibuya>=2024.5.15",
@@ -73,6 +75,7 @@ doc = [
73
75
  "sphinx-argparse>=0.6.1",
74
76
  "sphinx-design>=0.6.1",
75
77
  "sphinx-issues>=5.0.0",
78
+ "sphinxcontrib-mermaid>=2.1.1",
76
79
  ]
77
80
 
78
81
  [tool.uv]
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.7.0"
7
+ version = "0.8.0"
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.12.0",
32
+ "scim2-models>=0.12.2",
33
33
  ]
34
34
 
35
35
  [project.optional-dependencies]
@@ -39,6 +39,9 @@ testing = [
39
39
  precis = [
40
40
  "precis-i18n>=1.1.2",
41
41
  ]
42
+ cursor = [
43
+ "cryptography>=50.0.1",
44
+ ]
42
45
 
43
46
  [project.urls]
44
47
  repository = "https://github.com/python-scim/scim2-server"
@@ -58,9 +61,10 @@ dev = [
58
61
  "aiosqlite>=0.22.1",
59
62
  "precis-i18n>=1.1.2",
60
63
  "mypy>=2.3.1",
61
- "scim2-client[werkzeug]>=0.11.2",
64
+ "scim2-client>=0.12.0",
62
65
  "scim2-tester>=0.5.1",
63
66
  "werkzeug>=3.0.3",
67
+ "cryptography>=50.0.1",
64
68
  ]
65
69
  doc = [
66
70
  "shibuya>=2024.5.15",
@@ -68,6 +72,7 @@ doc = [
68
72
  "sphinx-argparse>=0.6.1",
69
73
  "sphinx-design>=0.6.1",
70
74
  "sphinx-issues>=5.0.0",
75
+ "sphinxcontrib-mermaid>=2.1.1",
71
76
  ]
72
77
 
73
78
  [tool.uv]
@@ -73,7 +73,9 @@ class ASGIApplication(BaseApplication):
73
73
  method=scope["method"],
74
74
  base_url=self.get_base_url(scope),
75
75
  path=self.split_path(self.get_path(scope)),
76
- query=dict(parse_qsl(scope["query_string"].decode())),
76
+ query=dict(
77
+ parse_qsl(scope["query_string"].decode(), keep_blank_values=True)
78
+ ),
77
79
  headers=[
78
80
  (name.decode(), value.decode()) for name, value in scope["headers"]
79
81
  ],
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import os
2
3
  from typing import Any
3
4
 
4
5
  from scim2_models import SCIMException
@@ -19,12 +20,18 @@ class BaseApplication:
19
20
  :param service: The service serving the requests, built upon ``provider``.
20
21
  Pass a subclass of :class:`~scim2_server.service.ScimService` to change
21
22
  one of its steps, such as the URL of the resources. A
22
- :class:`~scim2_server.service.ScimService` of ``provider`` by default.
23
+ :class:`~scim2_server.service.ScimService` of ``provider`` by default,
24
+ whose secret comes from the ``SCIM2_SERVER_SECRET`` environment
25
+ variable.
23
26
  """
24
27
 
25
28
  def __init__(self, provider: ScimProvider, service: ScimService | None = None):
26
29
  self.provider = provider
27
- self.service = service if service is not None else ScimService(provider)
30
+ self.service = (
31
+ service
32
+ if service is not None
33
+ else ScimService(provider, secret=os.environ.get("SCIM2_SERVER_SECRET"))
34
+ )
28
35
  self.log = logging.getLogger("scim2_server")
29
36
 
30
37
  @staticmethod
@@ -81,7 +81,9 @@ class WSGIApplication(BaseApplication):
81
81
  method=environ["REQUEST_METHOD"],
82
82
  base_url=self.get_base_url(environ),
83
83
  path=self.split_path(environ.get("PATH_INFO", "")),
84
- query=dict(parse_qsl(environ.get("QUERY_STRING", ""))),
84
+ query=dict(
85
+ parse_qsl(environ.get("QUERY_STRING", ""), keep_blank_values=True)
86
+ ),
85
87
  headers=headers,
86
88
  )
87
89
  request.body = self.read_body(environ, request)
@@ -0,0 +1,107 @@
1
+ """The cursors of :rfc:`RFC 9865 <9865>`, which hide the position of a page from the client.
2
+
3
+ This module needs the ``cursor`` extra, which installs ``cryptography``.
4
+ """
5
+
6
+ import base64
7
+ import json
8
+ import os
9
+ import time
10
+ from typing import Any
11
+
12
+ from cryptography.exceptions import InvalidTag
13
+ from cryptography.hazmat.primitives import hashes
14
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
15
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
16
+ from scim2_models import ExpiredCursorException
17
+ from scim2_models import InvalidCountException
18
+ from scim2_models import InvalidCursorException
19
+
20
+ VERSION = b"\x01"
21
+ SALT_SIZE = 16
22
+ NONCE_SIZE = 12
23
+ TAG_SIZE = 16
24
+ HEADER_SIZE = len(VERSION) + SALT_SIZE + NONCE_SIZE
25
+
26
+
27
+ class Cursors:
28
+ """Seal the position of a page into a cursor, and open the cursor back.
29
+
30
+ A cursor is encrypted and authenticated with AES-GCM, so the client can
31
+ neither read nor forge it (:rfc:`RFC 9865 §5.2 <9865#section-5.2>`). It
32
+ holds the query it belongs to, the ``count`` of the query, and the time it
33
+ was issued.
34
+
35
+ Each cursor has its own key, derived from the secret and a random salt.
36
+ The number of cursors a secret seals is then not bounded by the number of
37
+ messages AES-GCM allows for a single key.
38
+
39
+ :param secret: The secret the keys of the cursors derive from. Every
40
+ process of the server needs the same secret.
41
+ """
42
+
43
+ def __init__(self, secret: str | bytes):
44
+ self.secret = secret.encode() if isinstance(secret, str) else secret
45
+
46
+ def _aead(self, salt: bytes) -> AESGCM:
47
+ """Return the cipher of the cursors sealed with a salt."""
48
+ key = HKDF(
49
+ algorithm=hashes.SHA256(),
50
+ length=32,
51
+ salt=salt,
52
+ info=b"scim2-server cursor",
53
+ ).derive(self.secret)
54
+ return AESGCM(key)
55
+
56
+ def seal(self, query: str, count: int | None, position: Any) -> str:
57
+ """Return the cursor of a position, for the query and the count it belongs to."""
58
+ content = json.dumps(
59
+ {"q": query, "n": count, "t": int(time.time()), "p": position},
60
+ separators=(",", ":"),
61
+ ).encode()
62
+ salt = os.urandom(SALT_SIZE)
63
+ nonce = os.urandom(NONCE_SIZE)
64
+ sealed = self._aead(salt).encrypt(nonce, content, VERSION)
65
+ token = VERSION + salt + nonce + sealed
66
+ return base64.urlsafe_b64encode(token).rstrip(b"=").decode()
67
+
68
+ def open(
69
+ self, cursor: str, query: str, count: int | None, timeout: int | None
70
+ ) -> Any:
71
+ """Return the position a cursor holds.
72
+
73
+ :param timeout: The number of seconds a cursor stays valid, if any.
74
+ :raises ~scim2_models.InvalidCursorException: When the cursor was not
75
+ issued by this server, or for another query.
76
+ :raises ~scim2_models.InvalidCountException: When the cursor was
77
+ issued for another ``count``.
78
+ :raises ~scim2_models.ExpiredCursorException: When the cursor is
79
+ older than ``timeout``.
80
+ """
81
+ try:
82
+ token = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
83
+ except ValueError:
84
+ raise InvalidCursorException from None
85
+
86
+ if token[:1] != VERSION or len(token) < HEADER_SIZE + TAG_SIZE:
87
+ raise InvalidCursorException
88
+
89
+ salt = token[len(VERSION) : len(VERSION) + SALT_SIZE]
90
+ nonce = token[len(VERSION) + SALT_SIZE : HEADER_SIZE]
91
+ try:
92
+ content = json.loads(
93
+ self._aead(salt).decrypt(nonce, token[HEADER_SIZE:], VERSION)
94
+ )
95
+ except InvalidTag:
96
+ raise InvalidCursorException from None
97
+
98
+ if content["q"] != query:
99
+ raise InvalidCursorException
100
+
101
+ if content["n"] != count:
102
+ raise InvalidCountException
103
+
104
+ if timeout is not None and time.time() - content["t"] > timeout:
105
+ raise ExpiredCursorException
106
+
107
+ return content["p"]
@@ -2,6 +2,7 @@ from typing import Any
2
2
  from typing import cast
3
3
 
4
4
  from scim2_models import BulkOperation
5
+ from scim2_models import Pagination
5
6
  from scim2_models import PatchOp
6
7
  from scim2_models import Resource
7
8
  from scim2_models import ResourceType
@@ -15,6 +16,21 @@ from scim2_server.routing import Operation
15
16
  from scim2_server.service import ScimService
16
17
  from scim2_server.storage import AsyncScimStorage
17
18
  from scim2_server.storage import ScimStorage
19
+ from scim2_server.storage import async_search_page
20
+ from scim2_server.storage import projection
21
+ from scim2_server.storage import search_page
22
+
23
+
24
+ def _check_cursors(
25
+ service: ScimService, storage: ScimStorage | AsyncScimStorage
26
+ ) -> None:
27
+ """Refuse a storage without cursors, when the configuration of the service announces them."""
28
+ supported = storage.supports_cursors and storage._search_takes_position
29
+ if service._pages_by(Pagination.DefaultPaginationMethod.cursor) and not supported:
30
+ raise TypeError(
31
+ f"The configuration announces cursor pagination, which "
32
+ f"{type(storage).__qualname__} does not support."
33
+ )
18
34
 
19
35
 
20
36
  class ScimHandler:
@@ -27,9 +43,14 @@ class ScimHandler:
27
43
  :class:`~scim2_models.SCIMException`, that :meth:`ScimService.error_response
28
44
  <scim2_server.service.ScimService.error_response>` turns into a response.
29
45
  Any other exception is a bug.
46
+
47
+ :raises TypeError: When the configuration of the service announces
48
+ cursor pagination, and the storage does not
49
+ :attr:`~scim2_server.storage.ScimStorage.supports_cursors`.
30
50
  """
31
51
 
32
52
  def __init__(self, service: ScimService, storage: ScimStorage):
53
+ _check_cursors(service, storage)
33
54
  self.service = service
34
55
  self.storage = storage
35
56
 
@@ -75,7 +96,9 @@ class ScimHandler:
75
96
  )
76
97
  with self.storage.operation():
77
98
  resource = self.storage.get(
78
- resource_type, cast(str, target.resource_id)
99
+ resource_type,
100
+ cast(str, target.resource_id),
101
+ **projection(self.storage, response_parameters),
79
102
  )
80
103
  response = self.service.query_response(
81
104
  request.base_url,
@@ -202,13 +225,15 @@ class ScimHandler:
202
225
  authorized_types = self.service.authorized_types(
203
226
  request, target, resource_types
204
227
  )
205
- search_request = self.service.read_search_query(
228
+ search_request, position = self.service.read_search_query(
206
229
  resource_types, request.query
207
230
  )
208
231
  with self.storage.operation():
209
- total, resources = self.storage.search(authorized_types, search_request)
232
+ page = search_page(
233
+ self.storage, authorized_types, search_request, position=position
234
+ )
210
235
  return self.service.search_response(
211
- request.base_url, total, resources, search_request
236
+ request.base_url, resource_types, page, search_request
212
237
  )
213
238
 
214
239
  def search_with_body(self, request: ScimRequest) -> ScimResponse:
@@ -219,13 +244,15 @@ class ScimHandler:
219
244
  authorized_types = self.service.authorized_types(
220
245
  request, target, resource_types
221
246
  )
222
- search_request = self.service.read_search_body(
247
+ search_request, position = self.service.read_search_body(
223
248
  resource_types, request.body, request.header("Content-Type")
224
249
  )
225
250
  with self.storage.operation():
226
- total, resources = self.storage.search(authorized_types, search_request)
251
+ page = search_page(
252
+ self.storage, authorized_types, search_request, position=position
253
+ )
227
254
  return self.service.search_response(
228
- request.base_url, total, resources, search_request
255
+ request.base_url, resource_types, page, search_request
229
256
  )
230
257
 
231
258
  # -- Bulk -----------------------------------------------------------
@@ -350,9 +377,14 @@ class AsyncScimHandler:
350
377
 
351
378
  It calls the same steps of the same service, and awaits the storage
352
379
  between them.
380
+
381
+ :raises TypeError: When the configuration of the service announces
382
+ cursor pagination, and the storage does not
383
+ :attr:`~scim2_server.storage.AsyncScimStorage.supports_cursors`.
353
384
  """
354
385
 
355
386
  def __init__(self, service: ScimService, storage: AsyncScimStorage):
387
+ _check_cursors(service, storage)
356
388
  self.service = service
357
389
  self.storage = storage
358
390
 
@@ -398,7 +430,9 @@ class AsyncScimHandler:
398
430
  )
399
431
  async with self.storage.operation():
400
432
  resource = await self.storage.get(
401
- resource_type, cast(str, target.resource_id)
433
+ resource_type,
434
+ cast(str, target.resource_id),
435
+ **projection(self.storage, response_parameters),
402
436
  )
403
437
  response = self.service.query_response(
404
438
  request.base_url,
@@ -531,15 +565,15 @@ class AsyncScimHandler:
531
565
  authorized_types = self.service.authorized_types(
532
566
  request, target, resource_types
533
567
  )
534
- search_request = self.service.read_search_query(
568
+ search_request, position = self.service.read_search_query(
535
569
  resource_types, request.query
536
570
  )
537
571
  async with self.storage.operation():
538
- total, resources = await self.storage.search(
539
- authorized_types, search_request
572
+ page = await async_search_page(
573
+ self.storage, authorized_types, search_request, position=position
540
574
  )
541
575
  return self.service.search_response(
542
- request.base_url, total, resources, search_request
576
+ request.base_url, resource_types, page, search_request
543
577
  )
544
578
 
545
579
  async def search_with_body(self, request: ScimRequest) -> ScimResponse:
@@ -550,15 +584,15 @@ class AsyncScimHandler:
550
584
  authorized_types = self.service.authorized_types(
551
585
  request, target, resource_types
552
586
  )
553
- search_request = self.service.read_search_body(
587
+ search_request, position = self.service.read_search_body(
554
588
  resource_types, request.body, request.header("Content-Type")
555
589
  )
556
590
  async with self.storage.operation():
557
- total, resources = await self.storage.search(
558
- authorized_types, search_request
591
+ page = await async_search_page(
592
+ self.storage, authorized_types, search_request, position=position
559
593
  )
560
594
  return self.service.search_response(
561
- request.base_url, total, resources, search_request
595
+ request.base_url, resource_types, page, search_request
562
596
  )
563
597
 
564
598
  # -- Bulk -----------------------------------------------------------
@@ -10,12 +10,14 @@ from typing import Union
10
10
  from typing import cast
11
11
 
12
12
  from scim2_models import AttributeBinding
13
+ from scim2_models import InvalidCursorException
13
14
  from scim2_models import Meta
14
15
  from scim2_models import NotFoundException
15
16
  from scim2_models import Path
16
17
  from scim2_models import PreconditionFailedException
17
18
  from scim2_models import Resource
18
19
  from scim2_models import ResourceType
20
+ from scim2_models import ResponseParameters
19
21
  from scim2_models import ScimFilter
20
22
  from scim2_models import SearchRequest
21
23
  from scim2_models import Uniqueness
@@ -23,6 +25,9 @@ from scim2_models import UniquenessException
23
25
 
24
26
  from scim2_server.storage import AsyncScimStorage
25
27
  from scim2_server.storage import ScimStorage
28
+ from scim2_server.storage import SearchPage
29
+ from scim2_server.storage import projection
30
+ from scim2_server.storage import search_page
26
31
  from scim2_server.utils import parametrize
27
32
 
28
33
 
@@ -31,6 +36,112 @@ def utcnow() -> datetime.datetime:
31
36
  return datetime.datetime.now(datetime.UTC)
32
37
 
33
38
 
39
+ def _page(
40
+ search_request: SearchRequest[Any], position: Any, resources: list[Resource[Any]]
41
+ ) -> SearchPage:
42
+ """Sort every matching resource by its sort value then its identifier, and return the page a search asks for.
43
+
44
+ A cursor position holds the sort value and the identifier of the resource
45
+ the page starts after, or ends before, so the pages stay stable.
46
+ """
47
+ descending = (
48
+ search_request.sort_by is not None
49
+ and search_request.sort_order == SearchRequest.SortOrder.descending
50
+ )
51
+ pairs = sorted(
52
+ ((_sort_key(search_request, resource), resource) for resource in resources),
53
+ key=lambda pair: pair[0],
54
+ reverse=descending,
55
+ )
56
+ found = [resource for _, resource in pairs]
57
+ if search_request.cursor is None:
58
+ start = search_request.start_index_0 or 0
59
+ stop = None if search_request.count is None else start + search_request.count
60
+ return SearchPage(len(found), found[start:stop])
61
+
62
+ if search_request.count == 0:
63
+ return SearchPage(len(found), [])
64
+
65
+ start, stop = _bounds(
66
+ [key for key, _ in pairs], position, search_request.count, descending
67
+ )
68
+ page = SearchPage(len(found), found[start:stop])
69
+ if page.resources and stop < len(found):
70
+ page.next = _position("next", pairs[stop - 1][0])
71
+ if page.resources and start > 0:
72
+ page.previous = _position("previous", pairs[start][0])
73
+ return page
74
+
75
+
76
+ SortKey = tuple[bool, Any, str]
77
+
78
+
79
+ def _sort_key(search_request: SearchRequest[Any], resource: Resource[Any]) -> SortKey:
80
+ """Return the key that orders a resource: its sort value, missing values last, then its identifier."""
81
+ value = search_request.sort_value(resource)
82
+ return value is None, value if value is not None else "", cast(str, resource.id)
83
+
84
+
85
+ def _bounds(
86
+ keys: list[SortKey], position: Any, count: int | None, descending: bool
87
+ ) -> tuple[int, int]:
88
+ """Return the slice of the sorted keys that a cursor page covers."""
89
+ if position is None:
90
+ return 0, len(keys) if count is None else min(count, len(keys))
91
+
92
+ direction, anchor = _read_position(position)
93
+
94
+ def before(key: SortKey) -> bool:
95
+ return key > anchor if descending else key < anchor
96
+
97
+ if direction == "next":
98
+ start = sum(1 for key in keys if before(key) or key == anchor)
99
+ stop = len(keys) if count is None else min(start + count, len(keys))
100
+ return start, stop
101
+
102
+ stop = sum(1 for key in keys if before(key))
103
+ start = 0 if count is None else max(stop - count, 0)
104
+ return start, stop
105
+
106
+
107
+ def _position(direction: str, key: SortKey) -> dict[str, Any]:
108
+ """Return the position of the page that starts after, or ends before, a sort key."""
109
+ missing, value, identifier = key
110
+ return {"d": direction, "k": None if missing else _encode(value), "i": identifier}
111
+
112
+
113
+ def _read_position(position: Any) -> tuple[str, SortKey]:
114
+ """Return the direction and the sort key of a position that _page gave."""
115
+ try:
116
+ direction, value, identifier = position["d"], position["k"], position["i"]
117
+ except (TypeError, KeyError):
118
+ raise InvalidCursorException from None
119
+ if direction not in ("next", "previous") or not isinstance(identifier, str):
120
+ raise InvalidCursorException
121
+ if value is None:
122
+ return direction, (True, "", identifier)
123
+ return direction, (False, _decode(value), identifier)
124
+
125
+
126
+ def _encode(value: Any) -> Any:
127
+ """Return a sort value in a form JSON can hold."""
128
+ if isinstance(value, datetime.datetime):
129
+ return {"datetime": value.isoformat()}
130
+ if isinstance(value, str | bool | int | float):
131
+ return value
132
+ raise TypeError(f"A cursor cannot hold a sort value of type {type(value).__name__}")
133
+
134
+
135
+ def _decode(value: Any) -> Any:
136
+ """Return the sort value that _encode gave."""
137
+ if not isinstance(value, dict):
138
+ return value
139
+ try:
140
+ return datetime.datetime.fromisoformat(value["datetime"])
141
+ except (KeyError, TypeError, ValueError):
142
+ raise InvalidCursorException from None
143
+
144
+
34
145
  class InMemoryStorage(ScimStorage):
35
146
  """A storage keeping the resources in memory, for tests, debugging and demos.
36
147
 
@@ -41,6 +152,8 @@ class InMemoryStorage(ScimStorage):
41
152
  ``meta.lastModified``. Pass a fixed clock to get predictable dates.
42
153
  """
43
154
 
155
+ supports_cursors = True
156
+
44
157
  def __init__(self, clock: Callable[[], datetime.datetime] = utcnow) -> None:
45
158
  self.resources: list[Resource[Any]] = []
46
159
  self.clock = clock
@@ -68,15 +181,25 @@ class InMemoryStorage(ScimStorage):
68
181
  with self.lock:
69
182
  yield
70
183
 
71
- def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]:
184
+ def get(
185
+ self,
186
+ resource_type: ResourceType,
187
+ resource_id: str,
188
+ *,
189
+ response_parameters: ResponseParameters[Any] | None = None,
190
+ ) -> Resource[Any]:
72
191
  with self.lock:
73
192
  return self.resources[self._index(resource_type, resource_id)].model_copy(
74
193
  deep=True
75
194
  )
76
195
 
77
196
  def search(
78
- self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
79
- ) -> tuple[int, list[Resource[Any]]]:
197
+ self,
198
+ resource_types: list[ResourceType],
199
+ search_request: SearchRequest[Any],
200
+ *,
201
+ position: Any = None,
202
+ ) -> SearchPage:
80
203
  with self.lock:
81
204
  candidates = [
82
205
  resource.model_copy(deep=True)
@@ -90,13 +213,7 @@ class InMemoryStorage(ScimStorage):
90
213
  scim_filter = parametrize(ScimFilter, Union[models])(str(scim_filter)) # noqa: UP007
91
214
 
92
215
  found = [r for r in candidates if scim_filter is None or scim_filter.match(r)]
93
- found = search_request.sort(found)
94
-
95
- start_index = (search_request.start_index or 1) - 1
96
- page = found[start_index:]
97
- if search_request.count is not None:
98
- page = page[: search_request.count]
99
- return len(found), page
216
+ return _page(search_request, position, found)
100
217
 
101
218
  def create(
102
219
  self, resource_type: ResourceType, resource: Resource[Any]
@@ -228,6 +345,8 @@ class AsyncInMemoryStorage(AsyncScimStorage):
228
345
  :class:`InMemoryStorage` to change how identifiers are generated.
229
346
  """
230
347
 
348
+ supports_cursors = True
349
+
231
350
  def __init__(self, storage: InMemoryStorage | None = None) -> None:
232
351
  self.storage = storage if storage is not None else InMemoryStorage()
233
352
 
@@ -236,13 +355,27 @@ class AsyncInMemoryStorage(AsyncScimStorage):
236
355
  """The stored resources."""
237
356
  return self.storage.resources
238
357
 
239
- async def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]:
240
- return self.storage.get(resource_type, resource_id)
358
+ async def get(
359
+ self,
360
+ resource_type: ResourceType,
361
+ resource_id: str,
362
+ *,
363
+ response_parameters: ResponseParameters[Any] | None = None,
364
+ ) -> Resource[Any]:
365
+ return self.storage.get(
366
+ resource_type,
367
+ resource_id,
368
+ **projection(self.storage, response_parameters),
369
+ )
241
370
 
242
371
  async def search(
243
- self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
244
- ) -> tuple[int, list[Resource[Any]]]:
245
- return self.storage.search(resource_types, search_request)
372
+ self,
373
+ resource_types: list[ResourceType],
374
+ search_request: SearchRequest[Any],
375
+ *,
376
+ position: Any = None,
377
+ ) -> SearchPage:
378
+ return search_page(self.storage, resource_types, search_request, position)
246
379
 
247
380
  async def create(
248
381
  self, resource_type: ResourceType, resource: Resource[Any]