scim2-server 0.6.0__tar.gz → 0.6.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.6.0 → scim2_server-0.6.2}/PKG-INFO +2 -2
- {scim2_server-0.6.0 → scim2_server-0.6.2}/pyproject.toml +2 -2
- {scim2_server-0.6.0 → scim2_server-0.6.2}/pyproject.toml.orig +2 -2
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/applications/asgi.py +1 -5
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/applications/wsgi.py +1 -5
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/conditions.py +5 -2
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/handler.py +18 -16
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/service.py +186 -56
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/storage.py +3 -1
- {scim2_server-0.6.0 → scim2_server-0.6.2}/LICENSE +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/README.md +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/__init__.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/applications/__init__.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/applications/base.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/bulk.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/errors.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/memory.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/py.typed +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/requests.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/resources/default-resource-types.json +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/resources/default-schemas.json +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/resources/default-service-provider-config.json +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/responses.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/routing.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/testing.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/testserver/__init__.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/testserver/application.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/testserver/cli.py +0 -0
- {scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: scim2-server
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: SCIM server core for any storage and web framework
|
|
5
5
|
Keywords: scim,scim2,provisioning,rfc7643,rfc7644
|
|
6
6
|
Author: Yaal Coop, Christian Friedrich Coors
|
|
@@ -217,7 +217,7 @@ Classifier: License :: OSI Approved :: Apache Software License
|
|
|
217
217
|
Classifier: Environment :: Web Environment
|
|
218
218
|
Classifier: Programming Language :: Python
|
|
219
219
|
Classifier: Operating System :: OS Independent
|
|
220
|
-
Requires-Dist: scim2-models>=0.10.
|
|
220
|
+
Requires-Dist: scim2-models>=0.10.6
|
|
221
221
|
Requires-Dist: pytest>=9.1.1 ; extra == 'testing'
|
|
222
222
|
Requires-Python: >=3.11
|
|
223
223
|
Project-URL: repository, https://github.com/python-scim/scim2-server
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-server"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.2"
|
|
8
8
|
description = "SCIM server core for any storage and web framework"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [
|
|
@@ -28,7 +28,7 @@ classifiers = [
|
|
|
28
28
|
"Operating System :: OS Independent",
|
|
29
29
|
]
|
|
30
30
|
requires-python = ">= 3.11"
|
|
31
|
-
dependencies = ["scim2-models>=0.10.
|
|
31
|
+
dependencies = ["scim2-models>=0.10.6"]
|
|
32
32
|
|
|
33
33
|
[[project.authors]]
|
|
34
34
|
name = "Yaal Coop"
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-server"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.2"
|
|
8
8
|
description = "SCIM server core for any storage and web framework"
|
|
9
9
|
authors = [
|
|
10
10
|
{name="Yaal Coop", email="contact@yaal.coop"},
|
|
@@ -29,7 +29,7 @@ classifiers = [
|
|
|
29
29
|
|
|
30
30
|
requires-python = ">= 3.11"
|
|
31
31
|
dependencies = [
|
|
32
|
-
"scim2-models>=0.10.
|
|
32
|
+
"scim2-models>=0.10.6",
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
[project.optional-dependencies]
|
|
@@ -6,7 +6,6 @@ from collections.abc import Callable
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
from urllib.parse import parse_qsl
|
|
8
8
|
|
|
9
|
-
from scim2_models import SCIMException
|
|
10
9
|
from scim2_models import ScimProvider
|
|
11
10
|
|
|
12
11
|
from scim2_server.applications.base import VERSION_PREFIX
|
|
@@ -79,10 +78,7 @@ class ASGIApplication(BaseApplication):
|
|
|
79
78
|
(name.decode(), value.decode()) for name, value in scope["headers"]
|
|
80
79
|
],
|
|
81
80
|
)
|
|
82
|
-
|
|
83
|
-
limit = self.service.max_body_size(request)
|
|
84
|
-
except SCIMException:
|
|
85
|
-
limit = None
|
|
81
|
+
limit = self.service.max_body_size(request)
|
|
86
82
|
body = b""
|
|
87
83
|
more_body = True
|
|
88
84
|
while more_body:
|
|
@@ -10,7 +10,6 @@ from urllib.parse import parse_qsl
|
|
|
10
10
|
from wsgiref.util import application_uri
|
|
11
11
|
|
|
12
12
|
from scim2_models import NotFoundException
|
|
13
|
-
from scim2_models import SCIMException
|
|
14
13
|
from scim2_models import ScimProvider
|
|
15
14
|
|
|
16
15
|
from scim2_server.applications.base import VERSION_PREFIX
|
|
@@ -94,10 +93,7 @@ class WSGIApplication(BaseApplication):
|
|
|
94
93
|
A body without :mdn:`Content-Length` is read up to its end, when the
|
|
95
94
|
server marks its input as terminated.
|
|
96
95
|
"""
|
|
97
|
-
|
|
98
|
-
limit = self.service.max_body_size(request)
|
|
99
|
-
except SCIMException:
|
|
100
|
-
limit = None
|
|
96
|
+
limit = self.service.max_body_size(request)
|
|
101
97
|
if environ.get("CONTENT_LENGTH"):
|
|
102
98
|
size = int(environ["CONTENT_LENGTH"])
|
|
103
99
|
elif environ.get("wsgi.input_terminated"):
|
|
@@ -50,8 +50,11 @@ class EntityTags:
|
|
|
50
50
|
def matches(self, version: str | None) -> bool:
|
|
51
51
|
"""Tell whether a resource version matches the header, with the weak comparison.
|
|
52
52
|
|
|
53
|
-
Both :mdn:`If-Match` and :mdn:`If-None-Match` use the weak comparison
|
|
54
|
-
|
|
53
|
+
Both :mdn:`If-Match` and :mdn:`If-None-Match` use the weak comparison.
|
|
54
|
+
Per :rfc:`RFC 9110 §13.1.1 <9110#section-13.1.1>`, ``If-Match`` uses the
|
|
55
|
+
strong comparison. But SCIM prefers weak ETags
|
|
56
|
+
(:rfc:`RFC 7644 §3.14 <7644#section-3.14>`), and the strong comparison
|
|
57
|
+
never matches them.
|
|
55
58
|
|
|
56
59
|
:param version: The version of the resource, or :data:`None` when the
|
|
57
60
|
service does not version its resources.
|
|
@@ -245,18 +245,19 @@ class ScimHandler:
|
|
|
245
245
|
) -> tuple[dict[str, Any], Resource[Any] | None]:
|
|
246
246
|
"""Apply one step of a bulk request.
|
|
247
247
|
|
|
248
|
-
The step
|
|
249
|
-
authorizes it
|
|
250
|
-
error.
|
|
248
|
+
The step routes the path of the operation, resolves its references,
|
|
249
|
+
locates it and authorizes it, so that it fails as a single request
|
|
250
|
+
would. An operation that failed its validation then keeps its error.
|
|
251
251
|
|
|
252
252
|
:return: The outcome of the step, and the resource it created or updated.
|
|
253
253
|
"""
|
|
254
254
|
operation = plan.operation(step)
|
|
255
|
-
outcome = self.service.bulk_outcome(operation)
|
|
255
|
+
outcome = self.service.bulk_outcome(request.base_url, operation)
|
|
256
256
|
try:
|
|
257
|
+
resource_type = self.service.route_bulk_operation(operation)
|
|
257
258
|
operation = plan.resolve(step)
|
|
258
|
-
|
|
259
|
-
request.base_url, operation, outcome
|
|
259
|
+
self.service.locate_bulk_operation(
|
|
260
|
+
request.base_url, resource_type, operation, outcome
|
|
260
261
|
)
|
|
261
262
|
self.service.authorize_bulk_operation(request, operation, resource_type)
|
|
262
263
|
failure = self.service.bulk_validation_failure(operation, outcome)
|
|
@@ -280,12 +281,12 @@ class ScimHandler:
|
|
|
280
281
|
The data of the operation is already validated, in the context of the
|
|
281
282
|
request the operation stands for.
|
|
282
283
|
"""
|
|
283
|
-
|
|
284
|
-
if resource_id is None:
|
|
284
|
+
if operation.method == BulkOperation.Method.post:
|
|
285
285
|
return self.storage.create(
|
|
286
286
|
resource_type, cast(Resource[Any], operation.data)
|
|
287
287
|
)
|
|
288
288
|
|
|
289
|
+
resource_id = cast(str, operation.resource_id)
|
|
289
290
|
conditions = Conditions(if_match=operation.version)
|
|
290
291
|
match operation.method:
|
|
291
292
|
case BulkOperation.Method.put:
|
|
@@ -577,18 +578,19 @@ class AsyncScimHandler:
|
|
|
577
578
|
) -> tuple[dict[str, Any], Resource[Any] | None]:
|
|
578
579
|
"""Apply one step of a bulk request.
|
|
579
580
|
|
|
580
|
-
The step
|
|
581
|
-
authorizes it
|
|
582
|
-
error.
|
|
581
|
+
The step routes the path of the operation, resolves its references,
|
|
582
|
+
locates it and authorizes it, so that it fails as a single request
|
|
583
|
+
would. An operation that failed its validation then keeps its error.
|
|
583
584
|
|
|
584
585
|
:return: The outcome of the step, and the resource it created or updated.
|
|
585
586
|
"""
|
|
586
587
|
operation = plan.operation(step)
|
|
587
|
-
outcome = self.service.bulk_outcome(operation)
|
|
588
|
+
outcome = self.service.bulk_outcome(request.base_url, operation)
|
|
588
589
|
try:
|
|
590
|
+
resource_type = self.service.route_bulk_operation(operation)
|
|
589
591
|
operation = plan.resolve(step)
|
|
590
|
-
|
|
591
|
-
request.base_url, operation, outcome
|
|
592
|
+
self.service.locate_bulk_operation(
|
|
593
|
+
request.base_url, resource_type, operation, outcome
|
|
592
594
|
)
|
|
593
595
|
self.service.authorize_bulk_operation(request, operation, resource_type)
|
|
594
596
|
failure = self.service.bulk_validation_failure(operation, outcome)
|
|
@@ -612,12 +614,12 @@ class AsyncScimHandler:
|
|
|
612
614
|
The data of the operation is already validated, in the context of the
|
|
613
615
|
request the operation stands for.
|
|
614
616
|
"""
|
|
615
|
-
|
|
616
|
-
if resource_id is None:
|
|
617
|
+
if operation.method == BulkOperation.Method.post:
|
|
617
618
|
return await self.storage.create(
|
|
618
619
|
resource_type, cast(Resource[Any], operation.data)
|
|
619
620
|
)
|
|
620
621
|
|
|
622
|
+
resource_id = cast(str, operation.resource_id)
|
|
621
623
|
conditions = Conditions(if_match=operation.version)
|
|
622
624
|
match operation.method:
|
|
623
625
|
case BulkOperation.Method.put:
|
|
@@ -5,6 +5,7 @@ from typing import Any
|
|
|
5
5
|
from typing import TypeVar
|
|
6
6
|
from typing import Union
|
|
7
7
|
from typing import cast
|
|
8
|
+
from urllib.parse import urlsplit
|
|
8
9
|
|
|
9
10
|
from pydantic import ValidationError
|
|
10
11
|
from pydantic_core import from_json
|
|
@@ -14,8 +15,10 @@ from scim2_models import Bulk
|
|
|
14
15
|
from scim2_models import BulkOperation
|
|
15
16
|
from scim2_models import BulkRequest
|
|
16
17
|
from scim2_models import BulkResponse
|
|
18
|
+
from scim2_models import ChangePassword
|
|
17
19
|
from scim2_models import Context
|
|
18
20
|
from scim2_models import Error
|
|
21
|
+
from scim2_models import ETag
|
|
19
22
|
from scim2_models import Filter
|
|
20
23
|
from scim2_models import ForbiddenException
|
|
21
24
|
from scim2_models import InvalidSyntaxException
|
|
@@ -26,7 +29,9 @@ from scim2_models import NotFoundException
|
|
|
26
29
|
from scim2_models import NotImplementedException
|
|
27
30
|
from scim2_models import Patch
|
|
28
31
|
from scim2_models import PatchOp
|
|
32
|
+
from scim2_models import Path
|
|
29
33
|
from scim2_models import PayloadTooLargeException
|
|
34
|
+
from scim2_models import Reference
|
|
30
35
|
from scim2_models import Resource
|
|
31
36
|
from scim2_models import ResourceType
|
|
32
37
|
from scim2_models import ResponseParameters
|
|
@@ -46,7 +51,6 @@ from scim2_server.responses import ScimResponse
|
|
|
46
51
|
from scim2_server.routing import Operation
|
|
47
52
|
from scim2_server.routing import Target
|
|
48
53
|
from scim2_server.routing import match
|
|
49
|
-
from scim2_server.utils import load_default_service_provider_config
|
|
50
54
|
from scim2_server.utils import parametrize
|
|
51
55
|
|
|
52
56
|
|
|
@@ -105,6 +109,23 @@ def is_json_media_type(content_type: str | None) -> bool:
|
|
|
105
109
|
)
|
|
106
110
|
|
|
107
111
|
|
|
112
|
+
def patch_only_config() -> ServiceProviderConfig:
|
|
113
|
+
"""Return the configuration of a service that only supports PATCH.
|
|
114
|
+
|
|
115
|
+
PATCH only needs the storage to update a resource, so every storage
|
|
116
|
+
supports it. The other capabilities need a storage that implements them.
|
|
117
|
+
"""
|
|
118
|
+
return ServiceProviderConfig(
|
|
119
|
+
patch=Patch(supported=True),
|
|
120
|
+
bulk=Bulk(supported=False, max_operations=0, max_payload_size=0),
|
|
121
|
+
filter=Filter(supported=False),
|
|
122
|
+
change_password=ChangePassword(supported=False),
|
|
123
|
+
sort=Sort(supported=False),
|
|
124
|
+
etag=ETag(supported=False),
|
|
125
|
+
authentication_schemes=[],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
108
129
|
class ScimService:
|
|
109
130
|
"""The SCIM protocol, without input or output.
|
|
110
131
|
|
|
@@ -116,6 +137,9 @@ class ScimService:
|
|
|
116
137
|
reads the request, and turns the :class:`~scim2_server.responses.ScimResponse`
|
|
117
138
|
into a response of the framework.
|
|
118
139
|
|
|
140
|
+
A provider without ``config`` announces PATCH, and no other capability.
|
|
141
|
+
The storage must implement each capability the configuration announces.
|
|
142
|
+
|
|
119
143
|
Every public method can be overridden. ``base_url`` is the root URL of the
|
|
120
144
|
SCIM endpoints, as the client sees it, such as
|
|
121
145
|
``https://example.com/scim/v2``.
|
|
@@ -123,7 +147,7 @@ class ScimService:
|
|
|
123
147
|
|
|
124
148
|
def __init__(self, provider: ScimProvider):
|
|
125
149
|
self.provider = provider
|
|
126
|
-
self.config = provider.config or
|
|
150
|
+
self.config = provider.config or patch_only_config()
|
|
127
151
|
|
|
128
152
|
# -- Resource types and models --------------------------------------
|
|
129
153
|
|
|
@@ -294,13 +318,13 @@ class ScimService:
|
|
|
294
318
|
) -> None:
|
|
295
319
|
"""Authorize a resolved bulk operation with :meth:`authorize`.
|
|
296
320
|
|
|
297
|
-
An operation without a
|
|
298
|
-
|
|
321
|
+
An operation without a valid method or path keeps its validation
|
|
322
|
+
error, without authorization.
|
|
299
323
|
"""
|
|
300
|
-
if resource_type is None
|
|
324
|
+
if resource_type is None:
|
|
301
325
|
return
|
|
302
326
|
target = Target(
|
|
303
|
-
BULK_OPERATIONS[operation.method],
|
|
327
|
+
BULK_OPERATIONS[cast(BulkOperation.Method, operation.method)],
|
|
304
328
|
endpoint=self.endpoint_of(resource_type),
|
|
305
329
|
resource_id=operation.resource_id,
|
|
306
330
|
)
|
|
@@ -344,13 +368,25 @@ class ScimService:
|
|
|
344
368
|
def max_body_size(self, request: ScimRequest) -> int | None:
|
|
345
369
|
"""Return the largest body the service accepts for a request, in bytes.
|
|
346
370
|
|
|
347
|
-
An integration reads at most one byte more, and
|
|
348
|
-
|
|
349
|
-
|
|
371
|
+
An integration reads at most one byte more, and passes the body to the
|
|
372
|
+
handler. It must not refuse a larger body itself: the service answers,
|
|
373
|
+
with a 413 or with an error that comes first, such as a 501 when bulk
|
|
374
|
+
is not supported.
|
|
375
|
+
|
|
376
|
+
A bulk request is limited by maxPayloadSize
|
|
377
|
+
(:rfc:`RFC 7644 §3.7.4 <7644#section-3.7.4>`). A request that fails
|
|
378
|
+
whatever its body, such as a request on an unknown path, gets 0.
|
|
379
|
+
Other requests have no limit.
|
|
350
380
|
"""
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
381
|
+
try:
|
|
382
|
+
operation = self.match(request).operation
|
|
383
|
+
except SCIMException:
|
|
384
|
+
return 0
|
|
385
|
+
if operation is not Operation.bulk:
|
|
386
|
+
return None
|
|
387
|
+
if self.config.bulk is None or not self.config.bulk.supported:
|
|
388
|
+
return 0
|
|
389
|
+
return self.bulk_max_payload_size()
|
|
354
390
|
|
|
355
391
|
def resource_type_at(self, endpoint: str) -> ResourceType:
|
|
356
392
|
"""Return the resource type an endpoint serves.
|
|
@@ -434,8 +470,13 @@ class ScimService:
|
|
|
434
470
|
|
|
435
471
|
@staticmethod
|
|
436
472
|
def ensure_json(content_type: str | None) -> None:
|
|
437
|
-
"""Refuse with a 415 a request body that is not JSON.
|
|
438
|
-
|
|
473
|
+
"""Refuse with a 415 a request body that is not JSON.
|
|
474
|
+
|
|
475
|
+
Per :rfc:`RFC 7644 §3.8 <7644#section-3.8>`, JSON is the default
|
|
476
|
+
format: a body without a :mdn:`Content-Type`, or with an empty one,
|
|
477
|
+
is read as JSON.
|
|
478
|
+
"""
|
|
479
|
+
if content_type and not is_json_media_type(content_type):
|
|
439
480
|
raise UnsupportedMediaTypeException
|
|
440
481
|
|
|
441
482
|
def read_body(
|
|
@@ -474,11 +515,28 @@ class ScimService:
|
|
|
474
515
|
f"{base_url.rstrip('/')}/{resource_type.endpoint.strip('/')}/{resource_id}"
|
|
475
516
|
)
|
|
476
517
|
|
|
518
|
+
def reference_location(self, base_url: str, reference: str) -> str:
|
|
519
|
+
"""Return the URL of a reference to a resource.
|
|
520
|
+
|
|
521
|
+
A reference relative to the SCIM root, such as ``Users/2819c223``,
|
|
522
|
+
becomes the location of the resource
|
|
523
|
+
(:rfc:`RFC 7643 §2.3.7 <7643#section-2.3.7>`). Absolute references
|
|
524
|
+
are kept.
|
|
525
|
+
"""
|
|
526
|
+
if urlsplit(reference).scheme or reference.startswith("/"):
|
|
527
|
+
return reference
|
|
528
|
+
endpoint, _, resource_id = reference.rpartition("/")
|
|
529
|
+
resource_type = self.get_resource_type_by_endpoint(endpoint)
|
|
530
|
+
if resource_type is None or not resource_id:
|
|
531
|
+
return f"{base_url.rstrip('/')}/{reference}"
|
|
532
|
+
return self.resource_location(base_url, resource_type, resource_id)
|
|
533
|
+
|
|
477
534
|
def publish(self, base_url: str, resource: Resource[Any]) -> Resource[Any]:
|
|
478
535
|
"""Return a copy of a stored resource in the form sent to the client.
|
|
479
536
|
|
|
480
|
-
Its location is set,
|
|
481
|
-
|
|
537
|
+
Its location is set, its references to other resources become URLs
|
|
538
|
+
with :meth:`reference_location`, and its version is left out when the
|
|
539
|
+
service does not support ETags.
|
|
482
540
|
"""
|
|
483
541
|
assert resource.meta is not None
|
|
484
542
|
assert resource.id is not None
|
|
@@ -489,9 +547,41 @@ class ScimService:
|
|
|
489
547
|
}
|
|
490
548
|
if not self.etag_supported:
|
|
491
549
|
update["version"] = None
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
)
|
|
550
|
+
published = resource.model_copy(deep=True)
|
|
551
|
+
published.meta = resource.meta.model_copy(update=update)
|
|
552
|
+
self._publish_references(base_url, published)
|
|
553
|
+
return published
|
|
554
|
+
|
|
555
|
+
def _publish_references(self, base_url: str, resource: Resource[Any]) -> None:
|
|
556
|
+
"""Replace in place the references of a resource to other resources by their URLs."""
|
|
557
|
+
|
|
558
|
+
def locate(reference: Any) -> Any:
|
|
559
|
+
if isinstance(reference, list):
|
|
560
|
+
return [locate(item) for item in reference]
|
|
561
|
+
if reference is None:
|
|
562
|
+
return None
|
|
563
|
+
return self.reference_location(base_url, reference)
|
|
564
|
+
|
|
565
|
+
for path in parametrize(Path, type(resource)).iter_paths(
|
|
566
|
+
target_type=[Reference]
|
|
567
|
+
):
|
|
568
|
+
binding = path.resolve()
|
|
569
|
+
assert binding is not None
|
|
570
|
+
target_type = cast(type[Reference[Any]], binding.target_type)
|
|
571
|
+
reference_types = set(target_type.__reference_types__)
|
|
572
|
+
if not reference_types or reference_types & {"external", "uri"}:
|
|
573
|
+
continue
|
|
574
|
+
|
|
575
|
+
if binding.sub_field_name is None:
|
|
576
|
+
path.set(resource, locate(path.get(resource)), strict=False)
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
head = f"{path.schema}:{path.parts[0]}" if path.schema else path.parts[0]
|
|
580
|
+
entries = Path(head).get(resource)
|
|
581
|
+
for entry in entries if isinstance(entries, list) else [entries]:
|
|
582
|
+
if entry is not None:
|
|
583
|
+
reference = getattr(entry, binding.sub_field_name)
|
|
584
|
+
setattr(entry, binding.sub_field_name, locate(reference))
|
|
495
585
|
|
|
496
586
|
@staticmethod
|
|
497
587
|
def resource_response(
|
|
@@ -500,9 +590,15 @@ class ScimService:
|
|
|
500
590
|
response_parameters: ResponseParameters[Any] | None = None,
|
|
501
591
|
status: HTTPStatus = HTTPStatus.OK,
|
|
502
592
|
) -> ScimResponse:
|
|
503
|
-
"""Return the response carrying a published resource, with its ETag.
|
|
504
|
-
|
|
505
|
-
|
|
593
|
+
"""Return the response carrying a published resource, with its ETag.
|
|
594
|
+
|
|
595
|
+
Per :rfc:`RFC 7643 §3.1 <7643#section-3.1>`, the
|
|
596
|
+
:mdn:`Content-Location` header holds ``meta.location``.
|
|
597
|
+
"""
|
|
598
|
+
assert resource.meta is not None and resource.meta.location is not None
|
|
599
|
+
headers = {"Content-Location": resource.meta.location}
|
|
600
|
+
if resource.meta.version:
|
|
601
|
+
headers["ETag"] = resource.meta.version
|
|
506
602
|
body = resource.model_dump(
|
|
507
603
|
scim_ctx=scim_ctx, response_parameters=response_parameters
|
|
508
604
|
)
|
|
@@ -798,28 +894,73 @@ class ScimService:
|
|
|
798
894
|
)
|
|
799
895
|
|
|
800
896
|
@staticmethod
|
|
801
|
-
def bulk_outcome(
|
|
802
|
-
|
|
803
|
-
|
|
897
|
+
def bulk_outcome(
|
|
898
|
+
base_url: str, operation: BulkOperation[Resource[Any]]
|
|
899
|
+
) -> dict[str, Any]:
|
|
900
|
+
"""Start the outcome of a bulk operation, before it runs.
|
|
901
|
+
|
|
902
|
+
:rfc:`RFC 7644 §3.7.3 <7644#section-3.7.3>` requires a location for
|
|
903
|
+
every operation but a failed POST. Until the operation is located, its
|
|
904
|
+
location is the URL of its path, so that an operation with an unknown
|
|
905
|
+
endpoint or an unresolved reference still has one.
|
|
906
|
+
"""
|
|
907
|
+
outcome: dict[str, Any] = {
|
|
908
|
+
"method": operation.method,
|
|
909
|
+
"bulk_id": operation.bulk_id,
|
|
910
|
+
}
|
|
911
|
+
if operation.method != BulkOperation.Method.post:
|
|
912
|
+
path = (operation.path or "").lstrip("/")
|
|
913
|
+
outcome["location"] = f"{base_url.rstrip('/')}/{path}"
|
|
914
|
+
return outcome
|
|
915
|
+
|
|
916
|
+
def route_bulk_operation(
|
|
917
|
+
self, operation: BulkOperation[Resource[Any]]
|
|
918
|
+
) -> ResourceType | None:
|
|
919
|
+
"""Return the resource type a bulk operation acts on.
|
|
920
|
+
|
|
921
|
+
The path of the operation is routed as the path of a single request,
|
|
922
|
+
so that the operation fails with the same status
|
|
923
|
+
(:rfc:`RFC 7644 §3.7.3 <7644#section-3.7.3>`). An operation without a
|
|
924
|
+
valid method or path keeps its validation error, and has no resource
|
|
925
|
+
type.
|
|
926
|
+
|
|
927
|
+
:raises ~scim2_models.NotFoundException: When no endpoint has the path
|
|
928
|
+
of the operation, or no resource type is served at it.
|
|
929
|
+
:raises ~scim2_server.errors.MethodNotAllowedException: When the
|
|
930
|
+
endpoint does not support the method of the operation.
|
|
931
|
+
:raises ~scim2_models.InvalidValueException: When the path is not a
|
|
932
|
+
resource type endpoint or a resource, such as ``/Me`` or a search
|
|
933
|
+
(:rfc:`RFC 7644 §3.7 <7644#section-3.7>`).
|
|
934
|
+
"""
|
|
935
|
+
if operation.method is None or operation.path is None:
|
|
936
|
+
return None
|
|
937
|
+
target = match(operation.method, operation.path)
|
|
938
|
+
if target.me or target.operation not in BULK_OPERATIONS.values():
|
|
939
|
+
raise InvalidValueException(
|
|
940
|
+
detail="A bulk operation must target a resource type endpoint or a resource"
|
|
941
|
+
)
|
|
942
|
+
return self.resource_type_at(cast(str, target.endpoint))
|
|
804
943
|
|
|
805
944
|
def locate_bulk_operation(
|
|
806
945
|
self,
|
|
807
946
|
base_url: str,
|
|
947
|
+
resource_type: ResourceType | None,
|
|
808
948
|
operation: BulkOperation[Resource[Any]],
|
|
809
949
|
outcome: dict[str, Any],
|
|
810
|
-
) ->
|
|
811
|
-
"""
|
|
950
|
+
) -> None:
|
|
951
|
+
"""Locate the resource of a resolved bulk operation.
|
|
812
952
|
|
|
813
953
|
The location is set before the operation runs, so a failure still
|
|
814
|
-
knows it.
|
|
815
|
-
every operation but a failed POST.
|
|
954
|
+
knows it. A POST only gets a location when it succeeds.
|
|
816
955
|
"""
|
|
817
|
-
|
|
818
|
-
|
|
956
|
+
if (
|
|
957
|
+
resource_type is not None
|
|
958
|
+
and operation.resource_id
|
|
959
|
+
and operation.method != BulkOperation.Method.post
|
|
960
|
+
):
|
|
819
961
|
outcome["location"] = self.resource_location(
|
|
820
962
|
base_url, resource_type, operation.resource_id
|
|
821
963
|
)
|
|
822
|
-
return resource_type
|
|
823
964
|
|
|
824
965
|
@staticmethod
|
|
825
966
|
def bulk_validation_failure(
|
|
@@ -830,21 +971,6 @@ class ScimService:
|
|
|
830
971
|
return None
|
|
831
972
|
return {**outcome, "status": operation.status, "response": operation.response}
|
|
832
973
|
|
|
833
|
-
@staticmethod
|
|
834
|
-
def check_bulk_target(operation: BulkOperation[Resource[Any]]) -> str | None:
|
|
835
|
-
"""Check that the path of a bulk operation fits its method.
|
|
836
|
-
|
|
837
|
-
:return: The identifier of the targeted resource, or :data:`None` for a POST.
|
|
838
|
-
:raises ~scim2_models.InvalidValueException: When a POST targets a
|
|
839
|
-
resource, or another method a resource type endpoint.
|
|
840
|
-
"""
|
|
841
|
-
resource_id = operation.resource_id
|
|
842
|
-
if (operation.method == BulkOperation.Method.post) == bool(resource_id):
|
|
843
|
-
raise InvalidValueException(
|
|
844
|
-
detail="A POST path must target a resource type endpoint, other methods a resource"
|
|
845
|
-
)
|
|
846
|
-
return resource_id
|
|
847
|
-
|
|
848
974
|
def bulk_failure(
|
|
849
975
|
self, outcome: dict[str, Any], exception: Exception
|
|
850
976
|
) -> dict[str, Any]:
|
|
@@ -900,6 +1026,16 @@ class ScimService:
|
|
|
900
1026
|
meta = Meta(resource_type=type(resource).__name__, location=location)
|
|
901
1027
|
return resource.model_copy(update={"meta": meta})
|
|
902
1028
|
|
|
1029
|
+
@staticmethod
|
|
1030
|
+
def discovery_response(resource: DiscoveryResourceT) -> ScimResponse:
|
|
1031
|
+
"""Return the response carrying a discovery resource, with its :mdn:`Content-Location`."""
|
|
1032
|
+
assert resource.meta is not None and resource.meta.location is not None
|
|
1033
|
+
return ScimResponse(
|
|
1034
|
+
HTTPStatus.OK,
|
|
1035
|
+
resource.model_dump(),
|
|
1036
|
+
{"Content-Location": resource.meta.location},
|
|
1037
|
+
)
|
|
1038
|
+
|
|
903
1039
|
def service_provider_config(
|
|
904
1040
|
self, location: str, query: Mapping[str, str]
|
|
905
1041
|
) -> ScimResponse:
|
|
@@ -908,9 +1044,7 @@ class ScimService:
|
|
|
908
1044
|
:param location: The URL of the endpoint.
|
|
909
1045
|
"""
|
|
910
1046
|
self.forbid_filter(query)
|
|
911
|
-
return
|
|
912
|
-
HTTPStatus.OK, self.locate(self.config, location).model_dump()
|
|
913
|
-
)
|
|
1047
|
+
return self.discovery_response(self.locate(self.config, location))
|
|
914
1048
|
|
|
915
1049
|
def resource_types(self, location: str, query: Mapping[str, str]) -> ScimResponse:
|
|
916
1050
|
"""Return the list of the resource types."""
|
|
@@ -931,9 +1065,7 @@ class ScimService:
|
|
|
931
1065
|
self.forbid_filter(query)
|
|
932
1066
|
for resource_type in self.provider.resource_types:
|
|
933
1067
|
if resource_type.id == resource_type_id:
|
|
934
|
-
return
|
|
935
|
-
HTTPStatus.OK, self.locate(resource_type, location).model_dump()
|
|
936
|
-
)
|
|
1068
|
+
return self.discovery_response(self.locate(resource_type, location))
|
|
937
1069
|
raise NotFoundException(detail=f"Resource type {resource_type_id!r} not found")
|
|
938
1070
|
|
|
939
1071
|
def schemas(self, location: str, query: Mapping[str, str]) -> ScimResponse:
|
|
@@ -955,9 +1087,7 @@ class ScimService:
|
|
|
955
1087
|
self.forbid_filter(query)
|
|
956
1088
|
for schema in self.provider.schemas:
|
|
957
1089
|
if schema.id == schema_id:
|
|
958
|
-
return
|
|
959
|
-
HTTPStatus.OK, self.locate(schema, location).model_dump()
|
|
960
|
-
)
|
|
1090
|
+
return self.discovery_response(self.locate(schema, location))
|
|
961
1091
|
raise NotFoundException(detail=f"Schema {schema_id!r} not found")
|
|
962
1092
|
|
|
963
1093
|
# -- Errors ---------------------------------------------------------
|
|
@@ -26,7 +26,9 @@ class ScimStorage(ABC):
|
|
|
26
26
|
stored resource, and the resources it receives are not changed either.
|
|
27
27
|
- It fills ``id``, ``meta.resourceType``, ``meta.created``,
|
|
28
28
|
``meta.lastModified`` and ``meta.version``. It leaves ``meta.location``
|
|
29
|
-
to the server, which knows the URLs.
|
|
29
|
+
to the server, which knows the URLs. For the same reason, a reference
|
|
30
|
+
to another resource, such as ``members.$ref``, can be relative to the
|
|
31
|
+
SCIM root, such as ``Users/2819c223``. The server returns its URL.
|
|
30
32
|
- The version changes whenever the resource changes. An update that
|
|
31
33
|
changes nothing may keep it.
|
|
32
34
|
- A resource that does not exist raises
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scim2_server-0.6.0 → scim2_server-0.6.2}/scim2_server/resources/default-resource-types.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|