authentik.enterprise.providers.microsoft_entra.clients.groups

  1from deepmerge import always_merger
  2from django.db import transaction
  3from msgraph.generated.groups.groups_request_builder import GroupsRequestBuilder
  4from msgraph.generated.models.group import Group as MSGroup
  5from msgraph.generated.models.reference_create import ReferenceCreate
  6
  7from authentik.core.models import Group
  8from authentik.enterprise.providers.microsoft_entra.clients.base import MicrosoftEntraSyncClient
  9from authentik.enterprise.providers.microsoft_entra.models import (
 10    MicrosoftEntraProvider,
 11    MicrosoftEntraProviderGroup,
 12    MicrosoftEntraProviderMapping,
 13    MicrosoftEntraProviderUser,
 14)
 15from authentik.lib.sync.mapper import PropertyMappingManager
 16from authentik.lib.sync.outgoing.base import Direction
 17from authentik.lib.sync.outgoing.exceptions import (
 18    NotFoundSyncException,
 19    ObjectExistsSyncException,
 20    StopSync,
 21    TransientSyncException,
 22)
 23from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
 24
 25
 26class MicrosoftEntraGroupClient(
 27    MicrosoftEntraSyncClient[Group, MicrosoftEntraProviderGroup, MSGroup]
 28):
 29    """Microsoft client for groups"""
 30
 31    connection_type = MicrosoftEntraProviderGroup
 32    connection_type_query = "group"
 33    can_discover = True
 34
 35    def __init__(self, provider: MicrosoftEntraProvider) -> None:
 36        super().__init__(provider)
 37        self.mapper = PropertyMappingManager(
 38            self.provider.property_mappings_group.all().order_by("name").select_subclasses(),
 39            MicrosoftEntraProviderMapping,
 40            ["group", "provider", "connection"],
 41        )
 42
 43    def to_schema(self, obj: Group, connection: MicrosoftEntraProviderGroup) -> MSGroup:
 44        """Convert authentik group"""
 45        raw_microsoft_group = super().to_schema(obj, connection)
 46        try:
 47            return MSGroup(**raw_microsoft_group)
 48        except TypeError as exc:
 49            raise StopSync(exc, obj) from exc
 50
 51    def delete(self, identifier: str):
 52        """Delete group"""
 53        MicrosoftEntraProviderGroup.objects.filter(
 54            provider=self.provider, microsoft_id=identifier
 55        ).delete()
 56        if self.provider.group_delete_action == OutgoingSyncDeleteAction.DELETE:
 57            return self._request(self.client.groups.by_group_id(identifier).delete())
 58
 59    def create(self, group: Group):
 60        """Create group from scratch and create a connection object"""
 61        microsoft_group = self.to_schema(group, None)
 62        with transaction.atomic():
 63            try:
 64                response = self._request(self.client.groups.post(microsoft_group))
 65            except ObjectExistsSyncException:
 66                # group already exists in microsoft entra, so we can connect them manually
 67                # for groups we need to fetch the group from microsoft as we connect on
 68                # ID and not group email
 69                query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters(
 70                    filter=f"displayName eq '{microsoft_group.display_name}'",
 71                )
 72                request_configuration = (
 73                    GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration(
 74                        query_parameters=query_params,
 75                    )
 76                )
 77                group_data = self._request(self.client.groups.get(request_configuration))
 78                if group_data.odata_count < 1 or len(group_data.value) < 1:
 79                    self.logger.warning(
 80                        "Group which could not be created also does not exist", group=group
 81                    )
 82                    return
 83                ms_group = group_data.value[0]
 84                return MicrosoftEntraProviderGroup.objects.create(
 85                    provider=self.provider,
 86                    group=group,
 87                    microsoft_id=ms_group.id,
 88                    attributes=self.entity_as_dict(ms_group),
 89                )
 90            else:
 91                return MicrosoftEntraProviderGroup.objects.create(
 92                    provider=self.provider,
 93                    group=group,
 94                    microsoft_id=response.id,
 95                    attributes=self.entity_as_dict(response),
 96                )
 97
 98    def update(self, group: Group, connection: MicrosoftEntraProviderGroup):
 99        """Update existing group"""
