scim2-server 0.2.0__tar.gz → 0.3.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.2.0 → scim2_server-0.3.0}/PKG-INFO +8 -10
- {scim2_server-0.2.0 → scim2_server-0.3.0}/README.md +5 -5
- {scim2_server-0.2.0 → scim2_server-0.3.0}/pyproject.toml +17 -9
- {scim2_server-0.2.0 → scim2_server-0.3.0}/pyproject.toml.orig +10 -9
- scim2_server-0.3.0/scim2_server/backend.py +315 -0
- scim2_server-0.3.0/scim2_server/bulk.py +164 -0
- {scim2_server-0.2.0 → scim2_server-0.3.0}/scim2_server/cli.py +44 -18
- scim2_server-0.3.0/scim2_server/provider.py +800 -0
- {scim2_server-0.2.0 → scim2_server-0.3.0}/scim2_server/resources/default-resource-types.json +3 -11
- {scim2_server-0.2.0 → scim2_server-0.3.0}/scim2_server/resources/default-schemas.json +27 -35
- scim2_server-0.3.0/scim2_server/resources/default-service-provider-config.json +27 -0
- scim2_server-0.3.0/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.0}/LICENSE +0 -0
- {scim2_server-0.2.0 → scim2_server-0.3.0}/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.0
|
|
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.9.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 ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7))
|
|
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 ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7))
|
|
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.0"
|
|
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.9.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.0"
|
|
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.9.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,315 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import datetime
|
|
3
|
+
import pickle
|
|
4
|
+
import uuid
|
|
5
|
+
from inspect import isclass
|
|
6
|
+
from threading import Lock
|
|
7
|
+
from typing import Any
|
|
8
|
+
from typing import Union
|
|
9
|
+
|
|
10
|
+
from scim2_models import BaseModel
|
|
11
|
+
from scim2_models import CaseExact
|
|
12
|
+
from scim2_models import Extension
|
|
13
|
+
from scim2_models import Meta
|
|
14
|
+
from scim2_models import Resource
|
|
15
|
+
from scim2_models import ResourceType
|
|
16
|
+
from scim2_models import ScimFilter
|
|
17
|
+
from scim2_models import SearchRequest
|
|
18
|
+
from scim2_models import Uniqueness
|
|
19
|
+
from scim2_models import UniquenessException
|
|
20
|
+
from werkzeug.http import generate_etag
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Backend:
|
|
24
|
+
"""The base class for a SCIM provider backend.
|
|
25
|
+
|
|
26
|
+
A backend only stores resources: what the service serves is described by the
|
|
27
|
+
:class:`~scim2_models.ScimProvider` of the application.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __enter__(self):
|
|
31
|
+
"""Allow the backend to be used as a context manager.
|
|
32
|
+
|
|
33
|
+
This enables support for transactions.
|
|
34
|
+
"""
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
38
|
+
"""Exit the transaction."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def query_resources(
|
|
42
|
+
self,
|
|
43
|
+
search_request: SearchRequest,
|
|
44
|
+
resource_type: ResourceType | None = None,
|
|
45
|
+
) -> tuple[int, list[Resource]]:
|
|
46
|
+
"""Query the backend for a set of resources.
|
|
47
|
+
|
|
48
|
+
:param search_request: SearchRequest instance describing the
|
|
49
|
+
query.
|
|
50
|
+
:param resource_type: The resource type to query. If None, all
|
|
51
|
+
resource types are queried.
|
|
52
|
+
:return: A tuple of "total results" and a List of found
|
|
53
|
+
Resources. The List must contain a copy of resources.
|
|
54
|
+
Mutating elements in the List must not modify the data
|
|
55
|
+
stored in the backend.
|
|
56
|
+
:raises TooManyException: If the backend only supports querying
|
|
57
|
+
for one resource type at a time, setting resource_type to
|
|
58
|
+
None the backend may raise TooManyException.
|
|
59
|
+
"""
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
def get_resource(
|
|
63
|
+
self, resource_type: ResourceType, object_id: str
|
|
64
|
+
) -> Resource | None:
|
|
65
|
+
"""Query the backend for a resources by its ID.
|
|
66
|
+
|
|
67
|
+
:param resource_type: The resource type to get the object from.
|
|
68
|
+
:param object_id: ID of the object to get.
|
|
69
|
+
:return: The resource object if it exists, None otherwise. The
|
|
70
|
+
resource must be a copy, modifying it must not change the
|
|
71
|
+
data stored in the backend.
|
|
72
|
+
"""
|
|
73
|
+
raise NotImplementedError
|
|
74
|
+
|
|
75
|
+
def delete_resource(self, resource_type: ResourceType, object_id: str) -> bool:
|
|
76
|
+
"""Delete a resource.
|
|
77
|
+
|
|
78
|
+
:param resource_type: The resource type to delete the object
|
|
79
|
+
from.
|
|
80
|
+
:param object_id: ID of the object to delete.
|
|
81
|
+
:return: True if the resource was deleted, False otherwise.
|
|
82
|
+
"""
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
|
|
85
|
+
def create_resource(
|
|
86
|
+
self, resource_type: ResourceType, resource: Resource
|
|
87
|
+
) -> Resource | None:
|
|
88
|
+
"""Create a resource.
|
|
89
|
+
|
|
90
|
+
:param resource_type: The resource type to create.
|
|
91
|
+
:param resource: Resource to create.
|
|
92
|
+
:return: The created resource. Creation should set system-
|
|
93
|
+
defined attributes (ID, Metadata). May be the same object
|
|
94
|
+
that is passed in.
|
|
95
|
+
"""
|
|
96
|
+
raise NotImplementedError
|
|
97
|
+
|
|
98
|
+
def update_resource(
|
|
99
|
+
self, resource_type: ResourceType, resource: Resource
|
|
100
|
+
) -> Resource | None:
|
|
101
|
+
"""Update a resource. The resource is identified by its ID.
|
|
102
|
+
|
|
103
|
+
:param resource_type: The resource type to update.
|
|
104
|
+
:param resource: Resource to update.
|
|
105
|
+
:return: The updated resource. Updating should update the
|
|
106
|
+
"meta.lastModified" data. May be the same object that is
|
|
107
|
+
passed in.
|
|
108
|
+
"""
|
|
109
|
+
raise NotImplementedError
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class InMemoryBackend(Backend):
|
|
113
|
+
"""An example in-memory backend for the SCIM provider.
|
|
114
|
+
|
|
115
|
+
It is not optimized for performance. Many operations are O(n) or
|
|
116
|
+
worse, whereas they would perform better with an actual production
|
|
117
|
+
database in the backend. This is intentional to keep the
|
|
118
|
+
implementation simple.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
@dataclasses.dataclass(frozen=True)
|
|
122
|
+
class UniquenessDescriptor:
|
|
123
|
+
"""Used to mimic uniqueness constraints e.g. from a SQL database."""
|
|
124
|
+
|
|
125
|
+
extension: str | None
|
|
126
|
+
field_name: str
|
|
127
|
+
case_exact: bool
|
|
128
|
+
schema: str
|
|
129
|
+
|
|
130
|
+
def is_declared_by(self, resource: Resource) -> bool:
|
|
131
|
+
"""Tell whether the model of a resource holds the schema of the attribute."""
|
|
132
|
+
model = type(resource)
|
|
133
|
+
return self.schema == model.__schema__ or (
|
|
134
|
+
self.schema in model.get_extension_models()
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def get_attribute(self, resource: Resource) -> Any:
|
|
138
|
+
holder = getattr(resource, self.extension) if self.extension else resource
|
|
139
|
+
value = getattr(holder, self.field_name, None) if holder else None
|
|
140
|
+
if isinstance(value, str) and not self.case_exact:
|
|
141
|
+
return value.casefold()
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def collect_unique_attrs(
|
|
146
|
+
cls, model: type[BaseModel], extension: str | None = None
|
|
147
|
+
) -> list[UniquenessDescriptor]:
|
|
148
|
+
"""Return the uniqueness constraints the annotations of a model declare.
|
|
149
|
+
|
|
150
|
+
The ``id`` is left out: the backend issues it, so it cannot clash.
|
|
151
|
+
"""
|
|
152
|
+
descriptors = [
|
|
153
|
+
cls.UniquenessDescriptor(
|
|
154
|
+
extension,
|
|
155
|
+
field_name,
|
|
156
|
+
model.get_field_annotation(field_name, CaseExact) == CaseExact.true,
|
|
157
|
+
str(model.__schema__),
|
|
158
|
+
)
|
|
159
|
+
for field_name in model.model_fields
|
|
160
|
+
if field_name != "id"
|
|
161
|
+
and model.get_field_annotation(field_name, Uniqueness) != Uniqueness.none
|
|
162
|
+
]
|
|
163
|
+
for field_name in model.model_fields:
|
|
164
|
+
root_type = model.get_field_root_type(field_name)
|
|
165
|
+
if isclass(root_type) and issubclass(root_type, Extension):
|
|
166
|
+
descriptors.extend(cls.collect_unique_attrs(root_type, field_name))
|
|
167
|
+
return descriptors
|
|
168
|
+
|
|
169
|
+
def __init__(self):
|
|
170
|
+
super().__init__()
|
|
171
|
+
self.resources: list[Resource] = []
|
|
172
|
+
self.lock: Lock = Lock()
|
|
173
|
+
|
|
174
|
+
def __enter__(self):
|
|
175
|
+
"""See super docs.
|
|
176
|
+
|
|
177
|
+
The InMemoryBackend uses a simple Lock to synchronize all
|
|
178
|
+
access.
|
|
179
|
+
"""
|
|
180
|
+
super().__enter__()
|
|
181
|
+
self.lock.acquire()
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
185
|
+
super().__exit__(exc_type, exc_val, exc_tb)
|
|
186
|
+
self.lock.release()
|
|
187
|
+
|
|
188
|
+
def query_resources(
|
|
189
|
+
self,
|
|
190
|
+
search_request: SearchRequest,
|
|
191
|
+
resource_type: ResourceType | None = None,
|
|
192
|
+
) -> tuple[int, list[Resource]]:
|
|
193
|
+
start_index = (search_request.start_index or 1) - 1
|
|
194
|
+
|
|
195
|
+
candidates = [
|
|
196
|
+
r
|
|
197
|
+
for r in self.resources
|
|
198
|
+
if resource_type is None or self._is_of_type(r, resource_type)
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
scim_filter = search_request.filter
|
|
202
|
+
if scim_filter is not None and not scim_filter.models and candidates:
|
|
203
|
+
models = tuple(dict.fromkeys(type(r) for r in candidates))
|
|
204
|
+
scim_filter = ScimFilter[Union[models]](str(scim_filter)) # noqa: UP007
|
|
205
|
+
|
|
206
|
+
found_resources = [
|
|
207
|
+
r for r in candidates if scim_filter is None or scim_filter.match(r)
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
found_resources = search_request.sort(found_resources)
|
|
211
|
+
|
|
212
|
+
total_results = len(found_resources)
|
|
213
|
+
found_resources = found_resources[start_index:]
|
|
214
|
+
if search_request.count is not None:
|
|
215
|
+
found_resources = found_resources[: search_request.count]
|
|
216
|
+
return total_results, found_resources
|
|
217
|
+
|
|
218
|
+
def _is_of_type(self, resource: Resource, resource_type: ResourceType) -> bool:
|
|
219
|
+
"""Tell whether a resource belongs to a resource type.
|
|
220
|
+
|
|
221
|
+
RFC 7643 §3.1 has meta.resourceType carry the name of the resource type,
|
|
222
|
+
which may differ from its id.
|
|
223
|
+
"""
|
|
224
|
+
return resource.meta.resource_type == resource_type.name
|
|
225
|
+
|
|
226
|
+
def _get_resource_idx(
|
|
227
|
+
self, resource_type: ResourceType, object_id: str
|
|
228
|
+
) -> int | None:
|
|
229
|
+
return next(
|
|
230
|
+
(
|
|
231
|
+
idx
|
|
232
|
+
for idx, r in enumerate(self.resources)
|
|
233
|
+
if self._is_of_type(r, resource_type) and r.id == object_id
|
|
234
|
+
),
|
|
235
|
+
None,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def get_resource(
|
|
239
|
+
self, resource_type: ResourceType, object_id: str
|
|
240
|
+
) -> Resource | None:
|
|
241
|
+
resource_dict_idx = self._get_resource_idx(resource_type, object_id)
|
|
242
|
+
if resource_dict_idx is not None:
|
|
243
|
+
return self.resources[resource_dict_idx].model_copy(deep=True)
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def delete_resource(self, resource_type: ResourceType, object_id: str) -> bool:
|
|
247
|
+
found = self.get_resource(resource_type, object_id)
|
|
248
|
+
if found:
|
|
249
|
+
self.resources = [
|
|
250
|
+
r
|
|
251
|
+
for r in self.resources
|
|
252
|
+
if not (self._is_of_type(r, resource_type) and r.id == object_id)
|
|
253
|
+
]
|
|
254
|
+
return True
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
def create_resource(
|
|
258
|
+
self, resource_type: ResourceType, resource: Resource
|
|
259
|
+
) -> Resource | None:
|
|
260
|
+
resource = resource.model_copy(deep=True)
|
|
261
|
+
resource.id = uuid.uuid4().hex
|
|
262
|
+
utcnow = datetime.datetime.now(datetime.UTC)
|
|
263
|
+
resource.meta = Meta(
|
|
264
|
+
resource_type=resource_type.name,
|
|
265
|
+
created=utcnow,
|
|
266
|
+
last_modified=utcnow,
|
|
267
|
+
location="/v2" + resource_type.endpoint + "/" + resource.id,
|
|
268
|
+
)
|
|
269
|
+
self._touch_resource(resource, utcnow)
|
|
270
|
+
self._check_uniqueness(resource)
|
|
271
|
+
self.resources.append(resource)
|
|
272
|
+
return resource
|
|
273
|
+
|
|
274
|
+
def _check_uniqueness(self, resource: Resource):
|
|
275
|
+
"""Refuse a resource sharing a unique value with another one of the same schema.
|
|
276
|
+
|
|
277
|
+
RFC 7643 erratum 8279 scopes the uniqueness to the resources using the
|
|
278
|
+
schema that declares the attribute, whatever their resource type. A
|
|
279
|
+
missing value never clashes, as a SQL NULL does not.
|
|
280
|
+
"""
|
|
281
|
+
for unique_attribute in self.collect_unique_attrs(type(resource)):
|
|
282
|
+
value = unique_attribute.get_attribute(resource)
|
|
283
|
+
if value is None:
|
|
284
|
+
continue
|
|
285
|
+
for existing_resource in self.resources:
|
|
286
|
+
if (
|
|
287
|
+
unique_attribute.is_declared_by(existing_resource)
|
|
288
|
+
and existing_resource.id != resource.id
|
|
289
|
+
and unique_attribute.get_attribute(existing_resource) == value
|
|
290
|
+
):
|
|
291
|
+
raise UniquenessException()
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _touch_resource(resource: Resource, last_modified: datetime.datetime):
|
|
295
|
+
"""Touches a resource (updates last_modified and version).
|
|
296
|
+
|
|
297
|
+
Version is generated by hashing last_modified. Another option
|
|
298
|
+
would be to hash the entire resource instead.
|
|
299
|
+
"""
|
|
300
|
+
resource.meta.last_modified = last_modified
|
|
301
|
+
etag = generate_etag(pickle.dumps(resource.meta.last_modified))
|
|
302
|
+
resource.meta.version = f'W/"{etag}"'
|
|
303
|
+
|
|
304
|
+
def update_resource(
|
|
305
|
+
self, resource_type: ResourceType, resource: Resource
|
|
306
|
+
) -> Resource | None:
|
|
307
|
+
found_res_idx = self._get_resource_idx(resource_type, resource.id)
|
|
308
|
+
if found_res_idx is not None:
|
|
309
|
+
updated_resource = type(resource).model_validate(resource.model_dump())
|
|
310
|
+
self._touch_resource(updated_resource, datetime.datetime.now(datetime.UTC))
|
|
311
|
+
|
|
312
|
+
self._check_uniqueness(updated_resource)
|
|
313
|
+
self.resources[found_res_idx] = updated_resource
|
|
314
|
+
return updated_resource
|
|
315
|
+
return None
|