"""Logic for converting tenants/profiles from neo4j into roles used by settings app.""" import copy import uuid from ddtrace import tracer from flask import g from neo4j import exceptions as neo4j_exceptions from owsresponse import response from sqlalchemy.orm import exc as orm_exceptions from permissions.connectors import mysql, neo4j as neo4j_connector from permissions.connectors.sentry import sentry_client from permissions.constants import constants, v2_role_constants from permissions.exceptions.incomplete_result_error import IncompleteResultError from permissions.models import ( company_brand as company_brand_model, ows_account as account_model, parent_company, profile as profile_model, subaccount as subaccount_model, tenant as tenant_model, vend_contact_role, vendor as vendor_model, ) from permissions.types import ( AccessibleTenant, AdminableTenant, AdminIdentity, IdentityTenantsWithV2Roles, ProfileInfo, Tenant, TenantType, TenantWithV2Roles, ) from permissions.utils.dataloader_utils import format_for_dataloader def get_adminable_tenants_for_identity( admin_context: dict, identity_id: str, limit: int = 99999, offset: int = constants.DEFAULT_OFFSET, ) -> list[TenantWithV2Roles]: """Get a list of tenants a given identity has access to that a given admin can administer.""" tenants = tenant_model.get_adminable_tenants_for_identity( admin_context=admin_context, identity_id=identity_id, limit=limit, offset=offset, ) # filter out vendor star tenant as it is not supported by the settings app. tenants = [t for t in tenants if t.tenant.tenant_uuid != constants.VENDOR_STAR_UUID] return map_to_v2_roles(tenants) def map_to_v2_roles(adminable_tenants: list[AdminableTenant]) -> list[TenantWithV2Roles]: """Convert a list of tenants/profiles from neo4j to a list of tenants/v2 roles.""" adminable_tenants = _filter_unsupported_profiles_and_roles(adminable_tenants) return [ TenantWithV2Roles( tenant_uuid=adminable_tenant.tenant.tenant_uuid, tenant_type=adminable_tenant.tenant.tenant_type, roles=_profiles_to_v2_roles(adminable_tenant.profiles), ) for adminable_tenant in adminable_tenants ] def _filter_unsupported_profiles_and_roles(tenants: list[AdminableTenant]) -> list[AdminableTenant]: """Remove unsupported profiles and roles from adminable tenant objects.""" tenants_copy = copy.deepcopy(tenants) for adminable_tenant in tenants_copy: adminable_tenant.profiles = [ p for p in adminable_tenant.profiles if p.profile_type in constants.SETTINGS_SUPPORT_MAPPING['profileTypes'] ] for profile in adminable_tenant.profiles: profile.roles = _filter_unsupported_roles(profile) return tenants_copy def _filter_unsupported_roles(profile: tenant_model.ProfileInfo) -> list[str]: """Remove unsupported roles from a profile object.""" if not profile.roles: return [] return [ r for r in profile.roles if r in constants.PROFILE_TYPE_TO_ROLES_MAPPING[profile.profile_type] ] def _profiles_to_v2_roles(profiles) -> list[str]: """Convert a list of profiles into a list of roles for a tenant.""" roles = [] for profile in profiles: profile_type = profile.profile_type if ( profile_type in v2_role_constants.PROFILE_TO_V2_ROLE_MAP # Settings app doesn't have roles stored in neo4j roles attribute and (profile.roles or profile_type == constants.SETTINGSPROFILE) ): roles.append(v2_role_constants.PROFILE_TO_V2_ROLE_MAP[profile_type]) elif profile_type == constants.LABELPROFILE and profile.roles: if constants.ADMINISTRATOR_ROLE in profile.roles: # Check the user has a vend contact role for role id=4 _check_for_admin_vend_contact_role(profile) roles += [ v2_role_constants.LABEL_PROFILE_ROLES_TO_V2_ROLES_MAP[r] for r in profile.roles if r in v2_role_constants.LABEL_PROFILE_ROLES_TO_V2_ROLES_MAP ] return roles def _check_for_admin_vend_contact_role(profile: tenant_model.ProfileInfo) -> None: """Check if the user has a vend contact role for the administrator role and log if not.""" with mysql.db_session() as session: role_ids = vend_contact_role.VendContactRole.get_role_ids_by_vend_contact( tx=session, vend_contact_id=profile.profile_id, ) if constants.AR_ROLES_MAPPING_TO_ID[constants.ADMINISTRATOR_ROLE] not in role_ids: g.log.warn( 'User does not have a vend contact role for administrator role', resources={'vend_contact_id': profile.profile_id}, ) def get_my_adminable_tenant_types( identity_id: str, settings_profile: ProfileInfo ) -> response.Response: """Get the tenant types for an admin identity.""" # TODO: refactor to not use the response style. if profile_model.check_vendor_star_access( identity_id=identity_id, profile_type=settings_profile.profile_type, profile_id=settings_profile.profile_id, ): vendor_star_admin_tenant_types = [] for tenant_type in constants.ADMIN_ASSIGNABLE_TENANT_TYPES_FOR_UI: vendor_star_admin_tenant_types.append({'tenant_type': tenant_type, 'tenant_count': 2}) return response.Response(message=vendor_star_admin_tenant_types) return tenant_model.get_admin_tenant_type_count(identity_id) def check_admin_access_to_tenant( identity_uuid: str, tenant: Tenant, settings_profile: ProfileInfo, ) -> bool: """Call check_admin_access_to_tenants (see below) for a single tenant and return a bool.""" result = check_admin_access_to_tenants( identity_uuid=identity_uuid, tenants=[tenant], settings_profile=settings_profile, ) return len(result) == 1 and result[0].access def check_admin_access_to_tenants( identity_uuid: str, tenants: list[Tenant], settings_profile: ProfileInfo ) -> list[AccessibleTenant]: """ Determine admin access for a list of Tenants. This function checks whether the user (identity_id) has vendor star access, and if so, returns a list of AccessibleTenant objects where each object includes the tenant along with an 'access' attribute set to true. Otherwise, calls the check_access_to_adminable_tenants function to check tenant access. Args: identity_uuid (str): The user's identity id. 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. """ with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: try: try: profile_model._check_vendor_star_access_settings( tx=session, identity_id=identity_uuid, profile_id=settings_profile.profile_id ) return [ AccessibleTenant( tenant_type=t.tenant_type, tenant_uuid=t.tenant_uuid, access=True ) for t in tenants ] except IncompleteResultError: # user does not have vendor star access, continue to check tenant access pass return tenant_model.check_admin_access_to_tenants( session=session, tenants=tenants, settings_profile=settings_profile ) except ( neo4j_exceptions.Neo4jError, neo4j_exceptions.ConstraintError, neo4j_exceptions.TransientError, neo4j_exceptions.ClientError, ) as err: sentry_client.capture_exception(err) raise err def get_parent_company_brand_for_tenant(tenant: Tenant) -> str: """ Determine company brand via parent hierarchy for tenant. Args: tenant (Tenant): Tenant object for which to check company brand. Returns: (str): Company brand of the tenant via parent hierarchy. """ with neo4j_connector.db_session(access_mode=constants.NEO4j_READ_ACCESS) as session: try: brand = tenant_model.get_parent_company_brand_for_tenant( tx=session, tenant_type=tenant.tenant_type, tenant_uuid=tenant.tenant_uuid ) return brand except ( IncompleteResultError, neo4j_exceptions.Neo4jError, neo4j_exceptions.ConstraintError, neo4j_exceptions.TransientError, neo4j_exceptions.ClientError, ) as err: sentry_client.capture_exception(err) raise err def _extract_roles(apps) -> list[str]: return apps.pop('roles', []) def check_compatibility_with_tenant_configuration( tenant: Tenant, roles_to_check: list[str] ) -> bool: """ Check that roles are compatible with tenant configuration and prevent invalid role combinations. Args: tenant (Tenant): Tenant object to get type and uuid. roles_to_check (list[str]): roles that we are going to check. Returns: (bool): If roles are compatible with tenant or not. """ available_apps = account_model.get_enabled_tenant_applications(tenant) if not available_apps: g.log.warn( 'No applications found for tenant when checking roles', resources={'tenant_uuid': tenant.tenant_uuid}, ) return False available_roles = [item for items in map(_extract_roles, available_apps) for item in items] if not all(role in available_roles for role in roles_to_check): unavailable_roles = [role for role in roles_to_check if role not in available_roles] g.log.info( 'Unavailable roles requested', resources={ 'roles_to_check': roles_to_check, 'unavailable_roles': unavailable_roles, 'tenant_uuid': tenant.tenant_uuid, }, ) return False return True def _is_detaching_all_roles( tenant: Tenant, current_roles: list[str], roles_to_detach: list[str] ) -> bool: """ Check if request is going to detach last tenant. Args: tenant (Tenant): Tenant object to get type and uuid. current_roles (list[str]): roles that already assigned to user. roles_to_detach (list[str]): roles that we are going to detach. Returns: (bool): If roles are compatible with tenant or not. """ if set(current_roles).issubset(roles_to_detach): g.log.info( 'All roles attempted to detach from the last tenant.', resources={ 'roles_to_detach': roles_to_detach, 'current_roles': current_roles, 'tenant_uuid': tenant.tenant_uuid, }, ) return True return False def is_update_removing_last_tenant( admin: AdminIdentity, identity_id: str, tenant: Tenant, roles_to_detach: list[str] ): """Check is update removing last tenant. Args: admin (AdminIdentity): admin making the update. identity_id (str): Users identity id. tenant (Tenant): Tenant object. roles_to_detach (list[str]): Roles that we are going to detach. Return: (bool): is update removing the last tenant or not. """ tenants_access_count = tenant_model.get_identity_tenant_count(identity_id) if tenants_access_count == 1: admin_context = { 'identity_id': admin.id, 'profile_id': admin.settings_profile.profile_id, 'profile_type': constants.SETTINGSPROFILE, } current_tenant = get_adminable_tenants_for_identity(admin_context, identity_id)[0] if current_tenant.tenant_uuid != tenant.tenant_uuid: raise ValueError( 'You are not enable proceed with updating, because tenant' ' for update {} and accessible tenant {} are not matching.'.format( current_tenant.tenant_uuid, tenant.tenant_uuid ) ) if _is_detaching_all_roles(tenant, current_tenant.roles, roles_to_detach): return True return False def seat_is_update_removing_last_role( identity_id: str, tenant_uuid: str, roles_to_detach: list[str] ) -> bool: """Check if the requested update is removing last role for tenant. DOES NOT DO ACCESS CHECKS--should only be used when gated by SEAT role Args: identity_id (str): Users identity id. tenant_uuid (str): Tenant uuid. roles_to_detach (list[str]): Roles that we are going to detach. Return: (bool): is the update removing the last role for this tenant. """ tenant: AdminableTenant = tenant_model.seat_get_tenant_by_uuid( identity_id=identity_id, tenant_uuid=tenant_uuid, ) if not tenant: raise ValueError('Tenant not found for identity.') roles = map_to_v2_roles([tenant])[0].roles if _is_detaching_all_roles(tenant.tenant, roles, roles_to_detach): return True return False @tracer.wrap('logic.get_adminable_tenants_for_identities_dataloader') def get_adminable_tenants_for_identities_dataloader( identity_uuids: list[uuid.UUID], admin_context: dict, is_seater: bool = False, ) -> list[IdentityTenantsWithV2Roles | None]: """Get tenants for multiple identity UUIDs (dataloader pattern). This is a dataloader-style function that fetches tenants for multiple identities in a single database query. Results maintain order and return None for identities that have no tenants. Args: identity_uuids: List of identity UUIDs to fetch tenants for. admin_context: Admin's context data containing identity_id and profile_id. is_seater: Boolean indicating if the admin is a seater, which affects access checks. Returns: List of TenantWithV2Roles lists corresponding to input identity UUIDs. """ if is_seater: results = tenant_model.get_seater_adminable_tenants_for_identities( identity_uuids=identity_uuids, admin_context=admin_context ) else: admin_has_full_catalog_access = profile_model.check_vendor_star_access( identity_id=admin_context['identity_id'], profile_type=constants.SETTINGSPROFILE, profile_id=admin_context['profile_id'], ) results = tenant_model.get_adminable_tenants_for_identities( identity_uuids=identity_uuids, admin_context=admin_context, has_full_catalog_access=admin_has_full_catalog_access, ) # Convert tenant profiles to v2 roles and filter out vendor star # tenant as it is not supported by the settings app formatted_results: list[IdentityTenantsWithV2Roles] = [] for result in results: filtered_tenants = [ t for t in result.tenants if t.tenant.tenant_uuid != constants.VENDOR_STAR_UUID ] formatted_results.append( IdentityTenantsWithV2Roles( identity_uuid=result.identity_uuid, tenants=map_to_v2_roles(filtered_tenants), ) ) return format_for_dataloader( items=formatted_results, keys=[str(identity_uuid) for identity_uuid in identity_uuids], lookup_key='identity_uuid', ) def does_tenant_exist(tenant: Tenant) -> bool: """Check if a tenant exists in neo4j and possibly art_relations.""" try: tenant = tenant_model.get_tenant_by_uuid_and_type( tenant_type=tenant.tenant_type, tenant_uuid=tenant.tenant_uuid, ) except IncompleteResultError: return False if tenant.tenant_type in [ constants.ACCOUNT_TENANT_TYPE, constants.SUBACCOUNT_TENANT_TYPE, constants.PARENT_COMPANY_TENANT_TYPE, constants.COMPANY_BRAND_TENANT_TYPE, ]: return does_tenant_exist_in_art_relations(tenant) else: return True def does_tenant_exist_in_art_relations(tenant: Tenant) -> bool: """Check if a tenant exists in art_relations.""" try: with mysql.db_session() as session: if tenant.tenant_type == constants.ACCOUNT_TENANT_TYPE: return ( vendor_model.Vendor.get_by_uuid( session=session, uuid=tenant.tenant_uuid, ) is not None ) elif tenant.tenant_type == constants.SUBACCOUNT_TENANT_TYPE: return ( subaccount_model.get_subaccount_by_uuid( session=session, uuid=tenant.tenant_uuid, ) is not None ) elif tenant.tenant_type == constants.PARENT_COMPANY_TENANT_TYPE: return ( parent_company.ParentCompany.get_by_uuid( session=session, uuid=tenant.tenant_uuid, ) ) is not None elif tenant.tenant_type == constants.COMPANY_BRAND_TENANT_TYPE: return ( company_brand_model.CompanyBrand.get_by_uuid( session=session, uuid=tenant.tenant_uuid, ) ) is not None except orm_exceptions.NoResultFound: return False def resolve_employee_tenants(tenant: Tenant) -> list[Tenant]: """ Resolve the target tenants for employee profile connections. Parent company employees get access to vendor star, and "access" to parent company just for record-keeping purposes. """ tenants = [tenant] if tenant.tenant_type == TenantType.PARENT_COMPANY: tenants.append( Tenant( tenant_type=TenantType.ACCOUNT, tenant_uuid=constants.VENDOR_STAR_UUID, ) ) return tenants