"""Module for the Identity model.""" import textwrap import uuid from typing import Optional import neo4j from ddtrace import tracer from flask import g from permissions.connectors import neo4j as neo4j_connector from permissions.connectors.sentry import sentry_client from permissions.constants import constants from permissions.exceptions import incomplete_result_error from permissions.models import owsusers from permissions.types import ( AdminIdentity, Auth0Context, Identity, IdentityWithAuth0, ProfileInfo, TenantType, ) from permissions.utils.api_utils import to_snake def get_identity_by_id(tx: neo4j.Transaction, identity_id: str) -> dict: """Get an identity from neo4j.""" query1 = 'MATCH (i:Identity {id: $identityId}) RETURN i as identity' record = tx.run(query1, identityId=identity_id).single() if not record or not record.get('identity'): raise incomplete_result_error.IncompleteResultError( message='Identity not found with this id.' ) return to_snake(dict(record.get('identity'))) def get_identity_by_id_new(identity_id: str) -> Identity | None: """Get an identity by identity from neo4j. Improved version of get_identity_by_id. TODO: refactor get_identity_by_id to use this function. Args: identity_id (str): Identity id Returns: Identity | None: Identity object if found, else None """ with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = textwrap.dedent( """MATCH (i:Identity {id: $identityId}) RETURN i.id as id, i.firstName as firstName, i.lastName as lastName, i.name as name, i.email as email, i.auth0UserId as auth0UserId, i.active as active, i.defaultBrand as defaultBrand, coalesce(i.userTypes, []) as userTypes """ ) identity = session.run(query, identityId=identity_id).single() if not identity: return None return Identity( id=identity['id'], first_name=identity['firstName'], last_name=identity['lastName'], name=identity['name'], email=identity['email'], auth0_user_id=identity['auth0UserId'], active=identity['active'], user_types=identity['userTypes'], default_brand=identity['defaultBrand'], ) def create_identity( session: neo4j.Transaction, audit_user_id: str, first_name: str, last_name: str, email: str, default_brand: str, tenant_type: TenantType, localization: str, is_employee: bool | None = None, ) -> Identity | None: """Create an identity node in neo4j. May raise IncompleteResultError. Args: session (neo4j.Session): Neo4j session audit_user_id (str): Identity id of the user initiating the creation first_name (str): First name of the user whose identity is being created last_name (str): Last name of the user whose identity is being created email (str): Email of the user whose identity is being created default_brand (str): Brand of the tenant being associated with the user whose identity is being created, which will be set as their default tenant_type (str): The type of tenant being associated with the user whose identity is being created, which will determine their user_types value in neo4j (label or artist) localization (str): Localization to set on the identity. is_employee (bool, optional): Whether the identity being created is an employee. Defaults to None. Returns: dict: A dict of the record created in neo4j. """ identity_id = str(uuid.uuid4()) return run_neo4j_identity_create_with_minimal_params( session=session, identity_id=identity_id, first_name=first_name, last_name=last_name, email=email, audit_user_id=audit_user_id, default_brand=default_brand, user_types=user_types_from_tenant_type(tenant_type), localization=localization, is_employee=is_employee, ) def run_neo4j_identity_create_with_minimal_params( session: neo4j.Session | neo4j.Transaction, identity_id: str, first_name: str, last_name: str, email: str, audit_user_id: str, default_brand: str, user_types: list[str], localization: str, is_employee: bool | None = None, ) -> Identity: """Create an identity in neo4j, filling in defaults as needed.""" identity = run_neo4j_identity_create( session, identity_id=identity_id, email=email, first_name=first_name, last_name=last_name, name=first_name + ' ' + last_name, # Auth0 user doesn't exist yet, but this field needs to be set for...reasons. auth0_user_id=identity_id, localization=localization, # We're not yet providing a way for the audit user to specify number format number_format=constants.US_NUMBER_FORMAT, audit_user_id=audit_user_id, user_types=user_types, default_brand=default_brand, # This value is expected by auth0-hosted-pages to trigger some hooks auth0_user_created_by='invitation', # Should only be set when called via internal identity creation flow is_employee=is_employee, ) return Identity( id=identity['id'], first_name=identity['firstName'], last_name=identity['lastName'], name=identity['name'], email=identity['email'], auth0_user_id=identity['auth0UserId'], active='Y', user_types=identity['userTypes'], default_brand=identity['defaultBrand'], ) def run_neo4j_identity_create( session: neo4j.Session, identity_id: str, name: str, email: str, auth0_user_id: str, audit_user_id: str, localization: str, number_format: str, first_name: Optional[str] = None, last_name: Optional[str] = None, user_types: Optional[list[str]] = [], default_brand: Optional[str] = None, auth0_user_created_by: Optional[str] = 'permissions', is_employee: bool | None = None, ) -> dict: """Create an identity in neo4j.""" # TODO: is active Y by default? query = textwrap.dedent( """ MERGE (i:Identity {email: $email}) ON CREATE SET i.id = $identityId, i.name = $name, i.auth0UserId = $auth0UserId, i.firstName = $firstName, i.lastName = $lastName, i.localization = $localization, i.numberFormat = $numberFormat, i.active = "Y", i.lastModifiedBy = $auditUser, i.lastModifiedAt = datetime(), i.userTypes = $userTypes, i.defaultBrand = $defaultBrand, i.auth0UserCreatedBy = $auth0UserCreatedBy, i.isEmployee = $isEmployee, i.createdAt = datetime(), i.createdBy = $auditUser RETURN i as identity """ ) record = session.run( query, identityId=identity_id, email=email, name=name, auth0UserId=auth0_user_id, firstName=first_name, lastName=last_name, auth0UserCreatedBy=auth0_user_created_by, localization=localization, numberFormat=number_format, auditUser=audit_user_id, userTypes=user_types, defaultBrand=default_brand, isEmployee=is_employee, ).single() if not record or not record.get('identity'): raise incomplete_result_error.IncompleteResultError( message=f'Failed to create Identity for {email}.' ) return record.get('identity') def get_identity_settings_profile(identity_uuid: str) -> Optional[ProfileInfo]: """Get the settings profile for an identity.""" with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = textwrap.dedent( """MATCH (i:Identity {id: $identity_id})-[:HAS_PROFILE]->""" """(p:Profile {profileType: "SettingsProfile"}) RETURN p.profileType as profileType, p.profileId as profileId, p.roles as roles, p.uuid as uuid""" ) result = session.run(query, {'identity_id': identity_uuid}).single() if not result: return None return ProfileInfo( profile_type=result['profileType'], profile_id=result['profileId'], roles=result['roles'], uuid=result['uuid'], ) def has_settings_profile(identity_id: str) -> bool: """Check if an identity has a settings profile.""" return bool(get_identity_settings_profile(identity_id)) def get_identities_employee_status(identity_uuids: list[uuid.UUID]) -> dict[str, bool]: """Get isEmployee status for a list of identity UUIDs. Args: identity_uuids: List of identity UUID strings to check. Returns: Dict mapping identity_uuid to is_employee boolean. """ identity_uuid_strs = [str(uid) for uid in identity_uuids] with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = textwrap.dedent(""" UNWIND $identityUuids as identityUuid MATCH (i:Identity {id: identityUuid}) RETURN i.id as id, coalesce(i.isEmployee, false) as is_employee """) result = session.run(query, {'identityUuids': identity_uuid_strs}) return {record['id']: record['is_employee'] for record in result} def user_types_from_tenant_type(tenant_type: TenantType) -> list[str]: """Convert user types.""" if tenant_type == TenantType.LABEL_PARTICIPANT: return [constants.ARTIST_USER_TYPE] elif tenant_type == TenantType.COLLABORATOR: return [] else: return [constants.LABEL_USER_TYPE] def get_identity_by_email(email: str, session: neo4j.Session) -> Identity | None: """Check if the user exists by email. Args: email(str): Email of user. session(neo4j.Session): Neo4j session. """ query = textwrap.dedent( """MATCH (i:Identity {email: $email}) RETURN i.id as id, i.firstName as firstName, i.lastName as lastName, i.name as name, i.email as email, i.auth0UserId as auth0UserId, i.active as active, i.defaultBrand as defaultBrand, coalesce(i.userTypes, []) as userTypes""" ) record = session.run(query, email=email).single() if not record: return None return Identity( id=record['id'], first_name=record['firstName'], last_name=record['lastName'], name=record['name'], email=record['email'], auth0_user_id=record['auth0UserId'], active=record['active'], user_types=record['userTypes'], default_brand=record['defaultBrand'], ) def get_identity_with_auth0( admin: AdminIdentity, email: str, brand: str ) -> IdentityWithAuth0 | None: """Check if the user exists by email and then Auth0 orgs. Args: admin(AdminIdentity): Admin identity. email(str): Email of user. brand(str): Brand of tenant user is being invited to. Returns: IdentityWithAuth0(dict): Dict containing an information about identity and related orgs. """ with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: try: existing_identity = get_identity_by_email(email, session) # not found if not existing_identity: g.log.info(f'The user with an email {email} does not exist in Neo4j.') return None return get_identity_with_auth0_for_existing_identity( admin=admin, existing_identity=existing_identity, email=email, brand=brand, ) except incomplete_result_error.IncompleteResultError as err: sentry_client.capture_exception() g.log.exception(err.args) raise Exception(err.args) def get_identity_with_auth0_for_existing_identity( admin: AdminIdentity, existing_identity: Identity, email: str, brand: str, ) -> IdentityWithAuth0: """Get auth0 orgs for an existing identity.""" if existing_identity.is_pending(): g.log.info(f'The identity with an email {email} is in pending status.') return IdentityWithAuth0( id=existing_identity.id, email=existing_identity.email, first_name=existing_identity.first_name, last_name=existing_identity.last_name, name=existing_identity.name, auth0_user_id=existing_identity.auth0_user_id, active=existing_identity.active, user_types=existing_identity.user_types, default_brand=existing_identity.default_brand, auth0_context=Auth0Context( organizations=[], auth0_user_id=existing_identity.auth0_user_id, ), ) auth0_user_id = existing_identity.auth0_user_id if 'auth0' not in existing_identity.auth0_user_id: auth0_user_id = 'auth0|{}'.format(existing_identity.auth0_user_id) organizations = owsusers.get_auth0_user_organizations(admin=admin, auth0_user_id=auth0_user_id) if not organizations: g.log.warning( f'The Identity with email {email} has an auth0UserId and' f' yet not be part of an auth0 organization' ) else: if brand in organizations: g.log.info(f'Tenant brand {brand} is a part of the list of Auth0 orgs.') else: g.log.info(f'Tenant brand {brand} is NOT a part of the list of Auth0 orgs.') return IdentityWithAuth0( id=existing_identity.id, email=existing_identity.email, first_name=existing_identity.first_name, last_name=existing_identity.last_name, name=existing_identity.name, auth0_user_id=existing_identity.auth0_user_id, active=existing_identity.active, user_types=existing_identity.user_types, default_brand=existing_identity.default_brand, auth0_context=Auth0Context( organizations=organizations or [], auth0_user_id=auth0_user_id, ), ) @tracer.wrap('update_identity_active_status', service='neo4j') def update_identity_active_status( session: neo4j.Session, identity_id: str, active: str, audit_user_id: str ) -> Identity: """Update the active status of an identity. Args: session (neo4j.Session): Neo4j session identity_id (str): Identity id active (str): Active status Y or N audit_user_id (str): Identity id of the user initiating the update. Returns: Identity: Identity object """ additional_fields_str = '' if active == 'Y': # Must be set when reactivating an identity # If not, user will get an error when trying to accept an invite additional_fields_str = """, i.updatedOn = datetime(), i.auth0UserCreatedBy = 'invitation'""" query = textwrap.dedent( f""" MATCH (i:Identity {{id: $identityId}}) SET i.active = $active, i.lastModifiedBy = $auditUser, i.lastModifiedAt = datetime() {additional_fields_str} RETURN i.id as id, i.firstName as firstName, i.lastName as lastName, i.name as name, i.email as email, i.auth0UserId as auth0UserId, i.active as active, i.defaultBrand as defaultBrand, coalesce(i.userTypes, []) as userTypes """ ) record = session.run( query, identityId=identity_id, active=active, auditUser=audit_user_id ).single() if not record: raise incomplete_result_error.IncompleteResultError( message=f'Failed to update Identity {identity_id} active status.' ) return Identity( id=record['id'], first_name=record['firstName'], last_name=record['lastName'], name=record['name'], email=record['email'], auth0_user_id=record['auth0UserId'], active=record['active'], user_types=record['userTypes'], default_brand=record['defaultBrand'], ) def update_identity_default_brand( session: neo4j.Session, identity_id: str, default_brand: str, admin_identity_id: str ) -> Identity: """Update the default brand of an identity. Args: session (neo4j.Session): Neo4j session identity_id (str): Identity id default_brand (str): Default brand to set admin_identity_id (str): Identity id of the admin initiating the update. Returns: Identity: Identity object """ query = textwrap.dedent( """ MATCH (i:Identity {id: $identityId}) SET i.defaultBrand = $defaultBrand, i.lastModifiedBy = $adminIdentity, i.lastModifiedAt = datetime() RETURN i.id as id, i.firstName as firstName, i.lastName as lastName, i.name as name, i.email as email, i.auth0UserId as auth0UserId, i.active as active, i.defaultBrand as defaultBrand, coalesce(i.userTypes, []) as userTypes """ ) record = session.run( query, identityId=identity_id, defaultBrand=default_brand, adminIdentity=admin_identity_id ).single() if not record: raise incomplete_result_error.IncompleteResultError( message=f'Failed to update Identity {identity_id} default brand.' ) return Identity( id=record['id'], first_name=record['firstName'], last_name=record['lastName'], name=record['name'], email=record['email'], auth0_user_id=record['auth0UserId'], active=record['active'], user_types=record['userTypes'], default_brand=record['defaultBrand'], ) def get_profile_by_identity_id_and_profile_id_and_type( identity_id: uuid.UUID, profile_id: int, profile_type: str ) -> ProfileInfo | None: """Get profile if it exists. Args: identity_id (str): Identity UUID. profile_id (str): Profile ID. profile_type (str): Profile Type. Return: response (obj) """ with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = """ MATCH (i:Identity {id: $identity_id})-[:HAS_PROFILE]-> (p:Profile {profileId: toInteger($profile_id), profileType: $profile_type}) RETURN p """ result = session.run( query, { 'identity_id': str(identity_id), 'profile_id': profile_id, 'profile_type': profile_type, }, ).single() if result is None: return None return ProfileInfo( profile_id=result['p']['profileId'], profile_type=result['p']['profileType'], roles=result['p']['roles'], uuid=result['p']['uuid'], )