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'>
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