100        microsoft_group = self.to_schema(group, connection)
101        microsoft_group.id = connection.microsoft_id
102        try:
103            response = self._request(
104                self.client.groups.by_group_id(connection.microsoft_id).patch(microsoft_group)
105            )
106            if response:
107                always_merger.merge(connection.attributes, self.entity_as_dict(response))
108                connection.save()
109        except NotFoundSyncException:
110            # Resource missing is handled by self.write, which will re-create the group
111            raise
112
113    def write(self, obj: Group):
114        microsoft_group, created = super().write(obj)
115        self.create_sync_members(obj, microsoft_group)
116        return microsoft_group, created
117
118    def create_sync_members(self, obj: Group, microsoft_group: MicrosoftEntraProviderGroup):
119        """Sync all members after a group was created"""
120        users = list(obj.users.order_by("id").values_list("id", flat=True))
121        connections = MicrosoftEntraProviderUser.objects.filter(
122            provider=self.provider, user__pk__in=users
123        ).values_list("microsoft_id", flat=True)
124        self._patch(microsoft_group.microsoft_id, Direction.add, connections)
125
126    def update_group(self, group: Group, action: Direction, users_set: set[int]):
127        """Update a groups members"""
128        if action == Direction.add:
129            return self._patch_add_users(group, users_set)
130        if action == Direction.remove:
131            return self._patch_remove_users(group, users_set)
132
133    def _patch(self, microsoft_group_id: str, direction: Direction, members: list[str]):
134        for user in members:
135            try:
136                if direction == Direction.add:
137                    request_body = ReferenceCreate(
138                        odata_id=f"https://graph.microsoft.com/v1.0/directoryObjects/{user}",
139                    )
140                    self._request(
141                        self.client.groups.by_group_id(microsoft_group_id).members.ref.post(
142                            request_body
143                        )
144                    )
145                if direction == Direction.remove:
146                    self._request(
147                        self.client.groups.by_group_id(microsoft_group_id)
148                        .members.by_directory_object_id(user)
149                        .ref.delete()
150                    )
151            except ObjectExistsSyncException:
152                pass
153            except TransientSyncException:
154                raise
155
156    def _patch_add_users(self, group: Group, users_set: set[int]):
157        """Add users in users_set to group"""
158        if len(users_set) < 1:
159            return
160        microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
161            provider=self.provider, group=group
162        ).first()
163        if not microsoft_group:
164            self.logger.warning(
165                "could not sync group membership, group does not exist", group=group
166            )
167            return
168        user_ids = list(
169            MicrosoftEntraProviderUser.objects.filter(
170                user__pk__in=users_set, provider=self.provider
171            ).values_list("microsoft_id", flat=True)
172        )
173        if len(user_ids) < 1:
174            return
175        self._patch(microsoft_group.microsoft_id, Direction.add, user_ids)
176
177    def _patch_remove_users(self, group: Group, users_set: set[int]):
178        """Remove users in users_set from group"""
179        if len(users_set) < 1:
180            return
181        microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
182            provider=self.provider, group=group
183        ).first()
184        if not microsoft_group:
185            self.logger.warning(
186                "could not sync group membership, group does not exist", group=group
187            )
188            return
189        user_ids = list(
190            MicrosoftEntraProviderUser.objects.filter(
191                user__pk__in=users_set, provider=self.provider
192            ).values_list("microsoft_id", flat=True)
193        )
194        if len(user_ids) < 1:
195            return
196        self._patch(microsoft_group.microsoft_id, Direction.remove, user_ids)
197
198    def discover(self):
199        """Iterate through all groups and connect them with authentik groups if possible"""
200        groups = self._request(self.client.groups.get())
201        next_link = True
202        while next_link:
203            for group in groups.value:
204                self._discover_single_group(group)
205            next_link = groups.odata_next_link
206            if not next_link:
207                break
208            groups = self._request(self.client.groups.with_url(next_link).get())
209
210    def _discover_single_group(self, group: MSGroup):
211        """handle discovery of a single group"""
212        microsoft_name = group.unique_name
213        matching_authentik_group = (
214            self.provider.get_object_qs(Group).filter(name=microsoft_name).first()
215        )
216        if not matching_authentik_group:
217            return
218        MicrosoftEntraProviderGroup.objects.update_or_create(
219            provider=self.provider,
220            group=matching_authentik_group,
221            microsoft_id=group.id,
222            defaults={"attributes": self.entity_as_dict(group)},
223        )
224
225    def update_single_attribute(self, connection: MicrosoftEntraProviderGroup):
226        data = self._request(self.client.groups.by_group_id(connection.microsoft_id).get())
227        connection.attributes = self.entity_as_dict(data)
 27class MicrosoftEntraGroupClient(
 28    MicrosoftEntraSyncClient[Group, MicrosoftEntraProviderGroup, MSGroup]
 29):
 30    """Microsoft client for groups"""
 31
 32    connection_type = MicrosoftEntraProviderGroup
 33    connection_type_query = "group"
 34    can_discover = True
 35
 36    def __init__(self, provider: MicrosoftEntraProvider) -> None:
 37        super().__init__(provider)
 38        self.mapper = PropertyMappingManager(
 39            self.provider.property_mappings_group.all().order_by("name").select_subclasses(),
 40            MicrosoftEntraProviderMapping,
 41            ["group", "provider", "connection"],
 42        )
 43
 44    def to_schema(self, obj: Group, connection: MicrosoftEntraProviderGroup) -> MSGroup:
 45        """Convert authentik group"""
 46        raw_microsoft_group = super().to_schema(obj, connection)
 47        try:
 48            return MSGroup(**raw_microsoft_group)
 49        except TypeError as exc:
 50            raise StopSync(exc, obj) from exc
 51
 52    def delete(self, identifier: str):
 53        """Delete group"""
 54        MicrosoftEntraProviderGroup.objects.filter(
 55            provider=self.provider, microsoft_id=identifier
 56        ).delete()
 57        if self.provider.group_delete_action == OutgoingSyncDeleteAction.DELETE:
 58            return self._request(self.client.groups.by_group_id(identifier).delete())
 59
 60    def create(self, group: Group):
 61        """Create group from scratch and create a connection object"""
 62        microsoft_group = self.to_schema(group, None)
 63        with transaction.atomic():
 64            try:
 65                response = self._request(self.client.groups.post(microsoft_group))
 66            except ObjectExistsSyncException:
 67                # group already exists in microsoft entra, so we can connect them manually
 68                # for groups we need to fetch the group from microsoft as we connect on
 69                # ID and not group email
 70                query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters(
 71                    filter=f"displayName eq '{microsoft_group.display_name}'",
 72                )
 73                request_configuration = (
 74                    GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration(
 75                        query_parameters=query_params,
 76                    )
 77                )
 78                group_data = self._request(self.client.groups.get(request_configuration))
 79                if group_data.odata_count < 1 or len(group_data.value) < 1:
 80                    self.logger.warning(
 81                        "Group which could not be created also does not exist", group=group
 82                    )
 83                    return
 84                ms_group = group_data.value[0]
 85                return MicrosoftEntraProviderGroup.objects.create(
 86                    provider=self.provider,
 87                    group=group,
 88                    microsoft_id=ms_group.id,
 89                    attributes=self.entity_as_dict(ms_group),
 90                )
 91            else:
 92                return MicrosoftEntraProviderGroup.objects.create(
 93                    provider=self.provider,
 94                    group=group,
 95                    microsoft_id=response.id,
 96                    attributes=self.entity_as_dict(response),
 97                )
 98
 99    def update(self, group: Group, connection: MicrosoftEntraProviderGroup):
