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.
- {scim2_server-0.7.0 → scim2_server-0.8.0}/PKG-INFO +4 -2
- {scim2_server-0.7.0 → scim2_server-0.8.0}/pyproject.toml +6 -3
- {scim2_server-0.7.0 → scim2_server-0.8.0}/pyproject.toml.orig +8 -3
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/applications/asgi.py +3 -1
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/applications/base.py +9 -2
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/applications/wsgi.py +3 -1
- scim2_server-0.8.0/scim2_server/cursor.py +107 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/handler.py +50 -16
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/memory.py +148 -15
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/service.py +215 -19
- scim2_server-0.8.0/scim2_server/storage.py +356 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/testing.py +346 -33
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/testserver/cli.py +19 -2
- scim2_server-0.7.0/scim2_server/storage.py +0 -176
- {scim2_server-0.7.0 → scim2_server-0.8.0}/LICENSE +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/README.md +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/__init__.py +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/applications/__init__.py +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/bulk.py +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/conditions.py +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/errors.py +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/py.typed +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/requests.py +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/resources/default-resource-types.json +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/resources/default-schemas.json +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/resources/default-service-provider-config.json +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/responses.py +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/routing.py +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/testserver/__init__.py +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/testserver/application.py +0 -0
- {scim2_server-0.7.0 → scim2_server-0.8.0}/scim2_server/testserver/precis.py +0 -0
- {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.
|
|
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.
|
|
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
|
+
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.
|
|
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
|
|
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
|
+
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.
|
|
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
|
|
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(
|
|
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 =
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
79
|
-
|
|
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
|
-
|
|
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(
|
|
240
|
-
|
|
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,
|
|
244
|
-
|
|
245
|
-
|
|
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]
|