"""Model class for Identity graph nodes.""" from datetime import datetime, timezone import textwrap from connector_neo4j import get_session from ddtrace import tracer from owsresponse import response from users import constants from users.utils import api_utils, email def create_identity(identity): """Create identity node in graphdb. Args: identity (dict): object representing the identity to be created. email (str): the user's email name (str): the user's name identity_id (str): the auth0 user id audit_user (str): the user's identity id for audit localization (str): the user's language preference number_format (str): user's number format preference Returns: Response: dict of newly create identity """ identity_data = api_utils.to_camel(identity) identity_data['isEmployee'] = email.is_employee_email(identity_data['email']) identity_id = identity_data['identityId'] del identity_data['identityId'] if identity_data.get('id'): del identity_data['id'] audit_user = identity_data.get('auditUser') if audit_user: del identity_data['auditUser'] identity_data['updatedBy'] = audit_user identity_data['updatedOn'] = datetime.utcnow() identity_data['createdAt'] = datetime.utcnow() session = get_session() create_query = """MERGE (i: Identity {id: $identity_id}) SET i += $identity_data RETURN i as identity""" result = session.run(create_query, identity_data=identity_data, identity_id=identity_id) peek = result.peek().data() return response.Response(api_utils.to_snake(dict(peek['identity'].items()))) @tracer.wrap() def get_identity(identity_id): """Get Identity graph node associated with this orchard_identity_id. Args: identity_id (str): Identity node id (Auth0 user id). Returns: Response: with a dict of node details. """ session = get_session() query = """MATCH (i:Identity) WHERE i.id = $identity_id RETURN i as identity""" result = session.run(query, identity_id=identity_id) if not result.peek(): return response.create_not_found_response( message=constants.ERROR_MESSAGE_IDENTITY_NOT_FOUND ) identity = result.peek().data()['identity'] identity_with_pending_status = get_pending_status(identity) return response.Response(identity_with_pending_status) @tracer.wrap() def get_identity_label_profile_ids(identity_id): """Get LabelProfile profileIds for an identity. Uses the Identity -> HAS_PROFILE -> Profile relationship in Neo4j. profileId on LabelProfile nodes corresponds to vend_contact.id in MySQL. Args: identity_id (str): Identity node id (UUID). Returns: dict or None: {'profile_ids': list[int]} None if identity not found. """ session = get_session() query = """MATCH (i:Identity) WHERE i.id = $identity_id OPTIONAL MATCH (i)-[:HAS_PROFILE]->(p:Profile {profileType: 'LabelProfile'}) RETURN collect(p.profileId) as profile_ids""" result = session.run(query, identity_id=identity_id) record = result.single() if not record: return None return { 'profile_ids': record['profile_ids'], } @tracer.wrap() def get_identity_tx(tx, identity_id) -> response.Response: """Get Identity graph node associated with this orchard_identity_id. Args: identity_id (str): Identity node id (Auth0 user id). Returns: Response: with a dict of node details. """ query = """MATCH (i:Identity) WHERE i.id = $identity_id RETURN i as identity""" result = tx.run(query, identity_id=identity_id) if not result.peek(): return response.create_not_found_response( message=constants.ERROR_MESSAGE_IDENTITY_NOT_FOUND ) identity = result.single().data()['identity'] identity_with_pending_status = get_pending_status(identity) return response.Response(identity_with_pending_status) @tracer.wrap() def get_identity_for_admin(admin_context, identity_id=None, email=''): """Get identity if this admin has access to it. Note: Employees should have access to all users except other employees. Args: admin_context (dict): Admin Identity data. identity_id(str): Identity uuid for user that is being viewed/edited. """ if not identity_id and not email: return response.create_fatal_response( message='identity_id or email required to find identity.' ) where_clause = 'i.email = $email' if email else 'i.id = $identityId' session = get_session() query = textwrap.dedent( f""" OPTIONAL MATCH(:Profile {{profileType: '{constants.SETTINGS_PROFILE}', profileId: $adminProfileId}}) -[:HAS_ADMIN_ACCESS_TO]->(starVendor:Vendor {{id: '*'}}) CALL apoc.when( starVendor IS NOT NULL, ' MATCH (i:Identity) WHERE {where_clause} AND ( i.id = $adminIdentityId OR NOT (i)-[:HAS_PROFILE]->(:Profile {{profileType: "{constants.SETTINGS_PROFILE}"}})-[:HAS_ADMIN_ACCESS_TO]->(:Vendor {{id: "*"}}) ) RETURN i as node ', ' MATCH (ap:Profile)-[:HAS_ADMIN_ACCESS_TO]->(x) <-[:HAS_ACCESS_TO|DELETED_HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO|DELETED_HAS_ADMIN_ACCESS_TO]-(up:Profile) <-[:HAS_PROFILE]-(i:Identity) WHERE ap.profileType = $adminProfileType AND ap.profileId = $adminProfileId AND {where_clause} RETURN distinct(i) as node UNION MATCH (ap:Profile)-[:HAS_ADMIN_ACCESS_TO]->(x)-[:OWNS|HAS_LABEL_PARTICIPANT]->(s)<-[:HAS_ACCESS_TO]-(up:Profile)<-[:HAS_PROFILE]-(i:Identity) WHERE ap.profileType = $adminProfileType AND ap.profileId = $adminProfileId AND (s:Subaccount OR s:LabelParticipant OR s:Collaborator) AND {where_clause} RETURN distinct(i) as node ', {{ starVendor: starVendor, adminIdentityId: $adminIdentityId, adminProfileId: $adminProfileId, adminProfileType: $adminProfileType, identityId: $identityId, email: $email }} ) YIELD value RETURN value.node as identity LIMIT 1""" # noqa ) result = session.run( query, identityId=identity_id, email=email, adminIdentityId=admin_context['identity_id'], adminProfileType=admin_context['profile_type'], adminProfileId=admin_context['profile_id'], ) if not result.peek(): return response.create_not_found_response( message=constants.ERROR_MESSAGE_IDENTITY_NOT_FOUND ) identity = result.peek().data()['identity'] identity_with_pending_status = get_pending_status(identity) return response.Response(identity_with_pending_status) def get_identity_for_admin_tx(tx, admin_context, identity_id=None, email=None) -> response.Response: """Get identity if this admin has access to it, using a Neo4j Managed Transaction. This method will prioritise the email argument to perform the identity lookup. Note: Employees should have access to all users except other employees. Args: tx: Neo4j Session or Transaction. admin_context (dict): Admin Identity data. identity_id (str): Identity uuid for user that is being viewed/edited. email (str): Email address for user that is being viewed/edited. """ if not identity_id and not email: return response.create_fatal_response( message='identity_id or email required to find identity.' ) where_clause = 'i.email = $email' if email else 'i.id = $identityId' query = f""" OPTIONAL MATCH(:Profile {{profileType: '{constants.SETTINGS_PROFILE}', profileId: $adminProfileId}}) -[:HAS_ADMIN_ACCESS_TO]->(starVendor:Vendor {{id: '*'}}) CALL apoc.when( starVendor IS NOT NULL, ' MATCH (i:Identity) WHERE {where_clause} AND NOT (i)-[:HAS_PROFILE]->(:Profile {{profileType: "{constants.SETTINGS_PROFILE}"}})-[:HAS_ADMIN_ACCESS_TO]->(:Vendor {{id: "*"}}) RETURN i as node ', ' MATCH (ap:Profile)-[:HAS_ADMIN_ACCESS_TO]->(x) <-[:HAS_ACCESS_TO|DELETED_HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO|DELETED_HAS_ADMIN_ACCESS_TO]-(up:Profile) <-[:HAS_PROFILE]-(i:Identity) WHERE ap.profileType = $adminProfileType AND ap.profileId = $adminProfileId AND {where_clause} RETURN distinct(i) as node UNION MATCH (ap:Profile)-[:HAS_ADMIN_ACCESS_TO]->(x)-[:OWNS|HAS_LABEL_PARTICIPANT]->(s)<-[:HAS_ACCESS_TO]-(up:Profile)<-[:HAS_PROFILE]-(i:Identity) WHERE ap.profileType = $adminProfileType AND ap.profileId = $adminProfileId AND (s:Subaccount OR s:LabelParticipant OR s:Collaborator) AND {where_clause} RETURN distinct(i) as node ', {{ starVendor: starVendor, adminProfileId: $adminProfileId, adminProfileType: $adminProfileType, identityId: $identityId, email: $email }} ) YIELD value RETURN value.node as identity LIMIT 1""" # noqa result = tx.run( query, identityId=identity_id, email=email, adminProfileType=admin_context['profile_type'], adminProfileId=admin_context['profile_id'], ) if not result.peek(): return response.create_not_found_response( message=constants.ERROR_MESSAGE_IDENTITY_NOT_FOUND ) identity = result.single().data()['identity'] identity_with_pending_status = get_pending_status(identity) return response.Response(identity_with_pending_status) def get_identity_by_auth0_id(auth0_id): """Get Identity graph node associated with this auth0_id. Args: auth0_id (str): Auth0 user id. Returns: Response: with a dict of node details. """ session = get_session() query = """MATCH (i:Identity) WHERE i.auth0UserId = $identity_id RETURN i as identity""" result = session.run(query, identity_id=auth0_id) if not result.peek(): return response.create_not_found_response( message=constants.ERROR_MESSAGE_IDENTITY_NOT_FOUND ) peek = result.peek().data() return response.Response(api_utils.to_snake(dict(peek['identity'].items()))) def get_account_session_by_identity(auth0_id, label_profile_id): """Get alw session data for identity via neo4j. Args: auth0_id (str): auth0 user id from vend_contact label_profile_id (int): formatted alw:0000, integer value representing LabelProfile:profileId for identity. also known as orchard_user_id (legacy/before uuid) Returns: Response: dictionary object containing session data - identity node information - company_brand: company brand attached to LabelProfile vendor - service_tier: uuid and name. ...more to come as we migrate from ar data source. """ session = get_session() query = """MATCH (p:Profile{profileType: 'LabelProfile', profileId: $label_profile_id}) <-[:HAS_PROFILE]-(i:Identity{auth0UserId: $auth0_id}), (p:Profile)-[:HAS_ACCESS_TO]-(l:Label) OPTIONAL MATCH (l)<-[:OWNS]-(v:Vendor) WITH { identity: i, vendor: CASE WHEN v IS NOT NULL THEN v ELSE l END } AS path MATCH (x:Vendor)<-[:HAS_LABEL]-(cb:CompanyBrand) WHERE x.uuid = path.vendor.uuid OPTIONAL MATCH (x:Vendor)-[:IN_SERVICE_TIER]->(st:ServiceTier) WHERE x.uuid = path.vendor.uuid RETURN { identity: path.identity, company_brand: cb, service_tier: CASE WHEN st IS NOT NULL THEN st ELSE NULL END } as result""" result = session.run(query, auth0_id=auth0_id, label_profile_id=int(label_profile_id)) if not result.peek(): return response.create_not_found_response( message=constants.ERROR_MESSAGE_IDENTITY_NOT_FOUND ) peek = result.peek().data() return response.Response(api_utils.to_snake(dict(peek['result'].items()))) @tracer.wrap() def get_identity_by_email(email): """Get Identity graph node associated with this email. Args: email (str): email. Returns: Response: with a dict of node details. """ session = get_session() query = """MATCH (i:Identity) WHERE i.email = $email RETURN i as identity""" result = session.run(query, email=email) if not result.peek(): return response.create_not_found_response( message=constants.ERROR_MESSAGE_IDENTITY_NOT_FOUND ) peek = result.peek().data() return response.Response(api_utils.to_snake(dict(peek['identity'].items()))) @tracer.wrap() def get_identity_id_by_email(email: str) -> str | None: """Get Identity ID by email. Args: email (str): email. Returns: str | None: Identity ID if found, None otherwise. """ session = get_session() query = """MATCH (i:Identity) WHERE i.email = $email RETURN i.id as identity_id""" result = session.run(query, email=email) if not result.peek(): return None return result.peek().data()['identity_id'] def update_identity(identity_id, update_identity_data): """Update Identity graph node associated with this orchard_identity_id. Args: identity_id (str): Identity node id (Auth0 user id). update_identity_data (dict): data to update the identity with email (str): identity email name (str): full name first_name (str): first name last_name (str): last name localization (str): language preference number_format (str): Number format. date_format (str): Date format. Returns: flask.Response containing the updated user object from Auth0 """ if update_identity_data.get('identity_id'): del update_identity_data['identity_id'] update_identity_data = api_utils.to_camel(update_identity_data) session = get_session() update_query = """MATCH (i:Identity) WHERE i.id = $id SET i += $update_identity_data RETURN i as identity""" result = session.run(update_query, id=identity_id, update_identity_data=update_identity_data) if not result.peek(): return response.create_error_response( code=constants.ERROR_CODE_IDENTITY_NOT_FOUND, message=constants.ERROR_MESSAGE_IDENTITY_NOT_FOUND, ) peek = result.peek().data() return response.Response(api_utils.to_snake(dict(peek['identity'].items()))) def update_identity_by_email(email, update_identity_data): """Update Identity graph node associated with this email. Args: email (str): User email. update_identity_data (dict): data to update the identity with -name (str): full name of user associated to identity -identity_id (str): identity id. -auth0_user_id (str): auth0 user id. -google_user_id (str): google oauth user id. Returns: flask.Response containing the updated object. """ update_identity_data = api_utils.to_camel(update_identity_data) session = get_session() update_query = """MATCH (i:Identity) WHERE i.email = $email SET i += $update_identity_data RETURN i as identity""" result = session.run(update_query, email=email, update_identity_data=update_identity_data) peek = result.peek().data() return response.Response(api_utils.to_snake(dict(peek['identity'].items()))) def activate_deactivate_identity(auth0_user_id, blocked=True): """Activate or deactivate an identity. This is to match auth0 blocked status. Args: auth0_user_id (str): Auth0 user id. blocked (bool): if the user is blocked or active. Returns: flask.Response containing the updated user object from Auth0 """ session = get_session() update_query = """MATCH (i:Identity {auth0UserId: $id}) SET i.active = $active RETURN i as identity""" session.run(update_query, id=auth0_user_id, active='N' if blocked else 'Y') def delete_identity(identity_id): """Delete Identity graph node associated with this orchard_identity_id. Also delete all relationship going to or from it. Args: identity_id (str): auth0 id """ session = get_session() query = """MATCH (i:Identity) WHERE i.id = $identity_id OPTIONAL MATCH (i)-[:HAS_PROFILE]->(p:Profile {profileType: 'SettingsProfile'}) DETACH DELETE i, p RETURN i""" result = session.run(query, identity_id=identity_id) # neo4j client will execute but won't return errors unless results read [x for x in result] return response.Response(status=204) def get_identity_for_profile(profile_id, profile_type): """Get identity associated with profile.""" session = get_session() query = """MATCH (i:Identity)-[:HAS_PROFILE]->(p:Profile) WHERE p.profileId = $profile_id AND p.profileType = $profile_type RETURN i as identity""" result = session.run(query, profile_id=profile_id, profile_type=profile_type) if not result.peek(): return response.create_not_found_response( message=constants.ERROR_MESSAGE_IDENTITY_NOT_FOUND ) peek = result.peek().data() return response.Response(api_utils.to_snake(dict(peek['identity'].items()))) def get_identity_for_profile_uuid(profile_uuid): """Get identity associated with profile based on its uuid.""" session = get_session() query = """MATCH (i:Identity)-[:HAS_PROFILE]->(p:Profile) WHERE p.uuid = $profile_uuid RETURN i as identity""" result = session.run(query, profile_uuid=profile_uuid) if not result.peek(): return response.create_not_found_response( message=constants.ERROR_MESSAGE_IDENTITY_NOT_FOUND ) peek = result.peek().data() return response.Response(api_utils.to_snake(dict(peek['identity'].items()))) def update_auth0_primary_to_identity(auth0_id, profile_id): """Write auth0Primary flag to identity into neo4j. Args: - auth0_id (str): Unique auth0_id identifier. - profile_id (str): Profile_id that should be primary. Returns: Response: updated identity. """ session = get_session() query = """MATCH (i:Identity {auth0UserId: $auth0_id}) SET i.auth0Primary = $profile_id RETURN i as identity""" result = session.run(query, auth0_id=auth0_id, profile_id=profile_id) if not result.peek(): return response.create_not_found_response( message=constants.ERROR_MESSAGE_IDENTITY_NOT_FOUND ) peek = result.peek().data() return response.Response(api_utils.to_snake(dict(peek['identity'].items()))) def get_pending_status(identity_object): """Set pending status for identity. Args: - identity_object (dict): Identity. Returns: Response: updated identity. """ identity = api_utils.to_snake(dict(identity_object.items())) auth0_user_id = identity.get('auth0_user_id') identity_id = identity.get('id') if auth0_user_id == identity_id: identity['pending'] = True else: identity['pending'] = False return identity def get_identity_vendor(identity_id, label_profile_id): """Get vendor information associated with a label profile. Args: identity_id (str): identity uuid label_profile_id (int): label profile id / vendor contact id Returns: Response: dictionary object containing vendor information - vendor details - company_brand_name: vendor associated company brand name - service_tier_name: vendor associated service tier name - parent_company_name: the name of the parent company if any """ session = get_session() query = """ MATCH (i:Identity)-[:HAS_PROFILE]->(p:Profile)-[:HAS_ACCESS_TO]->(v:Vendor) WHERE i.id = $identity_id AND p.profileId = $label_profile_id AND p.profileType = "LabelProfile" OPTIONAL MATCH (v)<-[:HAS_LABEL]-(cb:CompanyBrand) OPTIONAL MATCH (v)-[:IN_SERVICE_TIER]->(st:ServiceTier) OPTIONAL MATCH (v)-[:BELONGS_TO]->(pc:ParentCompany) RETURN v { .*, companyBrandName: cb.name, serviceTierName: st.name, parentCompanyName: pc.name } as result""" result = session.run(query, identity_id=identity_id, label_profile_id=int(label_profile_id)) peek = result.peek() if not peek: return response.create_not_found_response( message='Vendor information not found for the given identity / label profile' ) data = peek.data() return response.Response(api_utils.to_snake(dict(data['result'].items()))) def set_identity_updated_on_date(identity_data, admin_identity_id): """Write updatedOn & updatedBy flags to identity into neo4j. Args: identity_data (dict): data to update the identity with -email (str)?: identity email -identity_id (str)?: identity id. -auth0_user_id (str)?: auth0 user id. admin_identity_id (str): identity uuid """ session = get_session() query = """MATCH (i:Identity {""" if identity_data.get('email'): query += """email: $email""" elif identity_data.get('identity_id'): query += """id: $identityId""" elif identity_data.get('auth0_user_id'): query += """auth0UserId: $auth0UserId""" query += """}) SET i.updatedOn = $updatedOn SET i.updatedBy = $updatedBy RETURN i as identity""" result = session.run( query, email=identity_data.get('email'), identityId=identity_data.get('identity_id'), auth0UserId=identity_data.get('auth0_user_id'), updatedOn=datetime.utcnow(), updatedBy=admin_identity_id, ) record = result.single() if record: return dict(record['identity']) def update_identity_organization_invitation( email: str, invitation_id: str, organization_id: str, organization_name: str, admin_identity_id: str, ): """Update identity with organization invitation details. This method consolidates multiple identity updates into a single Neo4j transaction: - Sets invitationId and organizationId (legacy fields for current invitation) - Adds/updates invitation in auth0Invitations map (one per organization). Key: org name Value: {"invitation_id": ..., "organization_id": ..., "timestamp": isoTimestamp} - Updates audit fields: updatedOn, updatedBy, lastModifiedAt, lastModifiedBy Args: email (str): Identity email address invitation_id (str): Auth0 invitation ID organization_id (str): Auth0 organization ID organization_name (str): Auth0 organization name (used as map key) admin_identity_id (str): Identity UUID of the admin creating the invitation Returns: dict: Updated identity node properties, or None if identity not found """ session = get_session() now = datetime.now(timezone.utc) invitation_value = { 'invitation_id': invitation_id, 'organization_id': organization_id, 'timestamp': now.isoformat(), } query = textwrap.dedent( """ MATCH (i:Identity {email: $email}) WITH i, apoc.convert.fromJsonMap(COALESCE(i.auth0Invitations, '{}')) as currentInvitations SET i.invitationId = $invitationId, i.organizationId = $organizationId, i.auth0Invitations = apoc.convert.toJson( apoc.map.setKey(currentInvitations, $organizationName, $invitationValue) ), i.updatedOn = $updatedOn, i.updatedBy = $updatedBy, i.lastModifiedAt = $lastModifiedAt, i.lastModifiedBy = $lastModifiedBy RETURN i as identity """ ) result = session.run( query, email=email, invitationId=invitation_id, organizationId=organization_id, organizationName=organization_name, invitationValue=invitation_value, updatedOn=now, updatedBy=admin_identity_id, lastModifiedAt=now, lastModifiedBy=admin_identity_id, ) record = result.single() if record: return dict(record['identity']) return None def get_identity_by_auth0_id_dict(auth0_id: str) -> dict | None: session = get_session() query = """MATCH (i:Identity) WHERE i.auth0UserId = $identity_id RETURN i as identity""" result = session.run(query, identity_id=auth0_id) if not result.peek(): return None peek = result.peek().data() return api_utils.to_snake(dict(peek['identity'].items())) def can_reset_mfa(admin_identity_id: str, identity: dict) -> bool: # if the user resetting their own mfa, return true if admin_identity_id == identity.get('id'): return True session = get_session() # Checks if identity has access to vendor *. if so, return true employee_query = """MATCH (admin:Identity)-[:HAS_PROFILE]-> (sp:Profile {profileType: 'SettingsProfile'})-[:HAS_ADMIN_ACCESS_TO]->(r:Vendor {id: '*'}) WHERE admin.id = $identity_id RETURN admin as identity """ result = session.run(employee_query, identity_id=admin_identity_id) if result.peek(): admin_identity = result.single().data().get('identity') if admin_identity: return True # Checks if admin and user have overlapping access. if so, return true tenant_overlap_between_admin_and_user_query = """ MATCH (admin:Identity)-[:HAS_PROFILE]->(sp:Profile {profileType: 'SettingsProfile'}) -[:HAS_ADMIN_ACCESS_TO]->(t)-[*0..1]->(tenant) <-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]-(up:Profile)<-[:HAS_PROFILE]-(user:Identity) USING JOIN ON t WHERE ( tenant:Vendor OR tenant:SubAccount OR tenant:Collaborator OR tenant:LabelParticipant ) AND admin.id = $admin_identity_id AND user.id = $user_identity_id WITH tenant RETURN COUNT(tenant) as tenant_count """ result = session.run( tenant_overlap_between_admin_and_user_query, {'admin_identity_id': admin_identity_id, 'user_identity_id': identity.get('id')}, ) tenant_count = result.single().data().get('tenant_count') if tenant_count > 0: return True return False def get_employees_by_search_term(term: str) -> list[str]: """Get a list of employees by search term. This function searches for employees whose name or email contains the given term. Args: term (str): The search term to look for in employee names and emails. Returns: list[str]: A list of employee identities matching the search term. """ session = get_session() search_term = ' '.join(term.lower().split()) query = textwrap.dedent( """ MATCH(i:Identity {isEmployee: true}) WHERE ( toLower(i.email) CONTAINS $search_term OR toLower(i.name) CONTAINS $search_term OR toLower(i.firstName) CONTAINS $search_term OR toLower(i.lastName) CONTAINS $search_term OR toLower(i.firstName + " " + i.lastName) CONTAINS $search_term OR toLower(i.lastName + " " + i.firstName) CONTAINS $search_term ) RETURN i.id """ ) result = session.run(query, search_term=search_term) return [record['i.id'] for record in result]