100        """Update existing group"""
101        microsoft_group = self.to_schema(group, connection)
102        microsoft_group.id = connection.microsoft_id
103        try:
104            response = self._request(
105                self.client.groups.by_group_id(connection.microsoft_id).patch(microsoft_group)
106            )
107            if response:
108                always_merger.merge(connection.attributes, self.entity_as_dict(response))
109                connection.save()
110        except NotFoundSyncException:
111            # Resource missing is handled by self.write, which will re-create the group
112            raise
113
114    def write(self, obj: Group):
115        microsoft_group, created = super().write(obj)
116        self.create_sync_members(obj, microsoft_group)
117        return microsoft_group, created
118
119    def create_sync_members(self, obj: Group, microsoft_group: MicrosoftEntraProviderGroup):
120        """Sync all members after a group was created"""
121        users = list(obj.users.order_by("id").values_list("id", flat=True))
122        connections = MicrosoftEntraProviderUser.objects.filter(
123            provider=self.provider, user__pk__in=users
124        ).values_list("microsoft_id", flat=True)
125        self._patch(microsoft_group.microsoft_id, Direction.add, connections)
126
127    def update_group(self, group: Group, action: Direction, users_set: set[int]):
128        """Update a groups members"""
129        if action == Direction.add:
130            return self._patch_add_users(group, users_set)
131        if action == Direction.remove:
132            return self._patch_remove_users(group, users_set)
133
134    def _patch(self, microsoft_group_id: str, direction: Direction, members: list[str]):
135        for user in members:
136            try:
137                if direction == Direction.add:
138                    request_body = ReferenceCreate(
139                        odata_id=f"https://graph.microsoft.com/v1.0/directoryObjects/{user}",
140                    )
141                    self._request(
142                        self.client.groups.by_group_id(microsoft_group_id).members.ref.post(
143                            request_body
144                        )
145                    )
146                if direction == Direction.remove:
147                    self._request(
148                        self.client.groups.by_group_id(microsoft_group_id)
149                        .members.by_directory_object_id(user)
150                        .ref.delete()
151                    )
152            except ObjectExistsSyncException:
153                pass
154            except TransientSyncException:
155                raise
156
157    def _patch_add_users(self, group: Group, users_set: set[int]):
158        """Add users in users_set to group"""
159        if len(users_set) < 1:
160            return
161        microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
162            provider=self.provider, group=group
163        ).first()
164        if not microsoft_group:
165            self.logger.warning(
166                "could not sync group membership, group does not exist", group=group
167            )
168            return
169        user_ids = list(
170            MicrosoftEntraProviderUser.objects.filter(
171                user__pk__in=users_set, provider=self.provider
172            ).values_list("microsoft_id", flat=True)
173        )
174        if len(user_ids) < 1:
175            return
176        self._patch(microsoft_group.microsoft_id, Direction.add, user_ids)
177
178    def _patch_remove_users(self, group: Group, users_set: set[int]):
179        """Remove users in users_set from group"""
180        if len(users_set) < 1:
181            return
182        microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
183            provider=self.provider, group=group
184        ).first()
185        if not microsoft_group:
186            self.logger.warning(
187                "could not sync group membership, group does not exist", group=group
188            )
189            return
190        user_ids = list(
191            MicrosoftEntraProviderUser.objects.filter(
192                user__pk__in=users_set, provider=self.provider
193            ).values_list("microsoft_id", flat=True)
194        )
195        if len(user_ids) < 1:
196            return
197        self._patch(microsoft_group.microsoft_id, Direction.remove, user_ids)
198
199    def discover(self):
200        """Iterate through all groups and connect them with authentik groups if possible"""
201        groups = self._request(self.client.groups.get())
202        next_link = True
203        while next_link:
204            for group in groups.value:
205                self._discover_single_group(group)
206            next_link = groups.odata_next_link
207            if not next_link:
208                break
209            groups = self._request(self.client.groups.with_url(next_link).get())
210
211    def _discover_single_group(self, group: MSGroup):
212        """handle discovery of a single group"""
213        microsoft_name = group.unique_name
214        matching_authentik_group = (
215            self.provider.get_object_qs(Group).filter(name=microsoft_name).first()
216        )
217        if not matching_authentik_group:
218            return
219        MicrosoftEntraProviderGroup.objects.update_or_create(
220            provider=self.provider,
221            group=matching_authentik_group,
222            microsoft_id=group.id,
223            defaults={"attributes": self.entity_as_dict(group)},
224        )
225
226    def update_single_attribute(self, connection: MicrosoftEntraProviderGroup):
227        data = self._request(self.client.groups.by_group_id(connection.microsoft_id).get())
228        connection.attributes = self.entity_as_dict(data)

