"""All identity endpoints.""" from connector_neo4j import Neo4jSession from flask import g, request from owsrequest import access as flask_access, error_response, flask_request from owsresponse import response from owsresponse.adaptors.flask import flaskify from users import config, constants from users.app import app from users.exceptions import device_not_found_error from users.logic import ( auth0_application_access, auth0_client, devices, profiles, user_info, zendesk, ) from users.logic.auth0_application_access import AppAccessCheckError from users.models.ows_pdp import OwsPdpError from users.utils import authorization from users.utils.api_utils import jwt_check, validate_request_data from users.validation.schemas.device import DeviceSchema from users.validation.schemas.identity import Identity as IdentitySchema from users.validation.schemas.logout_mobile import LogoutMobile @app.route('/users/identity//device', methods=['POST']) @validate_request_data(DeviceSchema()) @Neo4jSession(transaction=True, use_v2=True, database=constants.NEO4J_DATABASE_NAME) def add_user_device(identity_id): """Add push notification device to graphdb. Args: identity_id (str): Identity id (auth0 id). Body: data (json): object representing the push token info push_token (str): push notification token platform_type (str): the type of platform (can be 'android' or 'ios') device_id (str): device id localization (str): Language set on the device brand (str): *deprecated, uses identity.defaultBrand App brand ie orchard, awal, sme """ data = request.get_json() try: return flaskify(devices.add_push_notification_device(identity_id, data, g.correlation_id)) # Occurs if a platform endpoint already exists but has different attributes, and then when # we try to update the attributes, we can't get the device by push token. except device_not_found_error.DeviceNotFoundError as e: return flaskify( response.create_error_response( code=constants.ERROR_CODE_NOT_FOUND, message=str(e), status=404 ) ) @app.route('/logout/identity/', methods=['POST']) @validate_request_data(LogoutMobile()) @Neo4jSession(transaction=True, use_v2=True, database=constants.NEO4J_DATABASE_NAME) def logout_mobile_device(identity_id): """Logout a mobile device by deleting the refresh token and device token. Args: identity_id (str): Identity id (auth0 id). Body: data (json): object representing the push token info refresh_token (str): auth0 refresh token device_id (str): mobile device id used to register the device. """ data = request.get_json() graph_result = devices.delete_push_notification_device(identity_id, data['device_id']) if graph_result or graph_result.status == 404: return flaskify(auth0_client.revoke_refresh_token(data['refresh_token'])) return flaskify(graph_result) @app.route('/users/identity//device', methods=['GET']) @Neo4jSession(use_v2=True, database=constants.NEO4J_DATABASE_NAME) def get_user_device(identity_id): """Get push notification device from graphdb. Args: identity_id (str): Identity id (auth0 id). """ return flaskify(devices.get_push_notification_device(identity_id)) @app.route('/users/identity/email/', methods=['GET']) @Neo4jSession() def get_identity_by_email(email): """Get identity details by email. Args: email (str): Users email. """ if ( g.request_context.context_type == constants.PROFILE_CONTEXT_TYPE and g.request_context.profile_type == constants.SETTINGS_PROFILE ): # do extra permission check admin_context = { 'identity_id': g.request_context.identity_id, 'profile_type': g.request_context.profile_type, 'profile_id': int(g.request_context.profile_id), } return flaskify(profiles.get_identity_for_admin_by_email(admin_context, email)) return flaskify(profiles.get_identity_by_email(email.lower())) @app.route('/users/identity/auth0/', methods=['GET']) @Neo4jSession(use_v2=True, database=constants.NEO4J_DATABASE_NAME) def get_identity_by_auth0_id(auth0_user_id): """Get identity details by auth0 user id. Args: auth0_user_id (str): Auth0 user id. """ # forbidden in production: we have all users in auth0 so we can fetch from there if config.ENVIRONMENT == config.PROD_ENVIRONMENT: return flaskify(error_response.create_error_forbidden()) return flaskify(profiles.get_identity_by_auth0_id(auth0_user_id)) @app.route('/users/identity/email//valid-invitation', methods=['GET']) @Neo4jSession() @jwt_check def check_valid_invitation(email): """Check if email has a valid Auth0 invitation. Args: email (str): User's email to check. Returns: dict: JSON response with valid boolean flag. Raises: Forbidden: If not accessed through admin context. """ authorized = authorization.pdp_authorize_validate_invitation() if not authorized: return flaskify(error_response.create_error_forbidden_user()) return flaskify(user_info.verify_auth0_invitation(email.lower())) @app.route('/users/identity//device/', methods=['DELETE']) @Neo4jSession(transaction=True, use_v2=True, database=constants.NEO4J_DATABASE_NAME) def delete_user_device(identity_id, device_id): """Remove push notification device from graphdb and DNS. Args: identity_id (str): Identity id (auth0 id). device_id (str): Device identifier brand (str): App brand ie orchard, awal, sme """ brand = request.args.get('brand') return flaskify(devices.delete_push_notification_device(identity_id, device_id, brand)) @app.route('/users/identity/email/', methods=['PATCH']) @validate_request_data(IdentitySchema(), partial=True) @Neo4jSession(transaction=True, use_v2=True, database=constants.NEO4J_DATABASE_NAME) def update_identity_by_email(email): """Update existing identity node by email. Body: identity (json): object representing the identity to be updated -name (str): full name of user associated to identity -identity_id (str): identity id. -auth0_user_id (str): auth0 user id. -google_user_id (str): google oauth user id. Args: email (str): User Email. """ data = request.get_json() return flaskify(profiles.update_identity_by_email(email.lower(), data)) @app.route('/ows/users/identity/email/', methods=['PATCH']) @validate_request_data(IdentitySchema(), partial=True) @Neo4jSession(transaction=True, use_v2=True, database=constants.NEO4J_DATABASE_NAME) def update_identity_by_email_backend(email): """Update existing identity node by email only by backend services. Note: This allows updating the id attribute as well. Body: identity (json): object representing the identity to be updated -name (str): full name of user associated to identity -identity_id (str): identity id. -auth0_user_id (str): auth0 user id. -google_user_id (str): google oauth user id. Args: email (str): User Email. """ data = request.get_json() return flaskify(profiles.update_identity_and_id_by_email(email.lower(), data)) # Alias under /auth0/users/... so callers behind the ows-users-proxy ALB (allowlist # requires /auth0/users*, prod does not allow /identities/*) can reach this handler. @app.route( '/auth0/users/identities//application-access/', methods=['GET'], endpoint='get_application_access_auth0_users', ) @app.route('/identities//application-access/', methods=['GET']) @jwt_check def get_application_access(identity_id, app_name): """Get whether a user can access a particular auth0 application. Params: identity_id (str): Identity id. app_name (str): Name of the auth0 application to check access for. Returns: Flask.response: JSON response with access details. """ try: has_access = auth0_application_access.check_application_access(identity_id, app_name) return flaskify(response.Response({'has_access': has_access})) except (AppAccessCheckError, OwsPdpError) as e: return flaskify( response.create_error_response( code=constants.ERROR_CODE_ACCESS_CHECK_ERROR, message=str(e), status=e.status_code, ) ) # Endpoints moved from common.py @app.route('/users/identity', methods=['POST']) @validate_request_data(IdentitySchema()) @Neo4jSession(transaction=True, use_v2=True, database=constants.NEO4J_DATABASE_NAME) def create_identity(): """Create an identity in graphdb. Body: identity_id (str): auth0 id email (str): identity email name (str): full name first_name (str): User's first name. last_name (str): User's last name. auth0_user_id (str): auth0 user id. google_user_id (str): google oauth user id. audit_user (str): client identity id for audit. user_types (list): label, artist or employee. localization (str): User's language preference. number_format (str): User's number format preference. default_brand (str): User's default brand. """ data = request.get_json() data['email'] = data['email'].lower() # set default active for POST call. if not data.get('active'): data['active'] = 'Y' # default localization/numberformat if not sent if not data.get('localization'): data['localization'] = constants.LOCALES['English'] if not data.get('number_format'): data['number_format'] = constants.NUMBER_FORMAT[0] data['user_types'] = data.get('user_types', []) return flaskify(profiles.create_identity_in_graph(data)) @app.route('/users/identity/', methods=['GET']) @Neo4jSession(use_v2=True, database=constants.NEO4J_DATABASE_NAME) def get_identity_by_id(identity_id): """Get an identity from graphdb. Args: identity_id (str): auth0 id """ if ( g.request_context.context_type == constants.PROFILE_CONTEXT_TYPE and g.request_context.profile_type == constants.SETTINGS_PROFILE ): # do extra permission check admin_context = { 'identity_id': g.request_context.identity_id, 'profile_type': g.request_context.profile_type, 'profile_id': int(g.request_context.profile_id), } return flaskify(profiles.get_identity_for_admin_tx(admin_context, identity_id)) # there are no permissions checks! def _log_cross_identity_access(identity_id): """Log a warning when a non-Settings caller fetches a different identity.""" if g.request_context.context_type != constants.PROFILE_CONTEXT_TYPE: g.log.warning( 'ows-to-ows get_identity_by_id', resources={ 'requested_identity_id': identity_id, 'context_type': g.request_context.context_type, 'requestor_service_name': g.request_context.requestor_service_name, }, ) return caller_id = getattr(g.request_context, 'jwt_identity_id', None) or getattr( g.request_context, 'identity_id', None ) if caller_id == identity_id: return g.log.warning( 'Cross-identity get_identity_by_id', resources={ 'caller_identity_id': caller_id, 'requested_identity_id': identity_id, 'profile_type': g.request_context.profile_type, }, ) _log_cross_identity_access(identity_id) return flaskify(profiles.get_identity_from_graph_tx(identity_id)) @app.route('/users/identity/', methods=['PATCH']) @validate_request_data(IdentitySchema(), partial=True) @Neo4jSession(transaction=True, use_v2=True, database=constants.NEO4J_DATABASE_NAME) def update_identity(identity_id): """Update existing identity. Body: identity (json): object representing the identity to be updated email (str): identity email name (str): full name localization (str): Language preference number_format (str): Number format. date_format (str): Date format. first_name (str): User's first name. last_name (str): User's last name. auth0_user_id (str): auth0 user id. google_user_id (str): google oauth user id. Args: identity_id (str): Identity id (auth0 id / uuid). """ data = request.get_json() if data.get('email'): data['email'] = data['email'].lower() return flaskify(profiles.update_identity_in_graph(identity_id, data)) @app.route('/users/identity/', methods=['DELETE']) @Neo4jSession(transaction=True, use_v2=True, database=constants.NEO4J_DATABASE_NAME) def delete_identity(identity_id): """Delete identity and all relationships from graphdb. Args: identity_id (str): Identity id (auth0 id). """ return flaskify(profiles.delete_identity_from_graph(identity_id)) @app.route('/users/identity//update-auth0-id/', methods=['PATCH']) @Neo4jSession(transaction=True, use_v2=True, database=constants.NEO4J_DATABASE_NAME) def update_auth0_details_for_identity(identity_id, auth0_user_id): """Will be called by auth0 for users created via invite email. Params: identity_id (str): Identity UUID. auth0_user_id (str): Auth0 user id. Returns: flask.Response: containing user metadata. """ if 'auth0' in auth0_user_id: auth0_user_id = auth0_user_id.replace('auth0|', '') # step 1: update Identity node. result = profiles.update_identity_in_graph(identity_id, {'auth0_user_id': auth0_user_id}) # step 2: Update all records with uuid as auth0 id to actual auth0_user_id user_info.update_vend_contacts(identity_id, auth0_user_id) return flaskify(result) @app.route('/users/identity//profiles', methods=['GET']) @Neo4jSession(use_v2=True, database=constants.NEO4J_DATABASE_NAME) def get_all_identity_profiles(orchard_identity): """GET all profiles for this orchard_identity. Args: orchard_identity (str): Identity id. """ verify = flask_request.verify_profile_headers(request) if not verify: return flaskify(verify) header_identity = g.request_context.identity_id header_identity_uuid = g.request_context.identity_uuid if ( header_identity and not header_identity == orchard_identity and header_identity_uuid and not header_identity_uuid == orchard_identity ): return flaskify( response.create_error_response( code=flask_access.ERROR_CODE_BAD_HEADERS, message="Cannot access other user's profile.", ) ) return flaskify(profiles.get_all_profiles_for_identity(orchard_identity)) @app.route( '/users/identity//label-profile-access/', methods=['GET'], ) @Neo4jSession(use_v2=True, database=constants.NEO4J_DATABASE_NAME) def check_label_profile_access(identity_id: str, label_profile_id: str): """Check if this identity has active LabelProfile access to the given profile. Mirrors the query that fills the Workstation account switcher dropdown, so the auth0 account-switch action can rely on a single source of truth. Args: identity_id (str): Identity uuid. label_profile_id (str): LabelProfile profileId (vend_contact id). """ return flaskify(profiles.has_label_profile_access(identity_id, label_profile_id)) @app.route('/users/identity//vendor/', methods=['GET']) @Neo4jSession(use_v2=True, database=constants.NEO4J_DATABASE_NAME) def get_identity_vendor(identity_id, label_profile_id): """Get vendor information associated with a label profile. Params: identity_id (str): identity uuid label_profile_id (int): label profile id / vendor contact id Returns: flask.Response: vendor object with service tier and company brand. """ return flaskify(user_info.get_identity_vendor(identity_id, label_profile_id)) @app.route( '/users/identity//zendesk_token/', methods=['GET'] ) @Neo4jSession(use_v2=True, database=constants.NEO4J_DATABASE_NAME) def get_zendesk_token(identity_id, token_version): """Generate zendesk token for an identity. Params: identity_id (str): identity uuid token_version (str): version of token generator to use. Args: brand_experience (str): current site brand Returns: flask.Response: with zendesk token object like {"value": ""} """ brand_experience = request.args.get('brand_experience') return flaskify(zendesk.generate_zendesk_token(identity_id, brand_experience, token_version)) @app.route('/users/identity//can_unlink_social_account', methods=['GET']) def get_can_unlink_participant_social_account(identity_id): """Check whether the user with identity id can unlink a participant's social account. Checks whether the user with `identity_id` is authorized to unlink the social platform `platform` of participant with id `participant_id`. This information is retrieved from Dynamodb table configured with `DYNAMODB_SOCIAL_AUTH_TABLE`. Only the user who had linked the social account is authorized to unlink it. Args: identity_id (str): The identity id of the orchard user Params: participant_id (str): The id of the participant whose social account is unlinked platform (str): The social account platform to be unlinked Returns: Flask.response: contains the status of whether the user can unlink the participant's social account or not { can_unlink: bool } """ participant_id = request.args.get('participant_id', default=None, type=str) platform = request.args.get('platform', default=None, type=str) if not participant_id or not platform: return flaskify( response.create_error_response( code=constants.BAD_PARAMS_ERROR_CODE, message=constants.BAD_PARAMS_ERROR_message, status=400, ) ) return flaskify( user_info.can_unlink_participant_social_account(identity_id, participant_id, platform) ) @app.route('/users/identity//unlink_social_account', methods=['PATCH']) def unlink_participant_social_account(identity_id): """Unlink a participant's social account by user with identity_id. Checks whether the user with `identity_id` is authorized to unlink the social platform `platform` of participant with id `participant_id`. This information is retrieved from Dynamodb table configured with `DYNAMODB_SOCIAL_AUTH_TABLE`. Only the user who had linked the social account is authorized to unlink it. On verifying that information, makes a update to the table to reflect that Args: identity_id (str): The identity id of the orchard user Params: participant_id (str): The id of the participant whose social account is unlinked platform (str): The social account platform to be unlinked Returns: Flask.response: contains the status of whether the user can unlink the participant's social account or not { can_unlink: bool } """ participant_id = request.args.get('participant_id', default=None, type=str) platform = request.args.get('platform', default=None, type=str) if not participant_id or not platform: return flaskify( response.create_error_response( code=constants.BAD_PARAMS_ERROR_CODE, message=constants.BAD_PARAMS_ERROR_message, status=400, ) ) return flaskify( user_info.unlink_participant_social_account(identity_id, participant_id, platform) ) @app.route('/users/identity//primary-vend-contact', methods=['GET']) @Neo4jSession(use_v2=True, database=constants.NEO4J_DATABASE_NAME) def get_primary_vend_contact(identity_id): """Get the primary vend_contact_id for an identity. Resolves using Neo4j LabelProfile -> MySQL vend_contact mapping, without using auth0_user_id as glue. Args: identity_id (str): Identity UUID. Returns: flask.Response: containing {'vend_contact_id': int} or 404. """ return flaskify(user_info.get_primary_vend_contact_for_identity(identity_id))