"""Model class for resource entity.""" import dataclasses import textwrap from copy import deepcopy import neo4j as neo4j_lib import pymysql import sqlalchemy from ddtrace import tracer from flask import g from owsresponse import response from pythonfeatures import pythonfeatures from pythonfeatures.constants import split as split_constants from permissions.connectors import mysql, neo4j from permissions.connectors.sentry import sentry_client from permissions.constants import constants, error 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, db_entities from permissions.utils.api_utils import to_snake def get_artists_for_labelparticipants(profile_type, profile_id): """GET artists for label participants connected to this profile. Args: profile_type (str): Type of profile. profile_id (int): Profile Node Identifier. Returns: Response: with a list of artists resources. """ with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = """MATCH (p:Profile)-[r:HAS_ACCESS_TO]->(lp:LabelParticipant) -[:CREATED_FROM]->(a:ArtistInfo) WHERE p.profileType = $profile_type AND p.profileId = $profile_id RETURN a as resource, apoc.coll.toSet(apoc.coll.flatten(collect(r.roles)+collect(p.roles))) as roles """ result = session.run(query, profile_id=profile_id, profile_type=profile_type) data = [] for each in result: data.append(db_entities.node_to_dict(each.get('resource'), roles=each.get('roles'))) return response.Response(data) def get_artists_for_lp_by_profile_uuid(profile_uuid): """GET artists for label participants connected to this profile. Args: profile_uuid (str): Profile UUID. Returns: Response: with a list of artists resources. """ with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = """MATCH (p:Profile)-[r:HAS_ACCESS_TO]->(lp:LabelParticipant) -[:CREATED_FROM]->(a:ArtistInfo) WHERE p.uuid = $profile_uuid RETURN a as resource, apoc.coll.toSet(apoc.coll.flatten(collect(r.roles)+collect(p.roles))) as roles """ result = session.run(query, profile_uuid=profile_uuid) data = [] for each in result: data.append(db_entities.node_to_dict(each.get('resource'), roles=each.get('roles'))) return data def get_resources_of_type_for_profile(profile_type, profile_id, resource_types=[]): """GET selected resource_type connected to this profile. Filters out parent company resources as they are not supported. Args: profile_type (str): Type of profile. profile_id (int): Profile Node Identifier. resource_types (list): List of resource types. Returns: Response: with a list of resource dict. """ with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: resource_query = 'true' if resource_types: resource_query = ' OR '.join([f'x:{name}' for name in resource_types]) query = textwrap.dedent(f""" MATCH (p:Profile)-[r:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(x) WHERE p.profileType = $profile_type AND p.profileId = $profile_id AND ({resource_query}) AND NOT x:ParentCompany with p, r, x OPTIONAL MATCH (x)<-[:OWNS]-(v:Vendor) RETURN x as resource, apoc.coll.toSet(apoc.coll.flatten(collect(r.roles)+collect(p.roles))) as roles, v.id as vendorId, TRIM(v.name) as vendorName """) result = session.run(query, profile_id=profile_id, profile_type=profile_type) data = [] for each in result: resource = db_entities.node_to_dict(each.get('resource'), roles=each.get('roles')) # Always return Subaccount instead of SubAccount. if resource['type'] == constants.SUBACCOUNT_NEO4J_RESOURCE_TYPE: resource['type'] = constants.SUBACCOUNT_RESOURCE_TYPE if each.get('vendorId'): resource['vendor_id'] = each.get('vendorId') resource['vendor_name'] = each.get('vendorName') data.append(resource) return response.Response(data) def get_resources_of_type_for_profile_uuid(profile_uuid, resource_types=[]): """GET selected resource_type connected to this profile. Filters out parent company resources as they are not supported. Args: profile_uuid (str): Profile UUID. resource_types (list): List of resource types to return. If [] then it will return all. Returns: Response: with a list of resource dict. """ if len(resource_types) > 0: resource_labels = ' OR '.join([f'x:{name}' for name in resource_types]) resource_query = f'AND ({resource_labels})' else: resource_query = '' with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = textwrap.dedent(f""" MATCH (p:Profile)-[r:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(x) WHERE p.uuid = $profile_uuid {resource_query} AND NOT x:ParentCompany with p, r, x OPTIONAL MATCH (x)<-[:OWNS]-(v:Vendor) RETURN x as resource, apoc.coll.toSet(apoc.coll.flatten(collect(r.roles)+collect(p.roles))) as roles, v.id as vendorId, TRIM(v.name) as vendorName """) result = session.run(query, profile_uuid=profile_uuid) data = [] for each in result: resource = db_entities.node_to_dict(each.get('resource'), roles=each.get('roles')) # Always return Subaccount instead of SubAccount. if resource['type'] == constants.SUBACCOUNT_NEO4J_RESOURCE_TYPE: resource['type'] = constants.SUBACCOUNT_RESOURCE_TYPE if each.get('vendorId'): resource['vendor_id'] = each.get('vendorId') resource['vendor_name'] = each.get('vendorName') data.append(resource) return data def get_resources_for_identity_uuid(identity_uuid, limit, offset): """GET resources that this identity has admin access to. Args: identity_uuid (str): Identity UUID. Returns: paginated list of adminable resources, total number of resources """ with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: filter_x_types = ' OR '.join( [f'x:{resource_type}' for resource_type in constants.PP_SUPPORTED_RESOURCE_TYPES] ) query = f"""MATCH (i:Identity)-[hp:HAS_PROFILE]->(p:Profile) -[r:HAS_ADMIN_ACCESS_TO]->(x) WHERE p.profileType = "SettingsProfile" AND i.id = $identity_uuid AND ({filter_x_types}) """ count_query = f"""{query} RETURN COUNT(x) AS total """ data_query = f"""{query} RETURN x AS resource ORDER BY resource.uuid SKIP $offset LIMIT $limit """ total_records_result = session.run(count_query, identity_uuid=identity_uuid) total_records = total_records_result.single() if not total_records: return response.create_fatal_response(error.MESSAGE_GET_RESOURCES) result = session.run(data_query, identity_uuid=identity_uuid, limit=limit, offset=offset) data = [] for each in result: resource = db_entities.node_to_dict(each.get('resource')) data.append(resource) return data, total_records[0] def get_resources_for_profile(profile_type, profile_id, resource_type=None): """GET first degree connected resources for this profile id. Args: profile_type (str): Type of profile. profile_id (int): Profile Node Identifier. resource_type (str): Optionally return resources of type resource_type. Returns: Response: with a list of resource dict. """ resource_type_check = '' if resource_type: resource_type_check = f' x:{resource_type} AND ' with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = f"""MATCH (p:Profile)-[r:HAS_ACCESS_TO]->(x) WHERE {resource_type_check} p.profileType = $profile_type AND p.profileId = $profile_id WITH x,r OPTIONAL MATCH (x)<-[:OWNS]-(v:Vendor) RETURN x as resource, apoc.coll.toSet(apoc.coll.flatten(collect(r.roles))) as roles, v.id as vendor_id """ result = session.run(query, profile_id=profile_id, profile_type=profile_type) data = [] for each in result: resource = api_utils.to_snake(dict(each.get('resource').items())) resource['roles'] = each.get('roles') if each.get('vendor_id'): # to match SubAccount AR response. resource['vendor_id'] = each.get('vendor_id') valid_resource_types = list( set(constants.NEO4J_RESOURCE_TYPES) & each.get('resource').labels ) # noqa if valid_resource_types: resource['type'] = valid_resource_types.pop() data.append(api_utils.to_serializable_dict(resource)) return response.Response(data) def get_node(node_type, node_id, has_writes=False): """Fetch a matching resource node. Args: node_type (str): Label for graph node. node_id (str): id attribute on graph node. has_writes (bool): Has write operations later in the execution. Returns: Response: with a dict of node details. """ access_mode = constants.NEO4j_READ_ACCESS if has_writes: access_mode = constants.NEO4j_WRITE_ACCESS if node_type.lower() not in constants.RESOURCE_TO_NODE_MAPPING.keys(): return response.create_fatal_response('Invalid Resource type') node_type = constants.RESOURCE_TO_NODE_MAPPING[node_type.lower()] node_id = db_entities.cast_numeric_string_to_int(node_id) with neo4j.db_session(access_mode=access_mode) as session: query = f"""MATCH (a:{node_type}) WHERE a.id = $node_id RETURN a as resource""" result = session.run(query, node_id=node_id) data = result.single() if not data or not data.get('resource'): return response.create_not_found_response('Resource not found.') return response.Response(db_entities.node_to_dict(data.get('resource'))) def get_resources_brand(resources): """Get the brand related to this resource or resources. Args: resource(dict): dictionary containing resource info Returns: resource(dict): edited resource dictionary with brand property added. """ get_brands = deepcopy(resources) with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = """UNWIND $resources as res RETURN CASE res.resource_type WHEN 'LabelParticipant' THEN [(l:LabelParticipant{uuid:res.uuid})<-[:HAS_LABEL_PARTICIPANT]-(v:Vendor) <-[:HAS_LABEL]-(cb:CompanyBrand)| {companyBrand: cb.name}] WHEN 'Vendor' THEN [(v:Vendor{uuid:res.uuid})<-[:HAS_LABEL]-(cb:CompanyBrand) | {companyBrand: cb.name, vendorId: v.vendorId}] WHEN 'Subaccount' THEN [(s:Subaccount{uuid:res.uuid})<-[:OWNS]-(v:Vendor)<-[:HAS_LABEL]-(cb:CompanyBrand) | {companyBrand: cb.name, vendorId: v.vendorId}] WHEN 'Collaborator' THEN [(c:Collaborator{uuid:res.uuid})<-[:OWNS]-(v:Vendor)<-[:HAS_LABEL]-(cb:CompanyBrand) | {companyBrand: cb.name}] END AS result;""" record = list(session.run(query, resources=resources)) for resource, record in zip(get_brands, record): if len(record['result']) == 0: return response.create_not_found_response( 'Missing resource information. Could not assign resource access.' ) resource['brand'] = record['result'][0]['companyBrand'] resource['vendor_id'] = record['result'][0].get('vendorId') return get_brands def soft_delete_profile_to_resource_relationship(schema): """Delete existing relationship profile and a resource. Args: schema (dict): Schema object. Returns: Response: empty response with status 204. """ relationship_name = constants.PROFILE_TO_RESOURCE_RELATIONSHIP with neo4j.db_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: soft_delete_query = f"""MATCH (p:Profile)-[rel:{relationship_name}]-> (r:{schema.get('resource_type')}) WHERE p.profileType = $profile_type AND p.profileId = $profile_id AND r.id = $resource_id SET rel.dateDeleted = localdatetime() WITH rel CALL apoc.refactor.setType(rel, 'DELETED_{relationship_name}') YIELD input, output RETURN input, output""" result = session.run(soft_delete_query, **schema) data = result.single() if not data or not data.get('input') or not data.get('output'): return response.create_fatal_response(error.ERROR_MESSAGE_DELETE_RELATIONSHIP_FAILED) return response.Response(status=204) def soft_delete_profile_uuid_to_resource_relationship(schema): """Delete existing relationship profile and a resource. Args: schema (dict): Schema object. Returns: Response: empty response with status 204. """ relationship_name = constants.PROFILE_TO_RESOURCE_RELATIONSHIP with neo4j.db_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: soft_delete_query = f"""MATCH (p:Profile)-[rel:{relationship_name}]-> (r:{schema.get('resource_type')}) WHERE p.uuid = $profile_uuid AND r.id = $resource_id SET rel.dateDeleted = localdatetime() WITH rel CALL apoc.refactor.setType(rel, 'DELETED_{relationship_name}') YIELD input, output RETURN input, output""" result = session.run(soft_delete_query, **schema) data = result.single() if not data or not data.get('input') or not data.get('output'): return response.create_fatal_response(error.ERROR_MESSAGE_DELETE_RELATIONSHIP_FAILED) return response.Response(status=204) def create_node(node_type, node_id, properties={}): """Create a resource node. Args: node_type (str): Label for graph node. node_id (str): id attribute on graph node. properties (dict): additional attributes for graph node. Returns: Response: with a dict of node properties. """ if node_type.lower() not in constants.RESOURCE_TO_NODE_MAPPING.keys(): return response.create_fatal_response('Invalid Resource type') node_type = constants.RESOURCE_TO_NODE_MAPPING[node_type.lower()] node_id = db_entities.cast_numeric_string_to_int(node_id) properties['id'] = node_id with neo4j.db_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: query = f"""CREATE (a:{node_type}) SET a = $properties RETURN a as resource""" result = session.run(query, properties=properties) data = result.single() if not data or not data.get('resource'): return response.create_fatal_response('Failed to create resource') return response.Response(db_entities.node_to_dict(data.get('resource'))) def edit_full_catalog_access(profile_id, profile_type, has_full_catalog_access): """Add fullCatalogAccess property to Profile node. Args: profile_type (str): Label for graph node. profile_id (str): id attribute on graph node. has_full_access (bool): True if this profile has full catalog access. Returns: Response: with a dict of node properties. """ with neo4j.db_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: query = """MATCH (p:Profile {profileType: $profile_type, profileId: $profile_id}) SET p.fullCatalogAccess = $access RETURN p""" result = session.run( query, profile_type=profile_type, profile_id=profile_id, access=has_full_catalog_access ) data = result.single() if not data or not data.get('p'): return response.create_fatal_response('Failed to edit fullCatalogAccess to profile.') return response.Response(db_entities.node_to_dict(data.get('p'))) def delete_node(node_type, node_id): """Delete a resource node. Args: node_type (str): Label for graph node. node_id (str): id attribute on graph node. Returns: Response: with a dict of node properties. """ with neo4j.db_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: query = f"""Match (a:{node_type}) WHERE a.id = $node_id DETACH DELETE a""" session.run(query, node_id=node_id) return response.Response(status=204) def create_profile_to_resource_relationship( add_schema: dict, admin_id: str, set_full_catalog_access: bool = False, ): """Create relationship between 2 existing profile and a resource. Args: add_schema (dict): Schema object. Returns: Response: with dict containing both node's details. """ relationship_name = constants.PROFILE_TO_RESOURCE_RELATIONSHIP with neo4j.db_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: undo_query = f"""MATCH (p:Profile)-[r:DELETED_{relationship_name}]-> (a:{add_schema.get('resource_type')}) WHERE p.profileType = $profile_type AND p.profileId = $profile_id AND a.id = $resource_id CALL apoc.refactor.setType(r, '{relationship_name}') YIELD input,output RETURN output""" session.run(undo_query, **add_schema) query = f"""MATCH (p:Profile) MATCH(r:{add_schema.get('resource_type')}) WHERE p.profileType = $profile_type AND p.profileId = $profile_id AND r.id = $resource_id MERGE (p)-[rel:{relationship_name}]->(r) SET rel.roles = $roles, rel.createdAt = datetime() {", p.fullCatalogAccess = true" if set_full_catalog_access else ""} RETURN rel""" result = session.run(query, **add_schema) data = result.single() if not data or not data.get('rel'): return response.create_fatal_response(error.ERROR_MESSAGE_CREATE_RELATIONSHIP_FAILED) g.log.info( 'Successfully added resource to profile', resources={ 'resource_type': add_schema.get('resource_type'), 'resource_id': add_schema.get('resource_id'), 'profile_id': add_schema.get('profile_id'), 'profile_type': add_schema.get('profile_type'), 'admin_identity_id': admin_id, }, ) return response.Response(message=add_schema, status=201) def create_profile_uuid_to_resource_relationship( add_schema: dict, admin_id: str, set_full_catalog_access: bool = False ): """Create relationship between 2 existing profile and a resource. Args: add_schema (dict): Schema object. Returns: Response: with dict containing both node's details. """ relationship_name = constants.PROFILE_TO_RESOURCE_RELATIONSHIP with neo4j.db_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: undo_query = f"""MATCH (p:Profile)-[r:DELETED_{relationship_name}]-> (a:{add_schema.get('resource_type')}) WHERE p.uuid = $profile_uuid AND a.id = $resource_id CALL apoc.refactor.setType(r, '{relationship_name}') YIELD input,output RETURN output""" session.run(undo_query, **add_schema) query = f"""MATCH (p:Profile) MATCH(r:{add_schema.get('resource_type')}) WHERE p.uuid = $profile_uuid AND r.id = $resource_id MERGE (p)-[rel:{relationship_name}]->(r) SET rel.roles = $roles, rel.createdAt = datetime() {", p.fullCatalogAccess = true" if set_full_catalog_access else ""} RETURN rel""" result = session.run(query, **add_schema) data = result.single() if not data or not data.get('rel'): return response.create_fatal_response(error.ERROR_MESSAGE_CREATE_RELATIONSHIP_FAILED) g.log.info( 'Successfully added resource to profile', resources={ 'resource_type': add_schema.get('resource_type'), 'resource_id': add_schema.get('resource_id'), 'profile_id': add_schema.get('profile_id'), 'profile_type': add_schema.get('profile_type'), 'admin_identity_id': admin_id, }, ) return response.Response(message=add_schema, status=201) def get_all_user_profiles_for_admin( identity_id, profile_types=None, limit=constants.DEFAULT_USERS_LIMIT, offset=constants.DEFAULT_OFFSET, search_term=None, label_participants=None, active=None, pending=None, resource_access=None, parent_vendor_filter=None, include_subaccount_users=True, include_requester=False, ): """GET all profiles that an identity has admin access to. Args: identity_id (str): Identity Identifier (ex. auth0 user id, uuid) profile_types (list): Optional. Profile Type (ex. LabelProfile, InsightsProfile) limit (int): Optional. Limit number of records. Default 50. offset (int): Optional. Offset result set. Default 0. search_term (str): Optional. search term. Search is case-insensitive. label_participants (list): Optional. label_participants ids. active (str): Optional. active Y, N or None. pending (str): Optional. awaiting auth0 invite acception Y, N or None. resource_access (list): Optional. Resource uuids. parent_vendor_filter (list[str]): Optional. Filter results to parent vendor uuids. include_subaccount_users (bool): Optional. Whether to include subaccount users for D3 admins when "resource_access" is specified. Default is True. include_requester (bool): Optional. Whether to include the requesting user from the list of profiles returned. Default is False. Returns: Response: with a list of resource ids. """ where_condition = ['i.id = $identity_id'] if not include_requester: where_condition.append('others.id <> $identity_id') if profile_types: where_condition.append('up.profileType in $profile_types') if search_term: search_term = ' '.join(search_term.lower().split()) search_condition = ( '(toLower(others.email) CONTAINS $search_term OR ' 'toLower(others.name) CONTAINS $search_term OR ' 'toLower(others.firstName) CONTAINS $search_term OR ' 'toLower(others.lastName) CONTAINS $search_term OR ' 'toLower(others.firstName + " " + others.lastName) CONTAINS $search_term OR ' 'toLower(others.lastName + " " + others.firstName) CONTAINS $search_term )' ) where_condition.append(search_condition) if label_participants: where_condition.append('x.uuid IN $label_participants AND "LabelParticipant" IN labels(x)') if resource_access: where_condition.append('(x:LabelParticipant OR x:Vendor OR x:Subaccount OR x:Collaborator)') where_condition.append('x.uuid IN $resource_access') if parent_vendor_filter: where_condition.append('r:Vendor') where_condition.append('r.uuid IN $parent_vendor_filter') if pending == 'Y': where_condition.append('others.id = others.auth0UserId') elif pending == 'N': where_condition.append('NOT others.id = others.auth0UserId') if active == 'N': rel_type = ':DELETED_HAS_ACCESS_TO' where_condition.append('others.active = $active') elif active == 'Y': rel_type = ':HAS_ACCESS_TO' where_condition.append('others.active = $active') else: rel_type = ':HAS_ACCESS_TO' with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: # Traversal includes subaccount users for D3 admins. # A deactivated user should have only DELETED_HAS_ACCESS_TO # For an active user label admin and super admin should see only active access # and only access with HAS_ACCESS_TO relationship should be reverted query = f"""MATCH (i:Identity)-[:HAS_PROFILE]->(ap:Profile)- [:HAS_ADMIN_ACCESS_TO]->(r)-[*0..1]->(x)<-[{rel_type}]-(up:Profile)<-[:HAS_PROFILE]-(others:Identity) WHERE ap.profileType = 'SettingsProfile' AND {' AND '.join(where_condition)} WITH x, ap, collect(others) as rows """ if include_subaccount_users: query += f""" OPTIONAL MATCH (x)-[:OWNS]->(s:SubAccount)<-[{rel_type}]- (up2:Profile)<-[:HAS_PROFILE]-(others2:Identity) WHERE up2.profileType = ap.profileType WITH rows, collect(others2) as allOthers WITH rows + allOthers as allRows UNWIND allRows as row """ else: query += """ WITH rows as allRows UNWIND allRows as row """ data_query = f"""{query} RETURN DISTINCT row SKIP $offset LIMIT $limit """ count_query = f"""{query} RETURN count(distinct(row)) as total """ result = session.run( count_query, identity_id=identity_id, profile_types=profile_types, active=active, search_term=search_term, label_participants=label_participants, resource_access=resource_access, parent_vendor_filter=parent_vendor_filter, ) count_result = result.single() if not count_result: return response.create_fatal_response(error.MESSAGE_GET_IDENTITIES) if not count_result.get('total') or count_result.get('total') < 1: return response.Response({'total': count_result.get('total'), 'data': []}) # data result result = session.run( data_query, identity_id=identity_id, profile_types=profile_types, active=active, search_term=search_term, offset=offset, limit=limit, label_participants=label_participants, resource_access=resource_access, parent_vendor_filter=parent_vendor_filter, ) identities = [] for each in result: display_pending = False if each.get('row').get('auth0UserId') == each.get('row').get('id') and each.get( 'row' ).get('createdAt') == each.get('row').get('updatedOn'): display_pending = True identities.append( { 'lastName': each.get('row').get('lastName'), 'firstName': each.get('row').get('firstName'), 'name': each.get('row').get('name'), 'googleUserId': each.get('row').get('googleUserId'), 'active': each.get('row').get('active'), 'id': each.get('row').get('id'), 'auth0UserId': each.get('row').get('auth0UserId'), 'email': each.get('row').get('email'), 'defaultBrand': each.get('row').get('defaultBrand'), 'pending': display_pending, } ) return response.Response({'total': count_result.get('total'), 'data': identities}) @tracer.wrap('get_all_profiles_with_artist_access', service='neo4j') def get_all_profiles_with_artist_access( admin_identity_id, profile_types, limit=constants.DEFAULT_USERS_LIMIT, offset=constants.DEFAULT_OFFSET, search_term=None, label_participants=None, active=None, pending=None, resource_access=None, feature_flag=False, settings_support_profiles=constants.SETTINGS_SUPPORT_MAPPING['profileTypes'], ): """GET all identities. Note: This is called for super admin so no access check required. Args: admin_identity_id (str): Admin's Identity Identifier uuid profile_types (list): Optional. Profile Types (ex. LabelProfile, InsightsProfile) limit (int): Optional. Limit number of records. Default 50. offset (int): Optional. Offset result set. Default 0. search_term (str): Optional. search term. Search is case-insensitive. label_participants (list): Optional. resource_access (list): Optional. Resource uuids. active (str): Optional. Y or N pending (str): Optional. Y or N settings_support_profiles (list): List of supported profile types. Returns: Response: with a list of profiles and identities. """ # on feature_flag teardown, replace this with the default ELSE select select = 'MATCH (i:Identity)' # we don't need the extra LabelParticipant check or traverse up to x where_condition = ['true'] if pending == 'Y': where_condition.append('i.id = i.auth0UserId') elif pending == 'N': where_condition.append('NOT i.id = i.auth0UserId') if active == 'N': rel_type = ':DELETED_HAS_ACCESS_TO' where_condition.append('i.active = $active') elif active == 'Y': rel_type = ':HAS_ACCESS_TO' where_condition.append('i.active = $active') else: rel_type = ':HAS_ACCESS_TO' if not feature_flag: if label_participants or resource_access: select = f"MATCH (i:Identity)-[r:HAS_PROFILE]->(up:Profile)-[{rel_type}]->(x), (v:Vendor {{id: '*'}})" # noqa where_condition.append( "NOT EXISTS((i)-[:HAS_PROFILE]->(:Profile {profileType:'SettingsProfile'})-[:HAS_ADMIN_ACCESS_TO]->(v))" ) # noqa elif profile_types: select = "MATCH (i:Identity)-[:HAS_PROFILE]->(up:Profile), (v:Vendor {id: '*'}) WITH i, up, v" # noqa where_condition.append( "NOT EXISTS((i)-[:HAS_PROFILE]->(:Profile {profileType:'SettingsProfile'})-[:HAS_ADMIN_ACCESS_TO]->(v))" ) # noqa else: select = "MATCH (i:Identity)-[:HAS_PROFILE]->(ps:Profile {profileType:'SettingsProfile'}), (v:Vendor {id: '*'}) WITH i, ps, v" # noqa where_condition.append('NOT EXISTS((ps)-[:HAS_ADMIN_ACCESS_TO]->(v))') else: if label_participants or resource_access: select = f'MATCH (i:Identity)-[:HAS_PROFILE]->(up:Profile)-[{rel_type}]->(x)' # noqa elif profile_types: select = 'MATCH (i:Identity)-[:HAS_PROFILE]->(up:Profile)' # noqa if profile_types: where_condition.append('up.profileType in $profile_types') else: if label_participants or resource_access: where_condition.append('up.profileType in $settings_support_profiles') if search_term: search_term = ' '.join(search_term.lower().split()) search_condition = ( '(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 )' ) where_condition.append(search_condition) if label_participants: where_condition.append('x:LabelParticipant') where_condition.append('x.uuid IN $label_participants') if resource_access: where_condition.append('(x:LabelParticipant OR x:Vendor OR x:Subaccount OR x:Collaborator)') where_condition.append('x.uuid IN $resource_access') with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = f"""{select} WHERE {' AND '.join(where_condition)}""" data_query = f"""{query} RETURN DISTINCT i as identity SKIP $offset LIMIT $limit""" count_query = f"""{query} RETURN count(distinct(i)) as total""" result = session.run( count_query, profile_types=profile_types, active=active, admin_id=admin_identity_id, search_term=search_term, label_participants=label_participants, resource_access=resource_access, settings_support_profiles=settings_support_profiles, ) count_result = result.single() if not count_result: return response.create_fatal_response(error.MESSAGE_GET_IDENTITIES) if not count_result.get('total') or count_result.get('total') < 1: return response.Response({'total': count_result.get('total'), 'data': []}) # data result result = session.run( data_query, profile_types=profile_types, active=active, admin_id=admin_identity_id, search_term=search_term, label_participants=label_participants, offset=offset, limit=limit, resource_access=resource_access, settings_support_profiles=settings_support_profiles, ) identities = [] for each in result: display_pending = False identity = dict(each.get('identity', {})) if identity.get('auth0UserId') == identity.get('id'): display_pending = True identity['pending'] = display_pending identities.append(identity) return response.Response({'total': count_result.get('total'), 'data': identities}) @tracer.wrap('get_users_resources_by_type', service='neo4j') def get_users_resources_by_type( admin_context, identity_id, resource_type, limit=constants.DEFAULT_LIMIT, offset=constants.DEFAULT_OFFSET, active=True, ): """GET resources that identity_id has direct (DELETED_)HAS_ACCESS_TO and admin can administer. Note: This admin has selected access, so try and traverse from admin access nodes to users. Args: admin_context (dict): Admin's context data. identity_id (str): Users identity id. resource_type (str): Type of resource. limit (int): Optional. Limit number of records. Default 200. offset (int): Optional. Offset result set. Default 0. active (bool): Optional. Users active state. Default True. Return: Response: containing dict of resources or error message. """ if active: access_type = 'HAS_ACCESS_TO' else: access_type = 'DELETED_HAS_ACCESS_TO' with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = f"""MATCH (admin:Identity)-[:HAS_PROFILE]->(ap:Profile)-[:HAS_ACCESS_TO]-> (r)-[*0..1]->(x:{resource_type})<-[:{access_type}]-(up:Profile)<-[:HAS_PROFILE]-(user:Identity) WHERE admin.id = $adminIdentityId AND 'administrator' IN ap.roles AND ap.profileType = $adminProfileType AND ap.profileId = $adminProfileId AND user.id = $userIdentityId AND up.profileType = $adminProfileType WITH x, up OPTIONAL MATCH (x)<-[:OWNS]-(v:Vendor) """ # noqa for max-len data_query = f"""{query} RETURN DISTINCT x, v.id as vendorId, TRIM(v.name) as vendorName, apoc.coll.toSet(apoc.coll.flatten(collect( CASE WHEN up.profileType = "InsightsProfile" and (x:Vendor OR x:Subaccount) THEN [] ELSE up.roles END))) as roles, apoc.coll.max(collect(up.updatedOn)) as updatedOn SKIP $offset LIMIT $limit """ # noqa for max-len count_query = f"""{query} RETURN count(DISTINCT x) as total """ # count result params = dict( adminIdentityId=admin_context['identity_id'], adminProfileType=admin_context['profile_type'], adminProfileId=admin_context['profile_id'], userIdentityId=identity_id, offset=offset, limit=limit, ) result = session.run(count_query, **params) count_result = result.single() if not count_result: return response.create_fatal_response(error.MESSAGE_GET_RESOURCES) if not count_result.get('total') or count_result.get('total') < 1: return response.Response({'total': count_result.get('total'), 'data': []}) # data result result = session.run(data_query, **params) resources = [] for each in result: resource = db_entities.node_to_dict(each.get('x')) resource['profileAccess'] = { 'roles': each.get('roles'), 'updatedOn': each.get('updatedOn'), } if each.get('vendorId'): resource['vendorId'] = each.get('vendorId') resource['vendorName'] = each.get('vendorName') resources.append(api_utils.to_snake(resource)) return response.Response({'total': count_result.get('total'), 'data': resources}) @tracer.wrap('get_users_resources_for_settings_by_type', service='neo4j') def get_users_resources_for_settings_by_type( admin_context, identity_id, profile_types, resource_type, limit=constants.DEFAULT_LIMIT, offset=constants.DEFAULT_OFFSET, active=True, ): """GET resources that identity_id has direct HAS_ACCESS_TO and the admin can administer. Note: This settings admin has selected access, so try and traverse from admin to user. Args: admin_context (dict): Admin's context data. identity_id (str): Users identity id. profile_types (list): Type of profile types. resource_type (str): Type of resource. limit (int): Optional. Limit number of records. Default 200. offset (int): Optional. Offset result set. Default 0. active (bool): Optional. Users active state. Default True. Return: Response: containing dict of resources or error message. """ if active: access_type = 'HAS_ACCESS_TO' else: access_type = 'DELETED_HAS_ACCESS_TO' with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = f"""MATCH (admin:Identity)-[:HAS_PROFILE]->(sp:Profile {{profileType: 'SettingsProfile'}}) -[:HAS_ADMIN_ACCESS_TO]->(r)-[*0..1]->(x:{resource_type})<-[:{access_type}]-(up:Profile)<-[:HAS_PROFILE]-(user:Identity) USING JOIN ON r WHERE admin.id = $adminIdentityId AND sp.profileId = $adminProfileId AND user.id = $userIdentityId AND up.profileType IN $profileTypes WITH x,up, user OPTIONAL MATCH (x)<-[:OWNS]-(v:Vendor) """ # noqa for max-len data_query = f"""{query} RETURN DISTINCT x, v.id as vendorId, TRIM(v.name) as vendorName, apoc.coll.toSet(apoc.coll.flatten(collect( CASE WHEN up.profileType = "InsightsProfile" and (x:Vendor OR x:Subaccount) THEN [] WHEN (x)<-[:HAS_ADMIN_ACCESS_TO]-(:Profile {{profileType: 'SettingsProfile'}}) <-[:HAS_PROFILE]-(user) THEN ['administrator'] ELSE up.roles END))) as roles, apoc.coll.max(collect(up.updatedOn)) as updatedOn SKIP $offset LIMIT $limit """ count_query = f"""{query} RETURN count(DISTINCT x) as total """ # count result params = dict( adminIdentityId=admin_context['identity_id'], adminProfileType=admin_context['profile_type'], adminProfileId=admin_context['profile_id'], profileTypes=profile_types, userIdentityId=identity_id, offset=offset, limit=limit, ) result = session.run(count_query, **params) count_result = result.single() if not count_result: return response.create_fatal_response(error.MESSAGE_GET_RESOURCES) if not count_result.get('total') or count_result.get('total') < 1: return response.Response({'total': count_result.get('total'), 'data': []}) # data result result = session.run(data_query, **params) resources = [] for each in result: resource = db_entities.node_to_dict(each.get('x')) resource['profileAccess'] = { 'roles': each.get('roles'), 'updatedOn': each.get('updatedOn'), } if each.get('vendorId'): resource['vendorId'] = each.get('vendorId') resource['vendorName'] = each.get('vendorName') resources.append(api_utils.to_snake(resource)) return response.Response({'total': count_result.get('total'), 'data': resources}) @tracer.wrap('get_vend_star_for_admin', service='neo4j') def get_vend_star_for_admin( identity_id: str, profile_type: str, profile_id: int ) -> response.Response: """GET vendor star for identity. Only returns the resource if the identity_id's SettingsProfile HAS_ADMIN_ACCESS_TO vendor *. # TODO: remove unneeded profile_type and profile_id params """ with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: result = get_vendor_star_for_admin_by_identity_id(identity_id=identity_id, session=session) if not result: return response.create_not_found_response(error.ERROR_MESSAGE_FORBIDDEN_USER) vend_star = db_entities.node_to_dict(result.get('resource')) vend_star['profile_access'] = {'roles': [constants.ADMINISTRATOR_ROLE]} return response.Response({'total': len(result), 'data': [vend_star]}) def get_vendor_star_for_admin_by_identity_id( identity_id: str, session: neo4j_lib.Session ) -> neo4j_lib.Record | None: """Return a neo4j record indicating the identity has access to vendor * or None.""" query = textwrap.dedent(""" MATCH (i:Identity)-[:HAS_PROFILE]->(sp:Profile)-[:HAS_ADMIN_ACCESS_TO]->(r:Vendor {id: '*'}) WHERE i.id = $adminIdentityId AND sp.profileType = 'SettingsProfile' RETURN r as resource """) return session.run(query, adminIdentityId=identity_id).single() @tracer.wrap('get_user_vend_star', service='neo4j') def get_user_vend_star(admin_context, identity_id): """GET vendor star for identity. Only returns the resource if both the admin and the identity whos resources the admin is requesting HAVE_ADMIN_ACCESS_TO vendor *. """ if admin_context['profile_type'] != constants.SETTINGSPROFILE: return response.create_error_response( error.ERROR_CODE_AUTHORIZATION, error.ERROR_MESSAGE_INVALID_PROFILE ) allowed_profile_types = [constants.INSIGHTSPROFILE] is_songwhip_enabled_for_user = ( pythonfeatures.get_single_feature_by_attributes( 'orchard_suite_show_songwhip_app', {'identity_id': identity_id} ).message == split_constants.FEATURE_ENABLED ) if is_songwhip_enabled_for_user: allowed_profile_types.append(constants.SONGWHIPPROFILE) with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = """MATCH (admin:Identity {id: $adminIdentityId})-[:HAS_PROFILE] ->(sp:Profile {profileId: $adminProfileId}) -[:HAS_ADMIN_ACCESS_TO]->(r:Vendor {id: '*'}) WITH admin, sp, r MATCH (r:Vendor {id: '*'})<-[rel:HAS_ADMIN_ACCESS_TO]- (up:Profile {profileType: 'SettingsProfile'})<-[:HAS_PROFILE]- (user:Identity {id: $userIdentityId}) RETURN r as resource, type(rel) as resource_relationship, up.profileType as profile_type UNION MATCH (admin:Identity {id: $adminIdentityId})-[:HAS_PROFILE] ->(sp:Profile {profileId: $adminProfileId}) -[:HAS_ADMIN_ACCESS_TO]->(r:Vendor {id: '*'}) WITH admin, sp, r MATCH (r:Vendor {id: '*'})<-[rel:HAS_ACCESS_TO]-(up:Profile) <-[:HAS_PROFILE]-(user:Identity {id: $userIdentityId}) WHERE up.profileType IN $allowedProfileTypes RETURN r as resource, type(rel) as resource_relationship, up.profileType as profile_type""" params = dict( adminIdentityId=admin_context['identity_id'], adminProfileType=admin_context['profile_type'], adminProfileId=admin_context['profile_id'], userIdentityId=identity_id, allowedProfileTypes=allowed_profile_types, ) result = session.run(query, **params).single() if not result: return response.create_not_found_response(error.ERROR_MESSAGE_FORBIDDEN_USER) vend_star = db_entities.node_to_dict(result.get('resource')) access_role = result.get('resource_relationship') profile_type = result.get('profile_type') if access_role == constants.ADMIN_RESOURCE_RELATIONSHIP: roles = [constants.ADMINISTRATOR_ROLE] else: roles = constants.PROFILE_TYPE_TO_ROLES_MAPPING[profile_type] vend_star['profile_access'] = {'roles': roles} return response.Response({'total': 1, 'data': [vend_star]}) @tracer.wrap('get_user_resources_for_admin', service='neo4j') def get_user_resources_for_admin( identity_id, profile_types, resource_type, limit=constants.DEFAULT_LIMIT, offset=constants.DEFAULT_OFFSET, active=True, ): """GET resources that identity_id has direct HAS_ACCESS_TO/DELETED_HAS_ACCESS_TO for admin. Args: identity_id (str): Users identity id. profile_types (list): Profile type. resource_type (str): Type of resource. limit (int): Optional. Limit number of records. Default 200. offset (int): Optional. Offset result set. Default 0. active (bool): Optional. Users active state. Default True. Return: Response: containing dict of resources or error message. """ if active: access_type = 'HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO' else: access_type = 'DELETED_HAS_ACCESS_TO' with neo4j.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = f"""MATCH (user:Identity)-[:HAS_PROFILE]->(up:Profile)-[rel:{access_type}]->(x:{resource_type}) WHERE user.id = $userIdentityId AND up.profileType IN $profileTypes OPTIONAL MATCH (x)<-[:OWNS]-(v:Vendor) """ # noqa for max-len data_query = f"""{query} RETURN DISTINCT x, v.id as vendorId, TRIM(v.name) as vendorName, apoc.coll.toSet(apoc.coll.flatten(collect( CASE WHEN up.profileType = "InsightsProfile" AND (x:Vendor OR x:Subaccount) THEN ["analytics"] WHEN up.profileType = "SettingsProfile" AND type(rel)= "HAS_ADMIN_ACCESS_TO" THEN ["administrator"] ELSE up.roles END))) as roles, apoc.coll.max(collect(up.updatedOn)) as updatedOn SKIP $offset LIMIT $limit """ # noqa for max-len count_query = f"""{query} RETURN count(DISTINCT x) as total """ # count result params = dict( userIdentityId=identity_id, profileTypes=profile_types, offset=offset, limit=limit, active=active, ) result = session.run(count_query, **params) count_result = result.single() if not count_result: return response.create_fatal_response(error.MESSAGE_GET_RESOURCES) if not count_result.get('total') or count_result.get('total') < 1: return response.Response({'total': count_result.get('total'), 'data': []}) # data result result = session.run(data_query, **params) resources = [] for each in result: resource = db_entities.node_to_dict(each.get('x')) resource['profileAccess'] = { 'roles': each.get('roles'), 'updatedOn': each.get('updatedOn'), } if each.get('vendorId'): resource['vendorId'] = each.get('vendorId') resource['vendorName'] = each.get('vendorName') resources.append(api_utils.to_snake(resource)) return response.Response({'total': count_result.get('total'), 'data': resources}) def _deactivate_resources_common_with_admin(tx, admin_context, identity_id, profile_types): """Deactivate resources that user has direct HAS_ACCESS_TO and the admin can administer.""" query = """MATCH (admin:Identity)-[:HAS_PROFILE]->(ap:Profile)-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(r) WHERE admin.id = $adminIdentityId AND ap.profileType = $adminProfileType AND ap.profileId = $adminProfileId AND NOT r:ParentCompany CALL apoc.when( r.id = '*', // This admin has all access, so return all resources that user can access. 'MATCH (user:Identity)-[:HAS_PROFILE]->(up:Profile)-[rel:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(x) WHERE user.id = userIdentityId AND up.profileType in profileTypes RETURN rel, up.profileId as pId, up.profileType as pType' , // This admin has selected access, so try and traverse from admin access nodes to users. 'MATCH (r)-[*0..1]->(x)<-[rel:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]-(up:Profile)<-[:HAS_PROFILE]-(user:Identity) WHERE user.id = userIdentityId AND up.profileType in profileTypes RETURN rel, up.profileId as pId, up.profileType as pType', { r:r, userIdentityId:$userIdentityId, adminProfileType:$adminProfileType, profileTypes:$profileTypes } ) YIELD value WITH value.rel as rel, value.pId as pId, value.pType as pType CALL apoc.refactor.setType(rel, 'DELETED_HAS_ACCESS_TO') YIELD input, output RETURN pId, pType; """ # noqa for max-len params = dict( adminIdentityId=admin_context['identity_id'], adminProfileType=admin_context['profile_type'], adminProfileId=admin_context['profile_id'], userIdentityId=identity_id, profileTypes=profile_types, ) records = tx.run(query, **params).data() result = [] for row in records: if 'pId' in row: result.append({'id': row.get('pId'), 'type': row.get('pType')}) return result def _activate_identity(tx, identity_id): """Activate the user if the admin can administer it.""" query = """MATCH (user:Identity) WHERE (user.id = $userIdentityId OR user.auth0UserId = $userIdentityId) SET user.active = "Y" RETURN user""" result = tx.run(query, userIdentityId=identity_id).single() return to_snake(dict(result.get('user'))) def _get_identity_resource_count(tx, identity_id): """GET identity and active resource count.""" query = """MATCH (user:Identity)-[:HAS_PROFILE]->(p:Profile) WHERE (user.id = $userIdentityId OR user.auth0UserId = $userIdentityId) OPTIONAL MATCH (p)-[:HAS_ACCESS_TO]->(x) RETURN count(x) as resource_count, user as identity """ result = tx.run(query, userIdentityId=identity_id).single() if not result or not result.get('identity'): # ideally this should never happen as we have identity check in logic layer. raise IncompleteResultError(message='Identity with profiles not found.') data = to_snake(dict(result.get('identity'))) data['resource_count'] = result.get('resource_count') return data def _deactivate_identity(tx, identity_id): """Set active=Y/N for identity node.""" query = """MATCH (user:Identity)-[:HAS_PROFILE]->(p:Profile) WHERE (user.id = $userIdentityId OR user.auth0UserId = $userIdentityId) SET user.active = 'N', p.roles = [] RETURN user as identity """ result = tx.run(query, userIdentityId=identity_id).single() data = to_snake(dict(result.get('identity'))) return data # TODO: Move out of this module def deactivate_user(admin_context, identity_id, profile_types): """Delete access to its resources and Deactivate a user if it has no more access. Args: admin_context (dict): Admin's context data. identity_id (str): Identity UUID. profile_types (list): profile types to deactivate. Return: response (obj) """ with neo4j._get_neo4j_session( access_mode=constants.NEO4j_WRITE_ACCESS ) as session, mysql.db_session() as mysql_session: tx = session.begin_transaction() try: profiles = _deactivate_resources_common_with_admin( tx, admin_context, identity_id, profile_types ) # check to see of there are still some resources left. stats = _get_identity_resource_count(tx, identity_id) if stats['resource_count'] < 1: stats.update(_deactivate_identity(tx, identity_id)) if stats.get('auth0_user_id'): for p in profiles: if p['type'] == 'LabelProfile': label.update_vend_contact(mysql_session, p['id'], 'N') result = auth0.activate_deactivate_user(stats.get('auth0_user_id'), False) if not result: raise IncompleteResultError(result.errors) stats['auth0_result'] = result.message tx.commit() mysql_session.commit() return response.Response(stats) except ( IncompleteResultError, sqlalchemy.exc.SQLAlchemyError, sqlalchemy.exc.DatabaseError, pymysql.err.DatabaseError, ) as err: sentry_client.capture_exception() tx.rollback() mysql_session.rollback() return response.create_error_response(error.CODE_FAILED_DEACTIVATE_USER, err.message) # TODO: Move out of this module def activate_user(admin_id: str, identity_id: str) -> response.Response: """Activate a user in auth0 and neo4j. Args: identity_id (str): Identity UUID. Return: Response: containing dict of identity. """ with neo4j._get_neo4j_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: tx = session.begin_transaction() try: identity = identity_model.update_identity_active_status( session=tx, identity_id=identity_id, active='Y', audit_user_id=admin_id, ) identity_dict = dataclasses.asdict(identity) identity_dict['auth0_result'] = None auth0_user_id = owsusers.get_auth0_user_id_by_email(identity.email) if auth0_user_id: result = auth0.activate_deactivate_user(auth0_user_id, True) identity_dict['auth0_result'] = result.message if not result: g.log.error( 'Failed to activate user in auth0', identity_id=identity_id, auth0_user_id=auth0_user_id, errors=result.errors, ) raise IncompleteResultError(result.errors) tx.commit() return response.Response(identity_dict) except IncompleteResultError as err: tx.rollback() return response.create_error_response(error.CODE_FAILED_ACTIVATE_USER, err.message)