"""Model for profiles.""" import dataclasses import textwrap import uuid from typing import List, Union import neo4j as neo4j_lib import pymysql import sqlalchemy from ddtrace import tracer from flask import g from owsresponse import response from permissions import types from permissions.connectors import mysql, neo4j, redis from permissions.connectors.sentry import sentry_client from permissions.constants import constants, error from permissions.constants.constants import ( INSIGHTSPROFILE, SETTINGSPROFILE, SONGWHIPPROFILE, NEO4j_READ_ACCESS, NEO4j_WRITE_ACCESS, NEO4j_WRITE_TIMEOUT, ) from permissions.exceptions.incomplete_result_error import IncompleteResultError from permissions.models import auth0, identity as identity_model, label, owsusers from permissions.utils import api_utils, email from permissions.utils.api_utils import to_snake from permissions.utils.cache_utils import get_resources_key def _get_identity_for_admin(tx, admin_context, identity_id): query1 = """MATCH (ap:Profile)-[:HAS_ADMIN_ACCESS_TO]->(x) <-[:HAS_ACCESS_TO]-(up:Profile)<-[:HAS_PROFILE]-(other:Identity) WHERE ap.profileType = $adminProfileType AND ap.profileId = $adminProfileId AND other.id = $identityId RETURN distinct(other) as identity UNION MATCH (ap:Profile)-[:HAS_ADMIN_ACCESS_TO]->(x)-[:OWNS|HAS_LABEL_PARTICIPANT]->(s) <-[:HAS_ACCESS_TO]-(up:Profile)<-[:HAS_PROFILE]-(other:Identity) WHERE ap.profileType = $adminProfileType AND ap.profileId = $adminProfileId AND other.id = $identityId AND (s:Subaccount OR s:LabelParticipant OR s:Collaborator) RETURN distinct(other) as identity """ # noqa record = tx.run( query1, identityId=identity_id, adminIdentityId=admin_context['identity_id'], adminProfileType=admin_context['profile_type'], adminProfileId=admin_context['profile_id'], ).single() if not record or not record.get('identity'): raise IncompleteResultError(message='Identity not found with this id.') return to_snake(dict(record.get('identity'))) def _get_identities_by_emails(tx, emails): query1 = 'MATCH (i:Identity) WHERE i.email in $emails RETURN i as identity' records = tx.run(query1, emails=emails).data() result = {} for row in records: result[row.get('identity').get('email')] = to_snake(dict(row.get('identity'))) return result def _get_identity_by_email(tx, email): query1 = 'MATCH (i:Identity) WHERE i.email = $email RETURN i as identity' record = tx.run(query1, email=email).single() if not record or not record.get('identity'): return None result = to_snake(dict(record.get('identity'))) return result def update_existing_profiles_with_roles( tx: neo4j_lib.Transaction, identity_id: str, profile_types_and_roles: list[dict[str, Union[str, list[str]]]], audit_user: str, ) -> None: """Update profiles with given roles.""" query = textwrap.dedent(""" MATCH (i:Identity {id: $identityId}) UNWIND $profileTypesAndRoles AS profileTypesAndRoles WITH i, profileTypesAndRoles.profile_type AS profileType, profileTypesAndRoles.roles AS roles MATCH (i)-[:HAS_PROFILE]->(p:Profile {profileType: profileType}) WHERE p.roles <> roles SET p.roles = roles, p.updatedBy = $auditUser, p.updatedOn = datetime() RETURN p """) tx.run( query, identityId=identity_id, profileTypesAndRoles=profile_types_and_roles, auditUser=audit_user, ) def create_profiles_if_not_exist( tx: neo4j_lib.Transaction, identity_id: str, profile_name: str, # A list of [{"profile_type": "a", "roles": ["b, "c"]}] dicts profile_types_and_roles: list[dict[str, Union[str, list[str]]]], audit_user: str, ) -> None: """Create profiles of the given types if they do not yet exist.""" query = textwrap.dedent(""" MATCH (i:Identity {id: $identityId}) MATCH (inc:IncrementId {nodeName: 'Profile'}) WITH i, inc UNWIND $profileTypesAndRoles AS profileTypesAndRoles WITH i, inc, profileTypesAndRoles.profile_type AS profileType, profileTypesAndRoles.roles AS roles MATCH (i) WHERE NOT (i)-[:HAS_PROFILE]->(:Profile {profileType: profileType}) // Get new increment atomically CALL apoc.atomic.add(inc, 'id', 1, 3) YIELD newValue AS newIncrement // Then create profile with new profileId WITH i, profileType, roles, newIncrement CREATE (pNew:Profile { profileType: profileType, profileId: newIncrement, profileName: coalesce($profileName, (i.name + profileType)), roles: roles, uuid: randomUUID(), updatedBy: $auditUser, updatedOn: datetime(), createdAt: datetime(), createdBy: $auditUser, lastModifiedAt: datetime(), lastModifiedBy: $auditUser }) // connect the profile to the identity MERGE (i)-[rel:HAS_PROFILE]->(pNew) SET rel.updatedBy = $auditUser, rel.updatedOn = datetime(), rel.createdAt = datetime(), rel.createdBy = $auditUser, rel.lastModifiedAt = datetime(), rel.lastModifiedBy = $auditUser RETURN pNew """) tx.run( query, identityId=identity_id, profileName=profile_name, profileTypesAndRoles=profile_types_and_roles, auditUser=audit_user, ) def delete_access_for_profile( tx, audit_user_id, identity_id, profile_type, profile_id, resource_type, resource_uuid ): """Soft delete a profile's access to a given resource.""" query = f"""MATCH(i:Identity {{id: $identity_id}})-[rr:HAS_PROFILE]->(p:Profile {"{profileType:$profile_type, profileId:$profile_id}" if profile_id else "{profileType:$profile_type}"}) {"-[rel:HAS_ADMIN_ACCESS_TO]->" if profile_type == constants.SETTINGSPROFILE else "-[rel:HAS_ACCESS_TO]->"} (r:{resource_type} {{uuid: $resource_uuid}}) SET rel.updatedOn = localdatetime(), rel.updatedBy = $auditUser WITH rel, p CALL apoc.refactor.setType(rel, {"'DELETED_HAS_ADMIN_ACCESS_TO'" if profile_type == constants.SETTINGSPROFILE else "'DELETED_HAS_ACCESS_TO'"}) YIELD input,output RETURN p.profileType as profileType, p.profileId as profileId, p.roles as roles, rel """ result = tx.run( query, identity_id=identity_id, profile_type=profile_type, profile_id=profile_id, resource_type=resource_type, resource_uuid=resource_uuid, auditUser=audit_user_id, ).single() if result: return { 'profile_id': result.get('profileId'), 'profile_type': result.get('profileType'), 'roles': [], 'relation': result.get('rel'), } def _clear_cache_for_profile(edited_profile_roles: list[dict[str, str | list[str]]]): """Clear the redis entry for each profile's resources.""" for profile_role in edited_profile_roles: redis_prefix = get_resources_key( profile_role.get('profile_type'), profile_role.get('profile_id') ) redis.delete_all_matching_pattern(redis_prefix) def _create_profile_with_id_if_not_exist( tx, identity_id, profile_id, profile_type, roles, audit_user, brand, profile_name=None ): """Create single profile.""" query = """MATCH (i:Identity {id: $identityId}) MERGE (pNew:Profile {profileId: $profileId, profileType: $profileType}) ON CREATE SET pNew.profileName = coalesce($profileName, (i.name + $profileType)), pNew.roles = $roles, pNew.lastModifiedBy = $auditUser, pNew.lastModifiedAt = datetime(), pNew.brand = $brand, pNew.uuid = randomUUID(), pNew.createdAt = datetime() ON MATCH SET pNew.roles = $roles, pNew.lastModifiedBy = $auditUser, pNew.lastModifiedAt = datetime() MERGE (i)-[rel:HAS_PROFILE]->(pNew) SET rel.lastModifiedBy = $auditUser, rel.lastModifiedAt = datetime(), rel.createdAt = datetime()""" tx.run( query, identityId=identity_id, profileName=profile_name, profileId=profile_id, profileType=profile_type, auditUser=audit_user, roles=roles, brand=brand, ) def _update_identity_user_type(tx, identity_id, user_types=None): query1 = """MATCH (i:Identity {id: $identityId}) SET i.userTypes = $userTypes RETURN i as identity """ record = tx.run(query1, identityId=identity_id, userTypes=user_types).single() if not record or not record.get('identity'): raise IncompleteResultError(message=f'Failed to update Identity for {identity_id}.') def _update_identity_auth0_id(tx, identity_id, auth0_user_id): query1 = """MATCH (i:Identity {id: $identityId}) SET i.auth0UserId = $auth0UserId RETURN i as identity """ record = tx.run(query1, identityId=identity_id, auth0UserId=auth0_user_id).single() if not record or not record.get('identity'): raise IncompleteResultError(message=f'Failed to update Identity for {identity_id}.') def _create_settings_profile_if_not_exist(tx, identity_id, brand, audit_user, profile_name=None): """Create a settings proifile for an identity. Does nothing if one exists.""" query = """MATCH (i:Identity {id: $identityId}) WHERE NOT (i)-[:HAS_PROFILE]->(:Profile {profileType: 'SettingsProfile'}) MATCH (incId:IncrementId {nodeName: 'Profile'}) CALL apoc.atomic.add(incId, 'id', 1, 3) YIELD newValue AS newIncrement CREATE (p:Profile {profileId: incId.id, profileType: 'SettingsProfile'}) SET p.profileName = coalesce($profileName, (i.name + ' SettingsProfile')), p.brand = $brand, p.uuid = randomUUID(), p.createdAt = datetime(), p.updatedBy = $auditUser MERGE (i)-[rel:HAS_PROFILE]->(p) SET rel.createdAt = datetime() RETURN p as settingsProfile """ tx.run( query, identityId=identity_id, profileName=profile_name, brand=brand, auditUser=audit_user, ) def _get_formatted_profiles(profile_roles): """Return list of profiles as list required by cypher.""" formatted = [] for profile_type, roles in profile_roles.items(): formatted.append( { 'profile_type': profile_type, 'roles': _get_valid_roles(profile_type, roles), } ) return formatted def _get_valid_roles(profile_type, roles): """Return list of roles allowed for that profile type.""" valid_roles = list(set(roles) & set(constants.PROFILE_TYPE_TO_ROLES_MAPPING[profile_type])) is_awal_admin = 'administrator' in roles and profile_type in [ constants.COLLABORATORSPROFILE, constants.SONGWHIPPROFILE, constants.MONEYHUBPROFILE, constants.DOCUMENTSPROFILE, ] if profile_type in [constants.INSIGHTSPROFILE] or is_awal_admin: # always [analytics] valid_roles = constants.PROFILE_TYPE_TO_ROLES_MAPPING[profile_type] return valid_roles def _add_profile_specific_roles(tx, identity_id, profiles, audit_user): query1 = """MATCH (i1:Identity { id: $identityId }) UNWIND $profiles AS each MATCH (i1)-[:HAS_PROFILE]->(p:Profile {profileType: each.profile_type}) SET p.roles = coalesce(p.roles,[]) + [el in each.roles WHERE NOT el IN p.roles], p.updatedBy = $auditUser, p.updatedOn = datetime() RETURN p as profile """ records = tx.run(query1, identityId=identity_id, profiles=profiles, auditUser=audit_user).data() if not records or not len(records) or len(records) < len(profiles): # ideally this should never happen if you call create_profiles_if_not_exist() raise IncompleteResultError( message=f'Failed to create all profile_types for Identity {identity_id}.' ) result = [api_utils.to_serializable_dict(record.get('profile')) for record in records] return result def _add_resource_to_settings_profile(tx, identity_id, resource_type, resource_id, audit_user): """Create HAS_ADMIN_ACCESS_TO relationship between SettingsProfile and resource.""" query = f"""MATCH (r:{resource_type}) WHERE r.uuid = $resource_id WITH r MATCH (i:Identity {{id: $identity_id}})-[:HAS_PROFILE]->(sp:Profile) WHERE sp.profileType = 'SettingsProfile' MERGE (sp)-[newrel:HAS_ADMIN_ACCESS_TO]->(r) SET newrel.updatedOn = datetime(), newrel.updatedBy = $auditUser, newrel.createdAt = datetime() WITH sp, r, newrel OPTIONAL MATCH (sp)-[delrel:DELETED_HAS_ADMIN_ACCESS_TO]->(r) DELETE delrel RETURN newrel """ result = tx.run( query, identity_id=identity_id, resource_id=resource_id, auditUser=audit_user, ).single() if not result or not result.get('newrel'): raise IncompleteResultError( f'Failed to add {resource_type} to settings profile for user {identity_id}.' ) g.log.info( 'Successfully added resource to profile', resources={ 'resource_type': resource_type, 'resource_uuid': resource_id, 'profile_type': 'SettingsProfile', 'identity_id': identity_id, 'admin_identity_id': audit_user, }, ) def _add_single_resource_to_profiles( tx, profile_types, identity_id, resource_type, resource_uuid, audit_user ): """Add single resource to multiple profiles.""" query = f"""MATCH (r:{resource_type}) WHERE r.uuid = $resource_id WITH r MATCH(i:Identity {{id: $identity_id}})-[:HAS_PROFILE]->(p:Profile) WHERE p.profileType in $profileTypes MERGE (p)-[newrel:HAS_ACCESS_TO]->(r) SET newrel.updatedOn = datetime(), newrel.updatedBy = $auditUser, newrel.createdAt = datetime() WITH p, r OPTIONAL MATCH (p)-[rel:DELETED_HAS_ACCESS_TO]->(r) DELETE rel RETURN distinct(p) as profile, r.id as resources """ records = tx.run( query, identity_id=identity_id, profileTypes=profile_types, resource_type=resource_type, resource_id=resource_uuid, auditUser=audit_user, ).data() if not records: raise IncompleteResultError(f'Failed to find any {resource_type} with {resource_uuid}.') if len(records) != len(profile_types): raise IncompleteResultError(f'Failed to add {resource_type} to all profiles.') g.log.info( 'Successfully added resource to profiles', resources={ 'resource_type': resource_type, 'resource_uuid': resource_uuid, 'profile_types': profile_types, 'identity_id': identity_id, 'admin_identity_id': audit_user, }, ) def _add_single_resource_to_label_profiles( tx, profile_id, identity_id, resource_type, resource_uuid, audit_user ): """Add single resource to single Label profiles.""" query = f"""MATCH (r:{resource_type}) WHERE r.uuid = $resource_id WITH r MATCH(i:Identity {{id: $identity_id}})-[:HAS_PROFILE]-> (p:Profile {{profileType: 'LabelProfile', profileId: $profileId}}) MERGE (p)-[newrel:HAS_ACCESS_TO]->(r) SET newrel.updatedOn = datetime(), newrel.updatedBy = $auditUser, newrel.createdAt = datetime() WITH p, r OPTIONAL MATCH (p)-[rel:DELETED_HAS_ACCESS_TO]->(r) DELETE rel RETURN distinct(p) as profile, r.id as resources """ records = tx.run( query, identity_id=identity_id, profileId=profile_id, resource_type=resource_type, resource_id=resource_uuid, auditUser=audit_user, ).data() if not records: raise IncompleteResultError(f'Failed to find any {resource_type} with {resource_uuid}.') if len(records) != 1: raise IncompleteResultError(f'Failed to add {resource_type} to LabelProfile.') g.log.info( 'Successfully added resource to profile', resources={ 'resource_type': resource_type, 'resource_uuid': resource_uuid, 'profile_id': profile_id, 'profile_type': 'LabelProfile', 'identity_id': identity_id, 'admin_identity_id': audit_user, }, ) def _has_admin_access_to_resources(tx, admin_context, uuids): """For admin with limited access verify if admin has admin access to all resources.""" query = """MATCH (admin:Identity {id: $adminIdentityId})-[:HAS_PROFILE] ->(ap:Profile {profileType: $adminProfileType, profileId: $adminProfileId}) -[:HAS_ADMIN_ACCESS_TO]->(x)-[*0..1]->(r) WHERE r.uuid IN $uuids AND (r:Subaccount OR r:LabelParticipant OR r:Vendor or r:Collaborator) RETURN distinct(r.uuid) as uuids""" # noqa record = tx.run( query, uuids=uuids, adminIdentityId=admin_context['identity_id'], adminProfileType=admin_context['profile_type'], adminProfileId=admin_context['profile_id'], ).data() if not record or len(record) < len(uuids): raise IncompleteResultError(message='Admin does not have access to all the resources.') return response.Response(message='Admin has access to all these resources.') def _delete_all_user_resources(tx, profile_types, identity_id, audit_user): """For super admin delete all resources for the profiles.""" query = """MATCH(i:Identity {id: $identity_id})-[:HAS_PROFILE]->(p:Profile) -[rel:HAS_ACCESS_TO]->(r) WHERE p.profileType in $profileTypes AND r.id <> '*' SET rel.updatedOn = datetime(), rel.updatedBy = $auditUser WITH rel, p CALL apoc.refactor.setType(rel, 'DELETED_HAS_ACCESS_TO') YIELD input,output RETURN p""" tx.run(query, identity_id=identity_id, profileTypes=profile_types, auditUser=audit_user) def _delete_settings_vendor_star_resources(tx, profile_types, identity_id, audit_user): """For super admin delete all resources for the profiles.""" query = """MATCH(i:Identity {id: $identity_id})-[:HAS_PROFILE]->(p:Profile) -[rel:HAS_ACCESS_TO]->(r) WHERE p.profileType in $profileTypes AND r.id = '*' SET rel.updatedOn = datetime(), rel.updatedBy = $auditUser WITH rel, p CALL apoc.refactor.setType(rel, 'DELETED_HAS_ACCESS_TO') YIELD input,output RETURN p""" tx.run(query, identity_id=identity_id, profileTypes=profile_types, auditUser=audit_user) def _delete_admin_specific_resources(tx, profile_types, identity_id, admin_context): """For admin with limited access deleted only his administered resources of this type.""" query = """MATCH(i:Identity {id: $identityId})-[:HAS_PROFILE]->(p:Profile)-[rel:HAS_ACCESS_TO]->(r) WHERE p.profileType in $profileTypes AND r.id <> '*' WITH r, rel MATCH (admin:Identity {id: $adminIdentityId})-[:HAS_PROFILE]->(ap:Profile)-[:HAS_ACCESS_TO]->(x)-[*0..1]->(r) WHERE 'administrator' IN ap.roles AND ap.profileType = $adminProfileType AND ap.profileId = $adminProfileId SET rel.updatedBy = $adminIdentityId, rel.updatedOn = datetime() WITH rel, ap CALL apoc.refactor.setType(rel, 'DELETED_HAS_ACCESS_TO') YIELD input,output RETURN ap""" # noqa tx.run( query, identityId=identity_id, profileTypes=profile_types, adminIdentityId=admin_context['identity_id'], adminProfileType=admin_context['profile_type'], adminProfileId=admin_context['profile_id'], ) def _delete_gda_admin_specific_resources(tx, profile_types, identity_id, admin_context): """For a gda admin with limited access only delete resources they can administer.""" query = """MATCH (i:Identity {id: $identityId})-[:HAS_PROFILE]->(p:Profile)-[rel:HAS_ACCESS_TO]->(r) WHERE p.profileType in $profileTypes AND r.id <> '*' WITH r, rel MATCH (admin:Identity {id: $adminIdentityId})-[:HAS_PROFILE]->(ap:Profile)-[:HAS_ADMIN_ACCESS_TO]->(x)-[*0..1]->(r) WHERE ap.profileType = 'SettingsProfile' AND ap.profileId = $adminProfileId SET rel.updatedBy = $adminIdentityId, rel.updatedOn = datetime() WITH rel, ap CALL apoc.refactor.setType(rel, 'DELETED_HAS_ACCESS_TO') YIELD input, output RETURN ap""" # noqa tx.run( query, identityId=identity_id, profileTypes=profile_types, adminIdentityId=admin_context['identity_id'], adminProfileId=admin_context['profile_id'], ) def _delete_resource_to_settings_profile(tx, identity_id, audit_user, super_admin=False): """Soft delete HAS_ADMIN_ACCESS relationship between SettingsProfile and resource.""" check_admin_access_query = ( """MATCH (admin:Identity {id: $audit_user})-[:HAS_PROFILE] ->(ap:Profile {profileType: 'SettingsProfile'})-[:HAS_ADMIN_ACCESS_TO]->(x)-[*0..1]->(r) WITH r""" if not super_admin else '' ) query = f"""{check_admin_access_query} MATCH (i:Identity)-[:HAS_PROFILE]->(p:Profile)-[rel:HAS_ADMIN_ACCESS_TO]->(r) WHERE i.id = $identity_id AND p.profileType = 'SettingsProfile' SET rel.updatedOn = datetime(), rel.updatedBy = $audit_user WITH rel, p CALL apoc.refactor.setType(rel, 'DELETED_HAS_ADMIN_ACCESS_TO') YIELD input, output RETURN p""" tx.run(query, identity_id=identity_id, audit_user=audit_user) def _get_vendor_id_for_subaccount_id(tx, resource): """Return vendor_id for one subaccount_id.""" if resource['resource_type'] == constants.VENDOR_RESOURCE_TYPE: query1 = """MATCH (v:Vendor) WHERE v.uuid = $vendor_id RETURN v.id as vendor_id """ record = tx.run(query1, vendor_id=resource['uuid']).single() if not record or not record.get('vendor_id'): raise IncompleteResultError( message=f"Could not retrieve subaccount id with uuid {resource['uuid']}." ) return record.get('vendor_id'), None query1 = """MATCH (s:Subaccount)<-[:OWNS]-(v:Vendor) WHERE s.uuid = $subaccount_id RETURN v.id as vendor_id, s.id as subaccount_id """ record = tx.run(query1, subaccount_id=resource['uuid']).single() if not record or not record.get('vendor_id'): raise IncompleteResultError(message=f"Invalid subaccount id {resource['uuid']}.") return record.get('vendor_id'), record.get('subaccount_id') def _get_label_for_label_participant(tx, uuids): """Return label and artist name for uuids.""" query1 = """MATCH (lp:LabelParticipant)<-[:HAS_LABEL_PARTICIPANT]-(vendor:Vendor) WHERE lp.uuid in $uuids OPTIONAL MATCH (lp)<-[:HAS_LABEL_PARTICIPANT]-(subaccount:Subaccount) WITH lp, CASE WHEN subaccount IS NOT NULL THEN subaccount ELSE vendor END AS label RETURN lp.uuid as uuid, lp.name as name, label.name as label_name, label.id as label_id """ record = tx.run(query1, uuids=uuids).data() if not record or len(record) != len(uuids): raise IncompleteResultError(message='Invalid uuids for LabelParticipant.') data = [dict(r) for r in record] return data def _check_vendor_star_access_orch_suite(tx, identity_id, profile_type, profile_id): # Checks if identity has vendor * access outside of settings application g.log.info(f"""check_super_admin / check_vendor_star_access requested for {profile_type} {profile_id} for identity {identity_id}""") query = """MATCH (admin:Identity)-[:HAS_PROFILE]->(ap:Profile) -[:HAS_ACCESS_TO]->(r:Vendor {id: '*'}) WHERE admin.id = $adminIdentityId AND 'administrator' IN ap.roles AND ap.profileType = $adminProfileType AND ap.profileId = $adminProfileId RETURN admin as identity """ record = tx.run( query, adminIdentityId=identity_id, adminProfileType=profile_type, adminProfileId=profile_id ).single() if not record or not record.get('identity'): raise IncompleteResultError(message='Identity does not have access to Vendor *.') return to_snake(dict(record.get('identity'))) def _check_vendor_star_access_settings(tx, identity_id, profile_id): # Checks if identity has access to vendor *. This should only be employees. query = """MATCH (admin:Identity)-[:HAS_PROFILE]->(sp:Profile {profileType: 'SettingsProfile'}) -[:HAS_ADMIN_ACCESS_TO]->(r:Vendor {id: '*'}) WHERE admin.id = $adminIdentityId AND sp.profileId = $adminProfileId RETURN admin as identity """ record = tx.run(query, adminIdentityId=identity_id, adminProfileId=profile_id).single() if not record or not record.get('identity'): raise IncompleteResultError(message='Identity does not have access to Vendor *.') return to_snake(dict(record.get('identity'))) def get_label_for_label_participant(uuids): """Return associated label for label_participant.""" with neo4j.db_session(access_mode=NEO4j_READ_ACCESS) as session: try: labels = session.read_transaction(_get_label_for_label_participant, uuids) return response.Response(labels) except IncompleteResultError as err: return response.create_not_found_response(err.message) def has_admin_access_to_resources(admin_context, uuids): """Verify if admin has admin access to all resources.""" with neo4j.db_session(access_mode=NEO4j_READ_ACCESS) as session: try: result = session.read_transaction(_has_admin_access_to_resources, admin_context, uuids) return result except IncompleteResultError as err: return response.create_fatal_response(err.message) def get_identity_id_for_admin(admin_context, identity_id): """Get identity if it exist and is accessible by this admin. Args: admin_context (dict): Admin's context data. identity_id (str): Identity UUID. Return: response (obj) """ with neo4j.db_session(access_mode=NEO4j_READ_ACCESS) as session: try: identity = session.read_transaction(_get_identity_for_admin, admin_context, identity_id) return response.Response(identity) except IncompleteResultError as err: return response.create_not_found_response(err.message) @tracer.wrap('check_super_admin', service='neo4j') def check_vendor_star_access(identity_id, profile_type, profile_id, session=None): """Check if this Identity has vendor * access. Note: tracer is set to track check_super_admin for posterity as it was the previous name for method. This was renamed to differentiate the below super_admin: can assign * level access as a whole; can write across orchard catalog; controlled by split FF. can_access_vendor_star: can view users and view resources across catalog; an employee settings user; inferred via relationships. Args: identity_id (str): Identity UUID. profile_type (str): Type of Profile eg InsightsProfile, LabelProfile. profile_id (int): Profile id. session: Optional Neo4j session. Return: response (obj) """ # If not session provided, use context manager if session is None: with neo4j.db_session(access_mode=NEO4j_READ_ACCESS) as new_session: return _check_vendor_star_access_logic( identity_id, profile_type, profile_id, new_session ) else: # If session provided, use that instead return _check_vendor_star_access_logic(identity_id, profile_type, profile_id, session) def _check_vendor_star_access_logic(identity_id, profile_type, profile_id, session): """Encapsulate logic for checking vendor star access.""" try: if profile_type == constants.SETTINGSPROFILE: identity = session.read_transaction( _check_vendor_star_access_settings, identity_id, profile_id ) else: identity = session.read_transaction( _check_vendor_star_access_orch_suite, identity_id, profile_type, profile_id ) return response.Response(identity) except IncompleteResultError as err: return response.create_not_found_response(err.message) @tracer.wrap('get_identities_by_emails', service='neo4j') def get_identities_by_emails(emails): """Get list of identities by email. Args: emails (list): List of emails. """ with neo4j.db_session(access_mode=NEO4j_READ_ACCESS) as session: identity = session.read_transaction(_get_identities_by_emails, emails) return identity @tracer.wrap('get_identity_by_email', service='neo4j') def get_identity_by_email(email): """Get identity by email. Args: email (str): email. """ with neo4j.db_session(access_mode=NEO4j_READ_ACCESS) as session: identity = session.read_transaction(_get_identity_by_email, email) return identity def _filter_by_backend_structure(profiles): """Return list of profiles that can only occur once per user.""" single_profiles = [] only_types = [] label_profile = None for each in profiles: if each['profile_type'] == constants.LABELPROFILE: label_profile = each else: single_profiles.append(each) only_types.append(each['profile_type']) return single_profiles, only_types, label_profile def _get_vendor_ids_for_subaccounts(tx, subaccount_ids): """Return each resources on its own because Label profiles are 1-1 with resources.""" query1 = """MATCH (s:Subaccount)<-[:OWNS]-(v:Vendor) WHERE s.id in $subaccount_ids RETURN v.id as vendor_id, s.id as subaccount_id """ records = tx.run(query1, subaccount_ids=subaccount_ids).data() result = [] for row in records: result.append((row.get('vendor_id'), row.get('subaccount_id'))) return result def _restructure_label_resources(tx, resources): """Return each resources on its own because Label profiles are 1-1 with resources.""" label_profile_resources = [] for resource in resources: if resource['resource_type'] == constants.VENDOR_RESOURCE_TYPE: for each in resource['ids']: label_profile_resources.append((each, None)) elif resource['resource_type'] in [ constants.SUBACCOUNT_NEO4J_RESOURCE_TYPE, constants.SUBACCOUNT_RESOURCE_TYPE, ]: subaccounts = _get_vendor_ids_for_subaccounts(tx, resource['ids']) label_profile_resources.extend(subaccounts) return label_profile_resources def _map_to_vend_contact_roles(roles): """Return each resources on its own because Label profiles are 1-1 with resources.""" vend_contact_roles = [] for role in roles: if role.lower() in constants.AR_ROLES_MAPPING_TO_ID.keys(): vend_contact_roles.append(constants.AR_ROLES_MAPPING_TO_ID[role.lower()]) elif role.lower() == constants.ALL_ROLES: vend_contact_roles.append(0) return set(vend_contact_roles) def _create_auth0_user( tx, identity_id, identity, set_email_verified, default_brand, user_metadata={} ): """Create user in auth0.""" user_metadata['orchardIdentityId'] = identity_id user_metadata['defaultBrand'] = default_brand auth0_user = auth0.create_user( identity['email'], identity['name'], set_email_verified, identity.get('first_name'), identity.get('last_name'), identity.get('user_types'), user_metadata, identity.get('app_metadata', {}), ) if not auth0_user: raise IncompleteResultError( message=f"Failed to create user in auth0 for {identity['email']}." ) # once identity was success in neo4j and auth0, update neo4j with auth0_user_id _update_identity_auth0_id(tx, identity_id, auth0_user.message['auth0_user_id']) return auth0_user.message @tracer.wrap('add_resources_to_identity', service='neo4j') def add_resources_to_identity( identity, resources, profile_roles, admin_context, can_access_vendor_star, set_email_verified=True, overwrite_existing_access=True, brand=constants.THEORCHARD_BRAND, create_auth0_user=True, master_contact=False, user_metadata={}, ): """Create new profile if one does not exist and add resource access to it. Args: identity (dict): User Identity details. resources (list): List of resource object with roles and profile_types. profile_roles (dict): profile_types and its roles mapped by logic layer from resources. admin_context (dict): Admin User details. can_access_vendor_star (bool): user editing this can grant access to resources in vendor '*' catalog(s). set_email_verified (bool): set the email as verified. create_auth0_user (bool): determine if we should create user in auth0 or just neo4j. overwrite_existing_access (bool): delete old and replace with new access. user_metadata (dict): user_metadata to be added to auth0 user. Return: response (obj): Profiles affected. """ result = [] identities_affected = [] required_vc_ids = [] audit_user = admin_context['identity_id'] profiles = _get_formatted_profiles(profile_roles) all_types_with_label = constants.SETTINGS_SUPPORT_MAPPING['profileTypes'] # use for delete all_types_for_vendor_star = constants.SETTINGS_SUPPORT_MAPPING[ 'deleteProfileTypesForVendorStar' ] # use for delete identity_created = False # always create a fresh session for write and not reuse the g.neo4j_db # to avoid NotALeader on write WRITE operation to a read cluster. with neo4j._get_neo4j_session( access_mode=NEO4j_WRITE_ACCESS ) as session, mysql.db_session() as mysql_session: tx = session.begin_transaction(timeout=NEO4j_WRITE_TIMEOUT) try: existing_identity = get_identity_by_email(identity['email']) # check if user exists in auth0 auth0_user_id = owsusers.get_auth0_user_id_by_email(identity['email']) if auth0_user_id: auth0_user_id = auth0_user_id.replace('auth0|', '') if not existing_identity: identity_id = str(uuid.uuid4()) auth0_user_created_by = 'permissions' if create_auth0_user else 'invitation' # use the auth0 id and same uuid to create the identity node. # this happens in auth0 hooks but merge to remove dependency. identity_model.run_neo4j_identity_create( tx, identity_id, identity.get('name'), identity['email'], auth0_user_id, audit_user, identity.get('localization', constants.LOCALES['English']), identity.get('number_format', constants.NUMBER_FORMAT[0]), identity.get('first_name'), identity.get('last_name'), identity.get('user_types'), brand, auth0_user_created_by, is_employee=email.is_employee_email(identity['email']), ) if create_auth0_user: if auth0_user_id: _update_identity_auth0_id(tx, identity_id, auth0_user_id) identity['auth0_user_id'] = auth0_user_id else: identity = _create_auth0_user( tx, identity_id, identity, set_email_verified, brand, user_metadata ) identity['default_brand'] = brand else: # temporary set auth0_user_id to uuid so we can use that in vend_contact table. # leaving it null would make it difficult to identify those rows to update. identity['auth0_user_id'] = identity_id _update_identity_auth0_id(tx, identity_id, identity_id) identity['id'] = identity_id identity['default_brand'] = brand identity_created = True else: # only update user_types at identity level on edit user_types = identity.get('user_types') identity = existing_identity identity_id = existing_identity['id'] # in case of edit take default_brand from identity for all new profiles. brand = existing_identity.get('default_brand') _update_identity_user_type(tx, identity_id, user_types) # enforce settingsProfile exists even if identity already exists _create_settings_profile_if_not_exist( tx, identity_id, brand, audit_user, profile_name=identity.get('name') ) # delete existing access only when this flag is true. if overwrite_existing_access: if can_access_vendor_star: _delete_all_user_resources(tx, all_types_with_label, identity_id, audit_user) _delete_settings_vendor_star_resources( tx, all_types_for_vendor_star, identity_id, audit_user ) elif brand == constants.AWAL_BRAND: _delete_gda_admin_specific_resources( tx, all_types_with_label, identity_id, admin_context ) else: _delete_admin_specific_resources( tx, all_types_with_label, identity_id, admin_context ) _delete_resource_to_settings_profile( tx, identity_id, audit_user, can_access_vendor_star ) elif create_auth0_user and not auth0_user_id and 'auth0_user_id' not in identity: # Users that only have a gsuite connection in auth0 will need art-relations # connection. This should also update identity node with # newly created auth0_user_id in hooks. identity = _create_auth0_user( tx, identity_id, identity, set_email_verified, brand, user_metadata ) identities_affected.append(identity) # If we STILL don't have an auth0 id set at this point, just set it to identity id. # Would happen if it's an existing identity and overwrite_existing_access=True. if not identity.get('auth0_user_id'): identity['auth0_user_id'] = identity_id for resource in resources: g.log.info( 'Adding resource to user', resources={ 'admin_identity_id': audit_user, 'resource_uuid': resource['uuid'], 'identity_id': identity_id, 'roles': resource['roles'], }, ) # for implicit profile types, there can be diff logic. # We can remove implicit option when all users are created from settings and not WS. # doing this first as when we remove sync, we will need vc_id for profile id. # skip sql if the resource is vendor * if all( [ constants.LABELPROFILE in resource['implicit_profile_types'], resource['uuid'] != constants.VENDOR_STAR_UUID, ] ): vendor_id, subaccount_id = _get_vendor_id_for_subaccount_id(tx, resource) role_ids = _map_to_vend_contact_roles(resource['roles']) existing, auth0_primary = label.get_vend_contact_with_auth0_user_id( mysql_session, identity['auth0_user_id'], vendor_id, subaccount_id ) if not existing: first_name = identity.get('first_name') last_name = ( identity['name'] if not identity.get('last_name') else identity['last_name'] ) # required in vend_contact # set auth0_primary for gda-account-creation user creation existing = label.create_full_vend_contact_user( mysql_session, identity['auth0_user_id'], last_name, first_name, identity['email'], role_ids, vendor_id, auth0_primary, master_contact, subaccount_id, ) g.log.info( 'Created contact, vend_contact, and vend_contact_roles records', resources={ 'admin_identity_id': audit_user, 'vendor_id': vendor_id, 'subaccount_id': subaccount_id, 'identity_id': identity_id, }, ) required_vc_ids.append(existing['id']) else: required_vc_ids.append(existing.get('id')) if existing['all_roles'] != role_ids: label.update_vend_contact_roles(mysql_session, existing['id'], role_ids) g.log.info( 'Updated vend_contact_roles', resources={ 'admin_identity_id': audit_user, 'vendor_id': vendor_id, 'subaccount_id': subaccount_id, 'vend_contact_id': existing['id'], 'new_role_ids': role_ids, 'identity_id': identity_id, }, ) if existing['active'] == 'N': label.update_vend_contact(mysql_session, existing['id'], 'Y') g.log.info( 'Set vend_contact to active', resources={ 'admin_identity_id': audit_user, 'vendor_id': vendor_id, 'subaccount_id': subaccount_id, 'vend_contact_id': existing['id'], 'identity_id': identity_id, }, ) # this happens in sync process but merge to remove dependency. label_roles = _get_valid_roles(constants.LABELPROFILE, resource['roles']) _create_profile_with_id_if_not_exist( tx, identity_id, existing['id'], constants.LABELPROFILE, label_roles, audit_user, brand, ) _add_single_resource_to_label_profiles( tx, existing['id'], identity_id, resource['resource_type'], resource['uuid'], audit_user, ) result.append( { 'profile_type': constants.LABELPROFILE, 'profile_id': existing['id'], 'user_identity': to_snake(identity), } ) # add explicit profile types if resource['explicit_profile_types']: create_profiles_if_not_exist( tx, identity_id, identity['name'], [ {'profile_type': t, 'roles': []} for t in resource['explicit_profile_types'] ], audit_user, ) # Filter out profiles which aren't used in this resource resource_profiles = list( filter( lambda p: p['profile_type'] in resource['explicit_profile_types'], profiles, ) ) result.extend( _add_profile_specific_roles(tx, identity_id, resource_profiles, audit_user) ) _add_single_resource_to_profiles( tx, resource['explicit_profile_types'], identity_id, resource['resource_type'], resource['uuid'], audit_user, ) if resource['uuid'] == constants.VENDOR_STAR_UUID: g.log.info( 'Adding full catalog access to one or more profiles', resources={'admin_identity_id': audit_user, 'identity_id': identity_id}, ) _add_full_catalog_access(tx, identity_id, SETTINGSPROFILE) if INSIGHTSPROFILE in resource['explicit_profile_types']: _add_full_catalog_access(tx, identity_id, INSIGHTSPROFILE) if SONGWHIPPROFILE in resource['explicit_profile_types']: _add_full_catalog_access(tx, identity_id, SONGWHIPPROFILE) if 'administrator' in resource['roles'] or master_contact: _add_resource_to_settings_profile( tx, identity_id, resource['resource_type'], resource['uuid'], audit_user ) if not any(resource['uuid'] == constants.VENDOR_STAR_UUID for resource in resources): _remove_full_catalog_access(tx, identity_id, INSIGHTSPROFILE) _remove_full_catalog_access(tx, identity_id, SETTINGSPROFILE) _remove_full_catalog_access(tx, identity_id, SONGWHIPPROFILE) if can_access_vendor_star and overwrite_existing_access and len(required_vc_ids) > 0: # deactivate all other accounts for this user. label.deactivate_other_vend_contacts( mysql_session, identity['auth0_user_id'], required_vc_ids ) # Clear the redis entry for each profile's resources for profile in result: prof_type, prof_id = profile.get('profile_type'), profile.get('profile_id') if prof_type and prof_id: redis_prefix = get_resources_key(prof_type, prof_id) redis.delete_all_matching_pattern(redis_prefix) # done adding and removing all tx.commit() mysql_session.commit() return response.Response( { 'profiles_affected': [to_snake(each) for each in result], 'identities_affected': identities_affected, 'identity_created': identity_created, } ) except ( IncompleteResultError, sqlalchemy.exc.SQLAlchemyError, sqlalchemy.exc.DatabaseError, pymysql.err.DatabaseError, sqlalchemy.exc.IntegrityError, pymysql.err.IntegrityError, ) as err: tx.rollback() mysql_session.rollback() sentry_client.capture_exception() return response.create_error_response(error.BULK_ADD_IDENTITY_PROFILES, str(err)) def _add_full_catalog_access(tx, identity_id, profile_type): """Create full catalog access for profile.""" query = textwrap.dedent(""" MATCH(i:Identity {id: $identity_id})-[:HAS_PROFILE]-> (p:Profile {profileType: $profile_type}) SET p.fullCatalogAccess = true RETURN p as profile """) records = tx.run(query, identity_id=identity_id, profile_type=profile_type).data() if not records: raise IncompleteResultError( f'Failed to find {identity_id} ' f'with {profile_type} profile type.' ) def _remove_full_catalog_access(tx, identity_id, profile_type): """Remove full catalog access for profile.""" query = textwrap.dedent(""" MATCH(i:Identity {id: $identity_id})-[:HAS_PROFILE]-> (p:Profile {profileType: $profile_type}) WHERE p.fullCatalogAccess = true SET p.fullCatalogAccess = false RETURN p as profile """) records = tx.run(query, identity_id=identity_id, profile_type=profile_type).data() if not records: return None else: return response.Response(records) def _get_identities_given_profile(session, profile_type: str, profile_id: int) -> List[str]: """Find all identities with access to a Profile of profile type / profile id. session: a Neo4J session. profile_type: the type of profile, e.g. SettingsProfile. profile_id: the id of the profile, different from the UUID of the profile. Returns a list of identity_uuids. """ identity_uuids = [] query = ( 'MATCH (p:Profile {profileType: $profileType, profileId: $profileId})' '<-[:HAS_PROFILE]-(i:Identity) ' 'RETURN i.id AS identity_uuid' ) records = session.run(query, profileType=profile_type, profileId=profile_id) if not records: return identity_uuids for item in records.data(): identity_uuids.append(str(item['identity_uuid'])) return identity_uuids def _get_identities_given_profile_uuid(session, profile_uuid: str) -> List[str]: """Find all identities with access to a Profile of profile uuid. session: a Neo4J session. profile_uuid: the uuid of the profile. Returns a list of identity_uuids. """ identity_uuids = [] query = ( 'MATCH (p:Profile {uuid: $profileUuid})<-[:HAS_PROFILE]-(i:Identity) ' 'RETURN i.id AS identity_uuid' ) records = session.run(query, profileUuid=profile_uuid) if not records: return identity_uuids for item in records.data(): identity_uuids.append(str(item['identity_uuid'])) return identity_uuids def get_identities_by_profile( profile_type: str, profile_id: int, profile_uuid: str = None, ) -> List[str]: """Find all identities with access to a Profile by profile type and id, or by uuid. profile_type: the type of profile, e.g. SettingsProfile. profile_id: the id of the profile, different from the UUID of the profile. profile_uuid: the uuid of the profile. Returns a list of identity_uuids. """ with neo4j._get_neo4j_session(access_mode=constants.NEO4j_READ_ACCESS) as session: if profile_uuid: return _get_identities_given_profile_uuid(session, profile_uuid) else: return _get_identities_given_profile(session, profile_type, profile_id) def add_tenant_connection_to_profile_and_clear_cache( tx: neo4j_lib.Transaction, identity_id: str, profile_type: str, tenant: types.Tenant, audit_user_id: str, ) -> None: """Add a tenant connection to a profile unless it already exists.""" if profile_type == constants.SETTINGSPROFILE: relationship_name = 'HAS_ADMIN_ACCESS_TO' else: relationship_name = 'HAS_ACCESS_TO' tenant_neo_type = constants.TENANT_TYPE_TO_NEO_MAPPING[tenant.tenant_type.value] query = textwrap.dedent(f""" MATCH (i:Identity {{id: $identity_id}}) -[:HAS_PROFILE]->(p:Profile {{profileType: $profile_type}}) // Not really an optional match, but neo4j complains about cartesian products otherwise OPTIONAL MATCH (t:{tenant_neo_type} {{uuid: $tenant_uuid}}) OPTIONAL MATCH (p)-[existing_relationship:{relationship_name}]->(t) OPTIONAL MATCH (p)-[deleted_rel:{f"DELETED_{relationship_name}"}]->(t) MERGE (p)-[r:{relationship_name}]->(t) ON CREATE SET r.createdAt = datetime(), r.createdBy = $audit_user_id DELETE deleted_rel RETURN p.id, existing_relationship """) result = tx.run( query, identity_id=identity_id, profile_type=profile_type, tenant_uuid=tenant.tenant_uuid, audit_user_id=audit_user_id, ).single() if result.get('existing_relationship'): g.log.warn( 'Existing relationship found between profile and tenant during user invite', resources={ 'tenant_neo_type': tenant_neo_type, 'tenant_uuid': tenant.tenant_uuid, 'profile_type': profile_type, 'identity_id': identity_id, }, ) else: _clear_cache_for_profile( [{'profile_type': profile_type, 'profile_uuid': result.get('p.id')}] ) def get_label_profile_by_identity_and_tenant( tx: neo4j_lib.Transaction, identity_id: str, tenant: types.Tenant ) -> dict[str, str | int | list[str] | None] | None: """Return the label profile associated with the identity and tenant if it exists.""" tenant_neo_type = constants.TENANT_TYPE_TO_NEO_MAPPING[tenant.tenant_type.value] query = textwrap.dedent(f""" MATCH (i:Identity {{id: $identity_id}}) -[:HAS_PROFILE]->(p:Profile {{profileType: 'LabelProfile'}}) -[r:HAS_ACCESS_TO|DELETED_HAS_ACCESS_TO] ->(t:{tenant_neo_type} {{uuid: $tenant_uuid}}) RETURN p, r """) res = tx.run(query, identity_id=identity_id, tenant_uuid=tenant.tenant_uuid).single() if res and res.get('p') and res.get('r'): return dataclasses.asdict( types.ProfileInfoWithTenantRelationship( profile_id=res['p']['profileId'], profile_type=res['p']['profileType'], uuid=res['p']['uuid'], roles=res['p']['roles'], tenant_relationship=res['r'].type, ) ) def create_or_update_label_profile_with_tenant_relationship( tx: neo4j_lib.Transaction, identity_id: str, profile_id: int, roles: list[str], tenant: types.Tenant, audit_user_id: str, ) -> None: """Create a label profile with a given id and attach it to a tenant.""" tenant_neo_type = constants.TENANT_TYPE_TO_NEO_MAPPING[tenant.tenant_type.value] query = textwrap.dedent(f""" MATCH (t:{tenant_neo_type} {{uuid: $tenant_uuid}}) MATCH (i:Identity {{id: $identityId}}) OPTIONAL MATCH (p:Profile {{profileId: $profileId, profileType: $profileType}}) -[deleted_rel:DELETED_HAS_ACCESS_TO]->(t) MERGE (pNew:Profile {{profileId: $profileId, profileType: $profileType}}) ON CREATE SET pNew.profileName = coalesce($profileName, (i.name + $profileType)), pNew.roles = $roles, pNew.lastModifiedBy = $auditUser, pNew.lastModifiedAt = datetime(), pNew.uuid = randomUUID(), pNew.createdAt = datetime(), pNew.createdBy = $auditUser ON MATCH SET pNew.roles = $roles, pNew.lastModifiedBy = $auditUser, pNew.lastModifiedAt = datetime() MERGE (i)-[rel:HAS_PROFILE]->(pNew) SET rel.lastModifiedBy = $auditUser, rel.lastModifiedAt = datetime(), rel.createdAt = datetime(), rel.createdBy = $auditUser MERGE (pNew)-[r:HAS_ACCESS_TO]->(t) SET r.lastModifiedBy = $auditUser, r.lastModifiedAt = datetime(), r.createdAt = datetime(), r.createdBy = $auditUser DELETE deleted_rel """) tx.run( query, tenant_uuid=tenant.tenant_uuid, identityId=identity_id, profileId=profile_id, profileName=identity_id, profileType=constants.LABELPROFILE, roles=roles, auditUser=audit_user_id, ) def check_vendor_star_access_v2(identity_id: uuid.UUID, profile_id: int, profile_type: str) -> bool: """ Check if the identity and profile have vendor star access. This method is a simplified version of existing methods to check vendor star access. It just returns a bool and does not raise any exceptions. It will not inform the caller if the profile / identity does not exist. This can be used for Settings and Orch Suite profiles. Args: identity_id (str): Identity UUID. profile_id (int): Profile id. profile_type (str): Profile type. Returns: bool: True if the identity has vendor star access, False otherwise. """ with neo4j.db_session(access_mode=NEO4j_READ_ACCESS) as session: query = textwrap.dedent(""" MATCH (i:Identity {id: $identity_id})-[:HAS_PROFILE]-> (p:Profile {profileType: $profile_type, profileId: $profile_id, fullCatalogAccess: true}) OPTIONAL MATCH (p)-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(v:Vendor {id: '*'}) RETURN (v IS NOT NULL) AS vendorStarAccess """) record = session.run( query, identity_id=str(identity_id), profile_type=profile_type, profile_id=profile_id ).single() if record is None: has_vendor_star_access = False # No matching profile found else: has_vendor_star_access = record['vendorStarAccess'] return has_vendor_star_access