authentik.sources.scim.views.v2.groups

SCIM Group Views

  1"""SCIM Group Views"""
  2
  3from uuid import uuid4
  4
  5from django.db.models import Q
  6from django.db.transaction import atomic
  7from django.http import QueryDict
  8from django.urls import reverse
  9from pydantic import ValidationError as PydanticValidationError
 10from pydanticscim.group import GroupMember
 11from rest_framework.exceptions import ValidationError
 12from rest_framework.request import Request
 13from rest_framework.response import Response
 14from scim2_filter_parser.attr_paths import AttrPath
 15
 16from authentik.core.models import Group, User
 17from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOp, PatchOperation
 18from authentik.providers.scim.clients.schema import Group as SCIMGroupModel
 19from authentik.sources.scim.models import SCIMSourceGroup
 20from authentik.sources.scim.patch.processor import SCIMPatchProcessor
 21from authentik.sources.scim.views.v2.base import SCIMObjectView
 22from authentik.sources.scim.views.v2.exceptions import (
 23    SCIMConflictError,
 24    SCIMNotFoundError,
 25    SCIMValidationError,
 26)
 27
 28
 29class GroupsView(SCIMObjectView):
 30    """SCIM Group view"""
 31
 32    model = Group
 33
 34    def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict:
 35        """Convert Group to SCIM data"""
 36        payload = SCIMGroupModel(
 37            schemas=[SCIM_GROUP_SCHEMA],
 38            id=str(scim_group.group.pk),
 39            externalId=scim_group.external_id,
 40            displayName=scim_group.group.name,
 41            members=[],
 42            meta={
 43                "resourceType": "Group",
 44                "lastModified": scim_group.last_update,
 45                "location": self.request.build_absolute_uri(
 46                    reverse(
 47                        "authentik_sources_scim:v2-groups",
 48                        kwargs={
 49                            "source_slug": self.kwargs["source_slug"],
 50                            "group_id": str(scim_group.group.pk),
 51                        },
 52                    )
 53                ),
 54            },
 55        )
 56        for member in scim_group.group.users.order_by("pk"):
 57            member: User
 58            payload.members.append(GroupMember(value=str(member.uuid)))
 59        final_payload = payload.model_dump(mode="json", exclude_unset=True)
 60        final_payload.update(scim_group.attributes)
 61        return self.remove_excluded_attributes(
 62            SCIMGroupModel.model_validate(final_payload).model_dump(mode="json", exclude_unset=True)
 63        )
 64
 65    def get(self, request: Request, group_id: str | None = None, **kwargs) -> Response:
 66        """List Group handler"""
 67        base_query = SCIMSourceGroup.objects.select_related("group").prefetch_related(
 68            "group__users"
 69        )
 70        if group_id:
 71            connection = base_query.filter(source=self.source, group__group_uuid=group_id).first()
 72            if not connection:
 73                raise SCIMNotFoundError("Group not found.")
 74            return Response(self.group_to_scim(connection))
 75        connections = (
 76            base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request))
 77        )
 78        page = self.paginate_query(connections)
 79        return Response(
 80            {
 81                "totalResults": page.paginator.count,
 82                "itemsPerPage": page.paginator.per_page,
 83                "startIndex": page.start_index(),
 84                "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
 85                "Resources": [self.group_to_scim(connection) for connection in page],
 86            }
 87        )
 88
 89    @atomic
 90    def update_group(self, connection: SCIMSourceGroup | None, data: QueryDict, apply_members=True):
 91        """Partial update a group"""
 92        properties = self.build_object_properties(data)
 93
 94        if not properties.get("name"):
 95            raise ValidationError("Invalid group")
 96
 97        group = connection.group if connection else Group()
 98        if _group := Group.objects.filter(name=properties.get("name")).first():
 99            group = _group
