"""Logic related to inviting a user to a tenant.""" import neo4j from flask import g from sqlalchemy import orm from permissions.connectors import mysql, 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.logic import ( default_brand, identity as identity_logic, profile as profile_logic, tenant as tenant_logic, vend_contact as vend_contact_logic, ) from permissions.models import ( identity as identity_model, profile as profile_model, subaccount, vend_contact as vend_contact_model, vend_contact_role, vendor, vendor_role, ) from permissions.types import ( AdminIdentity, Identity, IdentityInput, IdentityWithAuth0, Tenant, TenantRolesInput, TenantType, ) from permissions.utils import email from permissions.utils.brand_utils import default_brand_from_brand def _create_and_update_profiles_from_v2_roles( tx: neo4j.Transaction, identity: Identity, v2_roles: list[str], audit_user_id: str, ) -> tuple[dict[str, list[str]], list[str] | None]: """ Create and update profiles from v2 roles. Helper function to handle the common logic of converting v2 roles to profiles, creating profile role dicts, and updating/creating profiles in Neo4j. Args: tx: Neo4j transaction identity: The identity to add profiles to v2_roles: List of v2 role names to convert to profiles audit_user_id: Identity ID of the user performing this action Returns: Tuple of (profiles_and_roles dict without label profile, label_profile_roles or None) """ profiles_and_roles = profile_logic.v2_roles_to_profiles_and_roles_dict(v2_roles) # Handle label profile separately, as it must have a specific id label_profile_roles = profiles_and_roles.pop(constants.LABELPROFILE, None) profile_role_dicts = [ {'profile_type': profile_type, 'roles': roles} for profile_type, roles in profiles_and_roles.items() ] # Everyone should have a settings profile even if not admin :) if constants.SETTINGSPROFILE not in profiles_and_roles: profile_role_dicts.append({'profile_type': constants.SETTINGSPROFILE, 'roles': []}) profile_model.update_existing_profiles_with_roles( tx=tx, identity_id=identity.id, profile_types_and_roles=profile_role_dicts, audit_user=audit_user_id, ) profile_model.create_profiles_if_not_exist( tx=tx, identity_id=identity.id, profile_name=identity.name, profile_types_and_roles=profile_role_dicts, audit_user=audit_user_id, ) return profiles_and_roles, label_profile_roles def add_profiles_to_identity_from_v2_roles( tx: neo4j.Transaction, identity: Identity, tenant_roles_input: TenantRolesInput, audit_user_id: str, master_contact: bool, ) -> vend_contact_model.VendContact | None: """Add profiles to an identity from v2 roles, including label profile handling.""" tenant = tenant_roles_input.tenant profiles_and_roles, label_profile_roles = _create_and_update_profiles_from_v2_roles( tx=tx, identity=identity, v2_roles=tenant_roles_input.roles_to_attach, audit_user_id=audit_user_id, ) _add_tenant_connections_for_profiles( tx=tx, identity_id=identity.id, profiles_and_roles=profiles_and_roles, tenant=tenant, audit_user_id=audit_user_id, ) if label_profile_roles: return add_label_profile_with_tenant_relationship( tx=tx, identity=identity, tenant=tenant, roles=label_profile_roles, audit_user_id=audit_user_id, master_contact=master_contact, ) return None def _add_tenant_connections_for_profiles( tx: neo4j.Transaction, identity_id: str, profiles_and_roles: dict[str, list[str]], tenant: Tenant, audit_user_id: str, ) -> None: """Connect profiles to a tenant in Neo4j.""" for profile_type in profiles_and_roles: profile_model.add_tenant_connection_to_profile_and_clear_cache( tx=tx, identity_id=identity_id, profile_type=profile_type, tenant=tenant, audit_user_id=audit_user_id, ) def add_profiles_to_employee_from_v2_roles( tx: neo4j.Transaction, identity: Identity, tenant_roles_input: TenantRolesInput, audit_user_id: str, ) -> None: """Add profiles to an employee identity from v2 roles.""" g.log.info( 'Adding profiles to employee identity', resources={ 'identity_id': identity.id, 'tenant_uuid': tenant_roles_input.tenant.tenant_uuid, 'roles': tenant_roles_input.roles_to_attach, 'admin_identity_id': audit_user_id, }, ) # Create profiles (ignore label profile roles - employees don't get those) profiles_and_roles, _ = _create_and_update_profiles_from_v2_roles( tx=tx, identity=identity, v2_roles=tenant_roles_input.roles_to_attach, audit_user_id=audit_user_id, ) # We add connections to vendor * AND (for now) to the parent company nodes, though the # latter will have no effect. It's just to keep track of what was assigned. target_tenants = tenant_logic.resolve_employee_tenants(tenant_roles_input.tenant) for target_tenant in target_tenants: _add_tenant_connections_for_profiles( tx=tx, identity_id=identity.id, profiles_and_roles=profiles_and_roles, tenant=target_tenant, audit_user_id=audit_user_id, ) def update_identity_with_v2_roles( tx: neo4j.Transaction, admin_identity: AdminIdentity, assignee_identity: IdentityWithAuth0, tenant_roles_input: TenantRolesInput, master_contact: bool, ) -> vend_contact_model.VendContact | None: """Add profiles/roles to an existing identity. Returns: vend_contact if a label profile was created/updated, otherwise None. """ vend_contact = add_profiles_to_identity_from_v2_roles( tx=tx, identity=assignee_identity, tenant_roles_input=tenant_roles_input, audit_user_id=admin_identity.id, master_contact=master_contact, ) tx.commit() return vend_contact def update_employee_with_v2_roles( tx: neo4j.Transaction, admin_identity: AdminIdentity, assignee_identity: IdentityWithAuth0, tenant_roles_input: TenantRolesInput, ) -> str: """Add profiles/roles to an existing employee identity.""" add_profiles_to_employee_from_v2_roles( tx=tx, identity=assignee_identity, tenant_roles_input=tenant_roles_input, audit_user_id=admin_identity.id, ) return assignee_identity.id def add_label_profile_with_tenant_relationship( tx: neo4j.Transaction, identity: Identity, tenant: Tenant, roles: list[str], audit_user_id: str, master_contact: bool, ) -> vend_contact_model.VendContact | None: """ Create a label profile with a tenant relationship unless it already exists. Includes creating contact, vend_contact, and vend_contact_roles unless they already exist. """ roles_to_set = roles existing_profile = profile_model.get_label_profile_by_identity_and_tenant( tx=tx, identity_id=identity.id, tenant=tenant ) # Records are committed upon exiting the context with mysql.db_session() as session: # This ensures our objects don't get detached from the session after committing, which # would prevent us from refreshing their data (in our case, getting the autoincremented id # from the vend_contact record) session.expire_on_commit = False try: if existing_profile: if existing_profile['tenant_relationship'] == 'HAS_ACCESS_TO': roles_to_set = list(set(existing_profile['roles'] + roles)) else: # If the existing profile has a deleted relationship, remove vend_contact_roles # that might be left over since v1 doesn't delete them upon revocation vend_contact_role.VendContactRole.delete_roles_by_vend_contact_id( session=session, vend_contact_id=existing_profile['profile_id'], ) g.log.info( 'Existing label profile found for identity and tenant', resources={ 'identity_id': identity.id, 'tenant_type': tenant.tenant_type.value, 'tenant_uuid': tenant.tenant_uuid, 'admin_identity_id': audit_user_id, }, ) vend_contact = _create_vend_contact_records( session=session, roles=roles_to_set, tenant=tenant, identity=identity, master_contact=master_contact, existing_profile=existing_profile, ) profile_id = vend_contact.id profile_model.create_or_update_label_profile_with_tenant_relationship( tx=tx, identity_id=identity.id, profile_id=profile_id, roles=roles_to_set, tenant=tenant, audit_user_id=audit_user_id, ) return vend_contact except Exception as e: g.log.error( 'Error creating vend_contact records or label profile for user', resources={ 'error': e, 'tenant_type': tenant.tenant_type.value, 'tenant_uuid': tenant.tenant_uuid, 'identity_id': identity.id, }, ) # Records in the sqlalchemy session are rolled back upon re-raise # top level exception handling will catch this and rollback the neo4j transaction raise IncompleteResultError(message=str(e)) def _create_vend_contact_records( session: orm.session.Session, roles: list[str], tenant: Tenant, identity: Identity, master_contact: bool, existing_profile: dict | None, ) -> vend_contact_model.VendContact: role_ids = vendor_role.vendor_role_ids_from_label_profile_roles(session=session, roles=roles) vendor_id, subaccount_id = get_vendor_and_subaccount_id_for_vend_contact( session=session, tenant=tenant ) return vend_contact_logic.create_vend_contact_and_roles( session=session, role_ids=role_ids, identity=identity, vendor_id=vendor_id, subaccount_id=subaccount_id, master_contact=master_contact, existing_profile=existing_profile, ) def get_vendor_and_subaccount_id_for_vend_contact( session: orm.session.Session, tenant: Tenant, ) -> tuple[int, int | None]: """Return a tuple of (vendor id, subaccount id) given the uuid of a vendor or subaccount.""" subaccount_id = None # We trust that it's one of these types because we validated roles to tenant type earlier if tenant.tenant_type == TenantType.ACCOUNT: vendor_id = vendor.get_vendor_id_by_uuid(session=session, uuid=tenant.tenant_uuid) return vendor_id, subaccount_id elif tenant.tenant_type == TenantType.SUBACCOUNT: subacc = subaccount.get_subaccount_by_uuid(session=session, uuid=tenant.tenant_uuid) vendor_id = subacc.vendor_id subaccount_id = subacc.subaccount_id return vendor_id, subaccount_id else: raise ValueError('Invalid tenant type') def create_identity_with_v2_roles( tx: neo4j.Transaction, admin_identity: AdminIdentity, assignee_identity: IdentityInput, tenant_roles_input: TenantRolesInput, brand: str, master_contact: bool, localization: str | None, ) -> tuple[Identity, vend_contact_model.VendContact | None]: """Create an identity and add profiles/roles to it. Returns: Tuple of (identity_id, vend_contact). """ identity = identity_model.create_identity( session=tx, audit_user_id=admin_identity.id, first_name=assignee_identity.first_name, last_name=assignee_identity.last_name, email=assignee_identity.email, default_brand=default_brand_from_brand(brand), tenant_type=tenant_roles_input.tenant.tenant_type, is_employee=email.is_employee_email(assignee_identity.email), # Default to English locale if not provided localization=localization or constants.LOCALES['English'], ) vend_contact = add_profiles_to_identity_from_v2_roles( tx=tx, identity=identity, tenant_roles_input=tenant_roles_input, audit_user_id=admin_identity.id, master_contact=master_contact, ) tx.commit() return identity, vend_contact def create_or_update_user( admin_identity: AdminIdentity, assignee_identity: IdentityWithAuth0 | IdentityInput, tenant_roles_input: TenantRolesInput, brand: str, master_contact: bool, localization: str | None, ) -> tuple[Identity, vend_contact_model.VendContact | None]: """ Create or update an identity in Neo4j and return the identity id and vend contact. Args: admin_identity(AdminIdentity): Admin identity data. assignee_identity (IdentityWithAuth0 | IdentityInput): Existing identity with Auth0 data, or input params for a new identity. tenant_roles_input(TenantRolesInput): The roles to attach. brand (str): Brand that should be assigned to the identity. master_contact (bool): Master contact flag. localization (str | None): Locale for the new identity. Returns: Tuple of (identity_id, vend_contact). """ with neo4j_connector.db_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: tx: neo4j.Transaction = session.begin_transaction() try: if isinstance(assignee_identity, IdentityInput): identity, vend_contact = create_identity_with_v2_roles( tx=tx, admin_identity=admin_identity, assignee_identity=assignee_identity, tenant_roles_input=tenant_roles_input, brand=brand, master_contact=master_contact, localization=localization, ) return identity, vend_contact else: # Reactivate user if needed before adding roles reactivate_needed = identity_logic.reactivate_user_if_needed( admin=admin_identity, identity_with_auth0=assignee_identity, ) vend_contact = update_identity_with_v2_roles( tx=tx, admin_identity=admin_identity, assignee_identity=assignee_identity, tenant_roles_input=tenant_roles_input, master_contact=master_contact, ) # After reactivation and tenant add, ensure default brand is still valid if reactivate_needed: try: default_brand.update_default_brand_if_needed( identity=assignee_identity, admin_identity_id=admin_identity.id, company_brand=brand, ) except Exception as e: g.log.error( 'Error updating default brand after reactivation--user is still reactivated', resources={ 'identity_id': assignee_identity.id, 'error': str(e), }, ) return assignee_identity, vend_contact except Exception as err: if not tx.closed(): tx.rollback() sentry_client.capture_exception(err) raise err def create_employee( assignee_identity: IdentityInput, admin_identity: AdminIdentity, tenant_roles_input: TenantRolesInput, brand: str | None, ) -> Identity: """ Create an employee identity and add profiles/roles to it. Args: assignee_identity(IdentityInput): Identity input data for the employee to be created. admin_identity(AdminIdentity): Admin identity data. tenant_roles_input(TenantRolesInput): The roles to attach. brand (str | None): Brand that should be assigned to the identity. None if account tenant. Returns: (Identity): The created employee identity. """ g.log.info('Creating employee identity', resources={'admin_identity_id': admin_identity.id}) if not brand: brand = tenant_logic.get_parent_company_brand_for_tenant(tenant_roles_input.tenant) with neo4j_connector.db_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: tx: neo4j.Transaction = session.begin_transaction() try: identity = identity_model.create_identity( session=tx, audit_user_id=admin_identity.id, first_name=assignee_identity.first_name, last_name=assignee_identity.last_name, email=assignee_identity.email, default_brand=default_brand_from_brand(brand), tenant_type=tenant_roles_input.tenant.tenant_type, localization=constants.LOCALES['English'], is_employee=True, ) add_profiles_to_employee_from_v2_roles( tx=tx, identity=identity, tenant_roles_input=tenant_roles_input, audit_user_id=admin_identity.id, ) tx.commit() return identity except Exception as err: if not tx.closed(): tx.rollback() sentry_client.capture_exception(err) raise err