Microsoft client for groups

MicrosoftEntraGroupClient( provider: authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider)
36    def __init__(self, provider: MicrosoftEntraProvider) -> None:
37        super().__init__(provider)
38        self.mapper = PropertyMappingManager(
39            self.provider.property_mappings_group.all().order_by("name").select_subclasses(),
40            MicrosoftEntraProviderMapping,
41            ["group", "provider", "connection"],
42        )
connection_type_query = 'group'
can_discover = True
mapper
def to_schema( self, obj: authentik.core.models.Group, connection: authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProviderGroup) -> msgraph.generated.models.group.Group:
44    def to_schema(self, obj: Group, connection: MicrosoftEntraProviderGroup) -> MSGroup:
45        """Convert authentik group"""
46        raw_microsoft_group = super().to_schema(obj, connection)
47        try:
48            return MSGroup(**raw_microsoft_group)
49        except TypeError as exc:
50            raise StopSync(exc, obj) from exc

Convert authentik group

def delete(self, identifier: str):
52    def delete(self, identifier: str):
53        """Delete group"""
54        MicrosoftEntraProviderGroup.objects.filter(
55            provider=self.provider, microsoft_id=identifier
56        ).delete()
57        if self.provider.group_delete_action == OutgoingSyncDeleteAction.DELETE:
58            return self._request(self.client.groups.by_group_id(identifier).delete())

