"""Identity v2 Handlers. Endpoints for Settings V2 and newer versions of existing endpoints. All endpoints are prefixed with /v2 and require a JWT token. """ from dataclasses import asdict from flask import g from owsrequest import error_response from owsresponse import response from owsresponse.adaptors.flask import flaskify from pythonfeatures import pythonfeatures from pythonfeatures.constants import split as split_constants import permissions.validations.schemas.identity as identity_schemas from permissions.api import app from permissions.constants import constants, error from permissions.logic import ( identity as identity_logic, tenant as tenant_logic, user_invite, user_notify, user_revoke, user_update, ) from permissions.logic.vendor_star import is_allowed_to_receive_vendor_star from permissions.models import identity as identity_model from permissions.types import AdminIdentity, IdentityInput, Tenant, TenantRolesInput, TenantType from permissions.utils import api_utils, authorization from permissions.validations.schemas.dataloader import IdentitiesDataloaderSchema from permissions.validations.schemas.tenant_access_check import TenantAccessCheckListSchema @app.route('/v2/identities', methods=['POST']) @api_utils.jwt_check @api_utils.validate_request_data(identity_schemas.CreateIdentity()) def v2_create_identity(deserialize_schema): """Create a new identity and assign the first set of roles to its first tenant. Json body: dict: containing first_name, last_name, email, roles_to_attach and tenant. Eg: { "first_name": "Foo", # required "last_name": "Bar", # required "email": "foo@bar.com", # required "roles_to_attach": [ # required "BANKING_TAX_BASE_ROLE" ], "tenant": { # required "tenant_type": "account", "tenant_uuid": "dffedd4d-b88d-444d-a9eb-6ce89aa4d2f6" }, "master_contact": False, # optional "send_invite": True, # optional "localization": "es" # optional } """ identity_uuid = g.request_context.jwt_identity_id edit_super_admins_enabled = ( pythonfeatures.get_single_feature(constants.EDIT_SUPER_ADMINS, g.request_context).message == split_constants.FEATURE_ENABLED ) admin_identity = identity_model.get_identity_by_id_new(identity_id=identity_uuid) if not admin_identity: response.create_not_found_response(f'Identity not found with this id: {identity_uuid}') settings_profile = identity_model.get_identity_settings_profile(identity_uuid) if not settings_profile: return flaskify(error_response.create_error_forbidden_user()) admin = AdminIdentity( id=admin_identity.id, first_name=admin_identity.first_name, last_name=admin_identity.last_name, name=admin_identity.name, email=admin_identity.email, auth0_user_id=admin_identity.auth0_user_id, active=admin_identity.active, user_types=admin_identity.user_types, default_brand=admin_identity.default_brand, settings_profile=settings_profile, ) email = deserialize_schema.get('email') tenant = deserialize_schema.get('tenant') roles = deserialize_schema.get('roles_to_attach') first_name = deserialize_schema.get('first_name') last_name = deserialize_schema.get('last_name') master_contact = deserialize_schema.get('master_contact') send_invite = deserialize_schema.get('send_invite') localization = deserialize_schema.get('localization') tenant_to_add = Tenant( tenant_type=TenantType(tenant['tenant_type']), tenant_uuid=tenant['tenant_uuid'] ) access_to_tenant = tenant_logic.check_admin_access_to_tenant( identity_uuid=identity_uuid, tenant=tenant_to_add, settings_profile=settings_profile ) if not access_to_tenant: return flaskify( response.create_error_response( code=error.ERROR_MESSAGE_FORBIDDEN_USER, message='There is no access to the tenant.', status=403, ) ) if tenant_to_add.is_vendor_star() and ( not edit_super_admins_enabled or not is_allowed_to_receive_vendor_star(email) ): g.log.info( error.INTERNAL_LOGGING_BLOCKED_VENDOR_STAR, resources={ 'admin_identity_id': admin_identity.id, 'identity_email': email, }, ) return flaskify( response.create_error_response( error.ERROR_CODE_BAD_REQUEST, error.ERROR_MESSAGE_CANNOT_ASSIGN_RESOURCE, status=403, ) ) if not tenant_logic.does_tenant_exist(tenant_to_add): return flaskify( response.create_error_response( code=error.ERROR_CODE_NOT_FOUND, message='Tenant not found.', status=404, ) ) valid_roles = tenant_logic.check_compatibility_with_tenant_configuration(tenant_to_add, roles) if not valid_roles: g.log.warn( 'Error creating(updating) identity.' ' The requested role(s) to attach/detach are not available for the tenant.', ) return flaskify( response.create_error_response( code=error.ERROR_MESSAGE_INVALID_DATA, message='Error creating identity.', status=422, ) ) brand = tenant_logic.get_parent_company_brand_for_tenant(tenant_to_add) if not brand: return flaskify( response.create_error_response( code=error.ERROR_MESSAGE_FORBIDDEN_USER, message='No one company brand is associated with the tenant.', status=403, ) ) existing_identity_with_orgs = identity_model.get_identity_with_auth0( admin=admin, email=email, brand=brand, ) if existing_identity_with_orgs: identity_input = existing_identity_with_orgs else: identity_input = IdentityInput(email=email, first_name=first_name, last_name=last_name) tenant_roles_input = TenantRolesInput( roles_to_attach=roles, roles_to_detach=[], tenant=tenant_to_add ) try: identity, vend_contact = user_invite.create_or_update_user( assignee_identity=identity_input, tenant_roles_input=tenant_roles_input, brand=brand, admin_identity=admin, master_contact=master_contact, localization=localization, ) user_notify.notify_user_of_changes( admin=admin, assignee=identity, new_user=isinstance(identity_input, IdentityInput), tenant_roles_input=tenant_roles_input, brand=brand, send_invite=send_invite, vend_contact=vend_contact, ) return flaskify(response.Response({'id': identity.id})) except Exception as e: return flaskify(response.create_fatal_response(e.args)) @app.route('/v2/identities/', methods=['PATCH']) @api_utils.jwt_check @api_utils.validate_request_data(identity_schemas.UpdateIdentity()) def v2_update_identity(identity_uuid: str, deserialize_schema): """Update an identity's access to a tenant by attaching or detaching roles. Example JSON body: { "roles_to_attach": [], "roles_to_detach": ["BANKING_TAX_BASE_ROLE"], "tenant": { "tenant_type": "account", "tenant_uuid": "dffedd4d-b88d-444d-a9eb-6ce89aa4d2f6" } } """ admin_id = g.request_context.jwt_identity_id edit_super_admins_enabled = ( pythonfeatures.get_single_feature(constants.EDIT_SUPER_ADMINS, g.request_context).message == split_constants.FEATURE_ENABLED ) settings_profile = identity_model.get_identity_settings_profile(admin_id) if not settings_profile: return flaskify(error_response.create_error_forbidden_user()) tenant_dict = deserialize_schema.get('tenant') roles_to_attach = deserialize_schema.get('roles_to_attach') roles_to_detach = deserialize_schema.get('roles_to_detach') tenant = Tenant( tenant_type=TenantType(tenant_dict['tenant_type']), tenant_uuid=tenant_dict['tenant_uuid'] ) access_to_tenant = tenant_logic.check_admin_access_to_tenant( identity_uuid=admin_id, tenant=tenant, settings_profile=settings_profile, ) if not access_to_tenant: return flaskify( response.create_error_response( code=error.ERROR_MESSAGE_FORBIDDEN_USER, message='There is no access to the tenant.', status=403, ) ) identity = identity_model.get_identity_by_id_new(identity_uuid) if not identity: return flaskify(error_response.create_error_user_does_not_exist()) if tenant.is_vendor_star() and ( not edit_super_admins_enabled or not is_allowed_to_receive_vendor_star(identity.email) ): g.log.info( error.INTERNAL_LOGGING_BLOCKED_VENDOR_STAR, resources={ 'admin_identity_id': admin_id, 'identity_email': identity.email, }, ) return flaskify( response.create_error_response( error.ERROR_CODE_BAD_REQUEST, error.ERROR_MESSAGE_CANNOT_ASSIGN_RESOURCE, status=403, ) ) if not tenant_logic.does_tenant_exist(tenant): return flaskify( response.create_error_response( code=error.ERROR_CODE_NOT_FOUND, message='Tenant not found.', status=404, ) ) has_valid_roles = tenant_logic.check_compatibility_with_tenant_configuration( tenant, roles_to_attach ) if not has_valid_roles: g.log.warn( 'Error creating(updating) identity.' ' The requested role(s) to attach/detach are not available for the tenant.', resources={'tenant': tenant, 'roles_to_check': roles_to_attach}, ) return flaskify( response.create_error_response( code=error.ERROR_MESSAGE_INVALID_DATA, message='Error updating identity.', status=422, ) ) brand = tenant_logic.get_parent_company_brand_for_tenant(tenant) if not brand: return flaskify( response.create_error_response( code=error.ERROR_MESSAGE_FORBIDDEN_USER, message='No one company brand is associated with the tenant.', status=403, ) ) admin_identity = identity_model.get_identity_by_id_new(admin_id) admin = AdminIdentity( id=admin_identity.id, first_name=admin_identity.first_name, last_name=admin_identity.last_name, name=admin_identity.name, email=admin_identity.email, auth0_user_id=admin_identity.auth0_user_id, active=admin_identity.active, user_types=admin_identity.user_types, default_brand=admin_identity.default_brand, settings_profile=settings_profile, ) if not roles_to_attach: try: is_update_removing_last_tenant = tenant_logic.is_update_removing_last_tenant( admin, identity_uuid, tenant, roles_to_detach ) except ValueError as err: return flaskify( response.create_error_response( code=error.ERROR_CODE_BAD_REQUEST, message=str(err), status=400, ) ) if is_update_removing_last_tenant: return flaskify( response.create_error_response( code=error.ERROR_MESSAGE_INVALID_DATA, message='You are trying to detach access from the last tenant.', status=422, ) ) identity_with_auth0 = identity_model.get_identity_with_auth0_for_existing_identity( admin=admin, existing_identity=identity, email=identity.email, brand=brand, ) tenant_roles_input = TenantRolesInput( roles_to_attach=roles_to_attach, roles_to_detach=roles_to_detach, tenant=tenant, ) vend_contact = user_update.update_user( admin=admin, identity_with_auth0=identity_with_auth0, tenant_roles_input=tenant_roles_input, brand=brand, ) if roles_to_attach: user_notify.notify_user_of_changes( admin=admin, assignee=identity_with_auth0, new_user=False, tenant_roles_input=tenant_roles_input, brand=brand, send_invite=True, vend_contact=vend_contact, ) return flaskify(response.Response({'id': identity_uuid})) @app.route('/v2/identities//tenants//', methods=['DELETE']) @api_utils.jwt_check @api_utils.validate_request_data(identity_schemas.RevokeIdentityTenantAccess()) def v2_revoke_access_to_single_tenant_for_identity( identity_id: str, tenant_type: str, tenant_uuid: str, deserialize_schema: dict ) -> response.Response: """Revoke all access to a single tenant for identity.""" admin_identity_id = g.request_context.jwt_identity_id settings_profile = identity_model.get_identity_settings_profile(admin_identity_id) if not settings_profile: return flaskify(error_response.create_error_forbidden_user()) tenant = Tenant( tenant_uuid=deserialize_schema['tenant_uuid'], tenant_type=TenantType(deserialize_schema['tenant_type']), ) access_to_tenant = tenant_logic.check_admin_access_to_tenant( identity_uuid=admin_identity_id, tenant=tenant, settings_profile=settings_profile, ) if not access_to_tenant: return flaskify( response.create_error_response( code=error.ERROR_MESSAGE_FORBIDDEN_USER, message='There is no access to the tenant.', status=403, ) ) if not tenant_logic.does_tenant_exist(tenant): return flaskify( response.create_error_response( code=error.ERROR_CODE_NOT_FOUND, message='Tenant not found.', status=404, ) ) user_revoke.revoke_access_to_tenant_for_identity( admin_identity_id=admin_identity_id, admin_profile_id=settings_profile.profile_id, identity_id=identity_id, tenant=tenant, ) return flaskify(response.Response(status=204)) @app.route('/v2/identities/', methods=['DELETE']) @api_utils.jwt_check def v2_revoke_all_access_for_identity( identity_id: str, ) -> response.Response: """Revoke access to all tenants and deactivate identity. V2 version of /deactivate/identity/. Deletes tenant access for tenants user has in common with the calling admin. If all the tenant access is deleted, then it disables/blocks the user in auth0. """ admin_identity_id = g.request_context.jwt_identity_id settings_profile = identity_model.get_identity_settings_profile(admin_identity_id) if not settings_profile: return flaskify(error_response.create_error_forbidden_user()) identity = identity_model.get_identity_by_id_new(identity_id) if not identity: return flaskify(error_response.create_error_user_does_not_exist()) user_revoke.revoke_access_to_all_tenants_for_identity( admin_id=admin_identity_id, admin_profile_id=settings_profile.profile_id, identity=identity, ) return flaskify(response.Response(status=204)) @app.route('/v2/identity//tenants', methods=['GET']) @api_utils.jwt_check def get_adminable_tenants_for_identity(identity_uuid): """Get tenants the given identity has access to that the calling user can administer.""" admin_identity_id = g.request_context.jwt_identity_id settings_profile = identity_model.get_identity_settings_profile(admin_identity_id) if not settings_profile: return flaskify(error_response.create_error_forbidden_user()) admin_context = { 'identity_id': admin_identity_id, 'profile_id': settings_profile.profile_id, } result = tenant_logic.get_adminable_tenants_for_identity( admin_context=admin_context, identity_id=identity_uuid, ) return flaskify(response.Response({'tenants': [asdict(r) for r in result]})) @app.route('/v2/identity/self/tenant-types', methods=['GET']) @api_utils.jwt_check def get_my_adminable_tenant_types(): """Get all tenant types that the admin can administer access to. Returns: (list): List of objects containing tenant_type and tenant_count. tenant_count returns 1 if the requesting admin can administer access to just 1 corresponding tenant type, otherwise tenant_count is set to > 1 if the requesting admin can administer access to multiple corresponding tenant types. Note: tenant_count is not reflective of the exact count of tenants the admin can administer access to. Eg: [ { "tenant_type": "account", "tenant_count": 1 }, { "tenant_type": "label_participant", "tenant_count": 2 }, { "tenant_type": "collaborator", "tenant_count": 2 } ] """ identity_id = g.request_context.jwt_identity_id settings_profile = identity_model.get_identity_settings_profile(identity_id) if not settings_profile: return flaskify(error_response.create_error_forbidden_user()) return flaskify( tenant_logic.get_my_adminable_tenant_types( identity_id=identity_id, settings_profile=settings_profile ) ) @app.route('/v2/identity/self/tenant-access', methods=['POST']) @api_utils.jwt_check @api_utils.validate_request_data(TenantAccessCheckListSchema()) def check_my_admin_access_to_tenants(deserialize_schema): """Check if the user has admin access to the given tenants.""" identity_id = g.request_context.jwt_identity_id settings_profile = identity_model.get_identity_settings_profile(identity_id) if not bool(settings_profile): return flaskify(error_response.create_error_forbidden_user()) tenants_payload = deserialize_schema['tenants'] tenants = [ Tenant(tenant_uuid=t['tenant_uuid'], tenant_type=TenantType(t['tenant_type'])) for t in tenants_payload ] tenants = tenant_logic.check_admin_access_to_tenants( identity_uuid=identity_id, tenants=tenants, settings_profile=settings_profile ) return flaskify(response.Response({'tenants': [asdict(t) for t in tenants]})) @app.route('/v2/identity/self/all-label-access') @api_utils.jwt_check def check_my_all_label_access(): """ Get whether the identity of the caller has all label access and ability to grant that access. The identity must have a settings profile with HAS_ADMIN_ACCESS_TO to the Vendor * node and the identity id must be in the settings_app_edit_super_admins feature flag. """ identity_id = g.request_context.jwt_identity_id settings_profile = identity_model.get_identity_settings_profile(identity_id) if not settings_profile: return flaskify(error_response.create_error_forbidden_user()) has_access = identity_logic.is_identity_super_admin( request_context=g.request_context, identity_id=identity_id ) return flaskify(response.Response({'has_access': has_access})) @app.route('/v2/identities/tenants/dataloader', methods=['POST']) @api_utils.jwt_check @api_utils.validate_request_data(IdentitiesDataloaderSchema()) def get_adminable_tenants_for_identities_dataloader(deserialize_schema): """Get adminable tenants for multiple identities in a single request. This is a dataloader-style endpoint that fetches tenants for multiple identities in a single database query. Results maintain order and return None for identities that have no tenants or that the admin doesn't have access to. Json body: dict: containing identity_uuids. Example: { "identity_uuids": [ "dffedd4d-b88d-444d-a9eb-6ce89aa4d2f6", "e3f9c8b7-a1d2-4e5f-b6c7-8d9e0f1a2b3c" ] } Returns: dict: A dictionary with 'identities' key containing a list of identity tenant data, ordered by the input identity_uuids with None for missing/inaccessible identities. Example: { "identities": [ { "identity_uuid": "dffedd4d-b88d-444d-a9eb-6ce89aa4d2f6", "tenants": [ { "tenant_type": "account", "tenant_uuid": "abc123...", "roles": ["BANKING_TAX_BASE_ROLE"] } ] }, null ] } """ identity_id = g.request_context.jwt_identity_id identity_uuids = deserialize_schema.get('identity_uuids') # SEAT path: full catalog access for ALL identities if authorization.pdp_authorize_manage_employee(): admin_context = { 'identity_id': identity_id, 'profile_id': None, } result = tenant_logic.get_adminable_tenants_for_identities_dataloader( identity_uuids=identity_uuids, admin_context=admin_context, is_seater=True, ) identities = [asdict(r) if r else None for r in result] return flaskify(response.Response({'identities': identities})) else: # Fall back to existing settings profile logic settings_profile = identity_model.get_identity_settings_profile(identity_id) if not settings_profile: return flaskify(error_response.create_error_forbidden_user()) admin_context = { 'identity_id': identity_id, 'profile_id': settings_profile.profile_id, } result = tenant_logic.get_adminable_tenants_for_identities_dataloader( identity_uuids=identity_uuids, admin_context=admin_context, is_seater=False, ) # TODO null out employee identities or error. # This is still needed because employees are given Fansifter roles in Settings V2. # identities = [ # asdict(r) if r and not employee_status_map.get(str(r.identity_uuid)) else None # for r in result # ] identities = [asdict(r) if r else None for r in result] return flaskify(response.Response({'identities': identities}))