scim2-server 0.1.8__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scim2_server-0.1.8 → scim2_server-0.2.0}/PKG-INFO +15 -3
- {scim2_server-0.1.8 → scim2_server-0.2.0}/README.md +13 -1
- scim2_server-0.2.0/pyproject.toml +174 -0
- scim2_server-0.1.8/pyproject.toml → scim2_server-0.2.0/pyproject.toml.orig +2 -2
- {scim2_server-0.1.8 → scim2_server-0.2.0}/scim2_server/backend.py +6 -8
- {scim2_server-0.1.8 → scim2_server-0.2.0}/scim2_server/cli.py +10 -4
- {scim2_server-0.1.8 → scim2_server-0.2.0}/scim2_server/filter.py +2 -3
- {scim2_server-0.1.8 → scim2_server-0.2.0}/scim2_server/operators.py +20 -17
- {scim2_server-0.1.8 → scim2_server-0.2.0}/scim2_server/provider.py +2 -2
- {scim2_server-0.1.8 → scim2_server-0.2.0}/scim2_server/utils.py +51 -51
- {scim2_server-0.1.8 → scim2_server-0.2.0}/LICENSE +0 -0
- {scim2_server-0.1.8 → scim2_server-0.2.0}/scim2_server/__init__.py +0 -0
- {scim2_server-0.1.8 → scim2_server-0.2.0}/scim2_server/resources/default-resource-types.json +0 -0
- {scim2_server-0.1.8 → scim2_server-0.2.0}/scim2_server/resources/default-schemas.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: scim2-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Lightweight SCIM2 server prototype
|
|
5
5
|
Keywords: scim,scim2,provisioning,rfc7643,rfc7644
|
|
6
6
|
Author: Yaal Coop, Christian Friedrich Coors
|
|
@@ -222,8 +222,8 @@ Requires-Dist: scim2-filter-parser>=0.7.0
|
|
|
222
222
|
Requires-Dist: scim2-models>=0.6.1
|
|
223
223
|
Requires-Dist: werkzeug>=3.0.3
|
|
224
224
|
Requires-Python: >=3.10
|
|
225
|
-
Project-URL: funding, https://github.com/sponsors/python-scim
|
|
226
225
|
Project-URL: repository, https://github.com/python-scim/scim2-server
|
|
226
|
+
Project-URL: funding, https://github.com/sponsors/python-scim
|
|
227
227
|
Description-Content-Type: text/markdown
|
|
228
228
|
|
|
229
229
|
# scim2-server
|
|
@@ -247,7 +247,7 @@ The only optional feature currently missing is support for Bulk operations ([RFC
|
|
|
247
247
|
## Usage
|
|
248
248
|
|
|
249
249
|
```shell
|
|
250
|
-
$ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES]
|
|
250
|
+
$ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--debug]
|
|
251
251
|
```
|
|
252
252
|
|
|
253
253
|
- `-h`/`--help`: Show help message
|
|
@@ -258,6 +258,18 @@ $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-
|
|
|
258
258
|
- `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
|
|
259
259
|
- `--port`: The port to listen on. Defaults to `8080`.
|
|
260
260
|
- `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally.
|
|
261
|
+
- `--debug`: Enable the interactive Werkzeug debugger, the reloader and the logging of the WSGI environment of each request. The debugger allows arbitrary code execution and the environment contains the bearer tokens, so never use this option on a server reachable by others.
|
|
262
|
+
|
|
263
|
+
### Container
|
|
264
|
+
|
|
265
|
+
A container image is published on the GitHub container registry for each release.
|
|
266
|
+
The server listens on `0.0.0.0:8080` inside the container, and the command line arguments are passed to `scim2-server`:
|
|
267
|
+
|
|
268
|
+
```shell
|
|
269
|
+
$ docker run --publish 8080:8080 ghcr.io/python-scim/scim2-server --bearer-token secret
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
To build the image yourself, use `docker build --file Containerfile .` or `podman build .`.
|
|
261
273
|
|
|
262
274
|
## Notes
|
|
263
275
|
|
|
@@ -19,7 +19,7 @@ The only optional feature currently missing is support for Bulk operations ([RFC
|
|
|
19
19
|
## Usage
|
|
20
20
|
|
|
21
21
|
```shell
|
|
22
|
-
$ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES]
|
|
22
|
+
$ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--debug]
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
- `-h`/`--help`: Show help message
|
|
@@ -30,6 +30,18 @@ $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-
|
|
|
30
30
|
- `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
|
|
31
31
|
- `--port`: The port to listen on. Defaults to `8080`.
|
|
32
32
|
- `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally.
|
|
33
|
+
- `--debug`: Enable the interactive Werkzeug debugger, the reloader and the logging of the WSGI environment of each request. The debugger allows arbitrary code execution and the environment contains the bearer tokens, so never use this option on a server reachable by others.
|
|
34
|
+
|
|
35
|
+
### Container
|
|
36
|
+
|
|
37
|
+
A container image is published on the GitHub container registry for each release.
|
|
38
|
+
The server listens on `0.0.0.0:8080` inside the container, and the command line arguments are passed to `scim2-server`:
|
|
39
|
+
|
|
40
|
+
```shell
|
|
41
|
+
$ docker run --publish 8080:8080 ghcr.io/python-scim/scim2-server --bearer-token secret
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
To build the image yourself, use `docker build --file Containerfile .` or `podman build .`.
|
|
33
45
|
|
|
34
46
|
## Notes
|
|
35
47
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.16,<0.13.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scim2-server"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Lightweight SCIM2 server prototype"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
keywords = [
|
|
11
|
+
"scim",
|
|
12
|
+
"scim2",
|
|
13
|
+
"provisioning",
|
|
14
|
+
"rfc7643",
|
|
15
|
+
"rfc7644",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
26
|
+
"License :: OSI Approved :: Apache Software License",
|
|
27
|
+
"Environment :: Web Environment",
|
|
28
|
+
"Programming Language :: Python",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
]
|
|
31
|
+
requires-python = ">= 3.10"
|
|
32
|
+
dependencies = [
|
|
33
|
+
"scim2-filter-parser>=0.7.0",
|
|
34
|
+
"scim2-models>=0.6.1",
|
|
35
|
+
"werkzeug>=3.0.3",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[[project.authors]]
|
|
39
|
+
name = "Yaal Coop"
|
|
40
|
+
email = "contact@yaal.coop"
|
|
41
|
+
|
|
42
|
+
[[project.authors]]
|
|
43
|
+
name = "Christian Friedrich Coors"
|
|
44
|
+
email = "me@ccoors.de"
|
|
45
|
+
|
|
46
|
+
[project.license]
|
|
47
|
+
file = "LICENSE"
|
|
48
|
+
|
|
49
|
+
[project.urls]
|
|
50
|
+
repository = "https://github.com/python-scim/scim2-server"
|
|
51
|
+
funding = "https://github.com/sponsors/python-scim"
|
|
52
|
+
|
|
53
|
+
[project.scripts]
|
|
54
|
+
scim2-server = "scim2_server.cli:main"
|
|
55
|
+
|
|
56
|
+
[dependency-groups]
|
|
57
|
+
dev = [
|
|
58
|
+
"httpx>=0.27.0",
|
|
59
|
+
"pytest>=8.2.2",
|
|
60
|
+
"pytest-coverage>=0.0",
|
|
61
|
+
"prek>=0.1.0",
|
|
62
|
+
"time-machine>=2.14.2",
|
|
63
|
+
"tox-uv>=1.16.0",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.uv.build-backend]
|
|
67
|
+
module-root = ""
|
|
68
|
+
|
|
69
|
+
[tool.coverage.run]
|
|
70
|
+
source = [
|
|
71
|
+
"scim2_server",
|
|
72
|
+
"tests",
|
|
73
|
+
]
|
|
74
|
+
omit = [
|
|
75
|
+
".tox/*",
|
|
76
|
+
"examples/*",
|
|
77
|
+
"scim2_server/cli.py",
|
|
78
|
+
]
|
|
79
|
+
branch = true
|
|
80
|
+
|
|
81
|
+
[tool.coverage.report]
|
|
82
|
+
exclude_lines = [
|
|
83
|
+
"@pytest.mark.skip",
|
|
84
|
+
"@pytest.mark.xfail",
|
|
85
|
+
"pragma: no cover",
|
|
86
|
+
"raise NotImplementedError",
|
|
87
|
+
'\.\.\.\s*$',
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
[tool.ruff.lint]
|
|
91
|
+
select = [
|
|
92
|
+
"B",
|
|
93
|
+
"D",
|
|
94
|
+
"E",
|
|
95
|
+
"F",
|
|
96
|
+
"I",
|
|
97
|
+
"UP",
|
|
98
|
+
]
|
|
99
|
+
ignore = [
|
|
100
|
+
"E501",
|
|
101
|
+
"E722",
|
|
102
|
+
"D100",
|
|
103
|
+
"D101",
|
|
104
|
+
"D102",
|
|
105
|
+
"D103",
|
|
106
|
+
"D104",
|
|
107
|
+
"D105",
|
|
108
|
+
"D106",
|
|
109
|
+
"D107",
|
|
110
|
+
"D203",
|
|
111
|
+
"D213",
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
[tool.ruff.lint.isort]
|
|
115
|
+
force-single-line = true
|
|
116
|
+
|
|
117
|
+
[tool.ruff.format]
|
|
118
|
+
docstring-code-format = true
|
|
119
|
+
|
|
120
|
+
[tool.pytest.ini_options]
|
|
121
|
+
addopts = "--doctest-modules --doctest-glob='*.rst'"
|
|
122
|
+
doctest_optionflags = "ALLOW_UNICODE IGNORE_EXCEPTION_DETAIL ELLIPSIS"
|
|
123
|
+
|
|
124
|
+
[tool.tox]
|
|
125
|
+
requires = ["tox>=4.19"]
|
|
126
|
+
env_list = [
|
|
127
|
+
"style",
|
|
128
|
+
"py310",
|
|
129
|
+
"py311",
|
|
130
|
+
"py312",
|
|
131
|
+
"py313",
|
|
132
|
+
"py314",
|
|
133
|
+
"minversions",
|
|
134
|
+
"coverage",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
[tool.tox.env_run_base]
|
|
138
|
+
runner = "uv-venv-lock-runner"
|
|
139
|
+
dependency_groups = ["dev"]
|
|
140
|
+
commands = [[
|
|
141
|
+
"pytest",
|
|
142
|
+
"--showlocals",
|
|
143
|
+
"--full-trace",
|
|
144
|
+
"{posargs}",
|
|
145
|
+
]]
|
|
146
|
+
|
|
147
|
+
[tool.tox.env.style]
|
|
148
|
+
runner = "uv-venv-runner"
|
|
149
|
+
commands = [[
|
|
150
|
+
"prek",
|
|
151
|
+
"run",
|
|
152
|
+
"--all-files",
|
|
153
|
+
"--show-diff-on-failure",
|
|
154
|
+
]]
|
|
155
|
+
|
|
156
|
+
[tool.tox.env.minversions]
|
|
157
|
+
runner = "uv-venv-runner"
|
|
158
|
+
uv_resolution = "lowest-direct"
|
|
159
|
+
|
|
160
|
+
[tool.tox.env.coverage]
|
|
161
|
+
commands = [
|
|
162
|
+
[
|
|
163
|
+
"pytest",
|
|
164
|
+
"--cov",
|
|
165
|
+
"--cov-fail-under=100",
|
|
166
|
+
"--cov-report",
|
|
167
|
+
"term:skip-covered",
|
|
168
|
+
"{posargs}",
|
|
169
|
+
],
|
|
170
|
+
[
|
|
171
|
+
"coverage",
|
|
172
|
+
"html",
|
|
173
|
+
],
|
|
174
|
+
]
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.
|
|
2
|
+
requires = ["uv_build>=0.12.16,<0.13.0"]
|
|
3
3
|
build-backend = "uv_build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-server"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Lightweight SCIM2 server prototype"
|
|
9
9
|
authors = [
|
|
10
10
|
{name="Yaal Coop", email="contact@yaal.coop"},
|
|
@@ -11,7 +11,6 @@ from scim2_filter_parser.parser import SCIMParser
|
|
|
11
11
|
from scim2_models import Attribute
|
|
12
12
|
from scim2_models import BaseModel
|
|
13
13
|
from scim2_models import CaseExact
|
|
14
|
-
from scim2_models import Error
|
|
15
14
|
from scim2_models import Extension
|
|
16
15
|
from scim2_models import Meta
|
|
17
16
|
from scim2_models import Resource
|
|
@@ -19,11 +18,11 @@ from scim2_models import ResourceType
|
|
|
19
18
|
from scim2_models import Schema
|
|
20
19
|
from scim2_models import SearchRequest
|
|
21
20
|
from scim2_models import Uniqueness
|
|
21
|
+
from scim2_models import UniquenessException
|
|
22
22
|
from werkzeug.http import generate_etag
|
|
23
23
|
|
|
24
24
|
from scim2_server.filter import evaluate_filter
|
|
25
25
|
from scim2_server.operators import ResolveSortOperator
|
|
26
|
-
from scim2_server.utils import SCIMException
|
|
27
26
|
from scim2_server.utils import get_by_alias
|
|
28
27
|
|
|
29
28
|
|
|
@@ -120,10 +119,9 @@ class Backend:
|
|
|
120
119
|
Resources. The List must contain a copy of resources.
|
|
121
120
|
Mutating elements in the List must not modify the data
|
|
122
121
|
stored in the backend.
|
|
123
|
-
:raises
|
|
124
|
-
one resource type at a time, setting resource_type_id to
|
|
125
|
-
None the backend may raise
|
|
126
|
-
SCIMException(Error.make_too_many_error()).
|
|
122
|
+
:raises TooManyException: If the backend only supports querying
|
|
123
|
+
for one resource type at a time, setting resource_type_id to
|
|
124
|
+
None the backend may raise TooManyException.
|
|
127
125
|
"""
|
|
128
126
|
raise NotImplementedError
|
|
129
127
|
|
|
@@ -353,7 +351,7 @@ class InMemoryBackend(Backend):
|
|
|
353
351
|
if existing_resource.meta.resource_type == resource_type_id:
|
|
354
352
|
existing_value = unique_attribute.get_attribute(existing_resource)
|
|
355
353
|
if existing_value == new_value:
|
|
356
|
-
raise
|
|
354
|
+
raise UniquenessException()
|
|
357
355
|
|
|
358
356
|
self.resources.append(resource)
|
|
359
357
|
return resource
|
|
@@ -392,7 +390,7 @@ class InMemoryBackend(Backend):
|
|
|
392
390
|
existing_resource
|
|
393
391
|
)
|
|
394
392
|
if existing_value == new_value:
|
|
395
|
-
raise
|
|
393
|
+
raise UniquenessException()
|
|
396
394
|
|
|
397
395
|
self.resources[found_res_idx] = updated_resource
|
|
398
396
|
return updated_resource
|
|
@@ -44,9 +44,14 @@ def main():
|
|
|
44
44
|
type=argparse.FileType("w"),
|
|
45
45
|
help="Dump resources to a JSON file on exit",
|
|
46
46
|
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--debug",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="Enable the interactive debugger, the reloader and the logging of the WSGI environment",
|
|
51
|
+
)
|
|
47
52
|
args = parser.parse_args()
|
|
48
53
|
|
|
49
|
-
logging.basicConfig(level=logging.DEBUG)
|
|
54
|
+
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
|
50
55
|
|
|
51
56
|
from werkzeug.serving import run_simple
|
|
52
57
|
|
|
@@ -77,7 +82,8 @@ def main():
|
|
|
77
82
|
for bearer_token in args.bearer_token:
|
|
78
83
|
app.register_bearer_token(bearer_token)
|
|
79
84
|
|
|
80
|
-
|
|
85
|
+
if args.debug:
|
|
86
|
+
app = log_environ(app)
|
|
81
87
|
if args.reverse_proxy:
|
|
82
88
|
app = ProxyFix(app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1)
|
|
83
89
|
|
|
@@ -85,8 +91,8 @@ def main():
|
|
|
85
91
|
args.hostname,
|
|
86
92
|
args.port,
|
|
87
93
|
app,
|
|
88
|
-
use_debugger=
|
|
89
|
-
use_reloader=
|
|
94
|
+
use_debugger=args.debug,
|
|
95
|
+
use_reloader=args.debug,
|
|
90
96
|
)
|
|
91
97
|
|
|
92
98
|
if args.dump_resources:
|
|
@@ -3,9 +3,8 @@ from types import NoneType
|
|
|
3
3
|
from scim2_filter_parser import ast as scim2ast
|
|
4
4
|
from scim2_models import BaseModel
|
|
5
5
|
from scim2_models import CaseExact
|
|
6
|
-
from scim2_models import
|
|
6
|
+
from scim2_models import InvalidFilterException
|
|
7
7
|
|
|
8
|
-
from scim2_server.utils import SCIMException
|
|
9
8
|
from scim2_server.utils import get_by_alias
|
|
10
9
|
from scim2_server.utils import parse_new_value
|
|
11
10
|
|
|
@@ -125,4 +124,4 @@ def check_comparable_value(value):
|
|
|
125
124
|
status code 400) with "scimType" of "invalidFilter"."
|
|
126
125
|
"""
|
|
127
126
|
if isinstance(value, bytes | bool | NoneType):
|
|
128
|
-
raise
|
|
127
|
+
raise InvalidFilterException()
|
|
@@ -5,15 +5,18 @@ from scim2_filter_parser.lexer import SCIMLexer
|
|
|
5
5
|
from scim2_filter_parser.parser import SCIMParser
|
|
6
6
|
from scim2_models import BaseModel
|
|
7
7
|
from scim2_models import CaseExact
|
|
8
|
-
from scim2_models import
|
|
8
|
+
from scim2_models import InvalidPathException
|
|
9
|
+
from scim2_models import InvalidValueException
|
|
9
10
|
from scim2_models import Mutability
|
|
11
|
+
from scim2_models import MutabilityException
|
|
12
|
+
from scim2_models import NoTargetException
|
|
10
13
|
from scim2_models import PatchOperation
|
|
11
14
|
from scim2_models import Required
|
|
12
15
|
from scim2_models import Resource
|
|
13
16
|
from scim2_models import Returned
|
|
17
|
+
from scim2_models import SensitiveException
|
|
14
18
|
|
|
15
19
|
from scim2_server.filter import evaluate_filter
|
|
16
|
-
from scim2_server.utils import SCIMException
|
|
17
20
|
from scim2_server.utils import get_by_alias
|
|
18
21
|
from scim2_server.utils import get_or_create
|
|
19
22
|
from scim2_server.utils import handle_extension
|
|
@@ -55,7 +58,7 @@ def parse_attribute_path(attribute_path: str | None) -> dict[str, Any] | None:
|
|
|
55
58
|
|
|
56
59
|
match = ATTRIBUTE_PATH_REGEX.match(attribute_path)
|
|
57
60
|
if not match:
|
|
58
|
-
raise
|
|
61
|
+
raise InvalidPathException()
|
|
59
62
|
parse_attribute_path.cache[attribute_path] = match.groupdict()
|
|
60
63
|
return match.groupdict()
|
|
61
64
|
|
|
@@ -148,7 +151,7 @@ class Operator:
|
|
|
148
151
|
attribute_name = get_by_alias(type(model), attribute)
|
|
149
152
|
multi_valued_attribute = get_or_create(model, attribute_name, True)
|
|
150
153
|
if not isinstance(multi_valued_attribute, list):
|
|
151
|
-
raise
|
|
154
|
+
raise InvalidPathException()
|
|
152
155
|
token_stream = SCIMLexer().tokenize(condition)
|
|
153
156
|
condition = SCIMParser().parse(token_stream)
|
|
154
157
|
self.init_return(model, attribute_name, sub_attribute, self.value)
|
|
@@ -160,13 +163,13 @@ class Operator:
|
|
|
160
163
|
self, attribute: str, condition: str, model: BaseModel
|
|
161
164
|
):
|
|
162
165
|
if self.REQUIRES_VALUE and not isinstance(self.value, dict):
|
|
163
|
-
raise
|
|
166
|
+
raise InvalidValueException()
|
|
164
167
|
attribute_name = get_by_alias(type(model), attribute)
|
|
165
168
|
multi_valued_attribute = get_or_create(
|
|
166
169
|
model, attribute_name, self.REQUIRES_VALUE
|
|
167
170
|
)
|
|
168
171
|
if not isinstance(multi_valued_attribute, list):
|
|
169
|
-
raise
|
|
172
|
+
raise InvalidPathException()
|
|
170
173
|
token_stream = SCIMLexer().tokenize(condition)
|
|
171
174
|
condition = SCIMParser().parse(token_stream)
|
|
172
175
|
if self.REQUIRES_VALUE:
|
|
@@ -196,7 +199,7 @@ class Operator:
|
|
|
196
199
|
self.match_attribute(sub_path, value)
|
|
197
200
|
else:
|
|
198
201
|
if not isinstance(complex_attribute, BaseModel):
|
|
199
|
-
raise
|
|
202
|
+
raise InvalidPathException()
|
|
200
203
|
self.match_attribute(sub_path, complex_attribute)
|
|
201
204
|
|
|
202
205
|
def match_attribute(self, attribute: str, model: BaseModel):
|
|
@@ -205,9 +208,9 @@ class Operator:
|
|
|
205
208
|
|
|
206
209
|
def call_on_root(self, model: Resource):
|
|
207
210
|
if not self.OPERATE_ON_ROOT:
|
|
208
|
-
raise
|
|
211
|
+
raise NoTargetException()
|
|
209
212
|
if not isinstance(self.value, dict):
|
|
210
|
-
raise
|
|
213
|
+
raise InvalidValueException()
|
|
211
214
|
for k, v in self.value.items():
|
|
212
215
|
ext, scim_name = handle_extension(model, k)
|
|
213
216
|
if ext == model:
|
|
@@ -233,7 +236,7 @@ class AddOperator(Operator):
|
|
|
233
236
|
return
|
|
234
237
|
|
|
235
238
|
if model.get_field_annotation(alias, Mutability) == Mutability.read_only:
|
|
236
|
-
raise
|
|
239
|
+
raise MutabilityException()
|
|
237
240
|
|
|
238
241
|
if model.get_field_multiplicity(alias):
|
|
239
242
|
if getattr(model, alias) is None:
|
|
@@ -247,7 +250,7 @@ class AddOperator(Operator):
|
|
|
247
250
|
model.get_field_annotation(alias, Required) == Required.true
|
|
248
251
|
and not new_value
|
|
249
252
|
):
|
|
250
|
-
raise
|
|
253
|
+
raise InvalidValueException()
|
|
251
254
|
setattr(model, alias, new_value)
|
|
252
255
|
|
|
253
256
|
|
|
@@ -268,10 +271,10 @@ class RemoveOperator(Operator):
|
|
|
268
271
|
Mutability.read_only,
|
|
269
272
|
Mutability.immutable,
|
|
270
273
|
):
|
|
271
|
-
raise
|
|
274
|
+
raise MutabilityException()
|
|
272
275
|
|
|
273
276
|
if model.get_field_annotation(alias, Required) == Required.true:
|
|
274
|
-
raise
|
|
277
|
+
raise InvalidValueException()
|
|
275
278
|
|
|
276
279
|
setattr(model, alias, None)
|
|
277
280
|
|
|
@@ -283,7 +286,7 @@ class ReplaceOperator(Operator):
|
|
|
283
286
|
def operation(cls, model: BaseModel, attribute: str, value: Any):
|
|
284
287
|
alias = get_by_alias(type(model), attribute)
|
|
285
288
|
if model.get_field_multiplicity(alias) and not isinstance(value, list):
|
|
286
|
-
raise
|
|
289
|
+
raise InvalidValueException()
|
|
287
290
|
|
|
288
291
|
existing_value = getattr(model, alias)
|
|
289
292
|
new_value = parse_new_value(model, alias, value)
|
|
@@ -291,13 +294,13 @@ class ReplaceOperator(Operator):
|
|
|
291
294
|
return
|
|
292
295
|
|
|
293
296
|
if model.get_field_annotation(alias, Mutability) == Mutability.read_only:
|
|
294
|
-
raise
|
|
297
|
+
raise MutabilityException()
|
|
295
298
|
|
|
296
299
|
if (
|
|
297
300
|
model.get_field_annotation(alias, Required) == Required.true
|
|
298
301
|
and not new_value
|
|
299
302
|
):
|
|
300
|
-
raise
|
|
303
|
+
raise InvalidValueException()
|
|
301
304
|
setattr(model, alias, new_value)
|
|
302
305
|
|
|
303
306
|
|
|
@@ -372,7 +375,7 @@ class ResolveOperator(Operator):
|
|
|
372
375
|
model.get_field_annotation(alias, Mutability) == Mutability.write_only
|
|
373
376
|
or model.get_field_annotation(alias, Returned) == Returned.never
|
|
374
377
|
):
|
|
375
|
-
raise
|
|
378
|
+
raise SensitiveException()
|
|
376
379
|
|
|
377
380
|
@classmethod
|
|
378
381
|
def operation(
|
|
@@ -20,6 +20,7 @@ from scim2_models import PatchOp
|
|
|
20
20
|
from scim2_models import Resource
|
|
21
21
|
from scim2_models import ResourceType
|
|
22
22
|
from scim2_models import Schema
|
|
23
|
+
from scim2_models import SCIMException
|
|
23
24
|
from scim2_models import SearchRequest
|
|
24
25
|
from scim2_models import ServiceProviderConfig
|
|
25
26
|
from scim2_models import Sort
|
|
@@ -39,7 +40,6 @@ from werkzeug.routing.exceptions import RequestRedirect
|
|
|
39
40
|
|
|
40
41
|
from scim2_server.backend import Backend
|
|
41
42
|
from scim2_server.operators import patch_resource
|
|
42
|
-
from scim2_server.utils import SCIMException
|
|
43
43
|
from scim2_server.utils import merge_resources
|
|
44
44
|
|
|
45
45
|
|
|
@@ -537,7 +537,7 @@ class SCIMProvider:
|
|
|
537
537
|
return self.make_error(Error(status=e.code, detail=e.description))
|
|
538
538
|
except SCIMException as e:
|
|
539
539
|
self.log.exception(e)
|
|
540
|
-
return self.make_error(e.
|
|
540
|
+
return self.make_error(e.to_error())
|
|
541
541
|
except ValidationError as e:
|
|
542
542
|
self.log.exception(e)
|
|
543
543
|
return self.make_error(Error(status=400, detail=str(e)))
|
|
@@ -8,21 +8,16 @@ from typing import Any
|
|
|
8
8
|
from pydantic import EmailStr
|
|
9
9
|
from pydantic import ValidationError
|
|
10
10
|
from scim2_models import BaseModel
|
|
11
|
-
from scim2_models import Error
|
|
12
11
|
from scim2_models import Extension
|
|
12
|
+
from scim2_models import InvalidValueException
|
|
13
13
|
from scim2_models import Mutability
|
|
14
|
+
from scim2_models import MutabilityException
|
|
15
|
+
from scim2_models import NoTargetException
|
|
14
16
|
from scim2_models import Resource
|
|
15
17
|
from scim2_models import ResourceType
|
|
16
18
|
from scim2_models import Schema
|
|
17
19
|
|
|
18
20
|
|
|
19
|
-
class SCIMException(Exception):
|
|
20
|
-
"""A wrapper class, because an "Error" does not inherit from Exception and should not be raised."""
|
|
21
|
-
|
|
22
|
-
def __init__(self, scim_error: Error):
|
|
23
|
-
self.scim_error = scim_error
|
|
24
|
-
|
|
25
|
-
|
|
26
21
|
def load_json_resource(json_name: str) -> list:
|
|
27
22
|
"""Load a JSON document from the scim2_server package resources."""
|
|
28
23
|
fp = importlib.resources.files("scim2_server") / "resources" / json_name
|
|
@@ -69,7 +64,7 @@ def merge_resources(target: Resource, updates: BaseModel):
|
|
|
69
64
|
if mutability == Mutability.immutable and getattr(
|
|
70
65
|
target, set_attribute
|
|
71
66
|
) not in (None, new_value):
|
|
72
|
-
raise
|
|
67
|
+
raise MutabilityException()
|
|
73
68
|
setattr(target, set_attribute, new_value)
|
|
74
69
|
|
|
75
70
|
|
|
@@ -82,8 +77,8 @@ def get_by_alias(
|
|
|
82
77
|
:param scim_name: SCIM attribute name
|
|
83
78
|
:param allow_none: Allow returning None if attribute is not found
|
|
84
79
|
:return: pydantic attribute name
|
|
85
|
-
:raises
|
|
86
|
-
False
|
|
80
|
+
:raises NoTargetException: If no attribute is found and allow_none
|
|
81
|
+
is False
|
|
87
82
|
"""
|
|
88
83
|
try:
|
|
89
84
|
return next(
|
|
@@ -94,7 +89,7 @@ def get_by_alias(
|
|
|
94
89
|
except StopIteration as e:
|
|
95
90
|
if allow_none:
|
|
96
91
|
return None
|
|
97
|
-
raise
|
|
92
|
+
raise NoTargetException() from e
|
|
98
93
|
|
|
99
94
|
|
|
100
95
|
def get_or_create(
|
|
@@ -107,7 +102,7 @@ def get_or_create(
|
|
|
107
102
|
:param check_mutability: If True, validate that the attribute is
|
|
108
103
|
mutable
|
|
109
104
|
:return: A complex attribute model
|
|
110
|
-
:raises
|
|
105
|
+
:raises MutabilityException: If attribute is not mutable and
|
|
111
106
|
check_mutability is True
|
|
112
107
|
"""
|
|
113
108
|
if check_mutability:
|
|
@@ -115,7 +110,7 @@ def get_or_create(
|
|
|
115
110
|
Mutability.read_only,
|
|
116
111
|
Mutability.immutable,
|
|
117
112
|
):
|
|
118
|
-
raise
|
|
113
|
+
raise MutabilityException()
|
|
119
114
|
ret = getattr(model, attribute_name, None)
|
|
120
115
|
if not ret:
|
|
121
116
|
if model.get_field_multiplicity(attribute_name):
|
|
@@ -149,51 +144,56 @@ def handle_extension(resource: Resource, scim_name: str) -> tuple[BaseModel, str
|
|
|
149
144
|
return resource, scim_name
|
|
150
145
|
|
|
151
146
|
|
|
152
|
-
def
|
|
153
|
-
"""
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
147
|
+
def parse_value(field_root_type: type, value: Any) -> Any:
|
|
148
|
+
"""Parse a PATCH value according to the target field root type."""
|
|
149
|
+
if isinstance(value, dict):
|
|
150
|
+
if not hasattr(field_root_type, "model_fields"):
|
|
151
|
+
raise TypeError
|
|
152
|
+
|
|
153
|
+
# Work around mixed display/displayName payloads emitted by MS Entra.
|
|
154
|
+
if (
|
|
155
|
+
"display" not in value
|
|
156
|
+
and "display" in field_root_type.model_fields
|
|
157
|
+
and "displayName" in value
|
|
158
|
+
):
|
|
159
|
+
value = value.copy()
|
|
160
|
+
value["display"] = value["displayName"]
|
|
161
|
+
del value["displayName"]
|
|
162
|
+
return field_root_type.model_validate(value)
|
|
163
|
+
|
|
164
|
+
if field_root_type is bool and isinstance(value, str):
|
|
165
|
+
return not value.lower() == "false"
|
|
166
|
+
|
|
167
|
+
if field_root_type is datetime.datetime and isinstance(value, str):
|
|
168
|
+
# ISO 8601 datetime format (notably with the Z suffix) are only supported from Python 3.11
|
|
169
|
+
if sys.version_info < (3, 11): # pragma: no cover
|
|
170
|
+
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", value))
|
|
171
|
+
return datetime.datetime.fromisoformat(value)
|
|
172
|
+
|
|
173
|
+
if field_root_type is EmailStr and isinstance(value, str):
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
if hasattr(field_root_type, "model_fields"):
|
|
177
|
+
primary_value = get_by_alias(field_root_type, "value", True)
|
|
178
|
+
if primary_value is not None:
|
|
179
|
+
return field_root_type(value=value)
|
|
180
|
+
raise TypeError
|
|
181
|
+
|
|
182
|
+
return field_root_type(value)
|
|
162
183
|
|
|
163
184
|
|
|
164
185
|
def parse_new_value(model: BaseModel, attribute_name: str, value: Any) -> Any:
|
|
165
186
|
"""Given a model and attribute name, attempt to parse a new value so that the type matches the type expected by the model.
|
|
166
187
|
|
|
167
|
-
:raises
|
|
168
|
-
required type
|
|
188
|
+
:raises InvalidValueException: If attribute can not be mapped to
|
|
189
|
+
the required type
|
|
169
190
|
"""
|
|
170
191
|
field_root_type = model.get_field_root_type(attribute_name)
|
|
171
192
|
try:
|
|
172
|
-
if isinstance(value,
|
|
173
|
-
new_value =
|
|
174
|
-
elif isinstance(value, list):
|
|
175
|
-
new_value = [model_validate_from_dict(field_root_type, v) for v in value]
|
|
193
|
+
if isinstance(value, list):
|
|
194
|
+
new_value = [parse_value(field_root_type, v) for v in value]
|
|
176
195
|
else:
|
|
177
|
-
|
|
178
|
-
new_value = not value.lower() == "false"
|
|
179
|
-
elif field_root_type is datetime.datetime and isinstance(value, str):
|
|
180
|
-
# ISO 8601 datetime format (notably with the Z suffix) are only supported from Python 3.11
|
|
181
|
-
if sys.version_info < (3, 11): # pragma: no cover
|
|
182
|
-
new_value = datetime.datetime.fromisoformat(
|
|
183
|
-
re.sub(r"Z$", "+00:00", value)
|
|
184
|
-
)
|
|
185
|
-
else:
|
|
186
|
-
new_value = datetime.datetime.fromisoformat(value)
|
|
187
|
-
elif field_root_type is EmailStr and isinstance(value, str):
|
|
188
|
-
new_value = value
|
|
189
|
-
elif hasattr(field_root_type, "model_fields"):
|
|
190
|
-
primary_value = get_by_alias(field_root_type, "value", True)
|
|
191
|
-
if primary_value is not None:
|
|
192
|
-
new_value = field_root_type(value=value)
|
|
193
|
-
else:
|
|
194
|
-
raise TypeError
|
|
195
|
-
else:
|
|
196
|
-
new_value = field_root_type(value)
|
|
196
|
+
new_value = parse_value(field_root_type, value)
|
|
197
197
|
except (AttributeError, TypeError, ValueError, ValidationError) as e:
|
|
198
|
-
raise
|
|
198
|
+
raise InvalidValueException() from e
|
|
199
199
|
return new_value
|
|
File without changes
|
|
File without changes
|
{scim2_server-0.1.8 → scim2_server-0.2.0}/scim2_server/resources/default-resource-types.json
RENAMED
|
File without changes
|
|
File without changes
|