mitol-django-scim 2025.3.31__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.
- mitol_django_scim-2025.3.31/.gitignore +73 -0
- mitol_django_scim-2025.3.31/CHANGELOG.md +16 -0
- mitol_django_scim-2025.3.31/PKG-INFO +51 -0
- mitol_django_scim-2025.3.31/README.md +36 -0
- mitol_django_scim-2025.3.31/mitol/__init__.py +1 -0
- mitol_django_scim-2025.3.31/mitol/scim/README.md +36 -0
- mitol_django_scim-2025.3.31/mitol/scim/__init__.py +6 -0
- mitol_django_scim-2025.3.31/mitol/scim/adapters.py +263 -0
- mitol_django_scim-2025.3.31/mitol/scim/apps.py +14 -0
- mitol_django_scim-2025.3.31/mitol/scim/config.py +13 -0
- mitol_django_scim-2025.3.31/mitol/scim/constants.py +16 -0
- mitol_django_scim-2025.3.31/mitol/scim/filters.py +125 -0
- mitol_django_scim-2025.3.31/mitol/scim/forms.py +0 -0
- mitol_django_scim-2025.3.31/mitol/scim/parser.py +194 -0
- mitol_django_scim-2025.3.31/mitol/scim/settings/__init__.py +0 -0
- mitol_django_scim-2025.3.31/mitol/scim/settings/scim.py +20 -0
- mitol_django_scim-2025.3.31/mitol/scim/urls.py +18 -0
- mitol_django_scim-2025.3.31/mitol/scim/utils.py +6 -0
- mitol_django_scim-2025.3.31/mitol/scim/views.py +214 -0
- mitol_django_scim-2025.3.31/pyproject.toml +41 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Temporary Python files
|
|
2
|
+
*.pyc
|
|
3
|
+
*.egg-info
|
|
4
|
+
__pycache__
|
|
5
|
+
.ipynb_checkpoints
|
|
6
|
+
|
|
7
|
+
# Temporary OS files
|
|
8
|
+
Icon*
|
|
9
|
+
|
|
10
|
+
# Temporary virtual environment files
|
|
11
|
+
.cache/
|
|
12
|
+
*venv/
|
|
13
|
+
|
|
14
|
+
# Temporary server files
|
|
15
|
+
.env
|
|
16
|
+
*.pid
|
|
17
|
+
|
|
18
|
+
# Testing and coverage results
|
|
19
|
+
.coverage
|
|
20
|
+
.coverage.*
|
|
21
|
+
coverage.*
|
|
22
|
+
htmlcov/
|
|
23
|
+
|
|
24
|
+
# Build and release directories
|
|
25
|
+
build/
|
|
26
|
+
dist/
|
|
27
|
+
*.spec
|
|
28
|
+
|
|
29
|
+
# Sublime Text
|
|
30
|
+
*.sublime-workspace
|
|
31
|
+
|
|
32
|
+
# Eclipse
|
|
33
|
+
.settings
|
|
34
|
+
|
|
35
|
+
# Pycharm/IDEA
|
|
36
|
+
.idea/
|
|
37
|
+
|
|
38
|
+
# OSX
|
|
39
|
+
*.DS_Store
|
|
40
|
+
|
|
41
|
+
# vscode
|
|
42
|
+
.vscode/
|
|
43
|
+
|
|
44
|
+
#pyenv
|
|
45
|
+
.python-version
|
|
46
|
+
|
|
47
|
+
# pytest
|
|
48
|
+
.pytest_cache/
|
|
49
|
+
|
|
50
|
+
# tox
|
|
51
|
+
.tox
|
|
52
|
+
coverage/
|
|
53
|
+
|
|
54
|
+
# poetry
|
|
55
|
+
pip-wheel-metadata/
|
|
56
|
+
setup.py
|
|
57
|
+
|
|
58
|
+
# django
|
|
59
|
+
db.sqlite3
|
|
60
|
+
testapp/settings/dev.py
|
|
61
|
+
|
|
62
|
+
# mypy
|
|
63
|
+
.mypy_cache/
|
|
64
|
+
|
|
65
|
+
# Pants workspace files
|
|
66
|
+
/.pants.d/
|
|
67
|
+
/dist/
|
|
68
|
+
/.pids
|
|
69
|
+
/.pants.workdir.file_lock*
|
|
70
|
+
*.pex
|
|
71
|
+
|
|
72
|
+
# SSH keys/etc.
|
|
73
|
+
ssh/*
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
All notable changes to this project will be documented in this file.
|
|
3
|
+
|
|
4
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
5
|
+
and this project uses date-based versioning.
|
|
6
|
+
|
|
7
|
+
<!-- scriv-insert-here -->
|
|
8
|
+
|
|
9
|
+
<a id='changelog-2025.3.31'></a>
|
|
10
|
+
## [2025.3.31] - 2025-03-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add the mitol-django-scim app
|
|
15
|
+
|
|
16
|
+
- Added a minimum version for pyparsing.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mitol-django-scim
|
|
3
|
+
Version: 2025.3.31
|
|
4
|
+
Summary: Django application for SCIM integrations
|
|
5
|
+
License-Expression: BSD-3-Clause
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: django-scim2>=0.19.1
|
|
8
|
+
Requires-Dist: django-stubs>=1.13.1
|
|
9
|
+
Requires-Dist: django>=3.0
|
|
10
|
+
Requires-Dist: mitol-django-common
|
|
11
|
+
Requires-Dist: pyparsing>=3.2
|
|
12
|
+
Requires-Dist: strenum>=0.4.15
|
|
13
|
+
Requires-Dist: strenum>=0.4.15; python_version < '3.11'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
## SCIM
|
|
17
|
+
|
|
18
|
+
## Prerequisites
|
|
19
|
+
|
|
20
|
+
- You need the following a local [Keycloak](https://www.keycloak.org/) instance running. Note which major version you are running (should be at least 26.x).
|
|
21
|
+
- You should have custom user profile fields setup on your `olapps` realm:
|
|
22
|
+
- `fullName`: required, otherwise defaults
|
|
23
|
+
- `emailOptIn`: defaults
|
|
24
|
+
|
|
25
|
+
## Install the scim-for-keycloak plugin
|
|
26
|
+
|
|
27
|
+
Sign up for an account on https://scim-for-keycloak.de and follow the instructions here: https://scim-for-keycloak.de/documentation/installation/install
|
|
28
|
+
|
|
29
|
+
## Configure SCIM
|
|
30
|
+
|
|
31
|
+
In the SCIM admin console, do the following:
|
|
32
|
+
|
|
33
|
+
### Configure Remote SCIM Provider
|
|
34
|
+
|
|
35
|
+
- In django-admin, go to OAuth Toolkit and create a new access token
|
|
36
|
+
- Go to Remote SCIM Provider
|
|
37
|
+
- Click the `+` button
|
|
38
|
+
- Specify a base URL for your learn API backend: `http://<IP_OR_HOSTNAME>:8063/scim/v2/`
|
|
39
|
+
- At the bottom of the page, click "Use default configuration"
|
|
40
|
+
- Add a new authentication method:
|
|
41
|
+
- Type: Long Life Bearer Token
|
|
42
|
+
- Bearer Token: the access token you created above
|
|
43
|
+
- On the Schemas tab, edit the User schema and add these custom attributes:
|
|
44
|
+
- Add a `fullName` attribute and set the Custom Attribute Name to `fullName`
|
|
45
|
+
- Add an attribute named `emailOptIn` with the following settings:
|
|
46
|
+
- Type: integer
|
|
47
|
+
- Custom Attribute Name: `emailOptIn`
|
|
48
|
+
- On the Realm Assignments tab, assign to the `olapps` realm
|
|
49
|
+
- Go to the Synchronization tab and perform one:
|
|
50
|
+
- Identifier attribute: email
|
|
51
|
+
- Synchronization strategy: Search and Bulk
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
## SCIM
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
- You need the following a local [Keycloak](https://www.keycloak.org/) instance running. Note which major version you are running (should be at least 26.x).
|
|
6
|
+
- You should have custom user profile fields setup on your `olapps` realm:
|
|
7
|
+
- `fullName`: required, otherwise defaults
|
|
8
|
+
- `emailOptIn`: defaults
|
|
9
|
+
|
|
10
|
+
## Install the scim-for-keycloak plugin
|
|
11
|
+
|
|
12
|
+
Sign up for an account on https://scim-for-keycloak.de and follow the instructions here: https://scim-for-keycloak.de/documentation/installation/install
|
|
13
|
+
|
|
14
|
+
## Configure SCIM
|
|
15
|
+
|
|
16
|
+
In the SCIM admin console, do the following:
|
|
17
|
+
|
|
18
|
+
### Configure Remote SCIM Provider
|
|
19
|
+
|
|
20
|
+
- In django-admin, go to OAuth Toolkit and create a new access token
|
|
21
|
+
- Go to Remote SCIM Provider
|
|
22
|
+
- Click the `+` button
|
|
23
|
+
- Specify a base URL for your learn API backend: `http://<IP_OR_HOSTNAME>:8063/scim/v2/`
|
|
24
|
+
- At the bottom of the page, click "Use default configuration"
|
|
25
|
+
- Add a new authentication method:
|
|
26
|
+
- Type: Long Life Bearer Token
|
|
27
|
+
- Bearer Token: the access token you created above
|
|
28
|
+
- On the Schemas tab, edit the User schema and add these custom attributes:
|
|
29
|
+
- Add a `fullName` attribute and set the Custom Attribute Name to `fullName`
|
|
30
|
+
- Add an attribute named `emailOptIn` with the following settings:
|
|
31
|
+
- Type: integer
|
|
32
|
+
- Custom Attribute Name: `emailOptIn`
|
|
33
|
+
- On the Realm Assignments tab, assign to the `olapps` realm
|
|
34
|
+
- Go to the Synchronization tab and perform one:
|
|
35
|
+
- Identifier attribute: email
|
|
36
|
+
- Synchronization strategy: Search and Bulk
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__import__("pkg_resources").declare_namespace(__name__)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
## SCIM
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
- You need the following a local [Keycloak](https://www.keycloak.org/) instance running. Note which major version you are running (should be at least 26.x).
|
|
6
|
+
- You should have custom user profile fields setup on your `olapps` realm:
|
|
7
|
+
- `fullName`: required, otherwise defaults
|
|
8
|
+
- `emailOptIn`: defaults
|
|
9
|
+
|
|
10
|
+
## Install the scim-for-keycloak plugin
|
|
11
|
+
|
|
12
|
+
Sign up for an account on https://scim-for-keycloak.de and follow the instructions here: https://scim-for-keycloak.de/documentation/installation/install
|
|
13
|
+
|
|
14
|
+
## Configure SCIM
|
|
15
|
+
|
|
16
|
+
In the SCIM admin console, do the following:
|
|
17
|
+
|
|
18
|
+
### Configure Remote SCIM Provider
|
|
19
|
+
|
|
20
|
+
- In django-admin, go to OAuth Toolkit and create a new access token
|
|
21
|
+
- Go to Remote SCIM Provider
|
|
22
|
+
- Click the `+` button
|
|
23
|
+
- Specify a base URL for your learn API backend: `http://<IP_OR_HOSTNAME>:8063/scim/v2/`
|
|
24
|
+
- At the bottom of the page, click "Use default configuration"
|
|
25
|
+
- Add a new authentication method:
|
|
26
|
+
- Type: Long Life Bearer Token
|
|
27
|
+
- Bearer Token: the access token you created above
|
|
28
|
+
- On the Schemas tab, edit the User schema and add these custom attributes:
|
|
29
|
+
- Add a `fullName` attribute and set the Custom Attribute Name to `fullName`
|
|
30
|
+
- Add an attribute named `emailOptIn` with the following settings:
|
|
31
|
+
- Type: integer
|
|
32
|
+
- Custom Attribute Name: `emailOptIn`
|
|
33
|
+
- On the Realm Assignments tab, assign to the `olapps` realm
|
|
34
|
+
- Go to the Synchronization tab and perform one:
|
|
35
|
+
- Identifier attribute: email
|
|
36
|
+
- Synchronization strategy: Search and Bulk
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Optional, Union
|
|
4
|
+
|
|
5
|
+
from django.contrib.auth import get_user_model
|
|
6
|
+
from django.db import transaction
|
|
7
|
+
from django_scim import constants
|
|
8
|
+
from django_scim.adapters import SCIMUser
|
|
9
|
+
from scim2_filter_parser.attr_paths import AttrPath
|
|
10
|
+
|
|
11
|
+
User = get_user_model()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_user_model_for_scim():
|
|
18
|
+
"""
|
|
19
|
+
Get function for the django_scim library configuration (USER_MODEL_GETTER).
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
model: User model.
|
|
23
|
+
"""
|
|
24
|
+
return User
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UserAdapter(SCIMUser):
|
|
28
|
+
"""
|
|
29
|
+
Custom adapter to extend django_scim library.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
password_changed = False
|
|
33
|
+
activity_changed = False
|
|
34
|
+
|
|
35
|
+
resource_type = "User"
|
|
36
|
+
|
|
37
|
+
id_field = "scim_id"
|
|
38
|
+
|
|
39
|
+
ATTR_MAP = {
|
|
40
|
+
("active", None, None): "is_active",
|
|
41
|
+
("name", "givenName", None): "first_name",
|
|
42
|
+
("name", "familyName", None): "last_name",
|
|
43
|
+
("userName", None, None): "username",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
IGNORED_PATHS = {
|
|
47
|
+
("schemas", None, None),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_new_user(self):
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
bool: True is the user does not currently exist,
|
|
56
|
+
False if the user already exists.
|
|
57
|
+
"""
|
|
58
|
+
return not bool(self.obj.id)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def id(self):
|
|
62
|
+
"""
|
|
63
|
+
Return the SCIM id
|
|
64
|
+
"""
|
|
65
|
+
return self.obj.scim_id
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def emails(self):
|
|
69
|
+
"""
|
|
70
|
+
Return the email of the user per the SCIM spec.
|
|
71
|
+
"""
|
|
72
|
+
return [{"value": self.obj.email, "primary": True}]
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def display_name(self):
|
|
76
|
+
"""
|
|
77
|
+
Return the displayName of the user per the SCIM spec.
|
|
78
|
+
"""
|
|
79
|
+
return f"{self.obj.first_name} {self.obj.last_name}"
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def meta(self):
|
|
83
|
+
"""
|
|
84
|
+
Return the meta object of the user per the SCIM spec.
|
|
85
|
+
"""
|
|
86
|
+
return {
|
|
87
|
+
"resourceType": self.resource_type,
|
|
88
|
+
"created": self.obj.created_on.isoformat(timespec="milliseconds"),
|
|
89
|
+
"lastModified": self.obj.updated_on.isoformat(timespec="milliseconds"),
|
|
90
|
+
"location": self.location,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
def to_dict(self):
|
|
94
|
+
"""
|
|
95
|
+
Return a ``dict`` conforming to the SCIM User Schema,
|
|
96
|
+
ready for conversion to a JSON object.
|
|
97
|
+
"""
|
|
98
|
+
return {
|
|
99
|
+
"id": self.id,
|
|
100
|
+
"externalId": self.obj.scim_external_id,
|
|
101
|
+
"schemas": [constants.SchemaURI.USER],
|
|
102
|
+
"userName": self.obj.username,
|
|
103
|
+
"name": {
|
|
104
|
+
"givenName": self.obj.first_name,
|
|
105
|
+
"familyName": self.obj.last_name,
|
|
106
|
+
},
|
|
107
|
+
"displayName": self.display_name,
|
|
108
|
+
"emails": self.emails,
|
|
109
|
+
"active": self.obj.is_active,
|
|
110
|
+
"groups": [],
|
|
111
|
+
"meta": self.meta,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
def from_dict(self, d):
|
|
115
|
+
"""
|
|
116
|
+
Consume a ``dict`` conforming to the SCIM User Schema, updating the
|
|
117
|
+
internal user object with data from the ``dict``.
|
|
118
|
+
|
|
119
|
+
Please note, the user object is not saved within this method. To
|
|
120
|
+
persist the changes made by this method, please call ``.save()`` on the
|
|
121
|
+
adapter. Eg::
|
|
122
|
+
|
|
123
|
+
scim_user.from_dict(d)
|
|
124
|
+
scim_user.save()
|
|
125
|
+
"""
|
|
126
|
+
self.parse_emails(d.get("emails"))
|
|
127
|
+
|
|
128
|
+
self.obj.is_active = d.get("active", True)
|
|
129
|
+
self.obj.username = d.get("userName")
|
|
130
|
+
self.obj.first_name = d.get("name", {}).get("givenName", "")
|
|
131
|
+
self.obj.last_name = d.get("name", {}).get("familyName", "")
|
|
132
|
+
self.obj.scim_username = d.get("userName")
|
|
133
|
+
self.obj.scim_external_id = d.get("externalId")
|
|
134
|
+
|
|
135
|
+
def _save_user(self):
|
|
136
|
+
self.obj.save()
|
|
137
|
+
|
|
138
|
+
def _save_related(self):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
def save(self):
|
|
142
|
+
"""
|
|
143
|
+
Save instances of the Profile and User models.
|
|
144
|
+
"""
|
|
145
|
+
with transaction.atomic():
|
|
146
|
+
# user must be saved first due to FK Profile -> User
|
|
147
|
+
self._save_user()
|
|
148
|
+
self._save_related()
|
|
149
|
+
logger.info("User saved. User id %i", self.obj.id)
|
|
150
|
+
|
|
151
|
+
def delete(self):
|
|
152
|
+
"""
|
|
153
|
+
Update User's is_active to False.
|
|
154
|
+
"""
|
|
155
|
+
self.obj.is_active = False
|
|
156
|
+
self.obj.save()
|
|
157
|
+
logger.info("Deactivated user id %i", self.obj.id)
|
|
158
|
+
|
|
159
|
+
def handle_add(
|
|
160
|
+
self,
|
|
161
|
+
path: Optional[AttrPath],
|
|
162
|
+
value: Union[str, list, dict],
|
|
163
|
+
operation: dict, # noqa: ARG002
|
|
164
|
+
):
|
|
165
|
+
"""
|
|
166
|
+
Handle add operations per:
|
|
167
|
+
https://tools.ietf.org/html/rfc7644#section-3.5.2.1
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
path (AttrPath)
|
|
171
|
+
value (Union[str, list, dict])
|
|
172
|
+
"""
|
|
173
|
+
if path is None:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
if path.first_path == ("externalId", None, None):
|
|
177
|
+
self.obj.scim_external_id = value
|
|
178
|
+
self.obj.save()
|
|
179
|
+
|
|
180
|
+
def parse_scim_for_keycloak_payload(self, payload: str) -> dict:
|
|
181
|
+
"""
|
|
182
|
+
Parse the payload sent from scim-for-keycloak and normalize it
|
|
183
|
+
"""
|
|
184
|
+
result = {}
|
|
185
|
+
|
|
186
|
+
for key, value in json.loads(payload).items():
|
|
187
|
+
if key == "schema":
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
if isinstance(value, dict):
|
|
191
|
+
for nested_key, nested_value in value.items():
|
|
192
|
+
result[self.split_path(f"{key}.{nested_key}")] = nested_value
|
|
193
|
+
else:
|
|
194
|
+
result[key] = value
|
|
195
|
+
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
def parse_path_and_values(
|
|
199
|
+
self, path: Optional[str], value: Union[str, list, dict]
|
|
200
|
+
) -> list:
|
|
201
|
+
"""Parse the incoming value(s)"""
|
|
202
|
+
if isinstance(value, str):
|
|
203
|
+
# scim-for-keycloak sends this as a noncompliant JSON-encoded string
|
|
204
|
+
if path is None:
|
|
205
|
+
val = json.loads(value)
|
|
206
|
+
else:
|
|
207
|
+
msg = "Called with a non-null path and a str value"
|
|
208
|
+
raise ValueError(msg)
|
|
209
|
+
else:
|
|
210
|
+
val = value
|
|
211
|
+
|
|
212
|
+
results = []
|
|
213
|
+
|
|
214
|
+
for attr_path, attr_value in val.items():
|
|
215
|
+
if isinstance(attr_value, dict):
|
|
216
|
+
# nested object, we want to recursively flatten it to `first.second`
|
|
217
|
+
results.extend(self.parse_path_and_values(attr_path, attr_value))
|
|
218
|
+
else:
|
|
219
|
+
flattened_path = (
|
|
220
|
+
f"{path}.{attr_path}" if path is not None else attr_path
|
|
221
|
+
)
|
|
222
|
+
new_path = self.split_path(flattened_path)
|
|
223
|
+
new_value = attr_value
|
|
224
|
+
results.append((new_path, new_value))
|
|
225
|
+
|
|
226
|
+
return results
|
|
227
|
+
|
|
228
|
+
def _handle_resplace_nested_path(self, nested_path, nested_value):
|
|
229
|
+
"""Handle processing a nested path"""
|
|
230
|
+
if nested_path.first_path in self.ATTR_MAP:
|
|
231
|
+
setattr(self.obj, self.ATTR_MAP[nested_path.first_path], nested_value)
|
|
232
|
+
elif nested_path.first_path == ("emails", None, None):
|
|
233
|
+
self.parse_emails(nested_value)
|
|
234
|
+
else:
|
|
235
|
+
return False
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
def handle_replace(
|
|
239
|
+
self,
|
|
240
|
+
path: Optional[AttrPath],
|
|
241
|
+
value: Union[str, list, dict],
|
|
242
|
+
operation: dict, # noqa: ARG002
|
|
243
|
+
):
|
|
244
|
+
"""
|
|
245
|
+
Handle the replace operations.
|
|
246
|
+
|
|
247
|
+
All operations happen within an atomic transaction.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
if not isinstance(value, dict):
|
|
251
|
+
# Restructure for use in loop below.
|
|
252
|
+
value = {path: value}
|
|
253
|
+
|
|
254
|
+
for nested_path, nested_value in (value or {}).items():
|
|
255
|
+
if (
|
|
256
|
+
not self._handle_resplace_nested_path(nested_path, nested_value)
|
|
257
|
+
and nested_path.first_path not in self.IGNORED_PATHS
|
|
258
|
+
):
|
|
259
|
+
logger.debug(
|
|
260
|
+
"Ignoring SCIM update for path: %s", nested_path.first_path
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
self.save()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ScimApp(AppConfig):
|
|
7
|
+
name = "mitol.scim"
|
|
8
|
+
label = "scim"
|
|
9
|
+
verbose_name = "SCIM"
|
|
10
|
+
|
|
11
|
+
required_settings = []
|
|
12
|
+
|
|
13
|
+
# necessary because this is a namespaced app
|
|
14
|
+
path = os.path.dirname(os.path.abspath(__file__)) # noqa: PTH100, PTH120
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django_scim.models import SCIMServiceProviderConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LearnSCIMServiceProviderConfig(SCIMServiceProviderConfig):
|
|
5
|
+
"""Custom provider config"""
|
|
6
|
+
|
|
7
|
+
def to_dict(self):
|
|
8
|
+
result = super().to_dict()
|
|
9
|
+
|
|
10
|
+
result["bulk"]["supported"] = True
|
|
11
|
+
result["filter"]["supported"] = True
|
|
12
|
+
|
|
13
|
+
return result
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""SCIM constants"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SchemaURI:
|
|
5
|
+
BULK_REQUEST = "urn:ietf:params:scim:api:messages:2.0:BulkRequest"
|
|
6
|
+
|
|
7
|
+
BULK_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:BulkResponse"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SORT_MAPPING = {
|
|
11
|
+
"id": "id",
|
|
12
|
+
"userName": "username",
|
|
13
|
+
"email": "email",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
VALID_SORTS = SORT_MAPPING.keys()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import operator
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from django.contrib.auth import get_user_model
|
|
6
|
+
from django.db.models import Model, Q
|
|
7
|
+
from pyparsing import ParseResults
|
|
8
|
+
|
|
9
|
+
from mitol.scim.parser import Filters, TermType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FilterQuery:
|
|
13
|
+
"""Filters for users"""
|
|
14
|
+
|
|
15
|
+
model_cls: type[Model]
|
|
16
|
+
|
|
17
|
+
attr_map: dict[tuple[str, Optional[str]], tuple[str, ...]]
|
|
18
|
+
|
|
19
|
+
related_selects: list[str] = []
|
|
20
|
+
|
|
21
|
+
dj_op_mapping = {
|
|
22
|
+
"eq": "exact",
|
|
23
|
+
"ne": "exact",
|
|
24
|
+
"gt": "gt",
|
|
25
|
+
"ge": "gte",
|
|
26
|
+
"lt": "lt",
|
|
27
|
+
"le": "lte",
|
|
28
|
+
"pr": "isnull",
|
|
29
|
+
"co": "contains",
|
|
30
|
+
"sw": "startswith",
|
|
31
|
+
"ew": "endswith",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
dj_negated_ops = ("ne", "pr")
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def _filter_expr(cls, parsed: ParseResults) -> Q:
|
|
38
|
+
if parsed is None:
|
|
39
|
+
msg = "Expected a filter, got: None"
|
|
40
|
+
raise ValueError(msg)
|
|
41
|
+
|
|
42
|
+
if parsed.term_type == TermType.attr_expr:
|
|
43
|
+
return cls._attr_expr(parsed)
|
|
44
|
+
|
|
45
|
+
msg = f"Unsupported term type: {parsed.term_type}"
|
|
46
|
+
raise ValueError(msg)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def _attr_expr(cls, parsed: ParseResults) -> Q:
|
|
50
|
+
dj_op = cls.dj_op_mapping[parsed.comparison_operator.lower()]
|
|
51
|
+
|
|
52
|
+
scim_keys = (parsed.attr_name, parsed.sub_attr)
|
|
53
|
+
|
|
54
|
+
path_parts = list(
|
|
55
|
+
filter(
|
|
56
|
+
lambda part: part is not None,
|
|
57
|
+
(
|
|
58
|
+
*cls.attr_map.get(scim_keys, scim_keys),
|
|
59
|
+
dj_op,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
path = "__".join(path_parts)
|
|
64
|
+
|
|
65
|
+
q = Q(**{path: parsed.value})
|
|
66
|
+
|
|
67
|
+
if parsed.comparison_operator in cls.dj_negated_ops:
|
|
68
|
+
q = ~q
|
|
69
|
+
|
|
70
|
+
return q
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def _filters(cls, parsed: ParseResults) -> Q:
|
|
74
|
+
parsed_iter = iter(parsed)
|
|
75
|
+
q = cls._filter_expr(next(parsed_iter))
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
while operator := cls._logical_op(next(parsed_iter)):
|
|
79
|
+
filter_q = cls._filter_expr(next(parsed_iter))
|
|
80
|
+
|
|
81
|
+
# combine the previous and next Q() objects using the bitwise operator
|
|
82
|
+
q = operator(q, filter_q)
|
|
83
|
+
except StopIteration:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
return q
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def _logical_op(cls, parsed: ParseResults) -> Callable[[Q, Q], Q] | None:
|
|
90
|
+
"""Convert a defined operator to the corresponding bitwise operator"""
|
|
91
|
+
if parsed is None:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
if parsed.logical_operator.lower() == "and":
|
|
95
|
+
return operator.and_
|
|
96
|
+
elif parsed.logical_operator.lower() == "or":
|
|
97
|
+
return operator.or_
|
|
98
|
+
else:
|
|
99
|
+
msg = f"Unexpected operator: {parsed.operator}"
|
|
100
|
+
raise ValueError(msg)
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def search(cls, filter_query, request=None): # noqa: ARG003
|
|
104
|
+
"""Create a search query"""
|
|
105
|
+
parsed = Filters.parse_string(filter_query, parse_all=True)
|
|
106
|
+
|
|
107
|
+
return cls.model_cls.objects.select_related(*cls.related_selects).filter(
|
|
108
|
+
cls._filters(parsed)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class UserFilterQuery(FilterQuery):
|
|
113
|
+
"""FilterQuery for User"""
|
|
114
|
+
|
|
115
|
+
attr_map: dict[tuple[str, Optional[str]], tuple[str, ...]] = {
|
|
116
|
+
("userName", None): ("username",),
|
|
117
|
+
("emails", "value"): ("email",),
|
|
118
|
+
("active", None): ("is_active",),
|
|
119
|
+
("name", "givenName"): ("first_name",),
|
|
120
|
+
("name", "familyName"): ("last_name",),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
related_selects = []
|
|
124
|
+
|
|
125
|
+
model_cls = get_user_model()
|
|
File without changes
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SCIM filter parsers
|
|
3
|
+
+ _tag_term_type(TermType.attr_name)
|
|
4
|
+
|
|
5
|
+
This module aims to compliantly parse SCIM filter queries per the spec:
|
|
6
|
+
https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2
|
|
7
|
+
|
|
8
|
+
Note that this implementation defines things slightly differently
|
|
9
|
+
because a naive implementation exactly matching the filter grammar will
|
|
10
|
+
result in hitting Python's recursion limit because the grammar defines
|
|
11
|
+
logical lists (AND/OR chains) as a recursive relationship.
|
|
12
|
+
|
|
13
|
+
This implementation avoids that by defining separately FilterExpr and
|
|
14
|
+
Filter. As a result of this, some definitions are collapsed and removed
|
|
15
|
+
(e.g. valFilter => FilterExpr).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from enum import auto
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
except ImportError:
|
|
23
|
+
from strenum import StrEnum
|
|
24
|
+
|
|
25
|
+
from pyparsing import (
|
|
26
|
+
CaselessKeyword,
|
|
27
|
+
Char,
|
|
28
|
+
Combine,
|
|
29
|
+
DelimitedList,
|
|
30
|
+
FollowedBy,
|
|
31
|
+
Forward,
|
|
32
|
+
Group,
|
|
33
|
+
Literal,
|
|
34
|
+
Suppress,
|
|
35
|
+
Tag,
|
|
36
|
+
alphanums,
|
|
37
|
+
alphas,
|
|
38
|
+
common,
|
|
39
|
+
dbl_quoted_string,
|
|
40
|
+
nested_expr,
|
|
41
|
+
one_of,
|
|
42
|
+
remove_quotes,
|
|
43
|
+
ungroup,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TagName(StrEnum):
|
|
48
|
+
"""Tag names"""
|
|
49
|
+
|
|
50
|
+
term_type = auto()
|
|
51
|
+
value_type = auto()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TermType(StrEnum):
|
|
55
|
+
"""Tag term type"""
|
|
56
|
+
|
|
57
|
+
urn = auto()
|
|
58
|
+
attr_name = auto()
|
|
59
|
+
attr_path = auto()
|
|
60
|
+
attr_expr = auto()
|
|
61
|
+
value_path = auto()
|
|
62
|
+
presence = auto()
|
|
63
|
+
|
|
64
|
+
logical_op = auto()
|
|
65
|
+
compare_op = auto()
|
|
66
|
+
negation_op = auto()
|
|
67
|
+
|
|
68
|
+
filter_expr = auto()
|
|
69
|
+
filters = auto()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ValueType(StrEnum):
|
|
73
|
+
"""Tag value_type"""
|
|
74
|
+
|
|
75
|
+
boolean = auto()
|
|
76
|
+
number = auto()
|
|
77
|
+
string = auto()
|
|
78
|
+
null = auto()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _tag_term_type(term_type: TermType) -> Tag:
|
|
82
|
+
return Tag(TagName.term_type.name, term_type)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _tag_value_type(value_type: ValueType) -> Tag:
|
|
86
|
+
return Tag(TagName.value_type.name, value_type)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
NameChar = Char(alphanums + "_-")
|
|
90
|
+
AttrName = Combine(
|
|
91
|
+
Char(alphas)
|
|
92
|
+
+ NameChar[...]
|
|
93
|
+
# ensure we're not somehow parsing an URN
|
|
94
|
+
+ ~FollowedBy(":")
|
|
95
|
+
).set_results_name("attr_name") + _tag_term_type(TermType.attr_name)
|
|
96
|
+
|
|
97
|
+
# Example URN-qualifed attr:
|
|
98
|
+
# urn:ietf:params:scim:schemas:core:2.0:User:userName
|
|
99
|
+
# |--------------- URN --------------------|:| attr |
|
|
100
|
+
UrnAttr = Combine(
|
|
101
|
+
Combine(
|
|
102
|
+
Literal("urn:")
|
|
103
|
+
+ DelimitedList(
|
|
104
|
+
# characters ONLY if followed by colon
|
|
105
|
+
Char(alphanums + ".-_")[1, ...] + FollowedBy(":"),
|
|
106
|
+
# separator
|
|
107
|
+
Literal(":"),
|
|
108
|
+
# combine everything back into a singular token
|
|
109
|
+
combine=True,
|
|
110
|
+
)[1, ...]
|
|
111
|
+
).set_results_name("urn")
|
|
112
|
+
# separator between URN and attribute name
|
|
113
|
+
+ Literal(":")
|
|
114
|
+
+ AttrName
|
|
115
|
+
+ _tag_term_type(TermType.urn)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
SubAttr = ungroup(Combine(Suppress(".") + AttrName)).set_results_name("sub_attr") ^ (
|
|
120
|
+
Tag("sub_attr", None)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
AttrPath = (
|
|
124
|
+
(
|
|
125
|
+
# match on UrnAttr first
|
|
126
|
+
UrnAttr ^ AttrName
|
|
127
|
+
)
|
|
128
|
+
+ SubAttr
|
|
129
|
+
+ _tag_term_type(TermType.attr_path)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
ComparisonOperator = one_of(
|
|
133
|
+
["eq", "ne", "co", "sw", "ew", "gt", "lt", "ge", "le"],
|
|
134
|
+
caseless=True,
|
|
135
|
+
as_keyword=True,
|
|
136
|
+
).set_results_name("comparison_operator") + _tag_term_type(TermType.compare_op)
|
|
137
|
+
|
|
138
|
+
LogicalOperator = Group(
|
|
139
|
+
one_of(["or", "and"], caseless=True).set_results_name("logical_operator")
|
|
140
|
+
+ _tag_term_type(TermType.logical_op)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
NegationOperator = Group(
|
|
144
|
+
(
|
|
145
|
+
CaselessKeyword("not")
|
|
146
|
+
+ _tag_term_type(TermType.negation_op)
|
|
147
|
+
+ Tag("negated", True) # noqa: FBT003
|
|
148
|
+
)[..., 1]
|
|
149
|
+
^ Tag("negated", False) # noqa: FBT003
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
ValueTrue = Literal("true").set_parse_action(lambda: True) + _tag_value_type(
|
|
153
|
+
ValueType.boolean
|
|
154
|
+
)
|
|
155
|
+
ValueFalse = Literal("false").set_parse_action(lambda: False) + _tag_value_type(
|
|
156
|
+
ValueType.boolean
|
|
157
|
+
)
|
|
158
|
+
ValueNull = Literal("null").set_parse_action(lambda: None) + _tag_value_type(
|
|
159
|
+
ValueType.null
|
|
160
|
+
)
|
|
161
|
+
ValueNumber = (common.integer | common.fnumber) + _tag_value_type(ValueType.number)
|
|
162
|
+
ValueString = dbl_quoted_string.set_parse_action(remove_quotes) + _tag_value_type(
|
|
163
|
+
ValueType.string
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
ComparisonValue = ungroup(
|
|
167
|
+
ValueTrue | ValueFalse | ValueNull | ValueNumber | ValueString
|
|
168
|
+
).set_results_name("value")
|
|
169
|
+
|
|
170
|
+
AttrPresence = Group(
|
|
171
|
+
AttrPath + Literal("pr").set_results_name("presence").set_parse_action(lambda: True)
|
|
172
|
+
) + _tag_term_type(TermType.presence)
|
|
173
|
+
AttrExpression = AttrPresence | Group(
|
|
174
|
+
AttrPath + ComparisonOperator + ComparisonValue + _tag_term_type(TermType.attr_expr)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# these are forward references, so that we can have
|
|
178
|
+
# parsers circularly reference themselves
|
|
179
|
+
FilterExpr = Forward()
|
|
180
|
+
Filters = Forward()
|
|
181
|
+
|
|
182
|
+
ValuePath = Group(AttrPath + nested_expr("[", "]", Filters)).set_results_name(
|
|
183
|
+
"value_path"
|
|
184
|
+
) + _tag_term_type(TermType.value_path)
|
|
185
|
+
|
|
186
|
+
FilterExpr <<= (
|
|
187
|
+
AttrExpression | ValuePath | (NegationOperator + nested_expr("(", ")", Filters))
|
|
188
|
+
) + _tag_term_type(TermType.filter_expr)
|
|
189
|
+
|
|
190
|
+
Filters <<= (
|
|
191
|
+
# comment to force it to wrap the below for operator precedence
|
|
192
|
+
(FilterExpr + (LogicalOperator + FilterExpr)[...])
|
|
193
|
+
+ _tag_term_type(TermType.filters)
|
|
194
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
SCIM_SERVICE_PROVIDER = {
|
|
2
|
+
"SCHEME": "https",
|
|
3
|
+
# use default value,
|
|
4
|
+
# this will be overridden by value returned by BASE_LOCATION_GETTER
|
|
5
|
+
"NETLOC": "localhost",
|
|
6
|
+
"AUTHENTICATION_SCHEMES": [
|
|
7
|
+
{
|
|
8
|
+
"type": "oauth2",
|
|
9
|
+
"name": "OAuth 2",
|
|
10
|
+
"description": "Oauth 2 implemented with bearer token",
|
|
11
|
+
"specUri": "",
|
|
12
|
+
"documentationUri": "",
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
"SERVICE_PROVIDER_CONFIG_MODEL": "mitol.scim.config.LearnSCIMServiceProviderConfig",
|
|
16
|
+
"USER_ADAPTER": "mitol.scim.adapters.UserAdapter",
|
|
17
|
+
"USER_MODEL_GETTER": "mitol.scim.adapters.get_user_model_for_scim",
|
|
18
|
+
"USER_FILTER_PARSER": "mitol.scim.filters.UserFilterQuery",
|
|
19
|
+
"GET_IS_AUTHENTICATED_PREDICATE": "mitol.scim.utils.is_authenticated_predicate",
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""URL configurations for SCIM"""
|
|
2
|
+
|
|
3
|
+
from django.urls import include, re_path
|
|
4
|
+
|
|
5
|
+
from mitol.scim import views
|
|
6
|
+
|
|
7
|
+
ol_scim_urls = (
|
|
8
|
+
[
|
|
9
|
+
re_path(r"^Bulk$", views.BulkView.as_view(), name="bulk"),
|
|
10
|
+
re_path(r"^Users/\.search$", views.SearchView.as_view(), name="users-search"),
|
|
11
|
+
],
|
|
12
|
+
"ol-scim",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
urlpatterns = [
|
|
16
|
+
re_path(r"^scim/v2/", include(ol_scim_urls)),
|
|
17
|
+
re_path(r"^scim/v2/", include("django_scim.urls", namespace="scim")),
|
|
18
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""SCIM view customizations"""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
from urllib.parse import urljoin, urlparse
|
|
8
|
+
|
|
9
|
+
from django.http import HttpRequest, HttpResponse
|
|
10
|
+
from django.urls import Resolver404, resolve, reverse
|
|
11
|
+
from django_scim import constants as djs_constants
|
|
12
|
+
from django_scim import exceptions
|
|
13
|
+
from django_scim import views as djs_views
|
|
14
|
+
from django_scim.utils import get_base_scim_location_getter
|
|
15
|
+
|
|
16
|
+
from mitol.scim import constants
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InMemoryHttpRequest(HttpRequest):
|
|
22
|
+
"""
|
|
23
|
+
A spoofed HttpRequest that only exists in-memory.
|
|
24
|
+
It does not implement all features of HttpRequest and is only used
|
|
25
|
+
for the bulk SCIM operations here so we can reuse view implementations.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, request, path, method, body):
|
|
29
|
+
super().__init__()
|
|
30
|
+
|
|
31
|
+
self.META = copy.deepcopy(
|
|
32
|
+
{
|
|
33
|
+
key: value
|
|
34
|
+
for key, value in request.META.items()
|
|
35
|
+
if not key.startswith(("wsgi", "uwsgi"))
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
self.path = path
|
|
39
|
+
self.method = method
|
|
40
|
+
self.content_type = djs_constants.SCIM_CONTENT_TYPE
|
|
41
|
+
|
|
42
|
+
# normally HttpRequest would read this in, but we already have the value
|
|
43
|
+
self._body = body
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BulkView(djs_views.SCIMView):
|
|
47
|
+
http_method_names = ["post"]
|
|
48
|
+
|
|
49
|
+
def post(self, request, *args, **kwargs): # noqa: ARG002
|
|
50
|
+
body = self.load_body(request.body)
|
|
51
|
+
|
|
52
|
+
if body.get("schemas") != [constants.SchemaURI.BULK_REQUEST]:
|
|
53
|
+
msg = "Invalid schema uri. Must be SearchRequest."
|
|
54
|
+
raise exceptions.BadRequestError(msg)
|
|
55
|
+
|
|
56
|
+
fail_on_errors = body.get("failOnErrors", None)
|
|
57
|
+
|
|
58
|
+
if fail_on_errors is not None and isinstance(int, fail_on_errors):
|
|
59
|
+
msg = "Invalid failOnErrors. Must be an integer."
|
|
60
|
+
raise exceptions.BaseRequestError(msg)
|
|
61
|
+
|
|
62
|
+
operations = body.get("Operations")
|
|
63
|
+
|
|
64
|
+
results = self._attempt_operations(request, operations, fail_on_errors)
|
|
65
|
+
|
|
66
|
+
response = {
|
|
67
|
+
"schemas": [constants.SchemaURI.BULK_RESPONSE],
|
|
68
|
+
"Operations": results,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
content = json.dumps(response)
|
|
72
|
+
|
|
73
|
+
return HttpResponse(
|
|
74
|
+
content=content,
|
|
75
|
+
content_type=djs_constants.SCIM_CONTENT_TYPE,
|
|
76
|
+
status=HTTPStatus.OK,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _attempt_operations(self, request, operations, fail_on_errors):
|
|
80
|
+
"""Attempt to run the operations that were passed"""
|
|
81
|
+
responses = []
|
|
82
|
+
num_errors = 0
|
|
83
|
+
|
|
84
|
+
for operation in operations:
|
|
85
|
+
# per-spec,if we've hit the error threshold stop processing and return
|
|
86
|
+
if fail_on_errors is not None and num_errors >= fail_on_errors:
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
op_response = self._attempt_operation(request, operation)
|
|
90
|
+
|
|
91
|
+
# if the operation returned a non-2xx status code, record it as a failure
|
|
92
|
+
if int(op_response.get("status")) >= HTTPStatus.MULTIPLE_CHOICES:
|
|
93
|
+
num_errors += 1
|
|
94
|
+
|
|
95
|
+
responses.append(op_response)
|
|
96
|
+
|
|
97
|
+
return responses
|
|
98
|
+
|
|
99
|
+
def _attempt_operation(self, bulk_request, operation):
|
|
100
|
+
"""Attempt an operation as part of a bulk request"""
|
|
101
|
+
|
|
102
|
+
method = operation.get("method")
|
|
103
|
+
bulk_id = operation.get("bulkId")
|
|
104
|
+
path = operation.get("path")
|
|
105
|
+
data = operation.get("data")
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
url_match = resolve(path, urlconf="django_scim.urls")
|
|
109
|
+
except Resolver404:
|
|
110
|
+
return self._operation_error(
|
|
111
|
+
bulk_id,
|
|
112
|
+
HTTPStatus.NOT_IMPLEMENTED,
|
|
113
|
+
"Endpoint is not supported for /Bulk",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# this is an ephemeral request not tied to the real request directly
|
|
117
|
+
op_request = InMemoryHttpRequest(
|
|
118
|
+
bulk_request, path, method, json.dumps(data).encode(djs_constants.ENCODING)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
op_response = url_match.func(op_request, *url_match.args, **url_match.kwargs)
|
|
122
|
+
result = {
|
|
123
|
+
"method": method,
|
|
124
|
+
"bulkId": bulk_id,
|
|
125
|
+
"status": str(op_response.status_code),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
location = None
|
|
129
|
+
|
|
130
|
+
if op_response.status_code >= HTTPStatus.BAD_REQUEST and op_response.content:
|
|
131
|
+
result["response"] = json.loads(op_response.content.decode("utf-8"))
|
|
132
|
+
|
|
133
|
+
location = op_response.headers.get("Location", None)
|
|
134
|
+
|
|
135
|
+
if location is not None:
|
|
136
|
+
result["location"] = location
|
|
137
|
+
# this is a custom field that the scim-for-keycloak plugin requires
|
|
138
|
+
try:
|
|
139
|
+
path = urlparse(location).path
|
|
140
|
+
location_match = resolve(path)
|
|
141
|
+
# this URL will be something like /scim/v2/Users/12345
|
|
142
|
+
# resolving it gives the uuid
|
|
143
|
+
result["id"] = location_match.kwargs["uuid"]
|
|
144
|
+
except Resolver404:
|
|
145
|
+
log.exception("Unable to resolve resource url: %s", location)
|
|
146
|
+
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
def _operation_error(self, method, bulk_id, status_code, detail):
|
|
150
|
+
"""Return a failure response"""
|
|
151
|
+
status_code = str(status_code)
|
|
152
|
+
return {
|
|
153
|
+
"method": method,
|
|
154
|
+
"status": status_code,
|
|
155
|
+
"bulkId": bulk_id,
|
|
156
|
+
"response": {
|
|
157
|
+
"schemas": [djs_constants.SchemaURI.ERROR],
|
|
158
|
+
"status": status_code,
|
|
159
|
+
"detail": detail,
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class SearchView(djs_views.UserSearchView):
|
|
165
|
+
"""
|
|
166
|
+
View for /.search endpoint
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def post(self, request, *args, **kwargs): # noqa: ARG002
|
|
170
|
+
body = self.load_body(request.body)
|
|
171
|
+
if body.get("schemas") != [djs_constants.SchemaURI.SERACH_REQUEST]:
|
|
172
|
+
msg = "Invalid schema uri. Must be SearchRequest."
|
|
173
|
+
raise exceptions.BadRequestError(msg)
|
|
174
|
+
|
|
175
|
+
# cast to ints because scim-for-keycloak sends strings
|
|
176
|
+
start = int(body.get("startIndex", 1))
|
|
177
|
+
count = int(body.get("count", 50))
|
|
178
|
+
sort_by = body.get("sortBy", "id")
|
|
179
|
+
sort_order = body.get("sortOrder", "ascending")
|
|
180
|
+
query = body.get("filter", None)
|
|
181
|
+
|
|
182
|
+
if sort_by not in constants.VALID_SORTS:
|
|
183
|
+
msg = f"Sorting only supports: {', '.join(constants.VALID_SORTS)}"
|
|
184
|
+
raise exceptions.BadRequestError(msg)
|
|
185
|
+
else:
|
|
186
|
+
sort_by = constants.SORT_MAPPING[sort_by]
|
|
187
|
+
|
|
188
|
+
if sort_order not in ("ascending", "descending"):
|
|
189
|
+
msg = "Sorting only supports ascending or descending"
|
|
190
|
+
raise exceptions.BadRequestError(msg)
|
|
191
|
+
|
|
192
|
+
if not query:
|
|
193
|
+
msg = "No filter query specified"
|
|
194
|
+
raise exceptions.BadRequestError(msg)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
qs = self.__class__.parser_getter().search(query, request)
|
|
198
|
+
except ValueError as e:
|
|
199
|
+
msg = "Invalid filter/search query: " + str(e)
|
|
200
|
+
raise exceptions.BadRequestError(msg) from e
|
|
201
|
+
|
|
202
|
+
qs = qs.order_by(sort_by)
|
|
203
|
+
|
|
204
|
+
if sort_order == "descending":
|
|
205
|
+
qs = qs.reverse()
|
|
206
|
+
|
|
207
|
+
response = self._build_response(request, qs, start, count)
|
|
208
|
+
|
|
209
|
+
path = reverse(self.scim_adapter.url_name)
|
|
210
|
+
url = urljoin(get_base_scim_location_getter()(request=request), path).rstrip(
|
|
211
|
+
"/"
|
|
212
|
+
)
|
|
213
|
+
response["Location"] = url + "/.search"
|
|
214
|
+
return response
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mitol-django-scim"
|
|
3
|
+
version = "2025.3.31"
|
|
4
|
+
description = "Django application for SCIM integrations"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"django-stubs>=1.13.1",
|
|
7
|
+
"django>=3.0",
|
|
8
|
+
"django_scim2>=0.19.1",
|
|
9
|
+
"mitol-django-common",
|
|
10
|
+
"pyparsing>=3.2",
|
|
11
|
+
# Remove this why py310 support is dropped
|
|
12
|
+
"StrEnum>=0.4.15 ; python_version < '3.11'",
|
|
13
|
+
"strenum>=0.4.15",
|
|
14
|
+
]
|
|
15
|
+
readme = "README.md"
|
|
16
|
+
license = "BSD-3-Clause"
|
|
17
|
+
requires-python = ">=3.10"
|
|
18
|
+
|
|
19
|
+
[tool.bumpver]
|
|
20
|
+
current_version = "2025.3.31"
|
|
21
|
+
version_pattern = "YYYY.MM.DD[.INC0]"
|
|
22
|
+
|
|
23
|
+
[tool.bumpver.file_patterns]
|
|
24
|
+
"pyproject.toml" = [
|
|
25
|
+
'version = "{version}"',
|
|
26
|
+
]
|
|
27
|
+
"mitol/scim/__init__.py" = [
|
|
28
|
+
'__version__ = "{version}"',
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.sdist]
|
|
36
|
+
include = ["CHANGELOG.md", "README.md", "py.typed", "**/*.py"]
|
|
37
|
+
exclude = ["BUILD", "pyproject.toml"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
include = ["CHANGELOG.md", "README.md", "py.typed", "**/*.py"]
|
|
41
|
+
exclude = ["BUILD", "pyproject.toml"]
|