Delete group

def create(self, group: authentik.core.models.Group):
60    def create(self, group: Group):
61        """Create group from scratch and create a connection object"""
62        microsoft_group = self.to_schema(group, None)
63        with transaction.atomic():
64            try:
65                response = self._request(self.client.groups.post(microsoft_group))
66            except ObjectExistsSyncException:
67                # group already exists in microsoft entra, so we can connect them manually
68                # for groups we need to fetch the group from microsoft as we connect on
69                # ID and not group email
70                query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters(
71                    filter=f"displayName eq '{microsoft_group.display_name}'",
72                )
73                request_configuration = (
74                    GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration(
75                        query_parameters=query_params,
76                    )
77                )
78                group_data = self._request(self.client.groups.get(request_configuration))
79                if group_data.odata_count < 1 or len(group_data.value) < 1:
80                    self.logger.warning(
81                        "Group which could not be created also does not exist", group=group
82                    )
83                    return
84                ms_group = group_data.value[0]
85                return MicrosoftEntraProviderGroup.objects.create(
86                    provider=self.provider,
87                    group=group,
88                    microsoft_id=ms_group.id,
89                    attributes=self.entity_as_dict(ms_group),
90                )
91            else:
92                return MicrosoftEntraProviderGroup.objects.create(
93                    provider=self.provider,
94                    group=group,
95                    microsoft_id=response.id,
96                    attributes=self.entity_as_dict(response),
97                )

Create group from scratch and create a connection object

 99    def update(self, group: Group, connection: MicrosoftEntraProviderGroup):
100        """Update existing group"""
101        microsoft_group = self.to_schema(group, connection)
102        microsoft_group.id = connection.microsoft_id
103        try:
104            response = self._request(
105                self.client.groups.by_group_id(connection.microsoft_id).patch(microsoft_group)
106            )
107            if response:
108                always_merger.merge(connection.attributes, self.entity_as_dict(response))
109                connection.save()
110        except NotFoundSyncException:
111            # Resource missing is handled by self.write, which will re-create the group
112            raise

Update existing group

def write(self, obj: authentik.core.models.Group):
114    def write(self, obj: Group):
115        microsoft_group, created = super().write(obj)
116        self.create_sync_members(obj, microsoft_group)
117        return microsoft_group, created

Write object to destination. Uses self.create and self.update, but can be overwritten for further logic

119    def create_sync_members(self, obj: Group, microsoft_group: MicrosoftEntraProviderGroup):
120        """Sync all members after a group was created"""
121        users = list(obj.users.order_by("id").values_list("id", flat=True))
122        connections = MicrosoftEntraProviderUser.objects.filter(
123            provider=self.provider, user__pk__in=users
124        ).values_list("microsoft_id", flat=True)
125        self._patch(microsoft_group.microsoft_id, Direction.add, connections)

Sync all members after a group was created

def update_group( self, group: authentik.core.models.Group, action: authentik.lib.sync.outgoing.base.Direction, users_set: set[int]):
127    def update_group(self, group: Group, action: Direction, users_set: set[int]):
128        """Update a groups members"""
129        if action == Direction.add:
130            return self._patch_add_users(group, users_set)
131        if action == Direction.remove:
132            return self._patch_remove_users(group, users_set)

Update a groups members

def discover(self):
199    def discover(self):
200        """Iterate through all groups and connect them with authentik groups if possible"""
201        groups = self._request(self.client.groups.get())
202        next_link = True
203        while next_link:
204            for group in groups.value:
205                self._discover_single_group(group)
206            next_link = groups.odata_next_link
207            if not next_link:
208                break
209            groups = self._request(self.client.groups.with_url(next_link).get())

Iterate through all groups and connect them with authentik groups if possible

def update_single_attribute( self, connection: authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProviderGroup):
226    def update_single_attribute(self, connection: MicrosoftEntraProviderGroup):
227        data = self._request(self.client.groups.by_group_id(connection.microsoft_id).get())
228        connection.attributes = self.entity_as_dict(data)

Update connection attributes on a connection object, when the connection is manually created