100
101        group.update_attributes(properties)
102
103        if "members" in data and apply_members:
104            query = Q()
105            for _member in data.get("members", []):
106                try:
107                    member = GroupMember.model_validate(_member)
108                except PydanticValidationError as exc:
109                    self.logger.warning("Invalid group member", exc=exc)
110                    continue
111                query |= Q(uuid=member.value)
112            if query:
113                group.users.set(User.objects.filter(query))
114        data["members"] = self._convert_members(group)
115        if not connection:
116            connection, _ = SCIMSourceGroup.objects.update_or_create(
117                external_id=data.get("externalId") or str(uuid4()),
118                source=self.source,
119                group=group,
120                defaults={
121                    "attributes": data,
122                },
123            )
124        else:
125            connection.external_id = data.get("externalId", connection.external_id)
126            connection.attributes = data
127            connection.save()
128        return connection
129
130    def post(self, request: Request, **kwargs) -> Response:
131        """Create group handler"""
132        connection = SCIMSourceGroup.objects.filter(
133            source=self.source,
134            group__group_uuid=request.data.get("id"),
135        ).first()
136        if connection:
137            self.logger.debug("Found existing group")
138            raise SCIMConflictError("Group with ID exists already.")
139        connection = self.update_group(None, request.data)
140        return Response(self.group_to_scim(connection), status=201)
141
142    def put(self, request: Request, group_id: str, **kwargs) -> Response:
143        """Update group handler"""
144        connection = SCIMSourceGroup.objects.filter(
145            source=self.source, group__group_uuid=group_id
146        ).first()
147        if not connection:
148            raise SCIMNotFoundError("Group not found.")
149        connection = self.update_group(connection, request.data)
150        return Response(self.group_to_scim(connection), status=200)
151
152    def _convert_members(self, group: Group):
153        users = []
154        for user in group.users.all().order_by("uuid"):
155            users.append({"value": str(user.uuid)})
156        return sorted(users, key=lambda u: u["value"])
157
158    @atomic
159    def patch(self, request: Request, group_id: str, **kwargs) -> Response:
160        """Patch group handler"""
161        connection = SCIMSourceGroup.objects.filter(
162            source=self.source, group__group_uuid=group_id
163        ).first()
164        if not connection:
165            raise SCIMNotFoundError("Group not found.")
166
167        for _op in request.data.get("Operations", []):
168            operation = PatchOperation.model_validate(_op)
169            if operation.op.lower() not in ["add", "remove", "replace"]:
170                raise SCIMValidationError()
171            attr_path = AttrPath(f'{operation.path} eq ""', {})
172            if attr_path.first_path == ("members", None, None):
173                # FIXME: this can probably be de-duplicated
174                if operation.op == PatchOp.add:
175                    if not isinstance(operation.value, list):
176                        operation.value = [operation.value]
177                    query = Q()
178                    for member in operation.value:
179                        query |= Q(uuid=member["value"])
180                    if query:
181                        connection.group.users.add(*User.objects.filter(query))
182                elif operation.op == PatchOp.remove:
183                    if not isinstance(operation.value, list):
184                        operation.value = [operation.value]
185                    query = Q()
186                    for member in operation.value:
187                        query |= Q(uuid=member["value"])
188                    if query:
189                        connection.group.users.remove(*User.objects.filter(query))
190        patcher = SCIMPatchProcessor()
191        patched_data = patcher.apply_patches(
192            connection.attributes, request.data.get("Operations", [])
193        )
194        patched_data["members"] = self._convert_members(connection.group)
195        if patched_data != connection.attributes:
196            self.update_group(connection, patched_data, apply_members=False)
197        return Response(self.group_to_scim(connection), status=200)
198
199    @atomic
200    def delete(self, request: Request, group_id: str, **kwargs) -> Response:
201        """Delete group handler"""
202        connection = SCIMSourceGroup.objects.filter(
203            source=self.source, group__group_uuid=group_id
204        ).first()
205        if not connection:
206            raise SCIMNotFoundError("Group not found.")
207        connection.group.delete()
208        connection.delete()
209        return Response(status=204)
 30class GroupsView(SCIMObjectView):
 31    """SCIM Group view"""
 32
 33    model = Group
 34
 35    def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict:
 36        """Convert Group to SCIM data"""
 37        payload = SCIMGroupModel(
 38            schemas=[SCIM_GROUP_SCHEMA],
 39            id=str(scim_group.group.pk),
 40            externalId=scim_group.external_id,
 41            displayName=scim_group.group.name,
 42            members=[],
 43            meta={
 44                "resourceType": "Group",
 45                "lastModified": scim_group.last_update,
 46                "location": self.request.build_absolute_uri(
 47                    reverse(
 48                        "authentik_sources_scim:v2-groups",
 49                        kwargs={
 50                            "source_slug": self.kwargs["source_slug"],
 51                            "group_id": str(scim_group.group.pk),
 52                        },
 53                    )
 54                ),
 55            },
 56        )
 57        for member in scim_group.group.users.order_by("pk"):
 58            member: User
 59            payload.members.append(GroupMember(value=str(member.uuid)))
 60        final_payload = payload.model_dump(mode="json", exclude_unset=True)
 61        final_payload.update(scim_group.attributes)
 62        return self.remove_excluded_attributes(
 63            SCIMGroupModel.model_validate(final_payload).model_dump(mode="json", exclude_unset=True)
 64        )
 65
 66    def get(self, request: Request, group_id: str | None = None, **kwargs) -> Response:
 67        """List Group handler"""
 68        base_query = SCIMSourceGroup.objects.select_related("group").prefetch_related(
 69            "group__users"
 70        )
 71        if group_id:
 72            connection = base_query.filter(source=self.source, group__group_uuid=group_id).first()
 73            if not connection:
 74                raise SCIMNotFoundError("Group not found.")
 75            return Response(self.group_to_scim(connection))
 76        connections = (
 77            base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request))
 78        )
 79        page = self.paginate_query(connections)
 80        return Response(
 81            {
 82                "totalResults": page.paginator.count,
 83                "itemsPerPage": page.paginator.per_page,
 84                "startIndex": page.start_index(),
 85                "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
 86                "Resources": [self.group_to_scim(connection) for connection in page],
 87            }
 88        )
 89
 90    @atomic
 91    def update_group(self, connection: SCIMSourceGroup | None, data: QueryDict, apply_members=True):
 92        """Partial update a group"""
 93        properties = self.build_object_properties(data)
 94
 95        if not properties.get("name"):
 96            raise ValidationError("Invalid group")
 97
 98        group = connection.group if connection else Group()
 99        if _group := Group.objects.filter(name=properties.get("name")).first():
