scim2-server 0.3.1__tar.gz → 0.3.2__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.2}/PKG-INFO +1 -1
- {scim2_server-0.3.1 → scim2_server-0.3.2}/pyproject.toml +12 -1
- {scim2_server-0.3.1 → scim2_server-0.3.2}/pyproject.toml.orig +14 -1
- {scim2_server-0.3.1 → scim2_server-0.3.2}/scim2_server/backend.py +52 -31
- {scim2_server-0.3.1 → scim2_server-0.3.2}/scim2_server/bulk.py +11 -8
- {scim2_server-0.3.1 → scim2_server-0.3.2}/scim2_server/cli.py +25 -8
- {scim2_server-0.3.1 → scim2_server-0.3.2}/scim2_server/provider.py +127 -67
- scim2_server-0.3.2/scim2_server/py.typed +0 -0
- {scim2_server-0.3.1 → scim2_server-0.3.2}/scim2_server/utils.py +13 -2
- {scim2_server-0.3.1 → scim2_server-0.3.2}/LICENSE +0 -0
- {scim2_server-0.3.1 → scim2_server-0.3.2}/README.md +0 -0
- {scim2_server-0.3.1 → scim2_server-0.3.2}/scim2_server/__init__.py +0 -0
- {scim2_server-0.3.1 → scim2_server-0.3.2}/scim2_server/resources/default-resource-types.json +0 -0
- {scim2_server-0.3.1 → scim2_server-0.3.2}/scim2_server/resources/default-schemas.json +0 -0
- {scim2_server-0.3.1 → scim2_server-0.3.2}/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.2"
|
|
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.2"
|
|
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
|
|
@@ -48,6 +51,11 @@ from scim2_server.backend import Backend
|
|
|
48
51
|
from scim2_server.bulk import BulkJob
|
|
49
52
|
from scim2_server.bulk import Resolver
|
|
50
53
|
from scim2_server.utils import load_default_service_provider_config
|
|
54
|
+
from scim2_server.utils import parametrize
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
from _typeshed.wsgi import StartResponse
|
|
58
|
+
from _typeshed.wsgi import WSGIEnvironment
|
|
51
59
|
|
|
52
60
|
SEARCH_REQUEST_PARAMETERS = (
|
|
53
61
|
"attributes",
|
|
@@ -59,6 +67,10 @@ SEARCH_REQUEST_PARAMETERS = (
|
|
|
59
67
|
"count",
|
|
60
68
|
)
|
|
61
69
|
|
|
70
|
+
DiscoveryResourceT = TypeVar(
|
|
71
|
+
"DiscoveryResourceT", ResourceType, Schema, ServiceProviderConfig
|
|
72
|
+
)
|
|
73
|
+
|
|
62
74
|
BULK_SUCCESS_STATUS = {
|
|
63
75
|
BulkOperation.Method.post: 201,
|
|
64
76
|
BulkOperation.Method.put: 200,
|
|
@@ -71,7 +83,7 @@ class SCIMApplication:
|
|
|
71
83
|
"""A WSGI application implementing a SCIM provider (server)."""
|
|
72
84
|
|
|
73
85
|
def __init__(self, backend: Backend, provider: ScimProvider):
|
|
74
|
-
self.bearer_tokens = set()
|
|
86
|
+
self.bearer_tokens: set[str] = set()
|
|
75
87
|
self.backend = backend
|
|
76
88
|
self.provider = provider
|
|
77
89
|
self.config = provider.config or load_default_service_provider_config()
|
|
@@ -138,11 +150,11 @@ class SCIMApplication:
|
|
|
138
150
|
|
|
139
151
|
self.url_map = Map(rules)
|
|
140
152
|
|
|
141
|
-
def get_model(self, resource_type: ResourceType) -> type[Resource]:
|
|
153
|
+
def get_model(self, resource_type: ResourceType) -> type[Resource[Any]]:
|
|
142
154
|
"""Return the model of a resource type, its extensions included."""
|
|
143
|
-
return cast(type[Resource], self.provider.model_for(resource_type))
|
|
155
|
+
return cast(type[Resource[Any]], self.provider.model_for(resource_type))
|
|
144
156
|
|
|
145
|
-
def get_models(self) -> list[type[Resource]]:
|
|
157
|
+
def get_models(self) -> list[type[Resource[Any]]]:
|
|
146
158
|
"""Return the models of every resource type."""
|
|
147
159
|
return [self.get_model(rt) for rt in self.provider.resource_types]
|
|
148
160
|
|
|
@@ -152,7 +164,7 @@ class SCIMApplication:
|
|
|
152
164
|
(
|
|
153
165
|
resource_type
|
|
154
166
|
for resource_type in self.provider.resource_types
|
|
155
|
-
if resource_type.endpoint.lstrip("/").casefold()
|
|
167
|
+
if (resource_type.endpoint or "").lstrip("/").casefold()
|
|
156
168
|
== endpoint.lstrip("/").casefold()
|
|
157
169
|
),
|
|
158
170
|
None,
|
|
@@ -163,12 +175,13 @@ class SCIMApplication:
|
|
|
163
175
|
"""Whether the configuration declares the resources versioned with ETags."""
|
|
164
176
|
return bool(self.config.etag and self.config.etag.supported)
|
|
165
177
|
|
|
166
|
-
def publish(self, request: Request, resource: Resource) -> Resource:
|
|
178
|
+
def publish(self, request: Request, resource: Resource[Any]) -> Resource[Any]:
|
|
167
179
|
"""Return a copy of a resource in the form sent to the client.
|
|
168
180
|
|
|
169
181
|
Its location is made absolute from the URL the client requested, and
|
|
170
182
|
its version is left out when the service does not support ETags.
|
|
171
183
|
"""
|
|
184
|
+
assert resource.meta is not None
|
|
172
185
|
update: dict[str, Any] = {
|
|
173
186
|
"location": urljoin(request.url + "/", resource.meta.location)
|
|
174
187
|
}
|
|
@@ -179,13 +192,14 @@ class SCIMApplication:
|
|
|
179
192
|
)
|
|
180
193
|
|
|
181
194
|
@staticmethod
|
|
182
|
-
def etag_header(resource: Resource) -> dict[str, str]:
|
|
195
|
+
def etag_header(resource: Resource[Any]) -> dict[str, str]:
|
|
183
196
|
"""Return the ETag header of a published resource, if it has a version."""
|
|
197
|
+
assert resource.meta is not None
|
|
184
198
|
return {"ETag": resource.meta.version} if resource.meta.version else {}
|
|
185
199
|
|
|
186
200
|
def check_preconditions(
|
|
187
201
|
self,
|
|
188
|
-
resource: Resource,
|
|
202
|
+
resource: Resource[Any],
|
|
189
203
|
method: str,
|
|
190
204
|
if_match: ETags | None = None,
|
|
191
205
|
if_none_match: ETags | None = None,
|
|
@@ -199,11 +213,13 @@ class SCIMApplication:
|
|
|
199
213
|
:return: :data:`False` when a GET should answer 304 Not Modified.
|
|
200
214
|
:raises PreconditionFailed: When the method must not be performed.
|
|
201
215
|
"""
|
|
216
|
+
assert resource.meta is not None
|
|
202
217
|
# A service that does not support ETags has no tag to match: RFC 7232
|
|
203
218
|
# §3.1 fails an If-Match listing tags, and lets "*" pass.
|
|
204
219
|
version, _ = (
|
|
205
220
|
unquote_etag(resource.meta.version) if self.etag_supported else (None, None)
|
|
206
221
|
)
|
|
222
|
+
version = version or ""
|
|
207
223
|
# RFC 7232 §3.1 compares If-Match strongly, which would never match
|
|
208
224
|
# the weak ETags RFC 7644 §3.14 recommends and sends in its example.
|
|
209
225
|
if if_match and not if_match.contains_weak(version):
|
|
@@ -218,7 +234,7 @@ class SCIMApplication:
|
|
|
218
234
|
|
|
219
235
|
def get_existing_resource(
|
|
220
236
|
self, resource_type: ResourceType, resource_id: str
|
|
221
|
-
) -> Resource:
|
|
237
|
+
) -> Resource[Any]:
|
|
222
238
|
"""Return a stored resource.
|
|
223
239
|
|
|
224
240
|
:raises NotFound: When no resource of this type has this identifier.
|
|
@@ -228,7 +244,7 @@ class SCIMApplication:
|
|
|
228
244
|
raise NotFound
|
|
229
245
|
return resource
|
|
230
246
|
|
|
231
|
-
def create(self, resource_type: ResourceType, payload: Any) -> Resource:
|
|
247
|
+
def create(self, resource_type: ResourceType, payload: Any) -> Resource[Any]:
|
|
232
248
|
"""Validate a creation payload and store the new resource."""
|
|
233
249
|
resource = self.get_model(resource_type).model_validate(
|
|
234
250
|
payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST
|
|
@@ -242,7 +258,7 @@ class SCIMApplication:
|
|
|
242
258
|
payload: Any,
|
|
243
259
|
if_match: ETags | None = None,
|
|
244
260
|
if_none_match: ETags | None = None,
|
|
245
|
-
) -> Resource:
|
|
261
|
+
) -> Resource[Any]:
|
|
246
262
|
"""Replace a stored resource with a payload and return the stored result."""
|
|
247
263
|
resource = self.get_existing_resource(resource_type, resource_id)
|
|
248
264
|
self.check_preconditions(resource, "PUT", if_match, if_none_match)
|
|
@@ -253,7 +269,7 @@ class SCIMApplication:
|
|
|
253
269
|
# A PUT that changes nothing keeps meta.lastModified and the ETag.
|
|
254
270
|
if not replacement.replace(resource):
|
|
255
271
|
return resource
|
|
256
|
-
return self.
|
|
272
|
+
return self.update(resource_type, replacement)
|
|
257
273
|
|
|
258
274
|
def patch(
|
|
259
275
|
self,
|
|
@@ -262,17 +278,31 @@ class SCIMApplication:
|
|
|
262
278
|
payload: Any,
|
|
263
279
|
if_match: ETags | None = None,
|
|
264
280
|
if_none_match: ETags | None = None,
|
|
265
|
-
) -> Resource:
|
|
281
|
+
) -> Resource[Any]:
|
|
266
282
|
"""Apply a PATCH payload to a stored resource and return the stored result."""
|
|
267
283
|
self.ensure_supported(self.config.patch, "PATCH")
|
|
268
|
-
patch_operation =
|
|
284
|
+
patch_operation = parametrize(
|
|
285
|
+
PatchOp, self.get_model(resource_type)
|
|
286
|
+
).model_validate(payload)
|
|
269
287
|
resource = self.get_existing_resource(resource_type, resource_id)
|
|
270
288
|
self.check_preconditions(resource, "PATCH", if_match, if_none_match)
|
|
271
289
|
|
|
272
290
|
# A PATCH that changes nothing keeps meta.lastModified and the ETag.
|
|
273
291
|
if not patch_operation.patch(resource):
|
|
274
292
|
return resource
|
|
275
|
-
return self.
|
|
293
|
+
return self.update(resource_type, resource)
|
|
294
|
+
|
|
295
|
+
def update(
|
|
296
|
+
self, resource_type: ResourceType, resource: Resource[Any]
|
|
297
|
+
) -> Resource[Any]:
|
|
298
|
+
"""Store an updated resource and return the stored result.
|
|
299
|
+
|
|
300
|
+
:raises NotFound: When the backend no longer has the resource.
|
|
301
|
+
"""
|
|
302
|
+
updated = self.backend.update_resource(resource_type, resource)
|
|
303
|
+
if updated is None:
|
|
304
|
+
raise NotFound
|
|
305
|
+
return updated
|
|
276
306
|
|
|
277
307
|
def delete(
|
|
278
308
|
self,
|
|
@@ -287,7 +317,7 @@ class SCIMApplication:
|
|
|
287
317
|
self.backend.delete_resource(resource_type, resource_id)
|
|
288
318
|
|
|
289
319
|
def call_single_resource(
|
|
290
|
-
self, request: Request, resource_endpoint: str, resource_id: str, **kwargs
|
|
320
|
+
self, request: Request, resource_endpoint: str, resource_id: str, **kwargs: Any
|
|
291
321
|
) -> Response:
|
|
292
322
|
resource_type = self.get_resource_type_by_endpoint(resource_endpoint)
|
|
293
323
|
if not resource_type:
|
|
@@ -369,10 +399,10 @@ class SCIMApplication:
|
|
|
369
399
|
|
|
370
400
|
@staticmethod
|
|
371
401
|
def get_response_parameters(
|
|
372
|
-
request: Request, model: type[Resource]
|
|
373
|
-
) -> ResponseParameters:
|
|
402
|
+
request: Request, model: type[Resource[Any]]
|
|
403
|
+
) -> ResponseParameters[Any]:
|
|
374
404
|
"""Parse the "attributes" and "excludedAttributes" HTTP request parameters."""
|
|
375
|
-
return ResponseParameters
|
|
405
|
+
return parametrize(ResponseParameters, model).model_validate(
|
|
376
406
|
{
|
|
377
407
|
key: request.args[key]
|
|
378
408
|
for key in ("attributes", "excludedAttributes")
|
|
@@ -381,8 +411,8 @@ class SCIMApplication:
|
|
|
381
411
|
)
|
|
382
412
|
|
|
383
413
|
def build_search_request(
|
|
384
|
-
self, request: Request, models: list[type[Resource]]
|
|
385
|
-
) -> SearchRequest:
|
|
414
|
+
self, request: Request, models: list[type[Resource[Any]]]
|
|
415
|
+
) -> SearchRequest[Any]:
|
|
386
416
|
"""Construct a SearchRequest object from a werkzeug request.
|
|
387
417
|
|
|
388
418
|
:param request: werkzeug request
|
|
@@ -410,9 +440,10 @@ class SCIMApplication:
|
|
|
410
440
|
if parameters & {"sortby", "sortorder"}:
|
|
411
441
|
self.ensure_supported(self.config.sort, "Sorting")
|
|
412
442
|
|
|
413
|
-
search_request =
|
|
414
|
-
|
|
415
|
-
|
|
443
|
+
search_request = parametrize(
|
|
444
|
+
SearchRequest,
|
|
445
|
+
Union[tuple(models)], # noqa: UP007
|
|
446
|
+
).model_validate(payload, scim_ctx=Context.SEARCH_REQUEST)
|
|
416
447
|
search_request.start_index = search_request.start_index or 1
|
|
417
448
|
max_results = self.config.filter.max_results if self.config.filter else None
|
|
418
449
|
if max_results is not None and (
|
|
@@ -421,7 +452,9 @@ class SCIMApplication:
|
|
|
421
452
|
search_request.count = max_results
|
|
422
453
|
return search_request
|
|
423
454
|
|
|
424
|
-
def query_resource(
|
|
455
|
+
def query_resource(
|
|
456
|
+
self, request: Request, resource: ResourceType | None
|
|
457
|
+
) -> ListResponse[Resource[Any]]:
|
|
425
458
|
models = self.get_models() if resource is None else [self.get_model(resource)]
|
|
426
459
|
search_request = self.build_search_request(request, models)
|
|
427
460
|
|
|
@@ -438,7 +471,7 @@ class SCIMApplication:
|
|
|
438
471
|
for s in results
|
|
439
472
|
]
|
|
440
473
|
|
|
441
|
-
return ListResponse
|
|
474
|
+
return parametrize(ListResponse, Union[tuple(self.get_models())])( # noqa: UP007
|
|
442
475
|
total_results=total_results,
|
|
443
476
|
items_per_page=len(resources),
|
|
444
477
|
start_index=search_request.start_index,
|
|
@@ -446,7 +479,7 @@ class SCIMApplication:
|
|
|
446
479
|
)
|
|
447
480
|
|
|
448
481
|
def call_resource(
|
|
449
|
-
self, request: Request, resource_endpoint: str, **kwargs
|
|
482
|
+
self, request: Request, resource_endpoint: str, **kwargs: Any
|
|
450
483
|
) -> Response:
|
|
451
484
|
resource_type = self.get_resource_type_by_endpoint(resource_endpoint)
|
|
452
485
|
if not resource_type:
|
|
@@ -463,6 +496,7 @@ class SCIMApplication:
|
|
|
463
496
|
created_resource = self.publish(
|
|
464
497
|
request, self.create(resource_type, request.json)
|
|
465
498
|
)
|
|
499
|
+
assert created_resource.meta is not None
|
|
466
500
|
return self.make_response(
|
|
467
501
|
created_resource.model_dump(
|
|
468
502
|
scim_ctx=Context.RESOURCE_CREATION_RESPONSE
|
|
@@ -471,7 +505,7 @@ class SCIMApplication:
|
|
|
471
505
|
headers={"Location": created_resource.meta.location},
|
|
472
506
|
)
|
|
473
507
|
|
|
474
|
-
def call_query_all(self, request: Request, **kwargs) -> Response:
|
|
508
|
+
def call_query_all(self, request: Request, **kwargs: Any) -> Response:
|
|
475
509
|
return self.make_response(
|
|
476
510
|
self.query_resource(request, None).model_dump(
|
|
477
511
|
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
|
|
@@ -479,7 +513,7 @@ class SCIMApplication:
|
|
|
479
513
|
)
|
|
480
514
|
|
|
481
515
|
def call_resource_search(
|
|
482
|
-
self, request: Request, resource_endpoint: str, **kwargs
|
|
516
|
+
self, request: Request, resource_endpoint: str, **kwargs: Any
|
|
483
517
|
) -> Response:
|
|
484
518
|
resource_type = self.get_resource_type_by_endpoint(resource_endpoint)
|
|
485
519
|
if not resource_type:
|
|
@@ -493,7 +527,7 @@ class SCIMApplication:
|
|
|
493
527
|
@staticmethod
|
|
494
528
|
def ensure_supported(
|
|
495
529
|
capability: Patch | Bulk | Filter | Sort | None, operation: str
|
|
496
|
-
):
|
|
530
|
+
) -> None:
|
|
497
531
|
"""Refuse with a 501 an operation the configuration does not declare supported.
|
|
498
532
|
|
|
499
533
|
RFC 7644 §3.12 answers 501 when the service provider does not support
|
|
@@ -502,23 +536,38 @@ class SCIMApplication:
|
|
|
502
536
|
if capability is None or not capability.supported:
|
|
503
537
|
raise WerkzeugNotImplemented(f"{operation} is not supported")
|
|
504
538
|
|
|
505
|
-
|
|
539
|
+
@staticmethod
|
|
540
|
+
def ensure_payload_size(request: Request, max_payload_size: int) -> None:
|
|
541
|
+
"""Refuse with a 413 a payload larger than max_payload_size, without reading more than that.
|
|
542
|
+
|
|
543
|
+
:raises RequestEntityTooLarge: When the payload is too large.
|
|
544
|
+
"""
|
|
545
|
+
# Werkzeug refuses a Content-Length above the limit, but silently cuts a
|
|
546
|
+
# streamed body at the limit. One extra byte tells a cut body apart.
|
|
547
|
+
request.max_content_length = max_payload_size + 1
|
|
548
|
+
try:
|
|
549
|
+
too_large = len(request.get_data()) > max_payload_size
|
|
550
|
+
except RequestEntityTooLarge:
|
|
551
|
+
too_large = True
|
|
552
|
+
|
|
553
|
+
if too_large:
|
|
554
|
+
raise RequestEntityTooLarge(
|
|
555
|
+
f"The payload exceeds the maxPayloadSize ({max_payload_size} bytes)"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
def call_bulk(self, request: Request, **kwargs: Any) -> Response:
|
|
506
559
|
"""Implement the /Bulk endpoint (RFC 7644 §3.7)."""
|
|
507
560
|
self.ensure_supported(self.config.bulk, "Bulk")
|
|
508
561
|
bulk = cast(Bulk, self.config.bulk)
|
|
509
562
|
|
|
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
|
-
)
|
|
563
|
+
if bulk.max_payload_size is not None:
|
|
564
|
+
self.ensure_payload_size(request, bulk.max_payload_size)
|
|
517
565
|
|
|
518
|
-
bulk_request =
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
566
|
+
bulk_request = parametrize(
|
|
567
|
+
BulkRequest,
|
|
568
|
+
Union[tuple(self.get_models())], # noqa: UP007
|
|
569
|
+
).model_validate(request.json, scim_ctx=Context.BULK_REQUEST)
|
|
570
|
+
operations = cast(list[BulkOperation[Resource[Any]]], bulk_request.operations)
|
|
522
571
|
if bulk.max_operations is not None and len(operations) > bulk.max_operations:
|
|
523
572
|
raise RequestEntityTooLarge(
|
|
524
573
|
f"The number of operations exceeds the maxOperations ({bulk.max_operations})"
|
|
@@ -532,14 +581,17 @@ class SCIMApplication:
|
|
|
532
581
|
),
|
|
533
582
|
).run()
|
|
534
583
|
return self.make_response(
|
|
535
|
-
BulkResponse
|
|
536
|
-
|
|
537
|
-
|
|
584
|
+
parametrize(BulkResponse, Union[tuple(self.get_models())]) # noqa: UP007
|
|
585
|
+
.model_validate({"operations": results})
|
|
586
|
+
.model_dump(scim_ctx=Context.BULK_RESPONSE)
|
|
538
587
|
)
|
|
539
588
|
|
|
540
589
|
def run_bulk_operation(
|
|
541
|
-
self,
|
|
542
|
-
|
|
590
|
+
self,
|
|
591
|
+
request: Request,
|
|
592
|
+
operation: BulkOperation[Resource[Any]],
|
|
593
|
+
resolve: Resolver,
|
|
594
|
+
) -> tuple[dict[str, Any], Resource[Any] | None]:
|
|
543
595
|
"""Apply one operation of a bulk job.
|
|
544
596
|
|
|
545
597
|
An operation that failed its validation keeps its error, once its
|
|
@@ -556,6 +608,7 @@ class SCIMApplication:
|
|
|
556
608
|
operation = resolve(operation)
|
|
557
609
|
resource_type = self.get_resource_type_by_endpoint(operation.endpoint or "")
|
|
558
610
|
if resource_type is not None and operation.resource_id:
|
|
611
|
+
assert resource_type.endpoint is not None
|
|
559
612
|
result["location"] = urljoin(
|
|
560
613
|
request.url,
|
|
561
614
|
f"{resource_type.endpoint.strip('/')}/{operation.resource_id}",
|
|
@@ -573,18 +626,20 @@ class SCIMApplication:
|
|
|
573
626
|
error = self.error_from(exception)
|
|
574
627
|
return {**result, "status": error.status, "response": error}, None
|
|
575
628
|
|
|
629
|
+
assert operation.method is not None
|
|
576
630
|
result["status"] = BULK_SUCCESS_STATUS[operation.method]
|
|
577
631
|
if resource is None:
|
|
578
632
|
return result, None
|
|
579
633
|
|
|
580
634
|
resource = self.publish(request, resource)
|
|
635
|
+
assert resource.meta is not None
|
|
581
636
|
result["location"] = resource.meta.location
|
|
582
637
|
result["version"] = resource.meta.version
|
|
583
638
|
return result, resource
|
|
584
639
|
|
|
585
640
|
def apply_bulk_operation(
|
|
586
|
-
self, resource_type: ResourceType, operation: BulkOperation
|
|
587
|
-
) -> Resource | None:
|
|
641
|
+
self, resource_type: ResourceType, operation: BulkOperation[Resource[Any]]
|
|
642
|
+
) -> Resource[Any] | None:
|
|
588
643
|
"""Apply a validated bulk operation, and return the resource it acted on.
|
|
589
644
|
|
|
590
645
|
The data of the operation is already validated, and the resource
|
|
@@ -596,10 +651,11 @@ class SCIMApplication:
|
|
|
596
651
|
detail="A POST path must target a resource type endpoint, other methods a resource"
|
|
597
652
|
)
|
|
598
653
|
|
|
654
|
+
if operation.method == BulkOperation.Method.post or resource_id is None:
|
|
655
|
+
return self.create(resource_type, operation.data)
|
|
656
|
+
|
|
599
657
|
if_match = parse_etags(operation.version) if operation.version else None
|
|
600
658
|
match operation.method:
|
|
601
|
-
case BulkOperation.Method.post:
|
|
602
|
-
return self.create(resource_type, operation.data)
|
|
603
659
|
case BulkOperation.Method.put:
|
|
604
660
|
return self.replace(
|
|
605
661
|
resource_type, resource_id, operation.data, if_match
|
|
@@ -610,7 +666,7 @@ class SCIMApplication:
|
|
|
610
666
|
self.delete(resource_type, resource_id, if_match)
|
|
611
667
|
return None
|
|
612
668
|
|
|
613
|
-
def call_me(self, request: Request, **kwargs):
|
|
669
|
+
def call_me(self, request: Request, **kwargs: Any) -> Response:
|
|
614
670
|
"""Implement the /Me endpoint.
|
|
615
671
|
|
|
616
672
|
RFC 7644, Section 3.11 allows raising a 501 (Not Implemented) if
|
|
@@ -618,14 +674,14 @@ class SCIMApplication:
|
|
|
618
674
|
"""
|
|
619
675
|
raise WerkzeugNotImplemented
|
|
620
676
|
|
|
621
|
-
def register_bearer_token(self, token: str):
|
|
677
|
+
def register_bearer_token(self, token: str) -> None:
|
|
622
678
|
"""Register a static bearer token for authentication.
|
|
623
679
|
|
|
624
680
|
:param token: Bearer token
|
|
625
681
|
"""
|
|
626
682
|
self.bearer_tokens.add(token)
|
|
627
683
|
|
|
628
|
-
def check_auth(self, request: Request):
|
|
684
|
+
def check_auth(self, request: Request) -> None:
|
|
629
685
|
"""Check the authorization headers."""
|
|
630
686
|
if not self.bearer_tokens:
|
|
631
687
|
return
|
|
@@ -636,7 +692,7 @@ class SCIMApplication:
|
|
|
636
692
|
raise Unauthorized
|
|
637
693
|
|
|
638
694
|
@staticmethod
|
|
639
|
-
def make_response(content, status=200, **kwargs) -> Response:
|
|
695
|
+
def make_response(content: Any, status: int = 200, **kwargs: Any) -> Response:
|
|
640
696
|
"""Construct a werkzeug response from any JSON-serializable content."""
|
|
641
697
|
etag = None
|
|
642
698
|
if content is not None:
|
|
@@ -667,17 +723,17 @@ class SCIMApplication:
|
|
|
667
723
|
case _:
|
|
668
724
|
return Error(status=500, detail="Internal server error")
|
|
669
725
|
|
|
670
|
-
def make_error(self, error: Error):
|
|
726
|
+
def make_error(self, error: Error) -> Response:
|
|
671
727
|
"""Construct a werkzeug response from a SCIM Error."""
|
|
672
|
-
return self.make_response(error.model_dump(), status=int(error.status))
|
|
728
|
+
return self.make_response(error.model_dump(), status=int(error.status or 500))
|
|
673
729
|
|
|
674
730
|
@staticmethod
|
|
675
|
-
def forbid_filter(request: Request):
|
|
731
|
+
def forbid_filter(request: Request) -> None:
|
|
676
732
|
"""RFC 7644, Section 4: "If a "filter" is provided, the service provider SHOULD respond with HTTP status code 403 (Forbidden)"."""
|
|
677
733
|
if "filter" in request.args:
|
|
678
734
|
raise Forbidden
|
|
679
735
|
|
|
680
|
-
def call_service_provider_config(self, request: Request, **kwargs):
|
|
736
|
+
def call_service_provider_config(self, request: Request, **kwargs: Any) -> Response:
|
|
681
737
|
"""Return the ServiceProviderConfig."""
|
|
682
738
|
self.forbid_filter(request)
|
|
683
739
|
return self.make_response(
|
|
@@ -685,12 +741,14 @@ class SCIMApplication:
|
|
|
685
741
|
)
|
|
686
742
|
|
|
687
743
|
@staticmethod
|
|
688
|
-
def locate(resource:
|
|
744
|
+
def locate(resource: DiscoveryResourceT, location: str) -> DiscoveryResourceT:
|
|
689
745
|
"""Return a copy of a discovery resource carrying its meta."""
|
|
690
746
|
meta = Meta(resource_type=type(resource).__name__, location=location)
|
|
691
747
|
return resource.model_copy(update={"meta": meta})
|
|
692
748
|
|
|
693
|
-
def call_resource_type(
|
|
749
|
+
def call_resource_type(
|
|
750
|
+
self, request: Request, resource_type: str, **kwargs: Any
|
|
751
|
+
) -> Response:
|
|
694
752
|
"""Return a single resource type."""
|
|
695
753
|
self.forbid_filter(request)
|
|
696
754
|
for res in self.provider.resource_types:
|
|
@@ -700,7 +758,7 @@ class SCIMApplication:
|
|
|
700
758
|
)
|
|
701
759
|
raise NotFound
|
|
702
760
|
|
|
703
|
-
def call_schema(self, request: Request, schema_id: str):
|
|
761
|
+
def call_schema(self, request: Request, schema_id: str) -> Response:
|
|
704
762
|
"""Return a single schema."""
|
|
705
763
|
self.forbid_filter(request)
|
|
706
764
|
for res in self.provider.schemas:
|
|
@@ -710,7 +768,7 @@ class SCIMApplication:
|
|
|
710
768
|
)
|
|
711
769
|
raise NotFound
|
|
712
770
|
|
|
713
|
-
def call_resource_types(self, request: Request, **kwargs):
|
|
771
|
+
def call_resource_types(self, request: Request, **kwargs: Any) -> Response:
|
|
714
772
|
"""Return a ListResponse of all known resource types."""
|
|
715
773
|
self.forbid_filter(request)
|
|
716
774
|
results = self.provider.resource_types
|
|
@@ -722,7 +780,7 @@ class SCIMApplication:
|
|
|
722
780
|
).model_dump()
|
|
723
781
|
return self.make_response(resp)
|
|
724
782
|
|
|
725
|
-
def call_schemas(self, request: Request, **kwargs):
|
|
783
|
+
def call_schemas(self, request: Request, **kwargs: Any) -> Response:
|
|
726
784
|
"""Return a ListResponse of all known schemas."""
|
|
727
785
|
self.forbid_filter(request)
|
|
728
786
|
results = self.provider.schemas
|
|
@@ -734,7 +792,7 @@ class SCIMApplication:
|
|
|
734
792
|
).model_dump()
|
|
735
793
|
return self.make_response(resp)
|
|
736
794
|
|
|
737
|
-
def wsgi_app(self, request: Request, environ):
|
|
795
|
+
def wsgi_app(self, request: Request, environ: "WSGIEnvironment") -> Response:
|
|
738
796
|
try:
|
|
739
797
|
urls = self.url_map.bind_to_environ(environ)
|
|
740
798
|
endpoint, args = urls.match()
|
|
@@ -746,7 +804,7 @@ class SCIMApplication:
|
|
|
746
804
|
# Wrap the entire call in a transaction. Should probably be optimized (use transaction only when necessary).
|
|
747
805
|
# The provider makes its policy the one every payload is read under.
|
|
748
806
|
with self.provider, self.backend:
|
|
749
|
-
response = getattr(self, f"call_{endpoint}")(request, **args)
|
|
807
|
+
response: Response = getattr(self, f"call_{endpoint}")(request, **args)
|
|
750
808
|
return response
|
|
751
809
|
except RequestRedirect as e:
|
|
752
810
|
# urls.match may cause a redirect, handle it as a special case of HTTPException
|
|
@@ -755,7 +813,9 @@ class SCIMApplication:
|
|
|
755
813
|
except Exception as e:
|
|
756
814
|
return self.make_error(self.error_from(e))
|
|
757
815
|
|
|
758
|
-
def __call__(
|
|
816
|
+
def __call__(
|
|
817
|
+
self, environ: "WSGIEnvironment", start_response: "StartResponse"
|
|
818
|
+
) -> Iterable[bytes]:
|
|
759
819
|
"""Return the actual WSGI server implementation."""
|
|
760
820
|
if environ.get("PATH_INFO", "").endswith(".scim"):
|
|
761
821
|
# 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.2}/scim2_server/resources/default-resource-types.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|