scim2-server 0.2.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scim2_server-0.2.0 → scim2_server-0.3.1}/PKG-INFO +8 -10
- {scim2_server-0.2.0 → scim2_server-0.3.1}/README.md +5 -5
- {scim2_server-0.2.0 → scim2_server-0.3.1}/pyproject.toml +17 -9
- {scim2_server-0.2.0 → scim2_server-0.3.1}/pyproject.toml.orig +10 -9
- scim2_server-0.3.1/scim2_server/backend.py +280 -0
- scim2_server-0.3.1/scim2_server/bulk.py +181 -0
- {scim2_server-0.2.0 → scim2_server-0.3.1}/scim2_server/cli.py +44 -18
- scim2_server-0.3.1/scim2_server/provider.py +774 -0
- {scim2_server-0.2.0 → scim2_server-0.3.1}/scim2_server/resources/default-resource-types.json +3 -11
- {scim2_server-0.2.0 → scim2_server-0.3.1}/scim2_server/resources/default-schemas.json +27 -35
- scim2_server-0.3.1/scim2_server/resources/default-service-provider-config.json +27 -0
- scim2_server-0.3.1/scim2_server/utils.py +52 -0
- scim2_server-0.2.0/scim2_server/backend.py +0 -397
- scim2_server-0.2.0/scim2_server/filter.py +0 -127
- scim2_server-0.2.0/scim2_server/operators.py +0 -476
- scim2_server-0.2.0/scim2_server/provider.py +0 -561
- scim2_server-0.2.0/scim2_server/utils.py +0 -199
- {scim2_server-0.2.0 → scim2_server-0.3.1}/LICENSE +0 -0
- {scim2_server-0.2.0 → scim2_server-0.3.1}/scim2_server/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: scim2-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Lightweight SCIM2 server prototype
|
|
5
5
|
Keywords: scim,scim2,provisioning,rfc7643,rfc7644
|
|
6
6
|
Author: Yaal Coop, Christian Friedrich Coors
|
|
@@ -208,7 +208,6 @@ License: Apache License
|
|
|
208
208
|
limitations under the License.
|
|
209
209
|
Classifier: Intended Audience :: Developers
|
|
210
210
|
Classifier: Development Status :: 4 - Beta
|
|
211
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
212
211
|
Classifier: Programming Language :: Python :: 3.11
|
|
213
212
|
Classifier: Programming Language :: Python :: 3.12
|
|
214
213
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -218,10 +217,9 @@ Classifier: License :: OSI Approved :: Apache Software License
|
|
|
218
217
|
Classifier: Environment :: Web Environment
|
|
219
218
|
Classifier: Programming Language :: Python
|
|
220
219
|
Classifier: Operating System :: OS Independent
|
|
221
|
-
Requires-Dist: scim2-
|
|
222
|
-
Requires-Dist: scim2-models>=0.6.1
|
|
220
|
+
Requires-Dist: scim2-models>=0.10.0
|
|
223
221
|
Requires-Dist: werkzeug>=3.0.3
|
|
224
|
-
Requires-Python: >=3.
|
|
222
|
+
Requires-Python: >=3.11
|
|
225
223
|
Project-URL: repository, https://github.com/python-scim/scim2-server
|
|
226
224
|
Project-URL: funding, https://github.com/sponsors/python-scim
|
|
227
225
|
Description-Content-Type: text/markdown
|
|
@@ -229,7 +227,7 @@ Description-Content-Type: text/markdown
|
|
|
229
227
|
# scim2-server
|
|
230
228
|
|
|
231
229
|
This is an example WSGI-SCIM server using [scim2-models](https://github.com/python-scim/scim2-models).
|
|
232
|
-
It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and
|
|
230
|
+
It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and keeps all resources in-memory,
|
|
233
231
|
they are lost once the process exits.
|
|
234
232
|
|
|
235
233
|
## Features
|
|
@@ -241,20 +239,20 @@ they are lost once the process exits.
|
|
|
241
239
|
- [x] Unique Constraints
|
|
242
240
|
- [x] HTTP PATCH (Add/Remove/Replace)
|
|
243
241
|
- [x] Sorting
|
|
244
|
-
|
|
245
|
-
The only optional feature currently missing is support for Bulk operations ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7)).
|
|
242
|
+
- [x] Bulk operations
|
|
246
243
|
|
|
247
244
|
## Usage
|
|
248
245
|
|
|
249
246
|
```shell
|
|
250
|
-
$ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--debug]
|
|
247
|
+
$ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--service-provider-config SERVICE_PROVIDER_CONFIG] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--debug]
|
|
251
248
|
```
|
|
252
249
|
|
|
253
250
|
- `-h`/`--help`: Show help message
|
|
254
251
|
- `--reverse-proxy`: Allow using the provider behind a Reverse Proxy (required for URL rewriting).
|
|
255
252
|
- `--schema`: Register schemas from specified JSON file. If not provided, loads the default schemas from RFC 7643.
|
|
256
253
|
- `--resource-type`: Register resource types from specified JSON file. If not provided, loads the default resource types from RFC 7643.
|
|
257
|
-
- `--
|
|
254
|
+
- `--service-provider-config`: Load the service provider configuration from specified JSON file. If not provided, loads the default configuration.
|
|
255
|
+
- `--bearer-token`: Registers a bearer token that can be used for accessing the service, and announces the bearer token authentication scheme. If no tokens are provided, anonymous access without authentication is allowed.
|
|
258
256
|
- `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
|
|
259
257
|
- `--port`: The port to listen on. Defaults to `8080`.
|
|
260
258
|
- `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# scim2-server
|
|
2
2
|
|
|
3
3
|
This is an example WSGI-SCIM server using [scim2-models](https://github.com/python-scim/scim2-models).
|
|
4
|
-
It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and
|
|
4
|
+
It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and keeps all resources in-memory,
|
|
5
5
|
they are lost once the process exits.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
@@ -13,20 +13,20 @@ they are lost once the process exits.
|
|
|
13
13
|
- [x] Unique Constraints
|
|
14
14
|
- [x] HTTP PATCH (Add/Remove/Replace)
|
|
15
15
|
- [x] Sorting
|
|
16
|
-
|
|
17
|
-
The only optional feature currently missing is support for Bulk operations ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7)).
|
|
16
|
+
- [x] Bulk operations
|
|
18
17
|
|
|
19
18
|
## Usage
|
|
20
19
|
|
|
21
20
|
```shell
|
|
22
|
-
$ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--debug]
|
|
21
|
+
$ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--service-provider-config SERVICE_PROVIDER_CONFIG] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--debug]
|
|
23
22
|
```
|
|
24
23
|
|
|
25
24
|
- `-h`/`--help`: Show help message
|
|
26
25
|
- `--reverse-proxy`: Allow using the provider behind a Reverse Proxy (required for URL rewriting).
|
|
27
26
|
- `--schema`: Register schemas from specified JSON file. If not provided, loads the default schemas from RFC 7643.
|
|
28
27
|
- `--resource-type`: Register resource types from specified JSON file. If not provided, loads the default resource types from RFC 7643.
|
|
29
|
-
- `--
|
|
28
|
+
- `--service-provider-config`: Load the service provider configuration from specified JSON file. If not provided, loads the default configuration.
|
|
29
|
+
- `--bearer-token`: Registers a bearer token that can be used for accessing the service, and announces the bearer token authentication scheme. If no tokens are provided, anonymous access without authentication is allowed.
|
|
30
30
|
- `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
|
|
31
31
|
- `--port`: The port to listen on. Defaults to `8080`.
|
|
32
32
|
- `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.12.
|
|
2
|
+
requires = ["uv_build>=0.12.13,<0.13.0"]
|
|
3
3
|
build-backend = "uv_build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-server"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Lightweight SCIM2 server prototype"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [
|
|
@@ -17,7 +17,6 @@ keywords = [
|
|
|
17
17
|
classifiers = [
|
|
18
18
|
"Intended Audience :: Developers",
|
|
19
19
|
"Development Status :: 4 - Beta",
|
|
20
|
-
"Programming Language :: Python :: 3.10",
|
|
21
20
|
"Programming Language :: Python :: 3.11",
|
|
22
21
|
"Programming Language :: Python :: 3.12",
|
|
23
22
|
"Programming Language :: Python :: 3.13",
|
|
@@ -28,10 +27,9 @@ classifiers = [
|
|
|
28
27
|
"Programming Language :: Python",
|
|
29
28
|
"Operating System :: OS Independent",
|
|
30
29
|
]
|
|
31
|
-
requires-python = ">= 3.
|
|
30
|
+
requires-python = ">= 3.11"
|
|
32
31
|
dependencies = [
|
|
33
|
-
"scim2-
|
|
34
|
-
"scim2-models>=0.6.1",
|
|
32
|
+
"scim2-models>=0.10.0",
|
|
35
33
|
"werkzeug>=3.0.3",
|
|
36
34
|
]
|
|
37
35
|
|
|
@@ -55,14 +53,25 @@ scim2-server = "scim2_server.cli:main"
|
|
|
55
53
|
|
|
56
54
|
[dependency-groups]
|
|
57
55
|
dev = [
|
|
58
|
-
"
|
|
56
|
+
"httpx2>=2.0.0",
|
|
59
57
|
"pytest>=8.2.2",
|
|
60
|
-
"pytest-
|
|
58
|
+
"pytest-cov>=6.0.0",
|
|
61
59
|
"prek>=0.1.0",
|
|
62
60
|
"time-machine>=2.14.2",
|
|
63
61
|
"tox-uv>=1.16.0",
|
|
64
62
|
]
|
|
65
63
|
|
|
64
|
+
[tool.uv]
|
|
65
|
+
exclude-newer = "14 days"
|
|
66
|
+
|
|
67
|
+
[tool.uv.exclude-newer-package]
|
|
68
|
+
scim2-models = false
|
|
69
|
+
scim2-client = false
|
|
70
|
+
scim2-server = false
|
|
71
|
+
scim2-cli = false
|
|
72
|
+
scim2-tester = false
|
|
73
|
+
pytest-scim2-server = false
|
|
74
|
+
|
|
66
75
|
[tool.uv.build-backend]
|
|
67
76
|
module-root = ""
|
|
68
77
|
|
|
@@ -125,7 +134,6 @@ doctest_optionflags = "ALLOW_UNICODE IGNORE_EXCEPTION_DETAIL ELLIPSIS"
|
|
|
125
134
|
requires = ["tox>=4.19"]
|
|
126
135
|
env_list = [
|
|
127
136
|
"style",
|
|
128
|
-
"py310",
|
|
129
137
|
"py311",
|
|
130
138
|
"py312",
|
|
131
139
|
"py313",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.12.
|
|
2
|
+
requires = ["uv_build>=0.12.13,<0.13.0"]
|
|
3
3
|
build-backend = "uv_build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-server"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Lightweight SCIM2 server prototype"
|
|
9
9
|
authors = [
|
|
10
10
|
{name="Yaal Coop", email="contact@yaal.coop"},
|
|
@@ -16,7 +16,6 @@ keywords = ["scim", "scim2", "provisioning", "rfc7643", "rfc7644"]
|
|
|
16
16
|
classifiers = [
|
|
17
17
|
"Intended Audience :: Developers",
|
|
18
18
|
"Development Status :: 4 - Beta",
|
|
19
|
-
"Programming Language :: Python :: 3.10",
|
|
20
19
|
"Programming Language :: Python :: 3.11",
|
|
21
20
|
"Programming Language :: Python :: 3.12",
|
|
22
21
|
"Programming Language :: Python :: 3.13",
|
|
@@ -28,10 +27,9 @@ classifiers = [
|
|
|
28
27
|
"Operating System :: OS Independent",
|
|
29
28
|
]
|
|
30
29
|
|
|
31
|
-
requires-python = ">= 3.
|
|
30
|
+
requires-python = ">= 3.11"
|
|
32
31
|
dependencies = [
|
|
33
|
-
"scim2-
|
|
34
|
-
"scim2-models>=0.6.1",
|
|
32
|
+
"scim2-models>=0.10.0",
|
|
35
33
|
"werkzeug>=3.0.3",
|
|
36
34
|
]
|
|
37
35
|
|
|
@@ -44,14 +42,18 @@ scim2-server = "scim2_server.cli:main"
|
|
|
44
42
|
|
|
45
43
|
[dependency-groups]
|
|
46
44
|
dev = [
|
|
47
|
-
"
|
|
45
|
+
"httpx2>=2.0.0",
|
|
48
46
|
"pytest>=8.2.2",
|
|
49
|
-
"pytest-
|
|
47
|
+
"pytest-cov>=6.0.0",
|
|
50
48
|
"prek>=0.1.0",
|
|
51
49
|
"time-machine>=2.14.2",
|
|
52
50
|
"tox-uv>=1.16.0",
|
|
53
51
|
]
|
|
54
52
|
|
|
53
|
+
[tool.uv]
|
|
54
|
+
exclude-newer = "14 days"
|
|
55
|
+
exclude-newer-package = { scim2-models = false, scim2-client = false, scim2-server = false, scim2-cli = false, scim2-tester = false, pytest-scim2-server = false }
|
|
56
|
+
|
|
55
57
|
[tool.uv.build-backend]
|
|
56
58
|
module-root = ""
|
|
57
59
|
|
|
@@ -111,7 +113,6 @@ doctest_optionflags= "ALLOW_UNICODE IGNORE_EXCEPTION_DETAIL ELLIPSIS"
|
|
|
111
113
|
requires = ["tox>=4.19"]
|
|
112
114
|
env_list = [
|
|
113
115
|
"style",
|
|
114
|
-
"py310",
|
|
115
116
|
"py311",
|
|
116
117
|
"py312",
|
|
117
118
|
"py313",
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import pickle
|
|
3
|
+
import uuid
|
|
4
|
+
from threading import Lock
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import Union
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from scim2_models import AttributeBinding
|
|
10
|
+
from scim2_models import Meta
|
|
11
|
+
from scim2_models import Path
|
|
12
|
+
from scim2_models import Resource
|
|
13
|
+
from scim2_models import ResourceType
|
|
14
|
+
from scim2_models import ScimFilter
|
|
15
|
+
from scim2_models import SearchRequest
|
|
16
|
+
from scim2_models import Uniqueness
|
|
17
|
+
from scim2_models import UniquenessException
|
|
18
|
+
from werkzeug.http import generate_etag
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Backend:
|
|
22
|
+
"""The base class for a SCIM provider backend.
|
|
23
|
+
|
|
24
|
+
A backend only stores resources: what the service serves is described by the
|
|
25
|
+
:class:`~scim2_models.ScimProvider` of the application.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __enter__(self):
|
|
29
|
+
"""Allow the backend to be used as a context manager.
|
|
30
|
+
|
|
31
|
+
This enables support for transactions.
|
|
32
|
+
"""
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
36
|
+
"""Exit the transaction."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def query_resources(
|
|
40
|
+
self,
|
|
41
|
+
search_request: SearchRequest,
|
|
42
|
+
resource_type: ResourceType | None = None,
|
|
43
|
+
) -> tuple[int, list[Resource]]:
|
|
44
|
+
"""Query the backend for a set of resources.
|
|
45
|
+
|
|
46
|
+
:param search_request: SearchRequest instance describing the
|
|
47
|
+
query.
|
|
48
|
+
:param resource_type: The resource type to query. If None, all
|
|
49
|
+
resource types are queried.
|
|
50
|
+
:return: A tuple of "total results" and a List of found
|
|
51
|
+
Resources. The List must contain a copy of resources.
|
|
52
|
+
Mutating elements in the List must not modify the data
|
|
53
|
+
stored in the backend.
|
|
54
|
+
:raises TooManyException: If the backend only supports querying
|
|
55
|
+
for one resource type at a time, setting resource_type to
|
|
56
|
+
None the backend may raise TooManyException.
|
|
57
|
+
"""
|
|
58
|
+
raise NotImplementedError
|
|
59
|
+
|
|
60
|
+
def get_resource(
|
|
61
|
+
self, resource_type: ResourceType, object_id: str
|
|
62
|
+
) -> Resource | None:
|
|
63
|
+
"""Query the backend for a resources by its ID.
|
|
64
|
+
|
|
65
|
+
:param resource_type: The resource type to get the object from.
|
|
66
|
+
:param object_id: ID of the object to get.
|
|
67
|
+
:return: The resource object if it exists, None otherwise. The
|
|
68
|
+
resource must be a copy, modifying it must not change the
|
|
69
|
+
data stored in the backend.
|
|
70
|
+
"""
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
def delete_resource(self, resource_type: ResourceType, object_id: str) -> bool:
|
|
74
|
+
"""Delete a resource.
|
|
75
|
+
|
|
76
|
+
:param resource_type: The resource type to delete the object
|
|
77
|
+
from.
|
|
78
|
+
:param object_id: ID of the object to delete.
|
|
79
|
+
:return: True if the resource was deleted, False otherwise.
|
|
80
|
+
"""
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
|
|
83
|
+
def create_resource(
|
|
84
|
+
self, resource_type: ResourceType, resource: Resource
|
|
85
|
+
) -> Resource | None:
|
|
86
|
+
"""Create a resource.
|
|
87
|
+
|
|
88
|
+
:param resource_type: The resource type to create.
|
|
89
|
+
:param resource: Resource to create.
|
|
90
|
+
:return: The created resource. Creation should set system-
|
|
91
|
+
defined attributes (ID, Metadata). May be the same object
|
|
92
|
+
that is passed in.
|
|
93
|
+
"""
|
|
94
|
+
raise NotImplementedError
|
|
95
|
+
|
|
96
|
+
def update_resource(
|
|
97
|
+
self, resource_type: ResourceType, resource: Resource
|
|
98
|
+
) -> Resource | None:
|
|
99
|
+
"""Update a resource. The resource is identified by its ID.
|
|
100
|
+
|
|
101
|
+
:param resource_type: The resource type to update.
|
|
102
|
+
:param resource: Resource to update.
|
|
103
|
+
:return: The updated resource. Updating should update the
|
|
104
|
+
"meta.lastModified" data. May be the same object that is
|
|
105
|
+
passed in.
|
|
106
|
+
"""
|
|
107
|
+
raise NotImplementedError
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class InMemoryBackend(Backend):
|
|
111
|
+
"""An example in-memory backend for the SCIM provider.
|
|
112
|
+
|
|
113
|
+
It is not optimized for performance. Many operations are O(n) or
|
|
114
|
+
worse, whereas they would perform better with an actual production
|
|
115
|
+
database in the backend. This is intentional to keep the
|
|
116
|
+
implementation simple.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self):
|
|
120
|
+
super().__init__()
|
|
121
|
+
self.resources: list[Resource] = []
|
|
122
|
+
self.lock: Lock = Lock()
|
|
123
|
+
|
|
124
|
+
def __enter__(self):
|
|
125
|
+
"""See super docs.
|
|
126
|
+
|
|
127
|
+
The InMemoryBackend uses a simple Lock to synchronize all
|
|
128
|
+
access.
|
|
129
|
+
"""
|
|
130
|
+
super().__enter__()
|
|
131
|
+
self.lock.acquire()
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
135
|
+
super().__exit__(exc_type, exc_val, exc_tb)
|
|
136
|
+
self.lock.release()
|
|
137
|
+
|
|
138
|
+
def query_resources(
|
|
139
|
+
self,
|
|
140
|
+
search_request: SearchRequest,
|
|
141
|
+
resource_type: ResourceType | None = None,
|
|
142
|
+
) -> tuple[int, list[Resource]]:
|
|
143
|
+
start_index = (search_request.start_index or 1) - 1
|
|
144
|
+
|
|
145
|
+
candidates = [
|
|
146
|
+
r
|
|
147
|
+
for r in self.resources
|
|
148
|
+
if resource_type is None or self._is_of_type(r, resource_type)
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
scim_filter = search_request.filter
|
|
152
|
+
if scim_filter is not None and not scim_filter.models and candidates:
|
|
153
|
+
models = tuple(dict.fromkeys(type(r) for r in candidates))
|
|
154
|
+
scim_filter = ScimFilter[Union[models]](str(scim_filter)) # noqa: UP007
|
|
155
|
+
|
|
156
|
+
found_resources = [
|
|
157
|
+
r for r in candidates if scim_filter is None or scim_filter.match(r)
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
found_resources = search_request.sort(found_resources)
|
|
161
|
+
|
|
162
|
+
total_results = len(found_resources)
|
|
163
|
+
found_resources = found_resources[start_index:]
|
|
164
|
+
if search_request.count is not None:
|
|
165
|
+
found_resources = found_resources[: search_request.count]
|
|
166
|
+
return total_results, found_resources
|
|
167
|
+
|
|
168
|
+
def _is_of_type(self, resource: Resource, resource_type: ResourceType) -> bool:
|
|
169
|
+
"""Tell whether a resource belongs to a resource type.
|
|
170
|
+
|
|
171
|
+
RFC 7643 §3.1 has meta.resourceType carry the name of the resource type,
|
|
172
|
+
which may differ from its id.
|
|
173
|
+
"""
|
|
174
|
+
return resource.meta.resource_type == resource_type.name
|
|
175
|
+
|
|
176
|
+
def _get_resource_idx(
|
|
177
|
+
self, resource_type: ResourceType, object_id: str
|
|
178
|
+
) -> int | None:
|
|
179
|
+
return next(
|
|
180
|
+
(
|
|
181
|
+
idx
|
|
182
|
+
for idx, r in enumerate(self.resources)
|
|
183
|
+
if self._is_of_type(r, resource_type) and r.id == object_id
|
|
184
|
+
),
|
|
185
|
+
None,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def get_resource(
|
|
189
|
+
self, resource_type: ResourceType, object_id: str
|
|
190
|
+
) -> Resource | None:
|
|
191
|
+
resource_dict_idx = self._get_resource_idx(resource_type, object_id)
|
|
192
|
+
if resource_dict_idx is not None:
|
|
193
|
+
return self.resources[resource_dict_idx].model_copy(deep=True)
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def delete_resource(self, resource_type: ResourceType, object_id: str) -> bool:
|
|
197
|
+
found = self.get_resource(resource_type, object_id)
|
|
198
|
+
if found:
|
|
199
|
+
self.resources = [
|
|
200
|
+
r
|
|
201
|
+
for r in self.resources
|
|
202
|
+
if not (self._is_of_type(r, resource_type) and r.id == object_id)
|
|
203
|
+
]
|
|
204
|
+
return True
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
def create_resource(
|
|
208
|
+
self, resource_type: ResourceType, resource: Resource
|
|
209
|
+
) -> Resource | None:
|
|
210
|
+
resource = resource.model_copy(deep=True)
|
|
211
|
+
resource.id = uuid.uuid4().hex
|
|
212
|
+
utcnow = datetime.datetime.now(datetime.UTC)
|
|
213
|
+
resource.meta = Meta(
|
|
214
|
+
resource_type=resource_type.name,
|
|
215
|
+
created=utcnow,
|
|
216
|
+
last_modified=utcnow,
|
|
217
|
+
location="/v2" + resource_type.endpoint + "/" + resource.id,
|
|
218
|
+
)
|
|
219
|
+
self._touch_resource(resource, utcnow)
|
|
220
|
+
self._check_uniqueness(resource)
|
|
221
|
+
self.resources.append(resource)
|
|
222
|
+
return resource
|
|
223
|
+
|
|
224
|
+
def _check_uniqueness(self, resource: Resource):
|
|
225
|
+
"""Refuse a resource sharing a unique value with another one of the same schema.
|
|
226
|
+
|
|
227
|
+
RFC 7643 erratum 8279 scopes the uniqueness to the resources using the
|
|
228
|
+
schema that declares the attribute, whatever their resource type. A
|
|
229
|
+
missing value never clashes, as a SQL NULL does not.
|
|
230
|
+
"""
|
|
231
|
+
unique_paths = Path[type(resource)].iter_paths(
|
|
232
|
+
include_subattributes=False,
|
|
233
|
+
uniqueness=[Uniqueness.server, Uniqueness.global_],
|
|
234
|
+
)
|
|
235
|
+
for path in unique_paths:
|
|
236
|
+
attribute = cast(AttributeBinding, path.resolve())
|
|
237
|
+
value = self._unique_value(resource, attribute)
|
|
238
|
+
if value is None:
|
|
239
|
+
continue
|
|
240
|
+
for existing_resource in self.resources:
|
|
241
|
+
if (
|
|
242
|
+
existing_resource.id != resource.id
|
|
243
|
+
and self._unique_value(existing_resource, attribute) == value
|
|
244
|
+
):
|
|
245
|
+
raise UniquenessException()
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _unique_value(resource: Resource, attribute: AttributeBinding) -> Any:
|
|
249
|
+
"""Return the value a resource holds for a unique attribute, in the form it is compared in.
|
|
250
|
+
|
|
251
|
+
A resource whose schemas do not declare the attribute holds no value.
|
|
252
|
+
"""
|
|
253
|
+
value = Path[type(resource)](attribute.urn).get(resource, strict=False)
|
|
254
|
+
if isinstance(value, str) and not attribute.case_exact:
|
|
255
|
+
return value.casefold()
|
|
256
|
+
return value
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _touch_resource(resource: Resource, last_modified: datetime.datetime):
|
|
260
|
+
"""Touches a resource (updates last_modified and version).
|
|
261
|
+
|
|
262
|
+
Version is generated by hashing last_modified. Another option
|
|
263
|
+
would be to hash the entire resource instead.
|
|
264
|
+
"""
|
|
265
|
+
resource.meta.last_modified = last_modified
|
|
266
|
+
etag = generate_etag(pickle.dumps(resource.meta.last_modified))
|
|
267
|
+
resource.meta.version = f'W/"{etag}"'
|
|
268
|
+
|
|
269
|
+
def update_resource(
|
|
270
|
+
self, resource_type: ResourceType, resource: Resource
|
|
271
|
+
) -> Resource | None:
|
|
272
|
+
found_res_idx = self._get_resource_idx(resource_type, resource.id)
|
|
273
|
+
if found_res_idx is not None:
|
|
274
|
+
updated_resource = type(resource).model_validate(resource.model_dump())
|
|
275
|
+
self._touch_resource(updated_resource, datetime.datetime.now(datetime.UTC))
|
|
276
|
+
|
|
277
|
+
self._check_uniqueness(updated_resource)
|
|
278
|
+
self.resources[found_res_idx] = updated_resource
|
|
279
|
+
return updated_resource
|
|
280
|
+
return None
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from scim2_models import BulkOperation
|
|
6
|
+
from scim2_models import InvalidValueException
|
|
7
|
+
from scim2_models import Resource
|
|
8
|
+
from werkzeug.exceptions import Conflict
|
|
9
|
+
|
|
10
|
+
BULK_ID_PREFIX = "bulkId:"
|
|
11
|
+
|
|
12
|
+
Resolver = Callable[[BulkOperation], BulkOperation]
|
|
13
|
+
"""Replaces the bulkId references of an operation."""
|
|
14
|
+
|
|
15
|
+
OperationRunner = Callable[
|
|
16
|
+
[BulkOperation, Resolver], tuple[dict[str, Any], Resource | None]
|
|
17
|
+
]
|
|
18
|
+
"""Applies an operation once resolved, and returns its outcome and the resource it acted on."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def replace_bulk_ids(value: Any, replace: Callable[[str], str]) -> Any:
|
|
22
|
+
"""Replace every "bulkId:" reference of a value.
|
|
23
|
+
|
|
24
|
+
A value without reference is returned as it is. Models are copied with
|
|
25
|
+
only the changed fields, so the fields the client set stay the same.
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(value, str) and value.startswith(BULK_ID_PREFIX):
|
|
28
|
+
return replace(value.removeprefix(BULK_ID_PREFIX))
|
|
29
|
+
|
|
30
|
+
if isinstance(value, list):
|
|
31
|
+
items = [replace_bulk_ids(item, replace) for item in value]
|
|
32
|
+
changed = any(new is not old for new, old in zip(items, value, strict=True))
|
|
33
|
+
return items if changed else value
|
|
34
|
+
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
entries = {key: replace_bulk_ids(item, replace) for key, item in value.items()}
|
|
37
|
+
changed = any(entries[key] is not item for key, item in value.items())
|
|
38
|
+
return entries if changed else value
|
|
39
|
+
|
|
40
|
+
if isinstance(value, BaseModel):
|
|
41
|
+
updates = {}
|
|
42
|
+
for name in type(value).model_fields:
|
|
43
|
+
field = getattr(value, name)
|
|
44
|
+
replaced = replace_bulk_ids(field, replace)
|
|
45
|
+
if replaced is not field:
|
|
46
|
+
updates[name] = replaced
|
|
47
|
+
return value.model_copy(update=updates) if updates else value
|
|
48
|
+
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_operation(
|
|
53
|
+
operation: BulkOperation, replace: Callable[[str], str]
|
|
54
|
+
) -> BulkOperation:
|
|
55
|
+
"""Replace the "bulkId:" references of the path and the data of a bulk operation."""
|
|
56
|
+
updates: dict[str, Any] = {}
|
|
57
|
+
if operation.path is not None:
|
|
58
|
+
path = "/".join(
|
|
59
|
+
replace_bulk_ids(segment, replace) for segment in operation.path.split("/")
|
|
60
|
+
)
|
|
61
|
+
if path != operation.path:
|
|
62
|
+
updates["path"] = path
|
|
63
|
+
|
|
64
|
+
data = replace_bulk_ids(operation.data, replace)
|
|
65
|
+
if data is not operation.data:
|
|
66
|
+
updates["data"] = data
|
|
67
|
+
|
|
68
|
+
return operation.model_copy(update=updates) if updates else operation
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BulkJob:
|
|
72
|
+
"""Run the operations of a bulk request, and resolve their "bulkId:" references.
|
|
73
|
+
|
|
74
|
+
RFC 7644 §3.7.2 lets an operation reference a resource that another POST
|
|
75
|
+
of the same request creates. The operations run in the order of the
|
|
76
|
+
request, except that a POST runs before the first operation that
|
|
77
|
+
references it. The results keep the order of the request.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
operations: list[BulkOperation],
|
|
83
|
+
fail_on_errors: int | None,
|
|
84
|
+
run: OperationRunner,
|
|
85
|
+
):
|
|
86
|
+
self.run_resolved = run
|
|
87
|
+
self.operations = operations
|
|
88
|
+
self.fail_on_errors = fail_on_errors
|
|
89
|
+
self.results: dict[int, dict[str, Any]] = {}
|
|
90
|
+
self.created: dict[str, Resource] = {}
|
|
91
|
+
self.running: set[int] = set()
|
|
92
|
+
self.errors = 0
|
|
93
|
+
|
|
94
|
+
self.creations: dict[str, int] = {}
|
|
95
|
+
for index, operation in enumerate(operations):
|
|
96
|
+
if (
|
|
97
|
+
operation.method == BulkOperation.Method.post
|
|
98
|
+
and operation.bulk_id is not None
|
|
99
|
+
):
|
|
100
|
+
self.creations.setdefault(operation.bulk_id, index)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def stopped(self) -> bool:
|
|
104
|
+
"""Whether the job reached the number of errors the client accepts.
|
|
105
|
+
|
|
106
|
+
RFC 7644 §3.7.3: the job goes on despite failures, unless the client
|
|
107
|
+
caps the errors it accepts with "failOnErrors".
|
|
108
|
+
"""
|
|
109
|
+
return (
|
|
110
|
+
self.errors > 0
|
|
111
|
+
and self.fail_on_errors is not None
|
|
112
|
+
and self.errors >= self.fail_on_errors
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def run(self) -> list[dict[str, Any]]:
|
|
116
|
+
"""Run every operation, and return the results of the operations that ran."""
|
|
117
|
+
for index in range(len(self.operations)):
|
|
118
|
+
self.run_operation(index)
|
|
119
|
+
return [self.results[index] for index in sorted(self.results)]
|
|
120
|
+
|
|
121
|
+
def run_operation(self, index: int) -> None:
|
|
122
|
+
"""Run an operation, after the creations it references."""
|
|
123
|
+
if index in self.results or index in self.running or self.stopped:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
operation = self.operations[index]
|
|
127
|
+
self.running.add(index)
|
|
128
|
+
for bulk_id in self.references(operation):
|
|
129
|
+
if bulk_id in self.creations:
|
|
130
|
+
self.run_operation(self.creations[bulk_id])
|
|
131
|
+
|
|
132
|
+
if not self.stopped:
|
|
133
|
+
result, resource = self.run_resolved(
|
|
134
|
+
operation, lambda operation: self.resolve(index, operation)
|
|
135
|
+
)
|
|
136
|
+
self.results[index] = result
|
|
137
|
+
if result["status"] >= 400:
|
|
138
|
+
self.errors += 1
|
|
139
|
+
elif resource is not None and self.is_creation(index, result["bulk_id"]):
|
|
140
|
+
self.created[result["bulk_id"]] = resource
|
|
141
|
+
self.running.discard(index)
|
|
142
|
+
|
|
143
|
+
def is_creation(self, index: int, bulk_id: str | None) -> bool:
|
|
144
|
+
"""Whether an operation is the creation a bulkId references."""
|
|
145
|
+
return bulk_id is not None and self.creations.get(bulk_id) == index
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def references(operation: BulkOperation) -> list[str]:
|
|
149
|
+
"""Return the bulkIds an operation references."""
|
|
150
|
+
bulk_ids: list[str] = []
|
|
151
|
+
|
|
152
|
+
def collect(bulk_id: str) -> str:
|
|
153
|
+
bulk_ids.append(bulk_id)
|
|
154
|
+
return bulk_id
|
|
155
|
+
|
|
156
|
+
resolve_operation(operation, collect)
|
|
157
|
+
return bulk_ids
|
|
158
|
+
|
|
159
|
+
def resolve(self, index: int, operation: BulkOperation) -> BulkOperation:
|
|
160
|
+
"""Replace the bulkId references of an operation with the identifiers of the created resources.
|
|
161
|
+
|
|
162
|
+
:raises Conflict: When a referenced resource was not created, as
|
|
163
|
+
RFC 7644 §3.7.1 allows for circular references.
|
|
164
|
+
"""
|
|
165
|
+
if (
|
|
166
|
+
operation.method == BulkOperation.Method.post
|
|
167
|
+
and operation.bulk_id is not None
|
|
168
|
+
and not self.is_creation(index, operation.bulk_id)
|
|
169
|
+
):
|
|
170
|
+
raise InvalidValueException(
|
|
171
|
+
detail=f"The bulkId {operation.bulk_id} is not unique in the request"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def replace(bulk_id: str) -> str:
|
|
175
|
+
if bulk_id in self.created:
|
|
176
|
+
return str(self.created[bulk_id].id)
|
|
177
|
+
if self.creations.get(bulk_id) in self.running:
|
|
178
|
+
raise Conflict(f"The bulkId {bulk_id} is part of a circular reference")
|
|
179
|
+
raise Conflict(f"No resource was created with the bulkId {bulk_id}")
|
|
180
|
+
|
|
181
|
+
return resolve_operation(operation, replace)
|