100            group = _group
101
102        group.update_attributes(properties)
103
104        if "members" in data and apply_members:
105            query = Q()
106            for _member in data.get("members", []):
107                try:
108                    member = GroupMember.model_validate(_member)
109                except PydanticValidationError as exc:
110                    self.logger.warning("Invalid group member", exc=exc)
111                    continue
112                query |= Q(uuid=member.value)
113            if query:
114                group.users.set(User.objects.filter(query))
115        data["members"] = self._convert_members(group)
116        if not connection:
117            connection, _ = SCIMSourceGroup.objects.update_or_create(
118                external_id=data.get("externalId") or str(uuid4()),
119                source=self.source,
120                group=group,
121                defaults={
122                    "attributes": data,
123                },
124            )
125        else:
126            connection.external_id = data.get("externalId", connection.external_id)
127            connection.attributes = data
128            connection.save()
129        return connection
130
131    def post(self, request: Request, **kwargs) -> Response:
132        """Create group handler"""
133        connection = SCIMSourceGroup.objects.filter(
134            source=self.source,
135            group__group_uuid=request.data.get("id"),
136        ).first()
137        if connection:
138            self.logger.debug("Found existing group")
139            raise SCIMConflictError("Group with ID exists already.")
140        connection = self.update_group(None, request.data)
141        return Response(self.group_to_scim(connection), status=201)
142
143    def put(self, request: Request, group_id: str, **kwargs) -> Response:
144        """Update group handler"""
145        connection = SCIMSourceGroup.objects.filter(
146            source=self.source, group__group_uuid=group_id
147        ).first()
148        if not connection:
149            raise SCIMNotFoundError("Group not found.")
150        connection = self.update_group(connection, request.data)
151        return Response(self.group_to_scim(connection), status=200)
152
153    def _convert_members(self, group: Group):
154        users = []
155        for user in group.users.all().order_by("uuid"):
156            users.append({"value": str(user.uuid)})
157        return sorted(users, key=lambda u: u["value"])
158
159    @atomic
160    def patch(self, request: Request, group_id: str, **kwargs) -> Response:
161        """Patch group handler"""
162        connection = SCIMSourceGroup.objects.filter(
163            source=self.source, group__group_uuid=group_id
164        ).first()
165        if not connection:
166            raise SCIMNotFoundError("Group not found.")
167
168        for _op in request.data.get("Operations", []):
169            operation = PatchOperation.model_validate(_op)
170            if operation.op.lower() not in ["add", "remove", "replace"]:
171                raise SCIMValidationError()
172            attr_path = AttrPath(f'{operation.path} eq ""', {})
173            if attr_path.first_path == ("members", None, None):
174                # FIXME: this can probably be de-duplicated
175                if operation.op == PatchOp.add:
176                    if not isinstance(operation.value, list):
177                        operation.value = [operation.value]
178                    query = Q()
179                    for member in operation.value:
180                        query |= Q(uuid=member["value"])
181                    if query:
182                        connection.group.users.add(*User.objects.filter(query))
183                elif operation.op == PatchOp.remove:
184                    if not isinstance(operation.value, list):
185                        operation.value = [operation.value]
186                    query = Q()
187                    for member in operation.value:
188                        query |= Q(uuid=member["value"])
189                    if query:
190                        connection.group.users.remove(*User.objects.filter(query))
191        patcher = SCIMPatchProcessor()
192        patched_data = patcher.apply_patches(
193            connection.attributes, request.data.get("Operations", [])
194        )
195        patched_data["members"] = self._convert_members(connection.group)
196        if patched_data != connection.attributes:
197            self.update_group(connection, patched_data, apply_members=False)
198        return Response(self.group_to_scim(connection), status=200)
199
200    @atomic
201    def delete(self, request: Request, group_id: str, **kwargs) -> Response:
202        """Delete group handler"""
203        connection = SCIMSourceGroup.objects.filter(
204            source=self.source, group__group_uuid=group_id
205        ).first()
206        if not connection:
207            raise SCIMNotFoundError("Group not found.")
208        connection.group.delete()
209        connection.delete()
210        return Response(status=204)

