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        )
 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)
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        )
connection_type_query = '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

def create(self, group: authentik.core.models.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

@transaction.atomic
def patch_compare_users(self, group: authentik.core.models.Group):
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