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.
@@ -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,6 @@
1
+ """mitol.scim"""
2
+
3
+ default_app_config = "mitol.scim.apps.ScimApp"
4
+
5
+ __version__ = "2025.3.31"
6
+ __distributionname__ = "mitol-django-scim"
@@ -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
+ )
@@ -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,6 @@
1
+ """Utils"""
2
+
3
+
4
+ def is_authenticated_predicate(user):
5
+ """Verify that the user is active and staff"""
6
+ return user.is_authenticated and user.is_active and user.is_staff
@@ -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"]