SCIM Group view

model = <class 'authentik.core.models.Group'>
def group_to_scim(self, scim_group: authentik.sources.scim.models.SCIMSourceGroup) -> dict:
35    def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict:
36        """Convert Group to SCIM data"""
37        payload = SCIMGroupModel(
38            schemas=[SCIM_GROUP_SCHEMA],
39            id=str(scim_group.group.pk),
40            externalId=scim_group.external_id,
41            displayName=scim_group.group.name,
42            members=[],
43            meta={
44                "resourceType": "Group",
45                "lastModified": scim_group.last_update,
46                "location": self.request.build_absolute_uri(
47                    reverse(
48                        "authentik_sources_scim:v2-groups",
49                        kwargs={
50                            "source_slug": self.kwargs["source_slug"],
51                            "group_id": str(scim_group.group.pk),
52                        },
53                    )
54                ),
55            },
56        )
57        for member in scim_group.group.users.order_by("pk"):
58            member: User
59            payload.members.append(GroupMember(value=str(member.uuid)))
60        final_payload = payload.model_dump(mode="json", exclude_unset=True)
61        final_payload.update(scim_group.attributes)
62        return self.remove_excluded_attributes(
63            SCIMGroupModel.model_validate(final_payload).model_dump(mode="json", exclude_unset=True)
64        )

Convert Group to SCIM data

def get( self, request: rest_framework.request.Request, group_id: str | None = None, **kwargs) -> rest_framework.response.Response:
66    def get(self, request: Request, group_id: str | None = None, **kwargs) -> Response:
67        """List Group handler"""
68        base_query = SCIMSourceGroup.objects.select_related("group").prefetch_related(
69            "group__users"
70        )
71        if group_id:
72            connection = base_query.filter(source=self.source, group__group_uuid=group_id).first()
73            if not connection:
74                raise SCIMNotFoundError("Group not found.")
75            return Response(self.group_to_scim(connection))
76        connections = (
77            base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request))
78        )
79        page = self.paginate_query(connections)
80        return Response(
81            {
82                "totalResults": page.paginator.count,
83                "itemsPerPage": page.paginator.per_page,
84                "startIndex": page.start_index(),
85                "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
86                "Resources": [self.group_to_scim(connection) for connection in page],
87            }
88        )

List Group handler

@atomic
def update_group( self, connection: authentik.sources.scim.models.SCIMSourceGroup | None, data: django.http.request.QueryDict, apply_members=True):
 90    @atomic
 91    def update_group(self, connection: SCIMSourceGroup | None, data: QueryDict, apply_members=True):
 92        """Partial update a group"""
 93        properties = self.build_object_properties(data)
 94
 95        if not properties.get("name"):
 96            raise ValidationError("Invalid group")
 97
 98        group = connection.group if connection else Group()
 99        if _group := Group.objects.filter(name=properties.get("name")).first():
