authentik.providers.scim.clients.groups
Group client
1"""Group client""" 2 3from itertools import batched 4from typing import Any 5 6from django.db import transaction 7from django.utils.http import urlencode 8from orjson import dumps 9from pydantic import ValidationError 10 11from authentik.core.models import Group 12from authentik.lib.merge import MERGE_LIST_UNIQUE 13from authentik.lib.sync.mapper import PropertyMappingManager 14from authentik.lib.sync.outgoing.base import Direction 15from authentik.lib.sync.outgoing.exceptions import ( 16 NotFoundSyncException, 17 ObjectExistsSyncException, 18 StopSync, 19) 20from authentik.policies.utils import delete_none_values 21from authentik.providers.scim.clients.base import SCIMClient 22from authentik.providers.scim.clients.exceptions import ( 23 SCIMRequestException, 24) 25from authentik.providers.scim.clients.schema import ( 26 SCIM_GROUP_SCHEMA, 27 GroupMember, 28 PatchOp, 29 PatchOperation, 30 PatchRequest, 31) 32from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema 33from authentik.providers.scim.models import ( 34 SCIMCompatibilityMode, 35 SCIMMapping, 36 SCIMProvider, 37 SCIMProviderGroup, 38 SCIMProviderUser, 39) 40 41 42class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]): 43 """SCIM client for groups""" 44 45 connection_type = SCIMProviderGroup 46 connection_type_query = "group" 47 mapper: PropertyMappingManager 48 49 def __init__(self, provider: SCIMProvider): 50 super().__init__(provider) 51 self.mapper = PropertyMappingManager( 52 self.provider.property_mappings_group.all().order_by("name").select_subclasses(), 53 SCIMMapping, 54 ["group", "provider", "connection"], 55 ) 56 57 def _create_group_member(self, id: str) -> GroupMember: 58 member = GroupMember(value=id) 59 # https://developer.webex.com/admin/docs/api/v1/scim-2-groups/create-a-group 60 if self.provider.compatibility_mode == SCIMCompatibilityMode.WEBEX: 61 member.type = "user" 62 return member 63 64 def to_schema(self, obj: Group, connection: SCIMProviderGroup) -> SCIMGroupSchema: 65 """Convert authentik user into SCIM""" 66 raw_scim_group = super().to_schema(obj, connection) 67 try: 68 scim_group = SCIMGroupSchema.model_validate(delete_none_values(raw_scim_group)) 69 except ValidationError as exc: 70 raise StopSync(exc, obj) from exc 71 if SCIM_GROUP_SCHEMA not in scim_group.schemas: 72 scim_group.schemas.insert(0, SCIM_GROUP_SCHEMA) 73 # As this might be unset, we need to tell pydantic it's set so ensure the schemas 74 # are included, even if its just the defaults 75 scim_group.schemas = list(scim_group.schemas) 76 if not scim_group.externalId: 77 scim_group.externalId = str(obj.pk) 78 79 if not self._config.patch.supported: 80 users = list(obj.users.order_by("id").values_list("id", flat=True)) 81 connections = SCIMProviderUser.objects.filter( 82 provider=self.provider, user__pk__in=users 83 ) 84 members = [] 85 for user in connections: 86 members.append( 87 self._create_group_member(user.scim_id), 88 ) 89 if members: 90 scim_group.members = members 91 else: 92 del scim_group.members 93 return scim_group 94 95 def delete(self, identifier: str): 96 """Delete group""" 97 SCIMProviderGroup.objects.filter(provider=self.provider, scim_id=identifier).delete() 98 return self._request("DELETE", f"/Groups/{identifier}") 99 100 def create(self, group: Group): 101 """Create group from scratch and create a connection object""" 102 scim_group = self.to_schema(group, None) 103 connection = None 104 with transaction.atomic(): 105 try: 106 response = self._request( 107 "POST", 108 "/Groups", 109 json=scim_group.model_dump( 110 mode="json", 111 exclude_unset=True, 112 ), 113 ) 114 except ObjectExistsSyncException as exc: 115 if not self._config.filter.supported: 116 raise exc 117 groups = self._request( 118 "GET", 119 f"/Groups?{urlencode({'filter': f'displayName eq "{group.name}"'})}", 120 ) 121 groups_res = groups.get("Resources", []) 122 if len(groups_res) < 1: 123 raise exc 124 connection = SCIMProviderGroup.objects.create( 125 provider=self.provider, 126 group=group, 127 scim_id=groups_res[0]["id"], 128 attributes=groups_res[0], 129 ) 130 else: 131 scim_id = response.get("id") 132 if not scim_id or scim_id == "": 133 raise StopSync("SCIM Response with missing or invalid `id`") 134 connection = SCIMProviderGroup.objects.create( 135 provider=self.provider, group=group, scim_id=scim_id, attributes=response 136 ) 137 users = list(group.users.order_by("id").values_list("id", flat=True)) 138 self._patch_add_users(connection, users) 139 return connection 140 141 def diff(self, local_created: dict[str, Any], connection: SCIMProviderUser): 142 """Check if a group is different than what we last wrote to the remote system. 143 Returns true if there is a difference in data.""" 144 local_known = connection.attributes 145 local_updated = {} 146 MERGE_LIST_UNIQUE.merge(local_updated, local_known) 147 MERGE_LIST_UNIQUE.merge(local_updated, local_created) 148 return dumps(local_updated) != dumps(local_known) 149 150 def update(self, group: Group, connection: SCIMProviderGroup): 151 """Update existing group""" 152 scim_group = self.to_schema(group, connection) 153 scim_group.id = connection.scim_id 154 payload = scim_group.model_dump(mode="json", exclude_unset=True) 155 if not self.diff(payload, connection): 156 self.logger.debug("Skipping group write as data has not changed") 157 return self.patch_compare_users(group) 158 try: 159 if self._config.patch.supported: 160 return self._update_patch(group, scim_group, connection) 161 return self._update_put(group, scim_group, connection) 162 except NotFoundSyncException: 163 # Resource missing is handled by self.write, which will re-create the group 164 raise 165 166 def _update_patch( 167 self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup 168 ): 169 """Apply provider-specific PATCH requests""" 170 match connection.provider.compatibility_mode: 171 case SCIMCompatibilityMode.AWS: 172 self._update_patch_aws(group, scim_group, connection) 173 case _: 174 self._update_patch_general(group, scim_group, connection) 175 return self.patch_compare_users(group) 176 177 def _update_patch_aws( 178 self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup 179 ): 180 """Run PATCH requests for supported attributes""" 181 group_dict = scim_group.model_dump(mode="json", exclude_unset=True) 182 self._patch_chunked( 183 connection.scim_id, 184 *[ 185 PatchOperation( 186 op=PatchOp.replace, 187 path=attr, 188 value=group_dict[attr], 189 ) 190 for attr in ("displayName", "externalId") 191 ], 192 ) 193 194 def _update_patch_general( 195 self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup 196 ): 197 """Update a group via PATCH request""" 198 # Patch group's attributes instead of replacing it and re-adding users if we can 199 self._request( 200 "PATCH", 201 f"/Groups/{connection.scim_id}", 202 json=PatchRequest( 203 Operations=[ 204 PatchOperation( 205 op=PatchOp.replace, 206 path=None, 207 value=scim_group.model_dump(mode="json", exclude_unset=True), 208 ) 209 ] 210 ).model_dump( 211 mode="json", 212 exclude_unset=True, 213 exclude_none=True, 214 ), 215 ) 216 217 def _update_put(self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup): 218 """Update a group via PUT request""" 219 try: 220 self._request( 221 "PUT", 222 f"/Groups/{connection.scim_id}", 223 json=scim_group.model_dump( 224 mode="json", 225 exclude_unset=True, 226 ), 227 ) 228 return self.patch_compare_users(group) 229 except SCIMRequestException, ObjectExistsSyncException: 230 # Some providers don't support PUT on groups, so this is mainly a fix for the initial 231 # sync, send patch add requests for all the users the group currently has 232 return self._update_patch(group, scim_group, connection) 233 234 def update_group(self, group: Group, action: Direction, users_set: set[int]): 235 """Update a group, either using PUT to replace it or PATCH if supported""" 236 scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first() 237 if not scim_group: 238 self.logger.warning( 239 "could not sync group membership, group does not exist", group=group 240 ) 241 return 242 if self._config.patch.supported: 243 if action == Direction.add: 244 return self._patch_add_users(scim_group, users_set) 245 if action == Direction.remove: 246 return self._patch_remove_users(scim_group, users_set) 247 try: 248 return self.write(group) 249 except SCIMRequestException as exc: 250 if self._config.is_fallback: 251 # Assume that provider does not support PUT and also doesn't support 252 # ServiceProviderConfig, so try PATCH as a fallback 253 if action == Direction.add: 254 return self._patch_add_users(scim_group, users_set) 255 if action == Direction.remove: 256 return self._patch_remove_users(scim_group, users_set) 257 raise exc 258 259 def _patch_chunked( 260 self, 261 group_id: str, 262 *ops: PatchOperation, 263 ): 264 """Helper function that chunks patch requests based on the maxOperations attribute. 265 This is not strictly according to specs but there's nothing in the schema that allows the 266 us to know what the maximum patch operations per request should be.""" 267 chunk_size = self._config.bulk.maxOperations 268 if chunk_size < 1: 269 chunk_size = len(ops) 270 if len(ops) < 1: 271 return 272 for chunk in batched(ops, chunk_size, strict=False): 273 req = PatchRequest(Operations=list(chunk)) 274 self._request( 275 "PATCH", 276 f"/Groups/{group_id}", 277 json=req.model_dump( 278 mode="json", 279 ), 280 ) 281 282 @transaction.atomic 283 def patch_compare_users(self, group: Group): 284 """Compare users with a SCIM group and add/remove any differences""" 285 # Get scim group first 286 scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first() 287 if not scim_group: 288 self.logger.warning( 289 "could not sync group membership, group does not exist", group=group 290 ) 291 return 292 # Get a list of all users in the authentik group 293 raw_users_should = list(group.users.order_by("id").values_list("id", flat=True)) 294 # Lookup the SCIM IDs of the users 295 users_should: list[str] = list( 296 SCIMProviderUser.objects.filter( 297 user__pk__in=raw_users_should, provider=self.provider 298 ).values_list("scim_id", flat=True) 299 ) 300 if len(raw_users_should) != len(users_should): 301 self.logger.warning( 302 "User count mismatch, not all users in the group are synced to SCIM yet.", 303 group=group, 304 ) 305 # Get current group status 306 current_group = SCIMGroupSchema.model_validate( 307 self._request("GET", f"/Groups/{scim_group.scim_id}") 308 ) 309 users_to_add = [] 310 users_to_remove = [] 311 # Check users currently in group and if they shouldn't be in the group and remove them 312 for user in current_group.members or []: 313 if user.value not in users_should: 314 users_to_remove.append(user.value) 315 # Check users that should be in the group and add them 316 if current_group.members is not None: 317 for user in users_should: 318 if len([x for x in current_group.members if x.value == user]) < 1: 319 users_to_add.append(user) 320 # Only send request if we need to make changes 321 if len(users_to_add) < 1 and len(users_to_remove) < 1: 322 return 323 return self._patch_chunked( 324 scim_group.scim_id, 325 *[ 326 PatchOperation( 327 op=PatchOp.add, 328 path="members", 329 value=[ 330 self._create_group_member(x).model_dump( 331 mode="json", 332 exclude_unset=True, 333 ) 334 ], 335 ) 336 for x in users_to_add 337 ], 338 *[ 339 PatchOperation( 340 op=PatchOp.remove, 341 path="members", 342 value=[ 343 self._create_group_member(x).model_dump( 344 mode="json", 345 exclude_unset=True, 346 ) 347 ], 348 ) 349 for x in users_to_remove 350 ], 351 ) 352 353 def _patch_add_users(self, scim_group: SCIMProviderGroup, users_set: set[int]): 354 """Add users in users_set to group""" 355 if len(users_set) < 1: 356 return 357 user_ids = list( 358 SCIMProviderUser.objects.filter( 359 user__pk__in=users_set, provider=self.provider 360 ).values_list("scim_id", flat=True) 361 ) 362 if len(user_ids) < 1: 363 return 364 self._patch_chunked( 365 scim_group.scim_id, 366 *[ 367 PatchOperation( 368 op=PatchOp.add, 369 path="members", 370 value=[ 371 self._create_group_member(x).model_dump( 372 mode="json", 373 exclude_unset=True, 374 ) 375 ], 376 ) 377 for x in user_ids 378 ], 379 ) 380 381 def _patch_remove_users(self, scim_group: SCIMProviderGroup, users_set: set[int]): 382 """Remove users in users_set from group""" 383 if len(users_set) < 1: 384 return 385 user_ids = list( 386 SCIMProviderUser.objects.filter( 387 user__pk__in=users_set, provider=self.provider 388 ).values_list("scim_id", flat=True) 389 ) 390 if len(user_ids) < 1: 391 return 392 self._patch_chunked( 393 scim_group.scim_id, 394 *[ 395 PatchOperation( 396 op=PatchOp.remove, 397 path="members", 398 value=[ 399 self._create_group_member(x).model_dump( 400 mode="json", 401 exclude_unset=True, 402 ) 403 ], 404 ) 405 for x in user_ids 406 ], 407 ) 408 409 def discover(self): 410 res = self._request("GET", "/Groups") 411 seen_items = 0 412 expected_items = int(res["totalResults"]) 413 while True: 414 for group in res["Resources"]: 415 self._discover_group_single(group) 416 seen_items += 1 417 if seen_items >= expected_items: 418 break 419 res = self._request("GET", f"/Groups?startIndex={seen_items + 1}") 420 421 def _discover_group_single(self, group: dict): 422 scim_group = SCIMGroupSchema.model_validate(group) 423 if SCIMProviderGroup.objects.filter(scim_id=scim_group.id, provider=self.provider).exists(): 424 return 425 ak_group = Group.objects.filter(name=scim_group.displayName).first() 426 if not ak_group: 427 return 428 SCIMProviderGroup.objects.create( 429 provider=self.provider, 430 group=ak_group, 431 scim_id=scim_group.id, 432 attributes=group, 433 )
class
SCIMGroupClient(authentik.providers.scim.clients.base.SCIMClient[authentik.core.models.Group, authentik.providers.scim.models.SCIMProviderGroup, authentik.providers.scim.clients.schema.Group]):
43class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]): 44 """SCIM client for groups""" 45 46 connection_type = SCIMProviderGroup 47 connection_type_query = "group" 48 mapper: PropertyMappingManager 49 50 def __init__(self, provider: SCIMProvider): 51 super().__init__(provider) 52 self.mapper = PropertyMappingManager( 53 self.provider.property_mappings_group.all().order_by("name").select_subclasses(), 54 SCIMMapping, 55 ["group", "provider", "connection"], 56 ) 57 58 def _create_group_member(self, id: str) -> GroupMember: 59 member = GroupMember(value=id) 60 # https://developer.webex.com/admin/docs/api/v1/scim-2-groups/create-a-group 61 if self.provider.compatibility_mode == SCIMCompatibilityMode.WEBEX: 62 member.type = "user" 63 return member 64 65 def to_schema(self, obj: Group, connection: SCIMProviderGroup) -> SCIMGroupSchema: 66 """Convert authentik user into SCIM""" 67 raw_scim_group = super().to_schema(obj, connection) 68 try: 69 scim_group = SCIMGroupSchema.model_validate(delete_none_values(raw_scim_group)) 70 except ValidationError as exc: 71 raise StopSync(exc, obj) from exc 72 if SCIM_GROUP_SCHEMA not in scim_group.schemas: 73 scim_group.schemas.insert(0, SCIM_GROUP_SCHEMA) 74 # As this might be unset, we need to tell pydantic it's set so ensure the schemas 75 # are included, even if its just the defaults 76 scim_group.schemas = list(scim_group.schemas) 77 if not scim_group.externalId: 78 scim_group.externalId = str(obj.pk) 79 80 if not self._config.patch.supported: 81 users = list(obj.users.order_by("id").values_list("id", flat=True)) 82 connections = SCIMProviderUser.objects.filter( 83 provider=self.provider, user__pk__in=users 84 ) 85 members = [] 86 for user in connections: 87 members.append( 88 self._create_group_member(user.scim_id), 89 ) 90 if members: 91 scim_group.members = members 92 else: 93 del scim_group.members 94 return scim_group 95 96 def delete(self, identifier: str): 97 """Delete group""" 98 SCIMProviderGroup.objects.filter(provider=self.provider, scim_id=identifier).delete() 99 return self._request("DELETE", f"/Groups/{identifier}") 100 101 def create(self, group: Group): 102 """Create group from scratch and create a connection object""" 103 scim_group = self.to_schema(group, None) 104 connection = None 105 with transaction.atomic(): 106 try: 107 response = self._request( 108 "POST", 109 "/Groups", 110 json=scim_group.model_dump( 111 mode="json", 112 exclude_unset=True, 113 ), 114 ) 115 except ObjectExistsSyncException as exc: 116 if not self._config.filter.supported: 117 raise exc 118 groups = self._request( 119 "GET", 120 f"/Groups?{urlencode({'filter': f'displayName eq "{group.name}"'})}", 121 ) 122 groups_res = groups.get("Resources", []) 123 if len(groups_res) < 1: 124 raise exc 125 connection = SCIMProviderGroup.objects.create( 126 provider=self.provider, 127 group=group, 128 scim_id=groups_res[0]["id"], 129 attributes=groups_res[0], 130 ) 131 else: 132 scim_id = response.get("id") 133 if not scim_id or scim_id == "": 134 raise StopSync("SCIM Response with missing or invalid `id`") 135 connection = SCIMProviderGroup.objects.create( 136 provider=self.provider, group=group, scim_id=scim_id, attributes=response 137 ) 138 users = list(group.users.order_by("id").values_list("id", flat=True)) 139 self._patch_add_users(connection, users) 140 return connection 141 142 def diff(self, local_created: dict[str, Any], connection: SCIMProviderUser): 143 """Check if a group is different than what we last wrote to the remote system. 144 Returns true if there is a difference in data.""" 145 local_known = connection.attributes 146 local_updated = {} 147 MERGE_LIST_UNIQUE.merge(local_updated, local_known) 148 MERGE_LIST_UNIQUE.merge(local_updated, local_created) 149 return dumps(local_updated) != dumps(local_known) 150 151 def update(self, group: Group, connection: SCIMProviderGroup): 152 """Update existing group""" 153 scim_group = self.to_schema(group, connection) 154 scim_group.id = connection.scim_id 155 payload = scim_group.model_dump(mode="json", exclude_unset=True) 156 if not self.diff(payload, connection): 157 self.logger.debug("Skipping group write as data has not changed") 158 return self.patch_compare_users(group) 159 try: 160 if self._config.patch.supported: 161 return self._update_patch(group, scim_group, connection) 162 return self._update_put(group, scim_group, connection) 163 except NotFoundSyncException: 164 # Resource missing is handled by self.write, which will re-create the group 165 raise 166 167 def _update_patch( 168 self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup 169 ): 170 """Apply provider-specific PATCH requests""" 171 match connection.provider.compatibility_mode: 172 case SCIMCompatibilityMode.AWS: 173 self._update_patch_aws(group, scim_group, connection) 174 case _: 175 self._update_patch_general(group, scim_group, connection) 176 return self.patch_compare_users(group) 177 178 def _update_patch_aws( 179 self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup 180 ): 181 """Run PATCH requests for supported attributes""" 182 group_dict = scim_group.model_dump(mode="json", exclude_unset=True) 183 self._patch_chunked( 184 connection.scim_id, 185 *[ 186 PatchOperation( 187 op=PatchOp.replace, 188 path=attr, 189 value=group_dict[attr], 190 ) 191 for attr in ("displayName", "externalId") 192 ], 193 ) 194 195 def _update_patch_general( 196 self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup 197 ): 198 """Update a group via PATCH request""" 199 # Patch group's attributes instead of replacing it and re-adding users if we can 200 self._request( 201 "PATCH", 202 f"/Groups/{connection.scim_id}", 203 json=PatchRequest( 204 Operations=[ 205 PatchOperation( 206 op=PatchOp.replace, 207 path=None, 208 value=scim_group.model_dump(mode="json", exclude_unset=True), 209 ) 210 ] 211 ).model_dump( 212 mode="json", 213 exclude_unset=True, 214 exclude_none=True, 215 ), 216 ) 217 218 def _update_put(self, group: Group, scim_group: SCIMGroupSchema, connection: SCIMProviderGroup): 219 """Update a group via PUT request""" 220 try: 221 self._request( 222 "PUT", 223 f"/Groups/{connection.scim_id}", 224 json=scim_group.model_dump( 225 mode="json", 226 exclude_unset=True, 227 ), 228 ) 229 return self.patch_compare_users(group) 230 except SCIMRequestException, ObjectExistsSyncException: 231 # Some providers don't support PUT on groups, so this is mainly a fix for the initial 232 # sync, send patch add requests for all the users the group currently has 233 return self._update_patch(group, scim_group, connection) 234 235 def update_group(self, group: Group, action: Direction, users_set: set[int]): 236 """Update a group, either using PUT to replace it or PATCH if supported""" 237 scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first() 238 if not scim_group: 239 self.logger.warning( 240 "could not sync group membership, group does not exist", group=group 241 ) 242 return 243 if self._config.patch.supported: 244 if action == Direction.add: 245 return self._patch_add_users(scim_group, users_set) 246 if action == Direction.remove: 247 return self._patch_remove_users(scim_group, users_set) 248 try: 249 return self.write(group) 250 except SCIMRequestException as exc: 251 if self._config.is_fallback: 252 # Assume that provider does not support PUT and also doesn't support 253 # ServiceProviderConfig, so try PATCH as a fallback 254 if action == Direction.add: 255 return self._patch_add_users(scim_group, users_set) 256 if action == Direction.remove: 257 return self._patch_remove_users(scim_group, users_set) 258 raise exc 259 260 def _patch_chunked( 261 self, 262 group_id: str, 263 *ops: PatchOperation, 264 ): 265 """Helper function that chunks patch requests based on the maxOperations attribute. 266 This is not strictly according to specs but there's nothing in the schema that allows the 267 us to know what the maximum patch operations per request should be.""" 268 chunk_size = self._config.bulk.maxOperations 269 if chunk_size < 1: 270 chunk_size = len(ops) 271 if len(ops) < 1: 272 return 273 for chunk in batched(ops, chunk_size, strict=False): 274 req = PatchRequest(Operations=list(chunk)) 275 self._request( 276 "PATCH", 277 f"/Groups/{group_id}", 278 json=req.model_dump( 279 mode="json", 280 ), 281 ) 282 283 @transaction.atomic 284 def patch_compare_users(self, group: Group): 285 """Compare users with a SCIM group and add/remove any differences""" 286 # Get scim group first 287 scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first() 288 if not scim_group: 289 self.logger.warning( 290 "could not sync group membership, group does not exist", group=group 291 ) 292 return 293 # Get a list of all users in the authentik group 294 raw_users_should = list(group.users.order_by("id").values_list("id", flat=True)) 295 # Lookup the SCIM IDs of the users 296 users_should: list[str] = list( 297 SCIMProviderUser.objects.filter( 298 user__pk__in=raw_users_should, provider=self.provider 299 ).values_list("scim_id", flat=True) 300 ) 301 if len(raw_users_should) != len(users_should): 302 self.logger.warning( 303 "User count mismatch, not all users in the group are synced to SCIM yet.", 304 group=group, 305 ) 306 # Get current group status 307 current_group = SCIMGroupSchema.model_validate( 308 self._request("GET", f"/Groups/{scim_group.scim_id}") 309 ) 310 users_to_add = [] 311 users_to_remove = [] 312 # Check users currently in group and if they shouldn't be in the group and remove them 313 for user in current_group.members or []: 314 if user.value not in users_should: 315 users_to_remove.append(user.value) 316 # Check users that should be in the group and add them 317 if current_group.members is not None: 318 for user in users_should: 319 if len([x for x in current_group.members if x.value == user]) < 1: 320 users_to_add.append(user) 321 # Only send request if we need to make changes 322 if len(users_to_add) < 1 and len(users_to_remove) < 1: 323 return 324 return self._patch_chunked( 325 scim_group.scim_id, 326 *[ 327 PatchOperation( 328 op=PatchOp.add, 329 path="members", 330 value=[ 331 self._create_group_member(x).model_dump( 332 mode="json", 333 exclude_unset=True, 334 ) 335 ], 336 ) 337 for x in users_to_add 338 ], 339 *[ 340 PatchOperation( 341 op=PatchOp.remove, 342 path="members", 343 value=[ 344 self._create_group_member(x).model_dump( 345 mode="json", 346 exclude_unset=True, 347 ) 348 ], 349 ) 350 for x in users_to_remove 351 ], 352 ) 353 354 def _patch_add_users(self, scim_group: SCIMProviderGroup, users_set: set[int]): 355 """Add users in users_set to group""" 356 if len(users_set) < 1: 357 return 358 user_ids = list( 359 SCIMProviderUser.objects.filter( 360 user__pk__in=users_set, provider=self.provider 361 ).values_list("scim_id", flat=True) 362 ) 363 if len(user_ids) < 1: 364 return 365 self._patch_chunked( 366 scim_group.scim_id, 367 *[ 368 PatchOperation( 369 op=PatchOp.add, 370 path="members", 371 value=[ 372 self._create_group_member(x).model_dump( 373 mode="json", 374 exclude_unset=True, 375 ) 376 ], 377 ) 378 for x in user_ids 379 ], 380 ) 381 382 def _patch_remove_users(self, scim_group: SCIMProviderGroup, users_set: set[int]): 383 """Remove users in users_set from group""" 384 if len(users_set) < 1: 385 return 386 user_ids = list( 387 SCIMProviderUser.objects.filter( 388 user__pk__in=users_set, provider=self.provider 389 ).values_list("scim_id", flat=True) 390 ) 391 if len(user_ids) < 1: 392 return 393 self._patch_chunked( 394 scim_group.scim_id, 395 *[ 396 PatchOperation( 397 op=PatchOp.remove, 398 path="members", 399 value=[ 400 self._create_group_member(x).model_dump( 401 mode="json", 402 exclude_unset=True, 403 ) 404 ], 405 ) 406 for x in user_ids 407 ], 408 ) 409 410 def discover(self): 411 res = self._request("GET", "/Groups") 412 seen_items = 0 413 expected_items = int(res["totalResults"]) 414 while True: 415 for group in res["Resources"]: 416 self._discover_group_single(group) 417 seen_items += 1 418 if seen_items >= expected_items: 419 break 420 res = self._request("GET", f"/Groups?startIndex={seen_items + 1}") 421 422 def _discover_group_single(self, group: dict): 423 scim_group = SCIMGroupSchema.model_validate(group) 424 if SCIMProviderGroup.objects.filter(scim_id=scim_group.id, provider=self.provider).exists(): 425 return 426 ak_group = Group.objects.filter(name=scim_group.displayName).first() 427 if not ak_group: 428 return 429 SCIMProviderGroup.objects.create( 430 provider=self.provider, 431 group=ak_group, 432 scim_id=scim_group.id, 433 attributes=group, 434 )
SCIM client for groups
SCIMGroupClient(provider: authentik.providers.scim.models.SCIMProvider)
connection_type =
<class 'authentik.providers.scim.models.SCIMProviderGroup'>
def
to_schema( self, obj: authentik.core.models.Group, connection: authentik.providers.scim.models.SCIMProviderGroup) -> authentik.providers.scim.clients.schema.Group:
65 def to_schema(self, obj: Group, connection: SCIMProviderGroup) -> SCIMGroupSchema: 66 """Convert authentik user into SCIM""" 67 raw_scim_group = super().to_schema(obj, connection) 68 try: 69 scim_group = SCIMGroupSchema.model_validate(delete_none_values(raw_scim_group)) 70 except ValidationError as exc: 71 raise StopSync(exc, obj) from exc 72 if SCIM_GROUP_SCHEMA not in scim_group.schemas: 73 scim_group.schemas.insert(0, SCIM_GROUP_SCHEMA) 74 # As this might be unset, we need to tell pydantic it's set so ensure the schemas 75 # are included, even if its just the defaults 76 scim_group.schemas = list(scim_group.schemas) 77 if not scim_group.externalId: 78 scim_group.externalId = str(obj.pk) 79 80 if not self._config.patch.supported: 81 users = list(obj.users.order_by("id").values_list("id", flat=True)) 82 connections = SCIMProviderUser.objects.filter( 83 provider=self.provider, user__pk__in=users 84 ) 85 members = [] 86 for user in connections: 87 members.append( 88 self._create_group_member(user.scim_id), 89 ) 90 if members: 91 scim_group.members = members 92 else: 93 del scim_group.members 94 return scim_group
Convert authentik user into SCIM
def
delete(self, identifier: str):
96 def delete(self, identifier: str): 97 """Delete group""" 98 SCIMProviderGroup.objects.filter(provider=self.provider, scim_id=identifier).delete() 99 return self._request("DELETE", f"/Groups/{identifier}")
Delete group
101 def create(self, group: Group): 102 """Create group from scratch and create a connection object""" 103 scim_group = self.to_schema(group, None) 104 connection = None 105 with transaction.atomic(): 106 try: 107 response = self._request( 108 "POST", 109 "/Groups", 110 json=scim_group.model_dump( 111 mode="json", 112 exclude_unset=True, 113 ), 114 ) 115 except ObjectExistsSyncException as exc: 116 if not self._config.filter.supported: 117 raise exc 118 groups = self._request( 119 "GET", 120 f"/Groups?{urlencode({'filter': f'displayName eq "{group.name}"'})}", 121 ) 122 groups_res = groups.get("Resources", []) 123 if len(groups_res) < 1: 124 raise exc 125 connection = SCIMProviderGroup.objects.create( 126 provider=self.provider, 127 group=group, 128 scim_id=groups_res[0]["id"], 129 attributes=groups_res[0], 130 ) 131 else: 132 scim_id = response.get("id") 133 if not scim_id or scim_id == "": 134 raise StopSync("SCIM Response with missing or invalid `id`") 135 connection = SCIMProviderGroup.objects.create( 136 provider=self.provider, group=group, scim_id=scim_id, attributes=response 137 ) 138 users = list(group.users.order_by("id").values_list("id", flat=True)) 139 self._patch_add_users(connection, users) 140 return connection
Create group from scratch and create a connection object
def
diff( self, local_created: dict[str, typing.Any], connection: authentik.providers.scim.models.SCIMProviderUser):
142 def diff(self, local_created: dict[str, Any], connection: SCIMProviderUser): 143 """Check if a group is different than what we last wrote to the remote system. 144 Returns true if there is a difference in data.""" 145 local_known = connection.attributes 146 local_updated = {} 147 MERGE_LIST_UNIQUE.merge(local_updated, local_known) 148 MERGE_LIST_UNIQUE.merge(local_updated, local_created) 149 return dumps(local_updated) != dumps(local_known)
Check if a group is different than what we last wrote to the remote system. Returns true if there is a difference in data.
def
update( self, group: authentik.core.models.Group, connection: authentik.providers.scim.models.SCIMProviderGroup):
151 def update(self, group: Group, connection: SCIMProviderGroup): 152 """Update existing group""" 153 scim_group = self.to_schema(group, connection) 154 scim_group.id = connection.scim_id 155 payload = scim_group.model_dump(mode="json", exclude_unset=True) 156 if not self.diff(payload, connection): 157 self.logger.debug("Skipping group write as data has not changed") 158 return self.patch_compare_users(group) 159 try: 160 if self._config.patch.supported: 161 return self._update_patch(group, scim_group, connection) 162 return self._update_put(group, scim_group, connection) 163 except NotFoundSyncException: 164 # Resource missing is handled by self.write, which will re-create the group 165 raise
Update existing group
def
update_group( self, group: authentik.core.models.Group, action: authentik.lib.sync.outgoing.base.Direction, users_set: set[int]):
235 def update_group(self, group: Group, action: Direction, users_set: set[int]): 236 """Update a group, either using PUT to replace it or PATCH if supported""" 237 scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first() 238 if not scim_group: 239 self.logger.warning( 240 "could not sync group membership, group does not exist", group=group 241 ) 242 return 243 if self._config.patch.supported: 244 if action == Direction.add: 245 return self._patch_add_users(scim_group, users_set) 246 if action == Direction.remove: 247 return self._patch_remove_users(scim_group, users_set) 248 try: 249 return self.write(group) 250 except SCIMRequestException as exc: 251 if self._config.is_fallback: 252 # Assume that provider does not support PUT and also doesn't support 253 # ServiceProviderConfig, so try PATCH as a fallback 254 if action == Direction.add: 255 return self._patch_add_users(scim_group, users_set) 256 if action == Direction.remove: 257 return self._patch_remove_users(scim_group, users_set) 258 raise exc
Update a group, either using PUT to replace it or PATCH if supported
283 @transaction.atomic 284 def patch_compare_users(self, group: Group): 285 """Compare users with a SCIM group and add/remove any differences""" 286 # Get scim group first 287 scim_group = SCIMProviderGroup.objects.filter(provider=self.provider, group=group).first() 288 if not scim_group: 289 self.logger.warning( 290 "could not sync group membership, group does not exist", group=group 291 ) 292 return 293 # Get a list of all users in the authentik group 294 raw_users_should = list(group.users.order_by("id").values_list("id", flat=True)) 295 # Lookup the SCIM IDs of the users 296 users_should: list[str] = list( 297 SCIMProviderUser.objects.filter( 298 user__pk__in=raw_users_should, provider=self.provider 299 ).values_list("scim_id", flat=True) 300 ) 301 if len(raw_users_should) != len(users_should): 302 self.logger.warning( 303 "User count mismatch, not all users in the group are synced to SCIM yet.", 304 group=group, 305 ) 306 # Get current group status 307 current_group = SCIMGroupSchema.model_validate( 308 self._request("GET", f"/Groups/{scim_group.scim_id}") 309 ) 310 users_to_add = [] 311 users_to_remove = [] 312 # Check users currently in group and if they shouldn't be in the group and remove them 313 for user in current_group.members or []: 314 if user.value not in users_should: 315 users_to_remove.append(user.value) 316 # Check users that should be in the group and add them 317 if current_group.members is not None: 318 for user in users_should: 319 if len([x for x in current_group.members if x.value == user]) < 1: 320 users_to_add.append(user) 321 # Only send request if we need to make changes 322 if len(users_to_add) < 1 and len(users_to_remove) < 1: 323 return 324 return self._patch_chunked( 325 scim_group.scim_id, 326 *[ 327 PatchOperation( 328 op=PatchOp.add, 329 path="members", 330 value=[ 331 self._create_group_member(x).model_dump( 332 mode="json", 333 exclude_unset=True, 334 ) 335 ], 336 ) 337 for x in users_to_add 338 ], 339 *[ 340 PatchOperation( 341 op=PatchOp.remove, 342 path="members", 343 value=[ 344 self._create_group_member(x).model_dump( 345 mode="json", 346 exclude_unset=True, 347 ) 348 ], 349 ) 350 for x in users_to_remove 351 ], 352 )
Compare users with a SCIM group and add/remove any differences
def
discover(self):
410 def discover(self): 411 res = self._request("GET", "/Groups") 412 seen_items = 0 413 expected_items = int(res["totalResults"]) 414 while True: 415 for group in res["Resources"]: 416 self._discover_group_single(group) 417 seen_items += 1 418 if seen_items >= expected_items: 419 break 420 res = self._request("GET", f"/Groups?startIndex={seen_items + 1}")
Optional method. Can be used to implement a "discovery" where upon creation of this provider, this function will be called and can pre-link any users/groups in the remote system with the respective object in authentik based on a common identifier