scim2-server 0.3.1__tar.gz → 0.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scim2_server-0.3.1 → scim2_server-0.3.3}/PKG-INFO +1 -1
- {scim2_server-0.3.1 → scim2_server-0.3.3}/pyproject.toml +12 -1
- {scim2_server-0.3.1 → scim2_server-0.3.3}/pyproject.toml.orig +14 -1
- {scim2_server-0.3.1 → scim2_server-0.3.3}/scim2_server/backend.py +52 -31
- {scim2_server-0.3.1 → scim2_server-0.3.3}/scim2_server/bulk.py +11 -8
- {scim2_server-0.3.1 → scim2_server-0.3.3}/scim2_server/cli.py +25 -8
- {scim2_server-0.3.1 → scim2_server-0.3.3}/scim2_server/provider.py +154 -72
- scim2_server-0.3.3/scim2_server/py.typed +0 -0
- {scim2_server-0.3.1 → scim2_server-0.3.3}/scim2_server/utils.py +13 -2
- {scim2_server-0.3.1 → scim2_server-0.3.3}/LICENSE +0 -0
- {scim2_server-0.3.1 → scim2_server-0.3.3}/README.md +0 -0
- {scim2_server-0.3.1 → scim2_server-0.3.3}/scim2_server/__init__.py +0 -0
- {scim2_server-0.3.1 → scim2_server-0.3.3}/scim2_server/resources/default-resource-types.json +0 -0
- {scim2_server-0.3.1 → scim2_server-0.3.3}/scim2_server/resources/default-schemas.json +0 -0
- {scim2_server-0.3.1 → scim2_server-0.3.3}/scim2_server/resources/default-service-provider-config.json +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-server"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
description = "Lightweight SCIM2 server prototype"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [
|
|
@@ -59,6 +59,7 @@ dev = [
|
|
|
59
59
|
"prek>=0.1.0",
|
|
60
60
|
"time-machine>=2.14.2",
|
|
61
61
|
"tox-uv>=1.16.0",
|
|
62
|
+
"mypy>=2.3.1",
|
|
62
63
|
]
|
|
63
64
|
|
|
64
65
|
[tool.uv]
|
|
@@ -75,6 +76,15 @@ pytest-scim2-server = false
|
|
|
75
76
|
[tool.uv.build-backend]
|
|
76
77
|
module-root = ""
|
|
77
78
|
|
|
79
|
+
[tool.mypy]
|
|
80
|
+
plugins = ["pydantic.mypy"]
|
|
81
|
+
files = ["scim2_server"]
|
|
82
|
+
strict = true
|
|
83
|
+
|
|
84
|
+
[tool.pydantic-mypy]
|
|
85
|
+
init_forbid_extra = true
|
|
86
|
+
init_typed = true
|
|
87
|
+
|
|
78
88
|
[tool.coverage.run]
|
|
79
89
|
source = [
|
|
80
90
|
"scim2_server",
|
|
@@ -93,6 +103,7 @@ exclude_lines = [
|
|
|
93
103
|
"@pytest.mark.xfail",
|
|
94
104
|
"pragma: no cover",
|
|
95
105
|
"raise NotImplementedError",
|
|
106
|
+
"if TYPE_CHECKING:",
|
|
96
107
|
'\.\.\.\s*$',
|
|
97
108
|
]
|
|
98
109
|
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-server"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
description = "Lightweight SCIM2 server prototype"
|
|
9
9
|
authors = [
|
|
10
10
|
{name="Yaal Coop", email="contact@yaal.coop"},
|
|
@@ -48,6 +48,7 @@ dev = [
|
|
|
48
48
|
"prek>=0.1.0",
|
|
49
49
|
"time-machine>=2.14.2",
|
|
50
50
|
"tox-uv>=1.16.0",
|
|
51
|
+
"mypy>=2.3.1",
|
|
51
52
|
]
|
|
52
53
|
|
|
53
54
|
[tool.uv]
|
|
@@ -57,6 +58,17 @@ exclude-newer-package = { scim2-models = false, scim2-client = false, scim2-serv
|
|
|
57
58
|
[tool.uv.build-backend]
|
|
58
59
|
module-root = ""
|
|
59
60
|
|
|
61
|
+
[tool.mypy]
|
|
62
|
+
plugins = [
|
|
63
|
+
"pydantic.mypy"
|
|
64
|
+
]
|
|
65
|
+
files = ["scim2_server"]
|
|
66
|
+
strict = true
|
|
67
|
+
|
|
68
|
+
[tool.pydantic-mypy]
|
|
69
|
+
init_forbid_extra = true
|
|
70
|
+
init_typed = true
|
|
71
|
+
|
|
60
72
|
[tool.coverage.run]
|
|
61
73
|
source = [
|
|
62
74
|
"scim2_server",
|
|
@@ -71,6 +83,7 @@ exclude_lines = [
|
|
|
71
83
|
"@pytest.mark.xfail",
|
|
72
84
|
"pragma: no cover",
|
|
73
85
|
"raise NotImplementedError",
|
|
86
|
+
"if TYPE_CHECKING:",
|
|
74
87
|
"\\.\\.\\.\\s*$", # ignore ellipsis
|
|
75
88
|
]
|
|
76
89
|
|
|
@@ -2,7 +2,9 @@ import datetime
|
|
|
2
2
|
import pickle
|
|
3
3
|
import uuid
|
|
4
4
|
from threading import Lock
|
|
5
|
+
from types import TracebackType
|
|
5
6
|
from typing import Any
|
|
7
|
+
from typing import Self
|
|
6
8
|
from typing import Union
|
|
7
9
|
from typing import cast
|
|
8
10
|
|
|
@@ -17,6 +19,8 @@ from scim2_models import Uniqueness
|
|
|
17
19
|
from scim2_models import UniquenessException
|
|
18
20
|
from werkzeug.http import generate_etag
|
|
19
21
|
|
|
22
|
+
from scim2_server.utils import parametrize
|
|
23
|
+
|
|
20
24
|
|
|
21
25
|
class Backend:
|
|
22
26
|
"""The base class for a SCIM provider backend.
|
|
@@ -25,22 +29,27 @@ class Backend:
|
|
|
25
29
|
:class:`~scim2_models.ScimProvider` of the application.
|
|
26
30
|
"""
|
|
27
31
|
|
|
28
|
-
def __enter__(self):
|
|
32
|
+
def __enter__(self) -> Self:
|
|
29
33
|
"""Allow the backend to be used as a context manager.
|
|
30
34
|
|
|
31
35
|
This enables support for transactions.
|
|
32
36
|
"""
|
|
33
37
|
return self
|
|
34
38
|
|
|
35
|
-
def __exit__(
|
|
39
|
+
def __exit__(
|
|
40
|
+
self,
|
|
41
|
+
exc_type: type[BaseException] | None,
|
|
42
|
+
exc_val: BaseException | None,
|
|
43
|
+
exc_tb: TracebackType | None,
|
|
44
|
+
) -> None:
|
|
36
45
|
"""Exit the transaction."""
|
|
37
46
|
pass
|
|
38
47
|
|
|
39
48
|
def query_resources(
|
|
40
49
|
self,
|
|
41
|
-
search_request: SearchRequest,
|
|
50
|
+
search_request: SearchRequest[Any],
|
|
42
51
|
resource_type: ResourceType | None = None,
|
|
43
|
-
) -> tuple[int, list[Resource]]:
|
|
52
|
+
) -> tuple[int, list[Resource[Any]]]:
|
|
44
53
|
"""Query the backend for a set of resources.
|
|
45
54
|
|
|
46
55
|
:param search_request: SearchRequest instance describing the
|
|
@@ -59,7 +68,7 @@ class Backend:
|
|
|
59
68
|
|
|
60
69
|
def get_resource(
|
|
61
70
|
self, resource_type: ResourceType, object_id: str
|
|
62
|
-
) -> Resource | None:
|
|
71
|
+
) -> Resource[Any] | None:
|
|
63
72
|
"""Query the backend for a resources by its ID.
|
|
64
73
|
|
|
65
74
|
:param resource_type: The resource type to get the object from.
|
|
@@ -81,8 +90,8 @@ class Backend:
|
|
|
81
90
|
raise NotImplementedError
|
|
82
91
|
|
|
83
92
|
def create_resource(
|
|
84
|
-
self, resource_type: ResourceType, resource: Resource
|
|
85
|
-
) -> Resource
|
|
93
|
+
self, resource_type: ResourceType, resource: Resource[Any]
|
|
94
|
+
) -> Resource[Any]:
|
|
86
95
|
"""Create a resource.
|
|
87
96
|
|
|
88
97
|
:param resource_type: The resource type to create.
|
|
@@ -94,15 +103,15 @@ class Backend:
|
|
|
94
103
|
raise NotImplementedError
|
|
95
104
|
|
|
96
105
|
def update_resource(
|
|
97
|
-
self, resource_type: ResourceType, resource: Resource
|
|
98
|
-
) -> Resource | None:
|
|
106
|
+
self, resource_type: ResourceType, resource: Resource[Any]
|
|
107
|
+
) -> Resource[Any] | None:
|
|
99
108
|
"""Update a resource. The resource is identified by its ID.
|
|
100
109
|
|
|
101
110
|
:param resource_type: The resource type to update.
|
|
102
111
|
:param resource: Resource to update.
|
|
103
|
-
:return: The updated resource
|
|
104
|
-
"meta.lastModified" data. May be
|
|
105
|
-
passed in.
|
|
112
|
+
:return: The updated resource, or None if no resource has its ID.
|
|
113
|
+
Updating should update the "meta.lastModified" data. May be
|
|
114
|
+
the same object that is passed in.
|
|
106
115
|
"""
|
|
107
116
|
raise NotImplementedError
|
|
108
117
|
|
|
@@ -116,12 +125,12 @@ class InMemoryBackend(Backend):
|
|
|
116
125
|
implementation simple.
|
|
117
126
|
"""
|
|
118
127
|
|
|
119
|
-
def __init__(self):
|
|
128
|
+
def __init__(self) -> None:
|
|
120
129
|
super().__init__()
|
|
121
|
-
self.resources: list[Resource] = []
|
|
130
|
+
self.resources: list[Resource[Any]] = []
|
|
122
131
|
self.lock: Lock = Lock()
|
|
123
132
|
|
|
124
|
-
def __enter__(self):
|
|
133
|
+
def __enter__(self) -> Self:
|
|
125
134
|
"""See super docs.
|
|
126
135
|
|
|
127
136
|
The InMemoryBackend uses a simple Lock to synchronize all
|
|
@@ -131,15 +140,20 @@ class InMemoryBackend(Backend):
|
|
|
131
140
|
self.lock.acquire()
|
|
132
141
|
return self
|
|
133
142
|
|
|
134
|
-
def __exit__(
|
|
143
|
+
def __exit__(
|
|
144
|
+
self,
|
|
145
|
+
exc_type: type[BaseException] | None,
|
|
146
|
+
exc_val: BaseException | None,
|
|
147
|
+
exc_tb: TracebackType | None,
|
|
148
|
+
) -> None:
|
|
135
149
|
super().__exit__(exc_type, exc_val, exc_tb)
|
|
136
150
|
self.lock.release()
|
|
137
151
|
|
|
138
152
|
def query_resources(
|
|
139
153
|
self,
|
|
140
|
-
search_request: SearchRequest,
|
|
154
|
+
search_request: SearchRequest[Any],
|
|
141
155
|
resource_type: ResourceType | None = None,
|
|
142
|
-
) -> tuple[int, list[Resource]]:
|
|
156
|
+
) -> tuple[int, list[Resource[Any]]]:
|
|
143
157
|
start_index = (search_request.start_index or 1) - 1
|
|
144
158
|
|
|
145
159
|
candidates = [
|
|
@@ -151,7 +165,7 @@ class InMemoryBackend(Backend):
|
|
|
151
165
|
scim_filter = search_request.filter
|
|
152
166
|
if scim_filter is not None and not scim_filter.models and candidates:
|
|
153
167
|
models = tuple(dict.fromkeys(type(r) for r in candidates))
|
|
154
|
-
scim_filter = ScimFilter
|
|
168
|
+
scim_filter = parametrize(ScimFilter, Union[models])(str(scim_filter)) # noqa: UP007
|
|
155
169
|
|
|
156
170
|
found_resources = [
|
|
157
171
|
r for r in candidates if scim_filter is None or scim_filter.match(r)
|
|
@@ -165,16 +179,17 @@ class InMemoryBackend(Backend):
|
|
|
165
179
|
found_resources = found_resources[: search_request.count]
|
|
166
180
|
return total_results, found_resources
|
|
167
181
|
|
|
168
|
-
def _is_of_type(self, resource: Resource, resource_type: ResourceType) -> bool:
|
|
182
|
+
def _is_of_type(self, resource: Resource[Any], resource_type: ResourceType) -> bool:
|
|
169
183
|
"""Tell whether a resource belongs to a resource type.
|
|
170
184
|
|
|
171
185
|
RFC 7643 §3.1 has meta.resourceType carry the name of the resource type,
|
|
172
186
|
which may differ from its id.
|
|
173
187
|
"""
|
|
188
|
+
assert resource.meta is not None
|
|
174
189
|
return resource.meta.resource_type == resource_type.name
|
|
175
190
|
|
|
176
191
|
def _get_resource_idx(
|
|
177
|
-
self, resource_type: ResourceType, object_id: str
|
|
192
|
+
self, resource_type: ResourceType, object_id: str | None
|
|
178
193
|
) -> int | None:
|
|
179
194
|
return next(
|
|
180
195
|
(
|
|
@@ -187,7 +202,7 @@ class InMemoryBackend(Backend):
|
|
|
187
202
|
|
|
188
203
|
def get_resource(
|
|
189
204
|
self, resource_type: ResourceType, object_id: str
|
|
190
|
-
) -> Resource | None:
|
|
205
|
+
) -> Resource[Any] | None:
|
|
191
206
|
resource_dict_idx = self._get_resource_idx(resource_type, object_id)
|
|
192
207
|
if resource_dict_idx is not None:
|
|
193
208
|
return self.resources[resource_dict_idx].model_copy(deep=True)
|
|
@@ -205,11 +220,12 @@ class InMemoryBackend(Backend):
|
|
|
205
220
|
return False
|
|
206
221
|
|
|
207
222
|
def create_resource(
|
|
208
|
-
self, resource_type: ResourceType, resource: Resource
|
|
209
|
-
) -> Resource
|
|
223
|
+
self, resource_type: ResourceType, resource: Resource[Any]
|
|
224
|
+
) -> Resource[Any]:
|
|
210
225
|
resource = resource.model_copy(deep=True)
|
|
211
226
|
resource.id = uuid.uuid4().hex
|
|
212
227
|
utcnow = datetime.datetime.now(datetime.UTC)
|
|
228
|
+
assert resource_type.endpoint is not None
|
|
213
229
|
resource.meta = Meta(
|
|
214
230
|
resource_type=resource_type.name,
|
|
215
231
|
created=utcnow,
|
|
@@ -221,14 +237,14 @@ class InMemoryBackend(Backend):
|
|
|
221
237
|
self.resources.append(resource)
|
|
222
238
|
return resource
|
|
223
239
|
|
|
224
|
-
def _check_uniqueness(self, resource: Resource):
|
|
240
|
+
def _check_uniqueness(self, resource: Resource[Any]) -> None:
|
|
225
241
|
"""Refuse a resource sharing a unique value with another one of the same schema.
|
|
226
242
|
|
|
227
243
|
RFC 7643 erratum 8279 scopes the uniqueness to the resources using the
|
|
228
244
|
schema that declares the attribute, whatever their resource type. A
|
|
229
245
|
missing value never clashes, as a SQL NULL does not.
|
|
230
246
|
"""
|
|
231
|
-
unique_paths = Path
|
|
247
|
+
unique_paths = parametrize(Path, type(resource)).iter_paths(
|
|
232
248
|
include_subattributes=False,
|
|
233
249
|
uniqueness=[Uniqueness.server, Uniqueness.global_],
|
|
234
250
|
)
|
|
@@ -245,30 +261,35 @@ class InMemoryBackend(Backend):
|
|
|
245
261
|
raise UniquenessException()
|
|
246
262
|
|
|
247
263
|
@staticmethod
|
|
248
|
-
def _unique_value(resource: Resource, attribute: AttributeBinding) -> Any:
|
|
264
|
+
def _unique_value(resource: Resource[Any], attribute: AttributeBinding) -> Any:
|
|
249
265
|
"""Return the value a resource holds for a unique attribute, in the form it is compared in.
|
|
250
266
|
|
|
251
267
|
A resource whose schemas do not declare the attribute holds no value.
|
|
252
268
|
"""
|
|
253
|
-
value = Path
|
|
269
|
+
value = parametrize(Path, type(resource))(attribute.urn).get(
|
|
270
|
+
resource, strict=False
|
|
271
|
+
)
|
|
254
272
|
if isinstance(value, str) and not attribute.case_exact:
|
|
255
273
|
return value.casefold()
|
|
256
274
|
return value
|
|
257
275
|
|
|
258
276
|
@staticmethod
|
|
259
|
-
def _touch_resource(
|
|
277
|
+
def _touch_resource(
|
|
278
|
+
resource: Resource[Any], last_modified: datetime.datetime
|
|
279
|
+
) -> None:
|
|
260
280
|
"""Touches a resource (updates last_modified and version).
|
|
261
281
|
|
|
262
282
|
Version is generated by hashing last_modified. Another option
|
|
263
283
|
would be to hash the entire resource instead.
|
|
264
284
|
"""
|
|
285
|
+
assert resource.meta is not None
|
|
265
286
|
resource.meta.last_modified = last_modified
|
|
266
287
|
etag = generate_etag(pickle.dumps(resource.meta.last_modified))
|
|
267
288
|
resource.meta.version = f'W/"{etag}"'
|
|
268
289
|
|
|
269
290
|
def update_resource(
|
|
270
|
-
self, resource_type: ResourceType, resource: Resource
|
|
271
|
-
) -> Resource | None:
|
|
291
|
+
self, resource_type: ResourceType, resource: Resource[Any]
|
|
292
|
+
) -> Resource[Any] | None:
|
|
272
293
|
found_res_idx = self._get_resource_idx(resource_type, resource.id)
|
|
273
294
|
if found_res_idx is not None:
|
|
274
295
|
updated_resource = type(resource).model_validate(resource.model_dump())
|
|
@@ -9,11 +9,12 @@ from werkzeug.exceptions import Conflict
|
|
|
9
9
|
|
|
10
10
|
BULK_ID_PREFIX = "bulkId:"
|
|
11
11
|
|
|
12
|
-
Resolver = Callable[[BulkOperation], BulkOperation]
|
|
12
|
+
Resolver = Callable[[BulkOperation[Resource[Any]]], BulkOperation[Resource[Any]]]
|
|
13
13
|
"""Replaces the bulkId references of an operation."""
|
|
14
14
|
|
|
15
15
|
OperationRunner = Callable[
|
|
16
|
-
[BulkOperation
|
|
16
|
+
[BulkOperation[Resource[Any]], Resolver],
|
|
17
|
+
tuple[dict[str, Any], Resource[Any] | None],
|
|
17
18
|
]
|
|
18
19
|
"""Applies an operation once resolved, and returns its outcome and the resource it acted on."""
|
|
19
20
|
|
|
@@ -50,8 +51,8 @@ def replace_bulk_ids(value: Any, replace: Callable[[str], str]) -> Any:
|
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
def resolve_operation(
|
|
53
|
-
operation: BulkOperation, replace: Callable[[str], str]
|
|
54
|
-
) -> BulkOperation:
|
|
54
|
+
operation: BulkOperation[Resource[Any]], replace: Callable[[str], str]
|
|
55
|
+
) -> BulkOperation[Resource[Any]]:
|
|
55
56
|
"""Replace the "bulkId:" references of the path and the data of a bulk operation."""
|
|
56
57
|
updates: dict[str, Any] = {}
|
|
57
58
|
if operation.path is not None:
|
|
@@ -79,7 +80,7 @@ class BulkJob:
|
|
|
79
80
|
|
|
80
81
|
def __init__(
|
|
81
82
|
self,
|
|
82
|
-
operations: list[BulkOperation],
|
|
83
|
+
operations: list[BulkOperation[Resource[Any]]],
|
|
83
84
|
fail_on_errors: int | None,
|
|
84
85
|
run: OperationRunner,
|
|
85
86
|
):
|
|
@@ -87,7 +88,7 @@ class BulkJob:
|
|
|
87
88
|
self.operations = operations
|
|
88
89
|
self.fail_on_errors = fail_on_errors
|
|
89
90
|
self.results: dict[int, dict[str, Any]] = {}
|
|
90
|
-
self.created: dict[str, Resource] = {}
|
|
91
|
+
self.created: dict[str, Resource[Any]] = {}
|
|
91
92
|
self.running: set[int] = set()
|
|
92
93
|
self.errors = 0
|
|
93
94
|
|
|
@@ -145,7 +146,7 @@ class BulkJob:
|
|
|
145
146
|
return bulk_id is not None and self.creations.get(bulk_id) == index
|
|
146
147
|
|
|
147
148
|
@staticmethod
|
|
148
|
-
def references(operation: BulkOperation) -> list[str]:
|
|
149
|
+
def references(operation: BulkOperation[Resource[Any]]) -> list[str]:
|
|
149
150
|
"""Return the bulkIds an operation references."""
|
|
150
151
|
bulk_ids: list[str] = []
|
|
151
152
|
|
|
@@ -156,7 +157,9 @@ class BulkJob:
|
|
|
156
157
|
resolve_operation(operation, collect)
|
|
157
158
|
return bulk_ids
|
|
158
159
|
|
|
159
|
-
def resolve(
|
|
160
|
+
def resolve(
|
|
161
|
+
self, index: int, operation: BulkOperation[Resource[Any]]
|
|
162
|
+
) -> BulkOperation[Resource[Any]]:
|
|
160
163
|
"""Replace the bulkId references of an operation with the identifiers of the created resources.
|
|
161
164
|
|
|
162
165
|
:raises Conflict: When a referenced resource was not created, as
|
|
@@ -2,8 +2,12 @@ import argparse
|
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
4
|
import pprint
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
5
7
|
|
|
6
8
|
from scim2_models import AuthenticationScheme
|
|
9
|
+
from scim2_models import External
|
|
10
|
+
from scim2_models import Reference
|
|
7
11
|
from scim2_models import ResourceType
|
|
8
12
|
from scim2_models import Schema
|
|
9
13
|
from scim2_models import ScimProvider
|
|
@@ -16,25 +20,32 @@ from scim2_server.utils import load_default_resource_types
|
|
|
16
20
|
from scim2_server.utils import load_default_schemas
|
|
17
21
|
from scim2_server.utils import load_default_service_provider_config
|
|
18
22
|
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from _typeshed.wsgi import StartResponse
|
|
25
|
+
from _typeshed.wsgi import WSGIApplication
|
|
26
|
+
from _typeshed.wsgi import WSGIEnvironment
|
|
27
|
+
|
|
19
28
|
BEARER_TOKEN_SCHEME = AuthenticationScheme(
|
|
20
|
-
type=
|
|
29
|
+
type=AuthenticationScheme.Type.oauthbearertoken,
|
|
21
30
|
name="bearer_token",
|
|
22
31
|
description="HTTP Bearer Token",
|
|
23
|
-
spec_uri="https://datatracker.ietf.org/doc/html/rfc6750",
|
|
32
|
+
spec_uri=Reference[External]("https://datatracker.ietf.org/doc/html/rfc6750"),
|
|
24
33
|
)
|
|
25
34
|
|
|
26
35
|
|
|
27
|
-
def log_environ(handler):
|
|
36
|
+
def log_environ(handler: "WSGIApplication") -> "WSGIApplication":
|
|
28
37
|
"""Build a simple decorator to log all WSGI environment variables."""
|
|
29
38
|
|
|
30
|
-
def _inner(
|
|
39
|
+
def _inner(
|
|
40
|
+
environ: "WSGIEnvironment", start_fn: "StartResponse"
|
|
41
|
+
) -> Iterable[bytes]:
|
|
31
42
|
logging.getLogger("log_environ").debug(pprint.pformat(environ))
|
|
32
43
|
return handler(environ, start_fn)
|
|
33
44
|
|
|
34
45
|
return _inner
|
|
35
46
|
|
|
36
47
|
|
|
37
|
-
def main():
|
|
48
|
+
def main() -> None:
|
|
38
49
|
parser = argparse.ArgumentParser()
|
|
39
50
|
parser.add_argument(
|
|
40
51
|
"--schema", type=argparse.FileType("r"), help="Schema definitions"
|
|
@@ -71,12 +82,14 @@ def main():
|
|
|
71
82
|
|
|
72
83
|
from werkzeug.serving import run_simple
|
|
73
84
|
|
|
85
|
+
schemas: Iterable[Schema]
|
|
74
86
|
if args.schema is None:
|
|
75
87
|
schemas = load_default_schemas().values()
|
|
76
88
|
else:
|
|
77
89
|
with args.schema:
|
|
78
90
|
schemas = [Schema.model_validate(sc) for sc in json.load(args.schema)]
|
|
79
91
|
|
|
92
|
+
resource_types: Iterable[ResourceType]
|
|
80
93
|
if args.resource_type is None:
|
|
81
94
|
resource_types = load_default_resource_types().values()
|
|
82
95
|
else:
|
|
@@ -108,17 +121,21 @@ def main():
|
|
|
108
121
|
for bearer_token in args.bearer_token:
|
|
109
122
|
app.register_bearer_token(bearer_token)
|
|
110
123
|
|
|
124
|
+
wsgi_app: WSGIApplication = app
|
|
111
125
|
if args.debug:
|
|
112
|
-
|
|
126
|
+
wsgi_app = log_environ(wsgi_app)
|
|
113
127
|
if args.reverse_proxy:
|
|
114
|
-
|
|
128
|
+
wsgi_app = ProxyFix(
|
|
129
|
+
wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
|
|
130
|
+
)
|
|
115
131
|
|
|
116
132
|
run_simple(
|
|
117
133
|
args.hostname,
|
|
118
134
|
args.port,
|
|
119
|
-
|
|
135
|
+
wsgi_app,
|
|
120
136
|
use_debugger=args.debug,
|
|
121
137
|
use_reloader=args.debug,
|
|
138
|
+
threaded=True,
|
|
122
139
|
)
|
|
123
140
|
|
|
124
141
|
if args.dump_resources:
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import itertools
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
4
6
|
from typing import Any
|
|
7
|
+
from typing import TypeVar
|
|
5
8
|
from typing import Union
|
|
6
9
|
from typing import cast
|
|
7
10
|
from urllib.parse import urljoin
|
|
@@ -33,6 +36,7 @@ from werkzeug import Response
|
|
|
33
36
|
from werkzeug.datastructures import ETags
|
|
34
37
|
from werkzeug.exceptions import Forbidden
|
|
35
38
|
from werkzeug.exceptions import HTTPException
|
|
39
|
+
from werkzeug.exceptions import MethodNotAllowed
|
|
36
40
|
from werkzeug.exceptions import NotFound
|
|
37
41
|
from werkzeug.exceptions import NotImplemented as WerkzeugNotImplemented
|
|
38
42
|
from werkzeug.exceptions import PreconditionFailed
|
|
@@ -40,6 +44,7 @@ from werkzeug.exceptions import RequestEntityTooLarge
|
|
|
40
44
|
from werkzeug.exceptions import Unauthorized
|
|
41
45
|
from werkzeug.http import parse_etags
|
|
42
46
|
from werkzeug.http import unquote_etag
|
|
47
|
+
from werkzeug.routing import BaseConverter
|
|
43
48
|
from werkzeug.routing import Map
|
|
44
49
|
from werkzeug.routing import Rule
|
|
45
50
|
from werkzeug.routing.exceptions import RequestRedirect
|
|
@@ -48,6 +53,11 @@ from scim2_server.backend import Backend
|
|
|
48
53
|
from scim2_server.bulk import BulkJob
|
|
49
54
|
from scim2_server.bulk import Resolver
|
|
50
55
|
from scim2_server.utils import load_default_service_provider_config
|
|
56
|
+
from scim2_server.utils import parametrize
|
|
57
|
+
|
|
58
|
+
if TYPE_CHECKING:
|
|
59
|
+
from _typeshed.wsgi import StartResponse
|
|
60
|
+
from _typeshed.wsgi import WSGIEnvironment
|
|
51
61
|
|
|
52
62
|
SEARCH_REQUEST_PARAMETERS = (
|
|
53
63
|
"attributes",
|
|
@@ -59,6 +69,10 @@ SEARCH_REQUEST_PARAMETERS = (
|
|
|
59
69
|
"count",
|
|
60
70
|
)
|
|
61
71
|
|
|
72
|
+
DiscoveryResourceT = TypeVar(
|
|
73
|
+
"DiscoveryResourceT", ResourceType, Schema, ServiceProviderConfig
|
|
74
|
+
)
|
|
75
|
+
|
|
62
76
|
BULK_SUCCESS_STATUS = {
|
|
63
77
|
BulkOperation.Method.post: 201,
|
|
64
78
|
BulkOperation.Method.put: 200,
|
|
@@ -67,11 +81,25 @@ BULK_SUCCESS_STATUS = {
|
|
|
67
81
|
}
|
|
68
82
|
|
|
69
83
|
|
|
84
|
+
class ResourceEndpointConverter(BaseConverter):
|
|
85
|
+
"""Match a resource endpoint, but not the endpoints RFC 7644 reserves nor the version prefix.
|
|
86
|
+
|
|
87
|
+
A request with a method a reserved endpoint does not support then gets a
|
|
88
|
+
405 answer, instead of being routed to a resource type of that name.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
# A reserved name followed by the end of the path segment is refused.
|
|
92
|
+
regex = (
|
|
93
|
+
r"(?!(?:ServiceProviderConfig|ResourceTypes|Schemas|Bulk|Me|v2)(?![^/]))[^/]+"
|
|
94
|
+
)
|
|
95
|
+
part_isolating = True
|
|
96
|
+
|
|
97
|
+
|
|
70
98
|
class SCIMApplication:
|
|
71
99
|
"""A WSGI application implementing a SCIM provider (server)."""
|
|
72
100
|
|
|
73
101
|
def __init__(self, backend: Backend, provider: ScimProvider):
|
|
74
|
-
self.bearer_tokens = set()
|
|
102
|
+
self.bearer_tokens: set[str] = set()
|
|
75
103
|
self.backend = backend
|
|
76
104
|
self.provider = provider
|
|
77
105
|
self.config = provider.config or load_default_service_provider_config()
|
|
@@ -111,17 +139,17 @@ class SCIMApplication:
|
|
|
111
139
|
methods=("GET", "POST", "PUT", "PATCH", "DELETE"),
|
|
112
140
|
),
|
|
113
141
|
Rule(
|
|
114
|
-
f"{prefix}/<
|
|
142
|
+
f"{prefix}/<resource_endpoint:resource_endpoint>",
|
|
115
143
|
endpoint="resource",
|
|
116
144
|
methods=("GET", "POST"),
|
|
117
145
|
),
|
|
118
146
|
Rule(
|
|
119
|
-
f"{prefix}/<
|
|
147
|
+
f"{prefix}/<resource_endpoint:resource_endpoint>/.search",
|
|
120
148
|
endpoint="resource_search",
|
|
121
149
|
methods=("POST",),
|
|
122
150
|
),
|
|
123
151
|
Rule(
|
|
124
|
-
f"{prefix}/<
|
|
152
|
+
f"{prefix}/<resource_endpoint:resource_endpoint>/<string:resource_id>",
|
|
125
153
|
endpoint="single_resource",
|
|
126
154
|
methods=("GET", "PUT", "PATCH", "DELETE"),
|
|
127
155
|
),
|
|
@@ -136,13 +164,15 @@ class SCIMApplication:
|
|
|
136
164
|
for prefix in ("", "/v2")
|
|
137
165
|
)
|
|
138
166
|
|
|
139
|
-
self.url_map = Map(
|
|
167
|
+
self.url_map = Map(
|
|
168
|
+
rules, converters={"resource_endpoint": ResourceEndpointConverter}
|
|
169
|
+
)
|
|
140
170
|
|
|
141
|
-
def get_model(self, resource_type: ResourceType) -> type[Resource]:
|
|
171
|
+
def get_model(self, resource_type: ResourceType) -> type[Resource[Any]]:
|
|
142
172
|
"""Return the model of a resource type, its extensions included."""
|
|
143
|
-
return cast(type[Resource], self.provider.model_for(resource_type))
|
|
173
|
+
return cast(type[Resource[Any]], self.provider.model_for(resource_type))
|
|
144
174
|
|
|
145
|
-
def get_models(self) -> list[type[Resource]]:
|
|
175
|
+
def get_models(self) -> list[type[Resource[Any]]]:
|
|
146
176
|
"""Return the models of every resource type."""
|
|
147
177
|
return [self.get_model(rt) for rt in self.provider.resource_types]
|
|
148
178
|
|
|
@@ -152,7 +182,7 @@ class SCIMApplication:
|
|
|
152
182
|
(
|
|
153
183
|
resource_type
|
|
154
184
|
for resource_type in self.provider.resource_types
|
|
155
|
-
if resource_type.endpoint.lstrip("/").casefold()
|
|
185
|
+
if (resource_type.endpoint or "").lstrip("/").casefold()
|
|
156
186
|
== endpoint.lstrip("/").casefold()
|
|
157
187
|
),
|
|
158
188
|
None,
|
|
@@ -163,12 +193,13 @@ class SCIMApplication:
|
|
|
163
193
|
"""Whether the configuration declares the resources versioned with ETags."""
|
|
164
194
|
return bool(self.config.etag and self.config.etag.supported)
|
|
165
195
|
|
|
166
|
-
def publish(self, request: Request, resource: Resource) -> Resource:
|
|
196
|
+
def publish(self, request: Request, resource: Resource[Any]) -> Resource[Any]:
|
|
167
197
|
"""Return a copy of a resource in the form sent to the client.
|
|
168
198
|
|
|
169
199
|
Its location is made absolute from the URL the client requested, and
|
|
170
200
|
its version is left out when the service does not support ETags.
|
|
171
201
|
"""
|
|
202
|
+
assert resource.meta is not None
|
|
172
203
|
update: dict[str, Any] = {
|
|
173
204
|
"location": urljoin(request.url + "/", resource.meta.location)
|
|
174
205
|
}
|
|
@@ -179,13 +210,14 @@ class SCIMApplication:
|
|
|
179
210
|
)
|
|
180
211
|
|
|
181
212
|
@staticmethod
|
|
182
|
-
def etag_header(resource: Resource) -> dict[str, str]:
|
|
213
|
+
def etag_header(resource: Resource[Any]) -> dict[str, str]:
|
|
183
214
|
"""Return the ETag header of a published resource, if it has a version."""
|
|
215
|
+
assert resource.meta is not None
|
|
184
216
|
return {"ETag": resource.meta.version} if resource.meta.version else {}
|
|
185
217
|
|
|
186
218
|
def check_preconditions(
|
|
187
219
|
self,
|
|
188
|
-
resource: Resource,
|
|
220
|
+
resource: Resource[Any],
|
|
189
221
|
method: str,
|
|
190
222
|
if_match: ETags | None = None,
|
|
191
223
|
if_none_match: ETags | None = None,
|
|
@@ -199,11 +231,13 @@ class SCIMApplication:
|
|
|
199
231
|
:return: :data:`False` when a GET should answer 304 Not Modified.
|
|
200
232
|
:raises PreconditionFailed: When the method must not be performed.
|
|
201
233
|
"""
|
|
234
|
+
assert resource.meta is not None
|
|
202
235
|
# A service that does not support ETags has no tag to match: RFC 7232
|
|
203
236
|
# §3.1 fails an If-Match listing tags, and lets "*" pass.
|
|
204
237
|
version, _ = (
|
|
205
238
|
unquote_etag(resource.meta.version) if self.etag_supported else (None, None)
|
|
206
239
|
)
|
|
240
|
+
version = version or ""
|
|
207
241
|
# RFC 7232 §3.1 compares If-Match strongly, which would never match
|
|
208
242
|
# the weak ETags RFC 7644 §3.14 recommends and sends in its example.
|
|
209
243
|
if if_match and not if_match.contains_weak(version):
|
|
@@ -218,7 +252,7 @@ class SCIMApplication:
|
|
|
218
252
|
|
|
219
253
|
def get_existing_resource(
|
|
220
254
|
self, resource_type: ResourceType, resource_id: str
|
|
221
|
-
) -> Resource:
|
|
255
|
+
) -> Resource[Any]:
|
|
222
256
|
"""Return a stored resource.
|
|
223
257
|
|
|
224
258
|
:raises NotFound: When no resource of this type has this identifier.
|
|
@@ -228,7 +262,7 @@ class SCIMApplication:
|
|
|
228
262
|
raise NotFound
|
|
229
263
|
return resource
|
|
230
264
|
|
|
231
|
-
def create(self, resource_type: ResourceType, payload: Any) -> Resource:
|
|
265
|
+
def create(self, resource_type: ResourceType, payload: Any) -> Resource[Any]:
|
|
232
266
|
"""Validate a creation payload and store the new resource."""
|
|
233
267
|
resource = self.get_model(resource_type).model_validate(
|
|
234
268
|
payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST
|
|
@@ -242,7 +276,7 @@ class SCIMApplication:
|
|
|
242
276
|
payload: Any,
|
|
243
277
|
if_match: ETags | None = None,
|
|
244
278
|
if_none_match: ETags | None = None,
|
|
245
|
-
) -> Resource:
|
|
279
|
+
) -> Resource[Any]:
|
|
246
280
|
"""Replace a stored resource with a payload and return the stored result."""
|
|
247
281
|
resource = self.get_existing_resource(resource_type, resource_id)
|
|
248
282
|
self.check_preconditions(resource, "PUT", if_match, if_none_match)
|
|
@@ -253,7 +287,7 @@ class SCIMApplication:
|
|
|
253
287
|
# A PUT that changes nothing keeps meta.lastModified and the ETag.
|
|
254
288
|
if not replacement.replace(resource):
|
|
255
289
|
return resource
|
|
256
|
-
return self.
|
|
290
|
+
return self.update(resource_type, replacement)
|
|
257
291
|
|
|
258
292
|
def patch(
|
|
259
293
|
self,
|
|
@@ -262,17 +296,31 @@ class SCIMApplication:
|
|
|
262
296
|
payload: Any,
|
|
263
297
|
if_match: ETags | None = None,
|
|
264
298
|
if_none_match: ETags | None = None,
|
|
265
|
-
) -> Resource:
|
|
299
|
+
) -> Resource[Any]:
|
|
266
300
|
"""Apply a PATCH payload to a stored resource and return the stored result."""
|
|
267
301
|
self.ensure_supported(self.config.patch, "PATCH")
|
|
268
|
-
patch_operation =
|
|
302
|
+
patch_operation = parametrize(
|
|
303
|
+
PatchOp, self.get_model(resource_type)
|
|
304
|
+
).model_validate(payload)
|
|
269
305
|
resource = self.get_existing_resource(resource_type, resource_id)
|
|
270
306
|
self.check_preconditions(resource, "PATCH", if_match, if_none_match)
|
|
271
307
|
|
|
272
308
|
# A PATCH that changes nothing keeps meta.lastModified and the ETag.
|
|
273
309
|
if not patch_operation.patch(resource):
|
|
274
310
|
return resource
|
|
275
|
-
return self.
|
|
311
|
+
return self.update(resource_type, resource)
|
|
312
|
+
|
|
313
|
+
def update(
|
|
314
|
+
self, resource_type: ResourceType, resource: Resource[Any]
|
|
315
|
+
) -> Resource[Any]:
|
|
316
|
+
"""Store an updated resource and return the stored result.
|
|
317
|
+
|
|
318
|
+
:raises NotFound: When the backend no longer has the resource.
|
|
319
|
+
"""
|
|
320
|
+
updated = self.backend.update_resource(resource_type, resource)
|
|
321
|
+
if updated is None:
|
|
322
|
+
raise NotFound
|
|
323
|
+
return updated
|
|
276
324
|
|
|
277
325
|
def delete(
|
|
278
326
|
self,
|
|
@@ -287,7 +335,7 @@ class SCIMApplication:
|
|
|
287
335
|
self.backend.delete_resource(resource_type, resource_id)
|
|
288
336
|
|
|
289
337
|
def call_single_resource(
|
|
290
|
-
self, request: Request, resource_endpoint: str, resource_id: str, **kwargs
|
|
338
|
+
self, request: Request, resource_endpoint: str, resource_id: str, **kwargs: Any
|
|
291
339
|
) -> Response:
|
|
292
340
|
resource_type = self.get_resource_type_by_endpoint(resource_endpoint)
|
|
293
341
|
if not resource_type:
|
|
@@ -369,10 +417,10 @@ class SCIMApplication:
|
|
|
369
417
|
|
|
370
418
|
@staticmethod
|
|
371
419
|
def get_response_parameters(
|
|
372
|
-
request: Request, model: type[Resource]
|
|
373
|
-
) -> ResponseParameters:
|
|
420
|
+
request: Request, model: type[Resource[Any]]
|
|
421
|
+
) -> ResponseParameters[Any]:
|
|
374
422
|
"""Parse the "attributes" and "excludedAttributes" HTTP request parameters."""
|
|
375
|
-
return ResponseParameters
|
|
423
|
+
return parametrize(ResponseParameters, model).model_validate(
|
|
376
424
|
{
|
|
377
425
|
key: request.args[key]
|
|
378
426
|
for key in ("attributes", "excludedAttributes")
|
|
@@ -381,8 +429,8 @@ class SCIMApplication:
|
|
|
381
429
|
)
|
|
382
430
|
|
|
383
431
|
def build_search_request(
|
|
384
|
-
self, request: Request, models: list[type[Resource]]
|
|
385
|
-
) -> SearchRequest:
|
|
432
|
+
self, request: Request, models: list[type[Resource[Any]]]
|
|
433
|
+
) -> SearchRequest[Any]:
|
|
386
434
|
"""Construct a SearchRequest object from a werkzeug request.
|
|
387
435
|
|
|
388
436
|
:param request: werkzeug request
|
|
@@ -410,9 +458,10 @@ class SCIMApplication:
|
|
|
410
458
|
if parameters & {"sortby", "sortorder"}:
|
|
411
459
|
self.ensure_supported(self.config.sort, "Sorting")
|
|
412
460
|
|
|
413
|
-
search_request =
|
|
414
|
-
|
|
415
|
-
|
|
461
|
+
search_request = parametrize(
|
|
462
|
+
SearchRequest,
|
|
463
|
+
Union[tuple(models)], # noqa: UP007
|
|
464
|
+
).model_validate(payload, scim_ctx=Context.SEARCH_REQUEST)
|
|
416
465
|
search_request.start_index = search_request.start_index or 1
|
|
417
466
|
max_results = self.config.filter.max_results if self.config.filter else None
|
|
418
467
|
if max_results is not None and (
|
|
@@ -421,7 +470,9 @@ class SCIMApplication:
|
|
|
421
470
|
search_request.count = max_results
|
|
422
471
|
return search_request
|
|
423
472
|
|
|
424
|
-
def query_resource(
|
|
473
|
+
def query_resource(
|
|
474
|
+
self, request: Request, resource: ResourceType | None
|
|
475
|
+
) -> ListResponse[Resource[Any]]:
|
|
425
476
|
models = self.get_models() if resource is None else [self.get_model(resource)]
|
|
426
477
|
search_request = self.build_search_request(request, models)
|
|
427
478
|
|
|
@@ -438,7 +489,7 @@ class SCIMApplication:
|
|
|
438
489
|
for s in results
|
|
439
490
|
]
|
|
440
491
|
|
|
441
|
-
return ListResponse
|
|
492
|
+
return parametrize(ListResponse, Union[tuple(self.get_models())])( # noqa: UP007
|
|
442
493
|
total_results=total_results,
|
|
443
494
|
items_per_page=len(resources),
|
|
444
495
|
start_index=search_request.start_index,
|
|
@@ -446,7 +497,7 @@ class SCIMApplication:
|
|
|
446
497
|
)
|
|
447
498
|
|
|
448
499
|
def call_resource(
|
|
449
|
-
self, request: Request, resource_endpoint: str, **kwargs
|
|
500
|
+
self, request: Request, resource_endpoint: str, **kwargs: Any
|
|
450
501
|
) -> Response:
|
|
451
502
|
resource_type = self.get_resource_type_by_endpoint(resource_endpoint)
|
|
452
503
|
if not resource_type:
|
|
@@ -463,6 +514,7 @@ class SCIMApplication:
|
|
|
463
514
|
created_resource = self.publish(
|
|
464
515
|
request, self.create(resource_type, request.json)
|
|
465
516
|
)
|
|
517
|
+
assert created_resource.meta is not None
|
|
466
518
|
return self.make_response(
|
|
467
519
|
created_resource.model_dump(
|
|
468
520
|
scim_ctx=Context.RESOURCE_CREATION_RESPONSE
|
|
@@ -471,7 +523,7 @@ class SCIMApplication:
|
|
|
471
523
|
headers={"Location": created_resource.meta.location},
|
|
472
524
|
)
|
|
473
525
|
|
|
474
|
-
def call_query_all(self, request: Request, **kwargs) -> Response:
|
|
526
|
+
def call_query_all(self, request: Request, **kwargs: Any) -> Response:
|
|
475
527
|
return self.make_response(
|
|
476
528
|
self.query_resource(request, None).model_dump(
|
|
477
529
|
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
|
|
@@ -479,7 +531,7 @@ class SCIMApplication:
|
|
|
479
531
|
)
|
|
480
532
|
|
|
481
533
|
def call_resource_search(
|
|
482
|
-
self, request: Request, resource_endpoint: str, **kwargs
|
|
534
|
+
self, request: Request, resource_endpoint: str, **kwargs: Any
|
|
483
535
|
) -> Response:
|
|
484
536
|
resource_type = self.get_resource_type_by_endpoint(resource_endpoint)
|
|
485
537
|
if not resource_type:
|
|
@@ -493,7 +545,7 @@ class SCIMApplication:
|
|
|
493
545
|
@staticmethod
|
|
494
546
|
def ensure_supported(
|
|
495
547
|
capability: Patch | Bulk | Filter | Sort | None, operation: str
|
|
496
|
-
):
|
|
548
|
+
) -> None:
|
|
497
549
|
"""Refuse with a 501 an operation the configuration does not declare supported.
|
|
498
550
|
|
|
499
551
|
RFC 7644 §3.12 answers 501 when the service provider does not support
|
|
@@ -502,23 +554,38 @@ class SCIMApplication:
|
|
|
502
554
|
if capability is None or not capability.supported:
|
|
503
555
|
raise WerkzeugNotImplemented(f"{operation} is not supported")
|
|
504
556
|
|
|
505
|
-
|
|
557
|
+
@staticmethod
|
|
558
|
+
def ensure_payload_size(request: Request, max_payload_size: int) -> None:
|
|
559
|
+
"""Refuse with a 413 a payload larger than max_payload_size, without reading more than that.
|
|
560
|
+
|
|
561
|
+
:raises RequestEntityTooLarge: When the payload is too large.
|
|
562
|
+
"""
|
|
563
|
+
# Werkzeug refuses a Content-Length above the limit, but silently cuts a
|
|
564
|
+
# streamed body at the limit. One extra byte tells a cut body apart.
|
|
565
|
+
request.max_content_length = max_payload_size + 1
|
|
566
|
+
try:
|
|
567
|
+
too_large = len(request.get_data()) > max_payload_size
|
|
568
|
+
except RequestEntityTooLarge:
|
|
569
|
+
too_large = True
|
|
570
|
+
|
|
571
|
+
if too_large:
|
|
572
|
+
raise RequestEntityTooLarge(
|
|
573
|
+
f"The payload exceeds the maxPayloadSize ({max_payload_size} bytes)"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def call_bulk(self, request: Request, **kwargs: Any) -> Response:
|
|
506
577
|
"""Implement the /Bulk endpoint (RFC 7644 §3.7)."""
|
|
507
578
|
self.ensure_supported(self.config.bulk, "Bulk")
|
|
508
579
|
bulk = cast(Bulk, self.config.bulk)
|
|
509
580
|
|
|
510
|
-
if
|
|
511
|
-
bulk.max_payload_size
|
|
512
|
-
and len(request.get_data()) > bulk.max_payload_size
|
|
513
|
-
):
|
|
514
|
-
raise RequestEntityTooLarge(
|
|
515
|
-
f"The payload exceeds the maxPayloadSize ({bulk.max_payload_size} bytes)"
|
|
516
|
-
)
|
|
581
|
+
if bulk.max_payload_size is not None:
|
|
582
|
+
self.ensure_payload_size(request, bulk.max_payload_size)
|
|
517
583
|
|
|
518
|
-
bulk_request =
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
584
|
+
bulk_request = parametrize(
|
|
585
|
+
BulkRequest,
|
|
586
|
+
Union[tuple(self.get_models())], # noqa: UP007
|
|
587
|
+
).model_validate(request.json, scim_ctx=Context.BULK_REQUEST)
|
|
588
|
+
operations = cast(list[BulkOperation[Resource[Any]]], bulk_request.operations)
|
|
522
589
|
if bulk.max_operations is not None and len(operations) > bulk.max_operations:
|
|
523
590
|
raise RequestEntityTooLarge(
|
|
524
591
|
f"The number of operations exceeds the maxOperations ({bulk.max_operations})"
|
|
@@ -532,14 +599,17 @@ class SCIMApplication:
|
|
|
532
599
|
),
|
|
533
600
|
).run()
|
|
534
601
|
return self.make_response(
|
|
535
|
-
BulkResponse
|
|
536
|
-
|
|
537
|
-
|
|
602
|
+
parametrize(BulkResponse, Union[tuple(self.get_models())]) # noqa: UP007
|
|
603
|
+
.model_validate({"operations": results})
|
|
604
|
+
.model_dump(scim_ctx=Context.BULK_RESPONSE)
|
|
538
605
|
)
|
|
539
606
|
|
|
540
607
|
def run_bulk_operation(
|
|
541
|
-
self,
|
|
542
|
-
|
|
608
|
+
self,
|
|
609
|
+
request: Request,
|
|
610
|
+
operation: BulkOperation[Resource[Any]],
|
|
611
|
+
resolve: Resolver,
|
|
612
|
+
) -> tuple[dict[str, Any], Resource[Any] | None]:
|
|
543
613
|
"""Apply one operation of a bulk job.
|
|
544
614
|
|
|
545
615
|
An operation that failed its validation keeps its error, once its
|
|
@@ -556,6 +626,7 @@ class SCIMApplication:
|
|
|
556
626
|
operation = resolve(operation)
|
|
557
627
|
resource_type = self.get_resource_type_by_endpoint(operation.endpoint or "")
|
|
558
628
|
if resource_type is not None and operation.resource_id:
|
|
629
|
+
assert resource_type.endpoint is not None
|
|
559
630
|
result["location"] = urljoin(
|
|
560
631
|
request.url,
|
|
561
632
|
f"{resource_type.endpoint.strip('/')}/{operation.resource_id}",
|
|
@@ -573,18 +644,20 @@ class SCIMApplication:
|
|
|
573
644
|
error = self.error_from(exception)
|
|
574
645
|
return {**result, "status": error.status, "response": error}, None
|
|
575
646
|
|
|
647
|
+
assert operation.method is not None
|
|
576
648
|
result["status"] = BULK_SUCCESS_STATUS[operation.method]
|
|
577
649
|
if resource is None:
|
|
578
650
|
return result, None
|
|
579
651
|
|
|
580
652
|
resource = self.publish(request, resource)
|
|
653
|
+
assert resource.meta is not None
|
|
581
654
|
result["location"] = resource.meta.location
|
|
582
655
|
result["version"] = resource.meta.version
|
|
583
656
|
return result, resource
|
|
584
657
|
|
|
585
658
|
def apply_bulk_operation(
|
|
586
|
-
self, resource_type: ResourceType, operation: BulkOperation
|
|
587
|
-
) -> Resource | None:
|
|
659
|
+
self, resource_type: ResourceType, operation: BulkOperation[Resource[Any]]
|
|
660
|
+
) -> Resource[Any] | None:
|
|
588
661
|
"""Apply a validated bulk operation, and return the resource it acted on.
|
|
589
662
|
|
|
590
663
|
The data of the operation is already validated, and the resource
|
|
@@ -596,10 +669,11 @@ class SCIMApplication:
|
|
|
596
669
|
detail="A POST path must target a resource type endpoint, other methods a resource"
|
|
597
670
|
)
|
|
598
671
|
|
|
672
|
+
if operation.method == BulkOperation.Method.post or resource_id is None:
|
|
673
|
+
return self.create(resource_type, operation.data)
|
|
674
|
+
|
|
599
675
|
if_match = parse_etags(operation.version) if operation.version else None
|
|
600
676
|
match operation.method:
|
|
601
|
-
case BulkOperation.Method.post:
|
|
602
|
-
return self.create(resource_type, operation.data)
|
|
603
677
|
case BulkOperation.Method.put:
|
|
604
678
|
return self.replace(
|
|
605
679
|
resource_type, resource_id, operation.data, if_match
|
|
@@ -610,7 +684,7 @@ class SCIMApplication:
|
|
|
610
684
|
self.delete(resource_type, resource_id, if_match)
|
|
611
685
|
return None
|
|
612
686
|
|
|
613
|
-
def call_me(self, request: Request, **kwargs):
|
|
687
|
+
def call_me(self, request: Request, **kwargs: Any) -> Response:
|
|
614
688
|
"""Implement the /Me endpoint.
|
|
615
689
|
|
|
616
690
|
RFC 7644, Section 3.11 allows raising a 501 (Not Implemented) if
|
|
@@ -618,14 +692,14 @@ class SCIMApplication:
|
|
|
618
692
|
"""
|
|
619
693
|
raise WerkzeugNotImplemented
|
|
620
694
|
|
|
621
|
-
def register_bearer_token(self, token: str):
|
|
695
|
+
def register_bearer_token(self, token: str) -> None:
|
|
622
696
|
"""Register a static bearer token for authentication.
|
|
623
697
|
|
|
624
698
|
:param token: Bearer token
|
|
625
699
|
"""
|
|
626
700
|
self.bearer_tokens.add(token)
|
|
627
701
|
|
|
628
|
-
def check_auth(self, request: Request):
|
|
702
|
+
def check_auth(self, request: Request) -> None:
|
|
629
703
|
"""Check the authorization headers."""
|
|
630
704
|
if not self.bearer_tokens:
|
|
631
705
|
return
|
|
@@ -636,7 +710,7 @@ class SCIMApplication:
|
|
|
636
710
|
raise Unauthorized
|
|
637
711
|
|
|
638
712
|
@staticmethod
|
|
639
|
-
def make_response(content, status=200, **kwargs) -> Response:
|
|
713
|
+
def make_response(content: Any, status: int = 200, **kwargs: Any) -> Response:
|
|
640
714
|
"""Construct a werkzeug response from any JSON-serializable content."""
|
|
641
715
|
etag = None
|
|
642
716
|
if content is not None:
|
|
@@ -667,17 +741,17 @@ class SCIMApplication:
|
|
|
667
741
|
case _:
|
|
668
742
|
return Error(status=500, detail="Internal server error")
|
|
669
743
|
|
|
670
|
-
def make_error(self, error: Error):
|
|
744
|
+
def make_error(self, error: Error) -> Response:
|
|
671
745
|
"""Construct a werkzeug response from a SCIM Error."""
|
|
672
|
-
return self.make_response(error.model_dump(), status=int(error.status))
|
|
746
|
+
return self.make_response(error.model_dump(), status=int(error.status or 500))
|
|
673
747
|
|
|
674
748
|
@staticmethod
|
|
675
|
-
def forbid_filter(request: Request):
|
|
749
|
+
def forbid_filter(request: Request) -> None:
|
|
676
750
|
"""RFC 7644, Section 4: "If a "filter" is provided, the service provider SHOULD respond with HTTP status code 403 (Forbidden)"."""
|
|
677
751
|
if "filter" in request.args:
|
|
678
752
|
raise Forbidden
|
|
679
753
|
|
|
680
|
-
def call_service_provider_config(self, request: Request, **kwargs):
|
|
754
|
+
def call_service_provider_config(self, request: Request, **kwargs: Any) -> Response:
|
|
681
755
|
"""Return the ServiceProviderConfig."""
|
|
682
756
|
self.forbid_filter(request)
|
|
683
757
|
return self.make_response(
|
|
@@ -685,12 +759,14 @@ class SCIMApplication:
|
|
|
685
759
|
)
|
|
686
760
|
|
|
687
761
|
@staticmethod
|
|
688
|
-
def locate(resource:
|
|
762
|
+
def locate(resource: DiscoveryResourceT, location: str) -> DiscoveryResourceT:
|
|
689
763
|
"""Return a copy of a discovery resource carrying its meta."""
|
|
690
764
|
meta = Meta(resource_type=type(resource).__name__, location=location)
|
|
691
765
|
return resource.model_copy(update={"meta": meta})
|
|
692
766
|
|
|
693
|
-
def call_resource_type(
|
|
767
|
+
def call_resource_type(
|
|
768
|
+
self, request: Request, resource_type: str, **kwargs: Any
|
|
769
|
+
) -> Response:
|
|
694
770
|
"""Return a single resource type."""
|
|
695
771
|
self.forbid_filter(request)
|
|
696
772
|
for res in self.provider.resource_types:
|
|
@@ -700,7 +776,7 @@ class SCIMApplication:
|
|
|
700
776
|
)
|
|
701
777
|
raise NotFound
|
|
702
778
|
|
|
703
|
-
def call_schema(self, request: Request, schema_id: str):
|
|
779
|
+
def call_schema(self, request: Request, schema_id: str) -> Response:
|
|
704
780
|
"""Return a single schema."""
|
|
705
781
|
self.forbid_filter(request)
|
|
706
782
|
for res in self.provider.schemas:
|
|
@@ -710,7 +786,7 @@ class SCIMApplication:
|
|
|
710
786
|
)
|
|
711
787
|
raise NotFound
|
|
712
788
|
|
|
713
|
-
def call_resource_types(self, request: Request, **kwargs):
|
|
789
|
+
def call_resource_types(self, request: Request, **kwargs: Any) -> Response:
|
|
714
790
|
"""Return a ListResponse of all known resource types."""
|
|
715
791
|
self.forbid_filter(request)
|
|
716
792
|
results = self.provider.resource_types
|
|
@@ -722,7 +798,7 @@ class SCIMApplication:
|
|
|
722
798
|
).model_dump()
|
|
723
799
|
return self.make_response(resp)
|
|
724
800
|
|
|
725
|
-
def call_schemas(self, request: Request, **kwargs):
|
|
801
|
+
def call_schemas(self, request: Request, **kwargs: Any) -> Response:
|
|
726
802
|
"""Return a ListResponse of all known schemas."""
|
|
727
803
|
self.forbid_filter(request)
|
|
728
804
|
results = self.provider.schemas
|
|
@@ -734,7 +810,7 @@ class SCIMApplication:
|
|
|
734
810
|
).model_dump()
|
|
735
811
|
return self.make_response(resp)
|
|
736
812
|
|
|
737
|
-
def wsgi_app(self, request: Request, environ):
|
|
813
|
+
def wsgi_app(self, request: Request, environ: "WSGIEnvironment") -> Response:
|
|
738
814
|
try:
|
|
739
815
|
urls = self.url_map.bind_to_environ(environ)
|
|
740
816
|
endpoint, args = urls.match()
|
|
@@ -746,16 +822,22 @@ class SCIMApplication:
|
|
|
746
822
|
# Wrap the entire call in a transaction. Should probably be optimized (use transaction only when necessary).
|
|
747
823
|
# The provider makes its policy the one every payload is read under.
|
|
748
824
|
with self.provider, self.backend:
|
|
749
|
-
response = getattr(self, f"call_{endpoint}")(request, **args)
|
|
825
|
+
response: Response = getattr(self, f"call_{endpoint}")(request, **args)
|
|
750
826
|
return response
|
|
751
827
|
except RequestRedirect as e:
|
|
752
828
|
# urls.match may cause a redirect, handle it as a special case of HTTPException
|
|
753
829
|
self.log.exception(e)
|
|
754
830
|
return e.get_response(environ)
|
|
755
831
|
except Exception as e:
|
|
756
|
-
|
|
832
|
+
response = self.make_error(self.error_from(e))
|
|
833
|
+
if isinstance(e, MethodNotAllowed) and e.valid_methods:
|
|
834
|
+
# RFC 9110 §15.5.6: a 405 answer lists the supported methods.
|
|
835
|
+
response.headers["Allow"] = ", ".join(sorted(e.valid_methods))
|
|
836
|
+
return response
|
|
757
837
|
|
|
758
|
-
def __call__(
|
|
838
|
+
def __call__(
|
|
839
|
+
self, environ: "WSGIEnvironment", start_response: "StartResponse"
|
|
840
|
+
) -> Iterable[bytes]:
|
|
759
841
|
"""Return the actual WSGI server implementation."""
|
|
760
842
|
if environ.get("PATH_INFO", "").endswith(".scim"):
|
|
761
843
|
# RFC 7644, Section 3.8
|
|
File without changes
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import importlib.resources
|
|
2
2
|
import json
|
|
3
3
|
from typing import Any
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
from typing import cast
|
|
4
6
|
|
|
5
7
|
from scim2_models import Resource
|
|
6
8
|
from scim2_models import ResourceType
|
|
@@ -8,20 +10,29 @@ from scim2_models import Schema
|
|
|
8
10
|
from scim2_models import ScimProvider
|
|
9
11
|
from scim2_models import ServiceProviderConfig
|
|
10
12
|
|
|
13
|
+
GenericT = TypeVar("GenericT")
|
|
14
|
+
ResourceT = TypeVar("ResourceT", bound=Resource[Any])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parametrize(generic: type[GenericT], parameter: Any) -> type[GenericT]:
|
|
18
|
+
"""Parametrize a generic class with a type only known at runtime."""
|
|
19
|
+
return cast("type[GenericT]", cast(Any, generic)[parameter])
|
|
20
|
+
|
|
11
21
|
|
|
12
22
|
def load_json_resource(json_name: str) -> Any:
|
|
13
23
|
"""Load a JSON document from the scim2_server package resources."""
|
|
14
24
|
fp = importlib.resources.files("scim2_server") / "resources" / json_name
|
|
15
|
-
with open(
|
|
25
|
+
with fp.open() as f:
|
|
16
26
|
return json.load(f)
|
|
17
27
|
|
|
18
28
|
|
|
19
|
-
def load_scim_resource(json_name: str, type_: type[
|
|
29
|
+
def load_scim_resource(json_name: str, type_: type[ResourceT]) -> dict[str, ResourceT]:
|
|
20
30
|
"""Load and validates a JSON document from the scim2_server package resources."""
|
|
21
31
|
ret = {}
|
|
22
32
|
definitions = load_json_resource(json_name)
|
|
23
33
|
for d in definitions:
|
|
24
34
|
model = type_.model_validate(d)
|
|
35
|
+
assert model.id is not None
|
|
25
36
|
ret[model.id] = model
|
|
26
37
|
return ret
|
|
27
38
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scim2_server-0.3.1 → scim2_server-0.3.3}/scim2_server/resources/default-resource-types.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|