100            group = _group
101
102        group.update_attributes(properties)
103
104        if "members" in data and apply_members:
105            query = Q()
106            for _member in data.get("members", []):
107                try:
108                    member = GroupMember.model_validate(_member)
109                except PydanticValidationError as exc:
110                    self.logger.warning("Invalid group member", exc=exc)
111                    continue
112                query |= Q(uuid=member.value)
113            if query:
114                group.users.set(User.objects.filter(query))
115        data["members"] = self._convert_members(group)
116        if not connection:
117            connection, _ = SCIMSourceGroup.objects.update_or_create(
118                external_id=data.get("externalId") or str(uuid4()),
119                source=self.source,
120                group=group,
121                defaults={
122                    "attributes": data,
123                },
124            )
125        else:
126            connection.external_id = data.get("externalId", connection.external_id)
127            connection.attributes = data
128            connection.save()
129        return connection

Partial update a group

def post( self, request: rest_framework.request.Request, **kwargs) -> rest_framework.response.Response:
131    def post(self, request: Request, **kwargs) -> Response:
132        """Create group handler"""
133        connection = SCIMSourceGroup.objects.filter(
134            source=self.source,
135            group__group_uuid=request.data.get("id"),
136        ).first()
137        if connection:
138            self.logger.debug("Found existing group")
139            raise SCIMConflictError("Group with ID exists already.")
140        connection = self.update_group(None, request.data)
141        return Response(self.group_to_scim(connection), status=201)

Create group handler

def put( self, request: rest_framework.request.Request, group_id: str, **kwargs) -> rest_framework.response.Response:
143    def put(self, request: Request, group_id: str, **kwargs) -> Response:
144        """Update group handler"""
145        connection = SCIMSourceGroup.objects.filter(
146            source=self.source, group__group_uuid=group_id
147        ).first()
148        if not connection:
149            raise SCIMNotFoundError("Group not found.")
150        connection = self.update_group(connection, request.data)
151        return Response(self.group_to_scim(connection), status=200)

Update group handler

@atomic
def patch( self, request: rest_framework.request.Request, group_id: str, **kwargs) -> rest_framework.response.Response:
159    @atomic
160    def patch(self, request: Request, group_id: str, **kwargs) -> Response:
161        """Patch group handler"""
162        connection = SCIMSourceGroup.objects.filter(
163            source=self.source, group__group_uuid=group_id
164        ).first()
165        if not connection:
166            raise SCIMNotFoundError("Group not found.")
167
168        for _op in request.data.get("Operations", []):
169            operation = PatchOperation.model_validate(_op)
170            if operation.op.lower() not in ["add", "remove", "replace"]:
171                raise SCIMValidationError()
172            attr_path = AttrPath(f'{operation.path} eq ""', {})
173            if attr_path.first_path == ("members", None, None):
174                # FIXME: this can probably be de-duplicated
175                if operation.op == PatchOp.add:
176                    if not isinstance(operation.value, list):
177                        operation.value = [operation.value]
178                    query = Q()
179                    for member in operation.value:
180                        query |= Q(uuid=member["value"])
181                    if query:
182                        connection.group.users.add(*User.objects.filter(query))
183                elif operation.op == PatchOp.remove:
184                    if not isinstance(operation.value, list):
185                        operation.value = [operation.value]
186                    query = Q()
187                    for member in operation.value:
188                        query |= Q(uuid=member["value"])
189                    if query:
190                        connection.group.users.remove(*User.objects.filter(query))
191        patcher = SCIMPatchProcessor()
192        patched_data = patcher.apply_patches(
193            connection.attributes, request.data.get("Operations", [])
194        )
195        patched_data["members"] = self._convert_members(connection.group)
196        if patched_data != connection.attributes:
197            self.update_group(connection, patched_data, apply_members=False)
198        return Response(self.group_to_scim(connection), status=200)

Patch group handler

@atomic
def delete( self, request: rest_framework.request.Request, group_id: str, **kwargs) -> rest_framework.response.Response:
200    @atomic
201    def delete(self, request: Request, group_id: str, **kwargs) -> Response:
202        """Delete group handler"""
203        connection = SCIMSourceGroup.objects.filter(
204            source=self.source, group__group_uuid=group_id
205        ).first()
206        if not connection:
207            raise SCIMNotFoundError("Group not found.")
208        connection.group.delete()
209        connection.delete()
210        return Response(status=204)

Delete group handler