def _save_user(req: Request, db_user: ScimApiUser) -> None: try: ctx_userdb(req).save(db_user) except DuplicateKeyError as e: if 'external-id' in e.details['errmsg']: raise BadRequest(detail='externalID must be unique') raise BadRequest(detail='Duplicated key error')
def on_post(self, req: Request, resp: Response): """ POST /Groups/.search Host: scim.eduid.se Accept: application/scim+json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], "filter": "displayName eq \"some display name\"", } HTTP/1.1 200 OK Content-Type: application/scim+json Location: https://example.com/Users/.search { "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "totalResults": 1, "Resources": [ { "displayName": "test group", "id": "46aee99f-f417-41fc-97f0-1ee8970078db" }, ] } """ self.context.logger.info('Searching for group(s)') try: query: SearchRequest = SearchRequestSchema().load(req.media) except ValidationError as e: raise BadRequest(detail=f"{e}") filter = parse_search_filter(query.filter) if filter.attr == 'displayname': groups, total_count = self._filter_display_name( req, filter, skip=query.start_index - 1, limit=query.count) elif filter.attr == 'meta.lastmodified': groups, total_count = self._filter_lastmodified( req, filter, skip=query.start_index - 1, limit=query.count) elif filter.attr.startswith('extensions.data.'): groups, total_count = self._filter_extensions_data( req, filter, skip=query.start_index - 1, limit=query.count) else: raise BadRequest( scim_type='invalidFilter', detail=f'Can\'t filter on attribute {filter.attr}') resources = [] for this in groups: resources.append({ 'id': str(this.scim_id), 'displayName': this.display_name }) list_response = ListResponse(total_results=total_count, resources=resources) resp.media = ListResponseSchema().dump(list_response)
def on_post(self, req: Request, resp: Response): """ POST /Invites/.search Host: scim.eduid.se Accept: application/scim+json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], "attributes": ["id"], "filter": "meta.lastModified ge \"2020-09-14T12:49:45\"", "encryptionKey": "h026jGKrSW%2BTTekkA8Y8mv8%2FGqkGgAfLzaj3ucD3STQ" "startIndex": 1, "count": 1 } HTTP/1.1 200 OK Content-Type: application/scim+json Location: https://example.com/Invites/.search { "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "totalResults": 1, "itemsPerPage": 1, "startIndex": 1, "Resources": [ { "id": "fb96a6d0-1837-4c3b-9945-4249c476875c", } ] } """ self.context.logger.info(f'Searching for users(s)') try: query: SearchRequest = SearchRequestSchema().load(req.media) except ValidationError as e: raise BadRequest(detail=f'{e}') self.context.logger.debug(f'Parsed user search query: {query}') filter = parse_search_filter(query.filter) if filter.attr == 'meta.lastmodified': # SCIM start_index 1 equals item 0 users, total_count = self._filter_lastmodified( req, filter, skip=query.start_index - 1, limit=query.count) else: raise BadRequest( scim_type='invalidFilter', detail=f'Can\'t filter on attribute {filter.attr}') list_response = ListResponse( resources=self._invites_to_resources_dicts(req, users), total_results=total_count) resp.media = ListResponseSchema().dump(list_response)
def _get_scim_referenced( req: Request, resource: NutidEventResource) -> Optional[ScimApiResourceBase]: if resource.resource_type == SCIMResourceType.USER: return ctx_userdb(req).get_user_by_scim_id(str(resource.scim_id)) elif resource.resource_type == SCIMResourceType.GROUP: return ctx_groupdb(req).get_group_by_scim_id(str(resource.scim_id)) elif resource.resource_type == SCIMResourceType.INVITE: return ctx_invitedb(req).get_invite_by_scim_id(str(resource.scim_id)) elif resource.resource_type == SCIMResourceType.EVENT: raise BadRequest(detail=f'Events can not refer to other events') raise BadRequest( detail= f'Events for resource {resource.resource_type.value} not implemented')
def _filter_externalid(req: Request, filter: SearchFilter) -> List[ScimApiUser]: if filter.op != 'eq': raise BadRequest(scim_type='invalidFilter', detail='Unsupported operator') if not isinstance(filter.val, str): raise BadRequest(scim_type='invalidFilter', detail='Invalid externalId') user = ctx_userdb(req).get_user_by_external_id(filter.val) if not user: return [] return [user]
def _filter_lastmodified( req: Request, filter: SearchFilter, skip: Optional[int] = None, limit: Optional[int] = None) -> Tuple[List[ScimApiInvite], int]: if filter.op not in ['gt', 'ge']: raise BadRequest(scim_type='invalidFilter', detail='Unsupported operator') if not isinstance(filter.val, str): raise BadRequest(scim_type='invalidFilter', detail='Invalid datetime') return ctx_invitedb(req).get_invites_by_last_modified( operator=filter.op, value=datetime.fromisoformat(filter.val), skip=skip, limit=limit)
def on_delete(self, req: Request, resp: Response, scim_id: str): self.context.logger.info(f'Deleting group {scim_id}') db_group = ctx_groupdb(req).get_group_by_scim_id(scim_id=scim_id) self.context.logger.debug(f'Found group: {db_group}') if not db_group: raise NotFound(detail="Group not found") # Check version if not self._check_version(req, db_group): raise BadRequest(detail="Version mismatch") res = ctx_groupdb(req).remove_group(db_group) add_api_event( context=self.context, data_owner=req.context['data_owner'], db_obj=db_group, resource_type=SCIMResourceType.GROUP, level=EventLevel.INFO, status=EventStatus.DELETED, message='Group was deleted', ) self.context.logger.debug(f'Remove group result: {res}') resp.status = HTTP_204
def on_delete(self, req: Request, resp: Response, scim_id: str): self.context.logger.info(f'Deleting invite {scim_id}') db_invite = ctx_invitedb(req).get_invite_by_scim_id(scim_id=scim_id) self.context.logger.debug(f'Found invite: {db_invite}') if not db_invite: raise NotFound(detail="Invite not found") # Check version if not self._check_version(req, db_invite): raise BadRequest(detail="Version mismatch") # Remove signup invite ref = self._create_signup_ref(req, db_invite) signup_invite = self.context.signup_invitedb.get_invite_by_reference( ref) self.context.signup_invitedb.remove_document(signup_invite.invite_id) # Remove scim invite res = ctx_invitedb(req).remove(db_invite) add_api_event( context=self.context, data_owner=req.context['data_owner'], db_obj=db_invite, resource_type=SCIMResourceType.INVITE, level=EventLevel.INFO, status=EventStatus.DELETED, message='Group was deleted', ) self.context.logger.debug(f'Remove invite result: {res}') resp.status = HTTP_204
def on_post(self, req: Request, resp: Response): """ POST /Groups HTTP/1.1 Host: example.com Accept: application/scim+json Content-Type: application/scim+json Authorization: Bearer h480djs93hd8 Content-Length: ... { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "displayName": "Test SCIMv2", "members": [] } HTTP/1.1 201 Created Date: Tue, 10 Sep 2019 04:54:18 GMT Content-Type: text/json;charset=UTF-8 { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "id": "abf4dd94-a4c0-4f67-89c9-76b03340cb9b", "displayName": "Test SCIMv2", "members": [], "meta": { "resourceType": "Group" } } """ self.context.logger.info('Creating group') try: create_request = GroupCreateRequestSchema().load(req.media) except ValidationError as e: raise BadRequest(detail=f"{e}") self.context.logger.debug(create_request) created_group = ctx_groupdb(req).create_group( create_request=create_request) # Load the group from the database to ensure results are consistent with subsequent GETs. # For example, timestamps have higher resolution in created_group than after a load. db_group = ctx_groupdb(req).get_group_by_scim_id( str(created_group.scim_id)) assert db_group # please mypy add_api_event( context=self.context, data_owner=req.context['data_owner'], db_obj=db_group, resource_type=SCIMResourceType.GROUP, level=EventLevel.INFO, status=EventStatus.CREATED, message='Group was created', ) self._db_group_to_response(resp, db_group) resp.status = HTTP_201
def on_get(self, req: Request, resp: Response, scim_id: Optional[str] = None): if scim_id is None: raise BadRequest(detail='Not implemented') self.context.logger.info(f'Fetching event {scim_id}') db_event = ctx_eventdb(req).get_event_by_scim_id(scim_id) if not db_event: raise NotFound(detail='Event not found') self._db_event_to_response(req, resp, db_event)
def on_get(self, req: Request, resp: Response, scim_id: Optional[str] = None): if scim_id is None: raise BadRequest(detail='Not implemented') self.context.logger.info(f'Fetching user {scim_id}') db_user = ctx_userdb(req).get_user_by_scim_id(scim_id) if not db_user: raise NotFound(detail='User not found') self._db_user_to_response(req=req, resp=resp, db_user=db_user)
def _filter_extensions_data( self, req: Request, filter: SearchFilter, skip: Optional[int] = None, limit: Optional[int] = None, ) -> Tuple[List[ScimApiGroup], int]: if filter.op != 'eq': raise BadRequest(scim_type='invalidFilter', detail='Unsupported operator') match = re.match(r'^extensions\.data\.([a-z_]+)$', filter.attr) if not match: raise BadRequest(scim_type='invalidFilter', detail='Unsupported extension search key') self.context.logger.debug( f'Searching for groups with {filter.attr} {filter.op} {repr(filter.val)}' ) groups, count = ctx_groupdb(req).get_groups_by_property( key=filter.attr, value=filter.val, skip=skip, limit=limit) return groups, count
def parse_search_filter(filter: str) -> SearchFilter: match = re.match('(.+?) (..) (.+)', filter) if not match: logger.debug(f'Unrecognised filter: {filter}') raise BadRequest(scim_type='invalidFilter', detail='Unrecognised filter') val: Union[str, int] attr, op, val = match.groups() if len(val) and val[0] == '"' and val[-1] == '"': val = val[1:-1] if not val.isprintable(): logger.debug(f'Unrecognised string value in filter: {repr(val)}') raise BadRequest(scim_type='invalidFilter', detail='Unrecognised string value in filter') elif val.isdecimal(): val = int(val) else: logger.debug(f'Unrecognised type of value (not string or integer) in filter: {val}') raise BadRequest( scim_type='invalidFilter', detail='Unrecognised type of value (not string or integer) in filter' ) return SearchFilter(attr=attr.lower(), op=op.lower(), val=val)
def _filter_display_name( self, req: Request, filter: SearchFilter, skip: Optional[int] = None, limit: Optional[int] = None, ) -> Tuple[List[ScimApiGroup], int]: if filter.op != 'eq': raise BadRequest(scim_type='invalidFilter', detail='Unsupported operator') if not isinstance(filter.val, str): raise BadRequest(scim_type='invalidFilter', detail='Invalid displayName') self.context.logger.debug( f'Searching for group with display name {repr(filter.val)}') groups, count = ctx_groupdb(req).get_groups_by_property( key='display_name', value=filter.val, skip=skip, limit=limit) if not groups: return [], 0 return groups, count
def on_get(self, req: Request, resp: Response, scim_id: Optional[str] = None): if scim_id is None: raise BadRequest(detail='Not implemented') self.context.logger.info(f'Fetching invite {scim_id}') db_invite = ctx_invitedb(req).get_invite_by_scim_id(scim_id) if not db_invite: raise NotFound(detail='Invite not found') ref = self._create_signup_ref(req, db_invite) signup_invite = self.context.signup_invitedb.get_invite_by_reference( ref) self._db_invite_to_response(req, resp, db_invite, signup_invite)
def on_post(self, req: Request, resp: Response): """ POST /Invites HTTP/1.1 Host: example.com Accept: application/scim+json Content-Type: application/scim+json Authorization: Bearer h480djs93hd8 Content-Length: ... { 'schemas': ['https://scim.eduid.se/schema/nutid/invite/v1', 'https://scim.eduid.se/schema/nutid/user/v1'], 'expiresAt': '2021-03-02T14:35:52', 'groups': [], 'phoneNumbers': [ {'type': 'fax', 'value': 'tel:+461234567', 'primary': True}, {'type': 'home', 'value': 'tel:+5-555-555-5555', 'primary': False}, ], 'meta': { 'location': 'http://*****:*****@example.com', 'primary': True}, {'type': 'home', 'value': '*****@*****.**', 'primary': False}, ], } """ self.context.logger.info(f'Creating invite') try: create_request: InviteCreateRequest = InviteCreateRequestSchema( ).load(req.media) self.context.logger.debug(create_request) except ValidationError as e: raise BadRequest(detail=f"{e}") profiles = {} for profile_name, profile in create_request.nutid_user_v1.profiles.items( ): profiles[profile_name] = ScimApiProfile( attributes=profile.attributes, data=profile.data) db_invite = ScimApiInvite( external_id=create_request.external_id, name=ScimApiName(**asdict(create_request.nutid_invite_v1.name)), emails=[ ScimApiEmail(**asdict(email)) for email in create_request.nutid_invite_v1.emails ], phone_numbers=[ ScimApiPhoneNumber(**asdict(number)) for number in create_request.nutid_invite_v1.phone_numbers ], nin=create_request.nutid_invite_v1.national_identity_number, preferred_language=create_request.nutid_invite_v1. preferred_language, groups=create_request.nutid_invite_v1.groups, profiles=profiles, ) signup_invite = self._create_signup_invite(req, resp, create_request, db_invite) self.context.signup_invitedb.save(signup_invite) ctx_invitedb(req).save(db_invite) if signup_invite.send_email: self._send_invite_mail(signup_invite) add_api_event( context=self.context, data_owner=req.context['data_owner'], db_obj=db_invite, resource_type=SCIMResourceType.INVITE, level=EventLevel.INFO, status=EventStatus.CREATED, message='Invite was created', ) self._db_invite_to_response(req, resp, db_invite, signup_invite) resp.status = HTTP_201
def on_post(self, req: Request, resp: Response): """ POST /Events HTTP/1.1 Host: example.com Accept: application/scim+json Content-Type: application/scim+json Authorization: Bearer h480djs93hd8 Content-Length: ... { 'schemas': ['https://scim.eduid.se/schema/nutid/event/core-v1', 'https://scim.eduid.se/schema/nutid/event/v1'], 'https://scim.eduid.se/schema/nutid/event/v1': { 'ref': {'resourceType': 'User', 'id': '199745a8-a4f5-46b9-9ae9-531da967bfb1', 'externalId': '*****@*****.**' }, 'data': {'create_test': True}, 'expiresAt': '2021-02-23T14:36:15+00:00', 'level': 'debug', 'source': 'eduid.se', 'timestamp': '2021-02-18T14:36:15+00:00' } } """ self.context.logger.info(f'Creating event') try: create_request: EventCreateRequest = EventCreateRequestSchema( ).load(req.media) self.context.logger.debug(create_request) except ValidationError as e: raise BadRequest(detail=str(e)) # TODO: Instead of checking input here we should use dump_only for the fields in the schema if create_request.nutid_event_v1.source: raise BadRequest(detail='source is read-only') if create_request.nutid_event_v1.expires_at: raise BadRequest(detail='expiresAt is read-only') if create_request.nutid_event_v1.resource.external_id: raise BadRequest(detail='resource.externalId is read-only') if create_request.nutid_event_v1.resource.location: raise BadRequest(detail='resource.location is read-only') # TODO: This check should move to schema validation if create_request.nutid_event_v1.timestamp: earliest_allowed = utc_now() - timedelta(days=1) if create_request.nutid_event_v1.timestamp < earliest_allowed: raise BadRequest(detail='timestamp is too old') referenced = _get_scim_referenced( req, create_request.nutid_event_v1.resource) if not referenced: raise BadRequest(detail='referenced object not found') _timestamp = utc_now() if create_request.nutid_event_v1.timestamp: _timestamp = create_request.nutid_event_v1.timestamp _expires_at = utc_now() + timedelta(days=1) event = ScimApiEvent( resource=ScimApiEventResource( resource_type=create_request.nutid_event_v1.resource. resource_type, scim_id=referenced.scim_id, external_id=referenced.external_id, ), level=create_request.nutid_event_v1.level, source=req.context['data_owner'], data=create_request.nutid_event_v1.data, expires_at=_expires_at, timestamp=_timestamp, ) ctx_eventdb(req).save(event) # Send notification message = self.context.notification_relay.format_message( version=1, data={ 'location': self.resource_url(SCIMResourceType.EVENT, event.scim_id) }) self.context.notification_relay.notify( data_owner=req.context['data_owner'], message=message) self._db_event_to_response(req, resp, event)
def on_put(self, req: Request, resp: Response, scim_id): try: self.context.logger.info(f'Updating user {scim_id}') update_request: UserUpdateRequest = UserUpdateRequestSchema().load( req.media) self.context.logger.debug(update_request) if scim_id != str(update_request.id): self.context.logger.error(f'Id mismatch') self.context.logger.debug(f'{scim_id} != {update_request.id}') raise BadRequest(detail='Id mismatch') db_user = ctx_userdb(req).get_user_by_scim_id(scim_id) if not db_user: raise NotFound(detail="User not found") # Check version if not self._check_version(req, db_user): raise BadRequest(detail="Version mismatch") self.context.logger.debug( f'Extra debug: user {scim_id} as dict:\n{db_user.to_dict()}') core_changed = False if SCIMSchema.CORE_20_USER in update_request.schemas: name_in = ScimApiName(**asdict(update_request.name)) emails_in = set( ScimApiEmail(**asdict(email)) for email in update_request.emails) phone_numbers_in = set( ScimApiPhoneNumber(**asdict(number)) for number in update_request.phone_numbers) # external_id if update_request.external_id != db_user.external_id: db_user = replace(db_user, external_id=update_request.external_id) core_changed = True # preferred_language if update_request.preferred_language != db_user.preferred_language: db_user = replace( db_user, preferred_language=update_request.preferred_language) core_changed = True # name if name_in != db_user.name: db_user = replace(db_user, name=name_in) core_changed = True # emails if emails_in != set(db_user.emails): db_user = replace(db_user, emails=list(emails_in)) core_changed = True # phone_numbers if phone_numbers_in != set(db_user.phone_numbers): db_user = replace(db_user, phone_numbers=list(phone_numbers_in)) core_changed = True nutid_changed = False if SCIMSchema.NUTID_USER_V1 in update_request.schemas: # Look for changes in profiles for this in update_request.nutid_user_v1.profiles.keys(): if this not in db_user.profiles: self.context.logger.info( f'Adding profile {this}/{update_request.nutid_user_v1.profiles[this]} to user' ) nutid_changed = True elif update_request.nutid_user_v1.profiles[this].to_dict( ) != db_user.profiles[this].to_dict(): self.context.logger.info( f'Profile {this}/{update_request.nutid_user_v1.profiles[this]} updated' ) nutid_changed = True else: self.context.logger.info( f'Profile {this}/{update_request.nutid_user_v1.profiles[this]} not changed' ) for this in db_user.profiles.keys(): if this not in update_request.nutid_user_v1.profiles: self.context.logger.info( f'Profile {this}/{db_user.profiles[this]} removed') nutid_changed = True if nutid_changed: for profile_name, profile in update_request.nutid_user_v1.profiles.items( ): db_profile = ScimApiProfile( attributes=profile.attributes, data=profile.data) db_user.profiles[profile_name] = db_profile self.context.logger.debug( f'Core changed: {core_changed}, nutid_changed: {nutid_changed}' ) if core_changed or nutid_changed: self._save_user(req, db_user) add_api_event( context=self.context, data_owner=req.context['data_owner'], db_obj=db_user, resource_type=SCIMResourceType.USER, level=EventLevel.INFO, status=EventStatus.UPDATED, message='User was updated', ) else: self.context.logger.info(f'No changes detected') self._db_user_to_response(req=req, resp=resp, db_user=db_user) except ValidationError as e: raise BadRequest(detail=f"{e}")
def on_post(self, req: Request, resp: Response): """ POST /Users HTTP/1.1 Host: example.com Accept: application/scim+json Content-Type: application/scim+json Authorization: Bearer h480djs93hd8 Content-Length: ... { "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], "userName":"******", "externalId":"bjensen", "name":{ "formatted":"Ms. Barbara J Jensen III", "familyName":"Jensen", "givenName":"Barbara" } } HTTP/1.1 201 Created Content-Type: application/scim+json Location: https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646 ETag: W/"e180ee84f0671b1" { "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], "id":"2819c223-7f76-453a-919d-413861904646", "externalId":"bjensen", "meta":{ "resourceType":"User", "created":"2011-08-01T21:32:44.882Z", "lastModified":"2011-08-01T21:32:44.882Z", "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", "version":"W\/\"e180ee84f0671b1\"" }, "name":{ "formatted":"Ms. Barbara J Jensen III", "familyName":"Jensen", "givenName":"Barbara" }, "userName":"******" } """ try: self.context.logger.info(f'Creating user') create_request: UserCreateRequest = UserCreateRequestSchema().load( req.media) self.context.logger.debug(create_request) profiles = {} for profile_name, profile in create_request.nutid_user_v1.profiles.items( ): profiles[profile_name] = ScimApiProfile( attributes=profile.attributes, data=profile.data) db_user = ScimApiUser( external_id=create_request.external_id, name=ScimApiName(**asdict(create_request.name)), emails=[ ScimApiEmail(**asdict(email)) for email in create_request.emails ], phone_numbers=[ ScimApiPhoneNumber(**asdict(number)) for number in create_request.phone_numbers ], preferred_language=create_request.preferred_language, profiles=profiles, ) self._save_user(req, db_user) add_api_event( context=self.context, data_owner=req.context['data_owner'], db_obj=db_user, resource_type=SCIMResourceType.USER, level=EventLevel.INFO, status=EventStatus.CREATED, message='User was created', ) self._db_user_to_response(req=req, resp=resp, db_user=db_user) resp.status = HTTP_201 except ValidationError as e: raise BadRequest(detail=f"{e}")
def on_put(self, req: Request, resp: Response, scim_id: str): """ PUT /Groups/c3819cbe-c893-4070-824c-fe3d0db8f955 HTTP/1.1 Host: example.com Accept: application/scim+json Content-Type: application/scim+json Authorization: Bearer h480djs93hd8 If-Match: W/"5e79df24f77769b475177bc7" Content-Length: ... { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "displayName": "Test SCIMv2", "members": [ { "value": "2819c223-7f76-453a-919d-413861904646", "$ref": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", "display": "Babs Jensen" }, ] } HTTP/1.1 200 OK content-type: application/json; charset=UTF-8 etag: W/"5e79df24f77769b475177bc7" location: http://scimapi.eduid.docker/scim/test/Groups/c3819cbe-c893-4070-824c-fe3d0db8f955 { "displayName": "test group", "id": "c3819cbe-c893-4070-824c-fe3d0db8f955", "members": [ { "value": "2819c223-7f76-453a-919d-413861904646", "$ref": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", "display": "Babs Jensen" }, ], "meta": { "created": "2020-03-24T10:21:24.686000", "lastModified": 2020-03-25T14:42:24.686000, "location": "http://scimapi.eduid.docker/scim/test/Groups/c3819cbe-c893-4070-824c-fe3d0db8f955", "resourceType": "Group", "version": "3e79d424f77269f475177bc5" }, "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:Group" ] } """ self.context.logger.info('Updating group') try: update_request = GroupUpdateRequestSchema().load(req.media) except ValidationError as e: raise BadRequest(detail=f"{e}") self.context.logger.debug(update_request) if scim_id != str(update_request.id): self.context.logger.error(f'Id mismatch') self.context.logger.debug(f'{scim_id} != {update_request.id}') raise BadRequest(detail='Id mismatch') self.context.logger.info(f"Fetching group {scim_id}") db_group = ctx_groupdb(req).get_group_by_scim_id(str( update_request.id)) self.context.logger.debug(f'Found group: {db_group}') if not db_group: raise NotFound(detail="Group not found") # Check version if not self._check_version(req, db_group): raise BadRequest(detail="Version mismatch") # Check that members exists in their respective db self.context.logger.info(f'Checking if group and user members exists') for member in update_request.members: if member.is_group: if not ctx_groupdb(req).group_exists(str(member.value)): self.context.logger.error( f'Group {member.value} not found') raise BadRequest(detail=f'Group {member.value} not found') if member.is_user: if not ctx_userdb(req).user_exists(scim_id=str(member.value)): self.context.logger.error(f'User {member.value} not found') raise BadRequest(detail=f'User {member.value} not found') updated_group, changed = ctx_groupdb(req).update_group( update_request=update_request, db_group=db_group) # Load the group from the database to ensure results are consistent with subsequent GETs. # For example, timestamps have higher resolution in updated_group than after a load. db_group = ctx_groupdb(req).get_group_by_scim_id( str(updated_group.scim_id)) assert db_group # please mypy if changed: add_api_event( context=self.context, data_owner=req.context['data_owner'], db_obj=db_group, resource_type=SCIMResourceType.GROUP, level=EventLevel.INFO, status=EventStatus.UPDATED, message='Group was updated', ) self._db_group_to_response(resp, db_group)