"""Logic for tenants.""" import textwrap import uuid import neo4j from ddtrace import tracer from neo4j import Transaction, exceptions as neo4j_exceptions from owsresponse import response from permissions.connectors import neo4j as neo4j_connector from permissions.connectors.sentry import sentry_client from permissions.constants import constants from permissions.exceptions.incomplete_result_error import IncompleteResultError from permissions.models import profile from permissions.types import ( AccessibleTenant, AdminableTenant, AdminableTenantDataloader, ProfileInfo, Tenant, TenantType, TenantWithName, ) from permissions.utils import db_entities @tracer.wrap('get_adminable_tenants_for_identity', service='neo4j') def get_adminable_tenants_for_identity( admin_context: dict, identity_id: str, limit: int, offset: int, include_deleted_tenants: bool = False, ) -> list[AdminableTenant]: """Get tenants that identity_id has direct HAS_ACCESS_TO and the admin can administer. A tenant can be a Vendor, SubAccount, Collaborator, or LabelParticipant. The tenant object will have a `type` field with one of these values. Args: admin_context (dict): Admin's context data. identity_id (str): User's identity id. limit (int): Limit number of records. Default 50. offset (int): Offset result set. Default 0. include_deleted_tenants (bool): Whether to include tenants that have been soft-deleted. Return: List[AdminableTenant]: Contains dicts of tenants and their corresponding profiles. """ with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: admin_has_full_catalog_access = profile.check_vendor_star_access( identity_id=admin_context['identity_id'], profile_type=constants.SETTINGSPROFILE, profile_id=admin_context['profile_id'], session=session, ) query = get_adminable_tenants_query(include_deleted_tenants, admin_has_full_catalog_access) params = { 'profileType': constants.SETTINGSPROFILE, 'adminIdentityId': admin_context['identity_id'], 'adminProfileId': admin_context['profile_id'], 'userIdentityId': identity_id, 'offset': offset, 'limit': limit, } result = session.run(query, **params) return format_adminable_tenants_result(result) def get_adminable_tenants_query(include_deleted_tenants: bool, has_full_catalog_access) -> str: """Return a query to get the adminable tenants of a user.""" relationship_match_str = ':HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO' if include_deleted_tenants: relationship_match_str += '|DELETED_HAS_ACCESS_TO|DELETED_HAS_ADMIN_ACCESS_TO' if has_full_catalog_access: return textwrap.dedent(f""" MATCH (tenant)<-[ {relationship_match_str} ]-(up:Profile) <-[:HAS_PROFILE]-(user:Identity) WHERE ( tenant:Vendor OR tenant:SubAccount OR tenant:Collaborator OR tenant:LabelParticipant ) AND user.id = $userIdentityId WITH tenant, COLLECT( {{ profileType: up.profileType, profileId: up.profileId, roles: up.roles, uuid:up.uuid }} ) as profiles RETURN DISTINCT tenant, profiles SKIP $offset LIMIT $limit """) else: return textwrap.dedent(f""" MATCH (admin:Identity)-[:HAS_PROFILE]->(sp:Profile {{profileType: $profileType}}) -[:HAS_ADMIN_ACCESS_TO]->(t)-[*0..1]->(tenant) <-[ {relationship_match_str} ]-(up:Profile)<-[:HAS_PROFILE]-(user:Identity) USING JOIN ON t WHERE ( tenant:Vendor OR tenant:SubAccount OR tenant:Collaborator OR tenant:LabelParticipant ) AND admin.id = $adminIdentityId AND sp.profileId = $adminProfileId AND user.id = $userIdentityId WITH tenant, COLLECT( {{ profileType: up.profileType, profileId: up.profileId, roles: up.roles, uuid:up.uuid }} ) as profiles RETURN DISTINCT tenant, profiles SKIP $offset LIMIT $limit """) def format_adminable_tenants_result(result: neo4j.Result) -> list[AdminableTenant]: """Return a list of AdminableTenant containing tenants and associated profiles.""" tenants = [] for each in result: tenant = each.get('tenant') profiles = each.get('profiles') tenants.append( AdminableTenant( tenant=Tenant( tenant_type=db_entities.get_tenant_type_from_neo4j_labels(tenant.labels), tenant_uuid=tenant.get('uuid'), ), profiles=[ ProfileInfo( profile_id=p.get('profileId'), profile_type=p.get('profileType'), roles=p.get('roles'), uuid=p.get('uuid'), ) for p in profiles ], ) ) return tenants def _get_tenant_type_label_count(identity_id: str, tx: Transaction) -> int: """Get account (vendor/subaccount) count for the admin.""" query = """ MATCH (i:Identity {id: $identityId})-[:HAS_PROFILE]-> (p:Profile {profileType: "SettingsProfile"}) OPTIONAL MATCH (p)-[:HAS_ADMIN_ACCESS_TO]->(v:Vendor) WITH p, v LIMIT 2 OPTIONAL MATCH (v)-[:OWNS]->(s:Subaccount) WITH p, v, s LIMIT 2 OPTIONAL MATCH (p)-[:HAS_ADMIN_ACCESS_TO]-> (directSA:Subaccount) WITH v, s, directSA LIMIT 2 RETURN COUNT(DISTINCT v) + COUNT(DISTINCT s) + COUNT(DISTINCT directSA) as labelCount""" result = tx.run(query, identityId=identity_id).single() return result.get('labelCount') def _get_tenant_type_label_participant_count(identity_id: str, tx: Transaction) -> int: """Get label participant count for the admin.""" query = """ OPTIONAL MATCH (i:Identity {id: $identityId})-[:HAS_PROFILE]-> (p:Profile {profileType: "SettingsProfile"})-[:HAS_ADMIN_ACCESS_TO]-> (x:Vendor)-[:HAS_LABEL_PARTICIPANT]->(accountLP:LabelParticipant) WITH accountLP LIMIT 2 OPTIONAL MATCH (i:Identity {id: $identityId})-[:HAS_PROFILE]-> (p:Profile {profileType: "SettingsProfile"})-[:HAS_ADMIN_ACCESS_TO]-> (directLP:LabelParticipant) WITH accountLP, directLP LIMIT 2 RETURN COUNT(DISTINCT accountLP)+COUNT(DISTINCT directLP) as labelParticipantCount""" result = tx.run(query, identityId=identity_id).single() return result.get('labelParticipantCount') def _get_tenant_type_collaborator_count(identity_id: str, tx: Transaction) -> int: """Get collaborator count for the admin.""" query = """ MATCH (i:Identity {id: $identityId})-[:HAS_PROFILE]-> (p:Profile {profileType: "SettingsProfile"})-[:HAS_ADMIN_ACCESS_TO]-> (v:Vendor)-[:OWNS]->(c:Collaborator) WITH DISTINCT c LIMIT 2 RETURN COUNT(c) as collaboratorCount""" result = tx.run(query, identityId=identity_id).single() return result.get('collaboratorCount') def get_admin_tenant_type_count(identity_id: str) -> response.Response: """Get account (vendor/subaccount), label participant and collaborator count for the admin.""" with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: tx = session.begin_transaction() try: admin_tenant_types = [] for tenant_type in constants.ADMIN_ASSIGNABLE_TENANT_TYPES_FOR_UI: match tenant_type: case constants.ACCOUNT_TENANT_TYPE: account_count = _get_tenant_type_label_count(identity_id, tx) if account_count: admin_tenant_types.append( {'tenant_type': tenant_type, 'tenant_count': account_count} ) case constants.LABEL_PARTICIPANT_TENANT_TYPE: label_participant_count = _get_tenant_type_label_participant_count( identity_id, tx ) if label_participant_count: admin_tenant_types.append( { 'tenant_type': tenant_type, 'tenant_count': label_participant_count, } ) case constants.COLLABORATOR_TENANT_TYPE: collaborator_count = _get_tenant_type_collaborator_count(identity_id, tx) if collaborator_count: admin_tenant_types.append( { 'tenant_type': tenant_type, 'tenant_count': _get_tenant_type_collaborator_count( identity_id, tx ), } ) tx.commit() return response.Response(message=admin_tenant_types) except neo4j_exceptions.Neo4jError as err: tx.rollback() sentry_client.capture_exception() return response.create_error_response(err.code, err.message) def _get_accessible_tenants_query_string(tenant_type: TenantType) -> str: """Generate the access check Neo4j query string based on the tenant type.""" # tenant might not match, so we return the input that we can use in constructing the object. queries = { TenantType.ACCOUNT: textwrap.dedent(""" MATCH (p:Profile { profileType: 'SettingsProfile', profileId: $profileId }) UNWIND $tenant_uuids as tenant_uuid OPTIONAL MATCH (p)-[:HAS_ADMIN_ACCESS_TO]->(tenant:Vendor { uuid: tenant_uuid }) RETURN tenant_uuid as uuid, 'Vendor' as type, tenant """), TenantType.SUBACCOUNT: textwrap.dedent(""" MATCH (p:Profile { profileType: 'SettingsProfile', profileId: $profileId }) UNWIND $tenant_uuids as tenant_uuid OPTIONAL MATCH (tenant:Subaccount { uuid: tenant_uuid }) WHERE (p)-[:HAS_ADMIN_ACCESS_TO]->(tenant) OR (p)-[:HAS_ADMIN_ACCESS_TO]->(:Vendor)-[:OWNS]->(tenant) RETURN tenant_uuid as uuid, 'Subaccount' as type, tenant """), TenantType.COLLABORATOR: textwrap.dedent(""" MATCH (p:Profile { profileType: 'SettingsProfile', profileId: $profileId }) UNWIND $tenant_uuids as tenant_uuid OPTIONAL MATCH (p)-[:HAS_ADMIN_ACCESS_TO]->(:Vendor) -[:OWNS]->(tenant:Collaborator { uuid: tenant_uuid }) RETURN tenant_uuid as uuid, 'Collaborator' as type, tenant """), TenantType.LABEL_PARTICIPANT: textwrap.dedent(""" MATCH (p:Profile {profileType: 'SettingsProfile', profileId: $profileId}) UNWIND $tenant_uuids as tenant_uuid OPTIONAL MATCH (tenant:LabelParticipant { uuid: tenant_uuid }) WHERE (p)-[:HAS_ADMIN_ACCESS_TO]->(:Label)-[:HAS_LABEL_PARTICIPANT]->(tenant) OR (p)-[:HAS_ADMIN_ACCESS_TO]->(tenant) RETURN tenant_uuid as uuid, 'LabelParticipant' as type, tenant """), } return queries.get(tenant_type) def check_admin_access_to_tenants( session: neo4j.Session, tenants: list[Tenant], settings_profile: ProfileInfo ) -> list[AccessibleTenant]: """ Determine admin access for a list of Tenants. This function checks whether the user(settings_profile) has admin access to each tenant in the given list. The function returns a list of AccessibleTenant objects, where each object includes the tenant along with an 'access' attribute indicating whether the user has admin access. Args: session (neo4j.Session): The Neo4j session used to perform database operations. tenants (list[Tenant]): A list of Tenant objects to check for admin access. settings_profile (ProfileInfo): The user's profile. Returns: list[AccessibleTenant]: A list of AccessibleTenant objects, each including tenant details and an 'access' attribute set to True if the user has admin access, or False otherwise. """ tenants_by_type = {tenant.tenant_type: [] for tenant in tenants} accessible_tenants = [] # Categorize tenants for tenant in tenants: tenant_type = tenant.tenant_type tenant_uuid = tenant.tenant_uuid tenants_by_type[tenant_type].append(tenant_uuid) for tenant_type in tenants_by_type: if tenants_by_type[tenant_type]: query = _get_accessible_tenants_query_string(TenantType(tenant_type)) result = session.run( query=query, profileId=settings_profile.profile_id, tenant_uuids=tenants_by_type[tenant_type], ) for r in result: tenant = r.get('tenant') accessible = True if tenant else False accessible_tenants.append( # tenant might be null, so the query returns the input # that we can use in constructing the object. AccessibleTenant( tenant_uuid=r.get('uuid'), tenant_type=TenantType( constants.NEO_TO_TENANT_TYPE_MAPPING.get(r.get('type')) ), access=accessible, ) ) return accessible_tenants def get_parent_company_brand_for_tenant( tx: Transaction, tenant_type: TenantType, tenant_uuid: str ) -> str | None: """ Determine company_brand for tenant. Args: tx (Transaction): session tenant_type (str): Type of the tenant. tenant_uuid (str): UUID of the tenant. Returns: (str | None): Company brand of the tenant or None if not found. """ query = textwrap.dedent(""" RETURN CASE $tenant_type WHEN 'label_participant' THEN [(l:LabelParticipant {uuid:$tenant_uuid})<-[:HAS_LABEL_PARTICIPANT]-(v:Vendor) <-[:HAS_LABEL]-(cb:CompanyBrand)| cb.name ] WHEN 'account' THEN [(v:Vendor {uuid:$tenant_uuid})<-[:HAS_LABEL]-(cb:CompanyBrand) | cb.name ] WHEN 'subaccount' THEN [(s:Subaccount {uuid:$tenant_uuid})<-[:OWNS]-(v:Vendor) <-[:HAS_LABEL]-(cb:CompanyBrand) | cb.name ] WHEN 'collaborator' THEN [(c:Collaborator {uuid: $tenant_uuid}) <-[:OWNS]-(v:Vendor)<-[:HAS_LABEL]-(cb:CompanyBrand) | cb.name ] END AS brand""") record = tx.run(query, tenant_uuid=tenant_uuid, tenant_type=tenant_type.value).single() if not record: return None return record.get('brand')[0] def soft_delete_access_to_tenant_for_identity( tx: Transaction, admin_identity_id: str, identity_id: str, tenant: Tenant, ): """Revoke all access to a single tenant for identity.""" query = _soft_delete_access_to_tenant_for_identity_query() deleted_by = f'ows-permissions/revoke-all-access-to-single-tenant/{admin_identity_id}' params = { 'identityId': identity_id, 'deletedBy': deleted_by, 'tenantUUID': tenant.tenant_uuid, 'tenantType': constants.TENANT_TYPE_TO_NEO_MAPPING[tenant.tenant_type.value], } try: tx.run(query, **params) except ( neo4j_exceptions.Neo4jError, neo4j_exceptions.ConstraintError, neo4j_exceptions.TransientError, neo4j_exceptions.ClientError, ) as err: raise IncompleteResultError(message=err.message) def _soft_delete_access_to_tenant_for_identity_query() -> str: return textwrap.dedent(""" MATCH (tenant)<-[rel:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]-(p:Profile) <-[:HAS_PROFILE]-(i:Identity) WHERE $tenantType in LABELS(tenant) AND i.id = $identityId AND tenant.uuid = $tenantUUID SET rel.deletedAt = datetime(), rel.deletedBy = $deletedBy WITH tenant, p, rel CALL apoc.refactor.setType(rel, apoc.text.join(['DELETED', apoc.rel.type(rel)], '_')) YIELD input, output RETURN true""") @tracer.wrap('soft_delete_access_to_multiple_tenants_for_identity', service='neo4j') def soft_delete_access_to_multiple_tenants_for_identity( tx: neo4j.Transaction | neo4j.Session, tenants: list[Tenant], admin_id: str, identity_id: str ): """Revoke all access to given tenants for identity.""" deleted_by = f'ows-permissions/revoke-all-access-to-tenants/{admin_id}' tenant_dicts = [ {'uuid': t.tenant_uuid, 'type': constants.TENANT_TYPE_TO_NEO_MAPPING[t.tenant_type.value]} for t in tenants ] # TODO: Clean any duplicate relationships as well query = textwrap.dedent(""" MATCH (i:Identity {id: $identityId})-[:HAS_PROFILE]->(p:Profile) -[rel:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(tenant) WHERE tenant.uuid IN [t IN $tenants | t.uuid] AND ANY(t IN $tenants WHERE t.uuid = tenant.uuid AND t.type IN LABELS(tenant)) SET rel.deletedAt = datetime(), rel.deletedBy = $deletedBy WITH tenant, p, rel CALL apoc.refactor.setType( rel, apoc.text.join(['DELETED', apoc.rel.type(rel)], '_') ) YIELD input, output RETURN true """) tx.run(query, {'identityId': identity_id, 'tenants': tenant_dicts, 'deletedBy': deleted_by}) def soft_delete_all_access_to_tenants_for_identity( session: neo4j.Session, identity_id: str, admin_id: str ) -> None: """Revoke all access to all tenants for identity. Used in SEAT-based deactivation.""" deleted_by = f'ows-permissions/revoke-all-access/{admin_id}' query = textwrap.dedent(""" MATCH (i:Identity {id: $identityId})-[:HAS_PROFILE]->(p:Profile) -[rel:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(tenant) WHERE tenant:Vendor OR tenant:Subaccount OR tenant:Collaborator OR tenant:LabelParticipant OR tenant:ParentCompany SET rel.deletedAt = datetime(), rel.deletedBy = $deletedBy WITH tenant, p, rel CALL apoc.refactor.setType( rel, apoc.text.join(['DELETED', apoc.rel.type(rel)], '_') ) YIELD input, output RETURN true """) session.run(query, {'identityId': identity_id, 'deletedBy': deleted_by}) def get_identity_tenant_count(identity_id: str) -> int: """ Get the count of tenants that an identity has access to. Args: identity_id (str): The user's identity id. """ with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = textwrap.dedent(""" MATCH (user:Identity)-[:HAS_PROFILE]->(p:Profile)-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(x) WHERE user.id = $identityId RETURN COUNT(DISTINCT x) as tenant_count""") # noqa result = session.run(query, identityId=identity_id).single() return result.get('tenant_count') def get_brands_for_identity(identity_id: str) -> list[str]: """ Get all unique company brands for all tenants that an identity has access to. This function queries Neo4j to find all Vendor, Subaccount, and LabelParticipant tenants the user has access to, and returns the distinct company brands associated with them. Args: identity_id (str): The user's identity id. Returns: list[str]: List of unique company brand names the user has access to. """ with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = textwrap.dedent(""" MATCH (:Identity {id: $identityId})-[:HAS_PROFILE]->(:Profile) -[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(tenant) WHERE tenant:Vendor OR tenant:Subaccount OR tenant:LabelParticipant OR tenant:Collaborator WITH DISTINCT tenant, // brands if tenant is a Vendor [(tenant)<-[:HAS_LABEL]-(cb:CompanyBrand) WHERE tenant:Vendor | cb.name] AS vendorBrands, // brands if tenant is a Subaccount or Collaborator (both owned by a Vendor) [(tenant)<-[:OWNS]-(:Vendor)<-[:HAS_LABEL]-(cb2:CompanyBrand) WHERE tenant:Subaccount OR tenant:Collaborator | cb2.name] AS ownedBrands, // brands if tenant is a LabelParticipant (linked via HAS_LABEL_PARTICIPANT to a Vendor) [(tenant)<-[:HAS_LABEL_PARTICIPANT]-(:Vendor)<-[:HAS_LABEL]-(cb3:CompanyBrand) WHERE tenant:LabelParticipant | cb3.name] AS lpBrands WITH vendorBrands + ownedBrands + lpBrands AS allBrands UNWIND allBrands AS brand RETURN DISTINCT brand ORDER BY brand; """) result = session.run(query, identityId=identity_id) brands = [record.get('brand') for record in result if record.get('brand') is not None] return brands def get_tenant_parent_of_tenant(tenant: TenantWithName) -> TenantWithName: """ Get a tenant parent of the specified one. Handles different relationship patterns by tenant type: - Non-LabelParticipant (Subaccount, Collaborator): uses OWNS relationships from parent Vendor - LabelParticipant: prioritizes parent Subaccount over parent Vendor (both via HAS_LABEL_PARTICIPANT relationship) Returns the highest priority parent found based on the CASE statement priority order. """ try: t_type_val = tenant.tenant_type.value tenant_lbl = constants.TENANT_TYPE_TO_NEO_MAPPING[t_type_val] except (KeyError, AttributeError): raise ValueError('Invalid tenant type') # Handle different relationship patterns by tenant type query = textwrap.dedent(f""" MATCH (t:{tenant_lbl}) WHERE t.uuid = $tenantUuid OPTIONAL MATCH (subaccount:Subaccount)-[:HAS_LABEL_PARTICIPANT]->(t) WHERE t:LabelParticipant OPTIONAL MATCH (vendor:Vendor)-[:HAS_LABEL_PARTICIPANT]->(t) WHERE t:LabelParticipant OPTIONAL MATCH (owner)-[:OWNS]->(t) WHERE NOT t:LabelParticipant // Prioritize the most specific parent WITH t, CASE WHEN subaccount IS NOT NULL THEN subaccount WHEN vendor IS NOT NULL THEN vendor WHEN owner IS NOT NULL THEN owner ELSE null END as parent RETURN DISTINCT parent LIMIT 1 """) with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: result = session.run(query, tenantUuid=tenant.tenant_uuid).single() if result and result.get('parent'): parent = result.get('parent') # Determine tenant type from node labels parent_labels = set(parent.labels) parent_type = None for tenant_type, neo_label in constants.TENANT_TYPE_TO_NEO_MAPPING.items(): if neo_label in parent_labels: parent_type = tenant_type break if not parent_type: msg = f'Could not determine tenant type for parent with labels: {parent_labels}' raise IncompleteResultError(message=msg) return TenantWithName( tenant_name=parent.get('name'), tenant_uuid=parent.get('uuid'), tenant_type=TenantType(parent_type), ) else: msg = f'Parent for tenant with uuid: {tenant.tenant_uuid}, type: {t_type_val} not found' raise IncompleteResultError(message=msg) @tracer.wrap('get_adminable_tenants_for_identities', service='neo4j') def get_adminable_tenants_for_identities( identity_uuids: list[uuid.UUID], admin_context: dict, has_full_catalog_access: bool ) -> list[AdminableTenantDataloader]: """Get tenants for multiple identity UUIDs that the admin can administer. This is a dataloader-style function that fetches tenants for multiple identities in a single database query for efficiency. Args: identity_uuids: List of identity UUIDs to fetch tenants for. admin_context: Admin's context data containing identity_id and profile_id. has_full_catalog_access: If True, bypass vendor star check and use full catalog query. Returns: List of dicts, each containing identity_uuid and their tenants list. """ with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = get_adminable_tenants_for_identities_query(has_full_catalog_access) params = { 'profileType': constants.SETTINGSPROFILE, 'adminIdentityId': admin_context['identity_id'], 'adminProfileId': admin_context['profile_id'], 'identityUuids': [str(identity_uuid) for identity_uuid in identity_uuids], } result = session.run(query, **params) return format_adminable_tenants_for_identities_result(result) @tracer.wrap('get_seater_adminable_tenants_for_identities', service='neo4j') def get_seater_adminable_tenants_for_identities( identity_uuids: list[uuid.UUID], admin_context: dict ) -> list[AdminableTenantDataloader]: """Get tenants for multiple identity UUIDs that the SEAT can administer. This is a dataloader-style function that fetches tenants for multiple identities in a single database query for efficiency. Args: identity_uuids: List of identity UUIDs to fetch tenants for. admin_context: Admin's context data containing identity_id and profile_id. Returns: List of dicts, each containing identity_uuid and their tenants list. """ with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: query = textwrap.dedent(""" UNWIND $identityUuids as identityUuid MATCH (tenant)<-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]-(up:Profile) <-[:HAS_PROFILE]-(user:Identity {id: identityUuid}) WHERE tenant:Vendor OR tenant:SubAccount OR tenant:Collaborator OR tenant:LabelParticipant OR tenant:ParentCompany WITH identityUuid, tenant, COLLECT(DISTINCT { profileType: up.profileType, profileId: up.profileId, roles: up.roles, uuid: up.uuid }) as profiles WITH identityUuid, COLLECT(DISTINCT { tenant_uuid: tenant.uuid, tenant_type: CASE WHEN tenant:Vendor THEN 'account' WHEN tenant:SubAccount THEN 'subaccount' WHEN tenant:Collaborator THEN 'collaborator' WHEN tenant:LabelParticipant THEN 'label_participant' WHEN tenant:ParentCompany THEN 'parent_company' END, profiles: profiles }) as tenants RETURN identityUuid as identity_uuid, tenants """) params = { 'profileType': constants.SETTINGSPROFILE, 'adminIdentityId': admin_context['identity_id'], 'adminProfileId': admin_context['profile_id'], 'identityUuids': [str(identity_uuid) for identity_uuid in identity_uuids], } result = session.run(query, **params) return format_adminable_tenants_for_identities_result(result) def get_adminable_tenants_for_identities_query(has_full_catalog_access: bool) -> str: """Return a query to get tenants for multiple identities.""" if has_full_catalog_access: return textwrap.dedent(""" UNWIND $identityUuids as identityUuid MATCH (tenant)<-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]-(up:Profile) <-[:HAS_PROFILE]-(user:Identity {id: identityUuid}) WHERE tenant:Vendor OR tenant:SubAccount OR tenant:Collaborator OR tenant:LabelParticipant WITH identityUuid, tenant, COLLECT(DISTINCT { profileType: up.profileType, profileId: up.profileId, roles: up.roles, uuid: up.uuid }) as profiles WITH identityUuid, COLLECT(DISTINCT { tenant_uuid: tenant.uuid, tenant_type: CASE WHEN tenant:Vendor THEN 'account' WHEN tenant:SubAccount THEN 'subaccount' WHEN tenant:Collaborator THEN 'collaborator' WHEN tenant:LabelParticipant THEN 'label_participant' END, profiles: profiles }) as tenants RETURN identityUuid as identity_uuid, tenants """) else: return textwrap.dedent(""" MATCH (admin:Identity)-[:HAS_PROFILE]->(sp:Profile {profileType: $profileType}) -[:HAS_ADMIN_ACCESS_TO]->(t) WHERE admin.id = $adminIdentityId AND sp.profileId = $adminProfileId WITH t, sp UNWIND $identityUuids as identityUuid MATCH (t)-[*0..1]->(tenant) <-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]-(up:Profile)<-[:HAS_PROFILE]-(user:Identity {id: identityUuid}) WHERE tenant:Vendor OR tenant:SubAccount OR tenant:Collaborator OR tenant:LabelParticipant WITH identityUuid, tenant, COLLECT(DISTINCT { profileType: up.profileType, profileId: up.profileId, roles: up.roles, uuid: up.uuid }) as profiles WITH identityUuid, COLLECT(DISTINCT { tenant_uuid: tenant.uuid, tenant_type: CASE WHEN tenant:Vendor THEN 'account' WHEN tenant:SubAccount THEN 'subaccount' WHEN tenant:Collaborator THEN 'collaborator' WHEN tenant:LabelParticipant THEN 'label_participant' END, profiles: profiles }) as tenants RETURN identityUuid as identity_uuid, tenants """) def format_adminable_tenants_for_identities_result( result: neo4j.Result, ) -> list[AdminableTenantDataloader]: """Format the result of get_tenants_for_identities into a list of dicts.""" results = [] for record in result: identity_uuid = record.get('identity_uuid') tenants_raw = record.get('tenants', []) # Filter out entries where tenant_uuid is None (no tenants found) tenants = [ AdminableTenant( tenant=Tenant( tenant_uuid=t['tenant_uuid'], tenant_type=t['tenant_type'], ), profiles=[ ProfileInfo( profile_id=p.get('profileId'), profile_type=p.get('profileType'), roles=p.get('roles'), uuid=p.get('uuid'), ) for p in t.get('profiles', []) if p.get('profileId') is not None ], ) for t in tenants_raw if t.get('tenant_uuid') is not None ] results.append( AdminableTenantDataloader( identity_uuid=identity_uuid, tenants=tenants, ) ) return results def get_tenant_by_uuid_and_type(tenant_uuid: str, tenant_type: str) -> TenantWithName: """ Get tenant name by uuid and type, following any Profile-based access from the Identity. Uses tenant_type to determine the correct Neo4j label (e.g. 'account' -> 'Vendor'). """ try: tenant_lbl = constants.TENANT_TYPE_TO_NEO_MAPPING[tenant_type] except KeyError: raise ValueError('Invalid tenant type') # Interpolate the label into the Cypher string as we cant use parameters for node labels. query = textwrap.dedent(f""" MATCH (t:{tenant_lbl}) WHERE t.uuid = $tenantUuid RETURN DISTINCT t LIMIT 1 """) with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: result = session.run(query, tenantUuid=tenant_uuid).single() if result: return TenantWithName( tenant_type=TenantType(tenant_type), tenant_uuid=result['t'].get('uuid'), tenant_name=result['t'].get('name'), ) else: msg = f'Tenant with uuid: {tenant_uuid}, type: {tenant_type} not found.' raise IncompleteResultError(message=msg) def seat_get_tenant_by_uuid(identity_id: str, tenant_uuid: str) -> AdminableTenant: """ Get SEAT-supported tenant with profiles by uuid for an employee identity. DOES NOT DO ACCESS CHECKS--should only be used when gated by SEAT role Args: identity_id (str): The user's identity id. tenant_uuid (str): The tenant's uuid. Returns: AdminableTenant: The tenant object with profiles. """ query = textwrap.dedent(""" MATCH (tenant)<-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]-(up:Profile) <-[:HAS_PROFILE]-(user:Identity {id: $identityId}) WHERE tenant.uuid = $tenantUuid AND ( tenant:Vendor OR tenant:CompanyBrand OR tenant:ParentCompany ) WITH tenant, COLLECT(DISTINCT { profileType: up.profileType, profileId: up.profileId, roles: up.roles, uuid: up.uuid }) as profiles RETURN DISTINCT tenant, profiles LIMIT 1 """) with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: result = session.run(query, identityId=identity_id, tenantUuid=tenant_uuid).single() if not result: return None tenant_node = result.get('tenant') profiles = result.get('profiles', []) return AdminableTenant( tenant=Tenant( tenant_type=db_entities.get_tenant_type_from_neo4j_labels(tenant_node.labels), tenant_uuid=tenant_node.get('uuid'), ), profiles=[ ProfileInfo( profile_id=p.get('profileId'), profile_type=p.get('profileType'), roles=p.get('roles'), uuid=p.get('uuid'), ) for p in profiles ], )