"""Logic for updating a user's permissions for a given tenant.""" import neo4j import neo4j.exceptions import pymysql import sqlalchemy from flask import g from permissions import types from permissions.connectors import mysql, neo4j as neo4j_connector from permissions.constants import application, 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, user_invite, vend_contact as vend_contact_logic, ) from permissions.models import ( profile as profile_model, vend_contact as vend_contact_model, vend_contact_role as vend_contact_role_model, vendor_role as vendor_role_model, ) def update_employee( admin: types.AdminIdentity, identity_with_auth0: types.IdentityWithAuth0, tenant_roles_input: types.TenantRolesInput, brand: str | None = None, ) -> None: """Update an employee's permissions. Args: admin: The admin making the change. identity_with_auth0: The identity to update. tenant_roles_input: The roles to attach/detach. brand: Optional brand for reactivation cases. """ reactivate_needed = False if tenant_roles_input.roles_to_attach: reactivate_needed = identity_logic.reactivate_user_if_needed( admin=admin, identity_with_auth0=identity_with_auth0, ) with neo4j_connector.db_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: try: # We can put attach/detach under one transaction because we don't have to get # a vend contact id and therefore don't need to commit early tx = session.begin_transaction() if tenant_roles_input.roles_to_attach: user_invite.update_employee_with_v2_roles( tx=tx, admin_identity=admin, assignee_identity=identity_with_auth0, tenant_roles_input=tenant_roles_input, ) if tenant_roles_input.roles_to_detach: detach_v2_roles( tx=tx, roles=tenant_roles_input.roles_to_detach, identity=identity_with_auth0, admin_identity=admin, tenant=tenant_roles_input.tenant, is_employee=True, ) tx.commit() except ( neo4j.exceptions.Neo4jError, IncompleteResultError, ) as e: tx.rollback() raise e if reactivate_needed and brand: # After reactivation and tenant add, ensure default brand is still valid try: default_brand.update_default_brand_if_needed( identity=identity_with_auth0, admin_identity_id=admin.id, company_brand=brand, ) except Exception as e: g.log.error( 'Error updating default brand after reactivation--user is still reactivated', resources={ 'identity_id': identity_with_auth0.id, 'error': str(e), }, ) def update_user( admin: types.AdminIdentity, identity_with_auth0: types.IdentityWithAuth0, tenant_roles_input: types.TenantRolesInput, brand: str, ) -> vend_contact_model.VendContact | None: """Update a user's profiles/roles given lists of v2 roles to attach/detach. Args: admin: The admin making the change. identity_with_auth0: The identity to update. tenant_roles_input: The roles to attach/detach. brand: The brand to update the user for. Returns: vend_contact if roles were attached, None otherwise. """ tenant = tenant_roles_input.tenant vend_contact = None if tenant_roles_input.roles_to_attach: reactivate_needed = identity_logic.reactivate_user_if_needed( admin=admin, identity_with_auth0=identity_with_auth0, ) with neo4j_connector.db_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: try: tx = session.begin_transaction() # Will log if a connection was already there vend_contact = user_invite.update_identity_with_v2_roles( tx=tx, admin_identity=admin, assignee_identity=identity_with_auth0, tenant_roles_input=tenant_roles_input, # Will not set an existing master contact to N master_contact=False, ) except ( neo4j.exceptions.Neo4jError, IncompleteResultError, ) as e: tx.rollback() raise e if reactivate_needed: # After reactivation and tenant add, ensure default brand is still valid try: default_brand.update_default_brand_if_needed( identity=identity_with_auth0, admin_identity_id=admin.id, company_brand=brand, ) except Exception as e: g.log.error( 'Error updating default brand after reactivation--user is still reactivated', resources={ 'identity_id': identity_with_auth0.id, 'error': str(e), }, ) if tenant_roles_input.roles_to_detach: with neo4j_connector.db_session(access_mode=constants.NEO4j_WRITE_ACCESS) as session: try: tx = session.begin_transaction() detach_v2_roles( tx=tx, roles=tenant_roles_input.roles_to_detach, identity=identity_with_auth0, admin_identity=admin, tenant=tenant, ) tx.commit() except ( neo4j.exceptions.Neo4jError, IncompleteResultError, ) as e: tx.rollback() raise e return vend_contact def detach_v2_roles( tx: neo4j.Transaction, roles: list[str], identity: types.IdentityWithAuth0, admin_identity: types.AdminIdentity, tenant: types.Tenant, is_employee: bool = False, ): """Remove the given roles from a user's roles for a tenant. For employees, roles are detached from all resolved tenants (parent company + vendor star). For non-employees, roles are detached from the specified tenant only. """ # Filter out unrecognized roles roles = [role for role in roles if role in application.ROLE_IDS_LIST] profiles_and_roles = profile_logic.v2_roles_to_profiles_and_roles_dict(roles) # Handle label profiles separately, as they are multi-role label_profile_roles = profiles_and_roles.pop(constants.LABELPROFILE, None) # Resolve target tenants based on whether this is an employee target_tenants = tenant_logic.resolve_employee_tenants(tenant) if is_employee else [tenant] if profiles_and_roles: for target_tenant in target_tenants: for profile_type, roles in profiles_and_roles.items(): result = profile_model.delete_access_for_profile( tx=tx, audit_user_id=admin_identity.id, identity_id=identity.id, # We don't have profile id right now but it's fine, the method can handle it profile_id=None, profile_type=profile_type, resource_type=constants.TENANT_TYPE_TO_NEO_MAPPING[ target_tenant.tenant_type.value ], resource_uuid=target_tenant.tenant_uuid, ) if not result: g.log.warn( 'Tried to delete access to tenant, but access did not exist', resources={ 'identity_id': identity.id, 'tenant_type': target_tenant.tenant_type, 'tenant_uuid': target_tenant.tenant_uuid, }, ) if label_profile_roles and not is_employee: detach_label_profile_roles( tx=tx, label_profile_roles=label_profile_roles, identity=identity, admin_identity=admin_identity, tenant=tenant, ) def detach_label_profile_roles( tx: neo4j.Transaction, label_profile_roles: list[str], identity: types.IdentityWithAuth0, admin_identity: types.AdminIdentity, tenant: types.Tenant, ): """Remove the given label profile roles from a user.""" tenant_uuid = tenant.tenant_uuid existing_profile = profile_model.get_label_profile_by_identity_and_tenant( tx=tx, identity_id=identity.id, tenant=tenant ) if existing_profile: # Assuming roles on profile are accurate and correspond with mysql... profile_id = existing_profile['profile_id'] existing_roles_set = set(existing_profile['roles']) roles_to_detach_set = set(label_profile_roles) roles_to_detach_that_arent_there = roles_to_detach_set - existing_roles_set if roles_to_detach_that_arent_there: g.log.warn( 'Tried to remove label profile roles that user does not have', resources={ 'identity_id': identity.id, 'profile_id': profile_id, 'roles_to_detach_that_arent_there': roles_to_detach_that_arent_there, }, ) new_roles = list(existing_roles_set - roles_to_detach_set) profile_model.create_or_update_label_profile_with_tenant_relationship( tx=tx, identity_id=identity.id, profile_id=profile_id, roles=new_roles, tenant=tenant, audit_user_id=admin_identity.id, ) detach_label_profile_roles_for_vend_contact( identity=identity, vend_contact_id=profile_id, roles_to_detach_set=roles_to_detach_set, ) if existing_roles_set - roles_to_detach_set == set(): # No roles left--time to soft-delete profile_model.delete_access_for_profile( tx=tx, audit_user_id=admin_identity.id, identity_id=identity.id, profile_id=profile_id, profile_type=constants.LABELPROFILE, resource_type=constants.TENANT_TYPE_TO_NEO_MAPPING[tenant.tenant_type.value], resource_uuid=tenant_uuid, ) # Also set new vend_contact_id on auth0 metadata, in case it was this one with mysql.db_session() as sqlalchemy_session: vend_contact_logic.set_new_auth0_vend_contact_id( session=sqlalchemy_session, identity=identity, admin_id=admin_identity.id, admin_profile_id=admin_identity.settings_profile.profile_id, ) else: g.log.warn( 'Tried to remove label profile roles, but profile did not exist', resources={ 'identity_id': identity.id, 'tenant_type': tenant.tenant_type, 'tenant_uuid': tenant.tenant_uuid, }, ) def detach_label_profile_roles_for_vend_contact( identity: types.IdentityWithAuth0, vend_contact_id: int, roles_to_detach_set: set[str], ): """Remove the given label profile roles for a vendor contact.""" with mysql.db_session() as mysql_session: existing_roles_ids = vend_contact_role_model.VendContactRole.get_role_ids_by_vend_contact( tx=mysql_session, vend_contact_id=vend_contact_id, ) roles_ids_to_detach = vendor_role_model.vendor_role_ids_from_label_profile_roles( session=mysql_session, roles=list(roles_to_detach_set) ) # set(exist[1, 2, 3]) & set(detach[2, 3, 4]) => [2,3] - detach only existing roles roles_to_detach = list(set(existing_roles_ids) & set(roles_ids_to_detach)) # set(exist[1, 2, 3]) - set(detach[2, 3, 4]) => [1,] - if [] deactivate vend-contact roles_after_detach = list(set(existing_roles_ids) - set(roles_ids_to_detach)) try: if roles_to_detach: vend_contact_role_model.VendContactRole.delete_roles_by_ids_and_vend_contact_id( tx=mysql_session, role_ids=roles_to_detach, vend_contact_id=vend_contact_id, ) if not roles_after_detach: vend_contact_model.VendContact.deactivate_by_id( mysql_session, vend_contact_id=vend_contact_id, ) except ( sqlalchemy.exc.SQLAlchemyError, sqlalchemy.exc.DatabaseError, pymysql.err.DatabaseError, ) as err: g.log.error( 'Error detaching roles for vend_contact.', resources={ 'error': err, 'identity_id': identity.id, 'vend_contact_id': vend_contact_id, 'detaching_roles': roles_ids_to_detach, 'existing_roles': existing_roles_ids, }, ) raise IncompleteResultError(message=str(err))