"""Logic layer for working with user devices.""" import json import boto3 from botocore.exceptions import ClientError from ddtrace import tracer from flask import g from owsresponse import response from users import config, constants from users.exceptions.device_not_found_error import DeviceNotFoundError from users.models import devices, identities, ows_notifications, profiles SNS_CLIENT = boto3.client('sns', region_name=config.AWS_REGION) def _sns_topic_name(orchard_identity_id, brand=None): # we are accepting "orchard" for backward compatibility reasons but replacing it before save if not brand or brand == constants.ORCHARD_BRAND or brand == constants.AUTH0_ORCHARD_ORG_NAME: # for backward compatibility keep this same as existing topic names. return f'{config.ENVIRONMENT}-push-notifications-{orchard_identity_id}' else: return f'{config.ENVIRONMENT}-{brand}-push-notifications-{orchard_identity_id}' @tracer.wrap(name='logic.devices.register_device') def register_device(identity_id, device_data, brand): """Register device_id with SNS by creating a platform endpoint. Args: device_data (dict): push_token (str): push notification token platform_type (str): "ios" or "android" brand (str): supported brand type from identity.defaultBrand Returns: string: EndpointArn returned from CreateEndpoint action """ application_arn = config.PUSH_NOTIFICATION_ARN.get(f"{device_data['platform_type']}_{brand}") # add extra data to be used during auto-unregister custom_user_data = json.dumps( {'device_id': device_data.get('device_id'), 'identity_id': identity_id, 'brand': brand} ) try: result = SNS_CLIENT.create_platform_endpoint( PlatformApplicationArn=application_arn, CustomUserData=custom_user_data, Token=device_data['push_token'], ) g.ows.log.info( f'Successfully created new platform endpoint for {identity_id}, {brand}: {result}' ) except ClientError as err: g.ows.log.error( f'Failed to created a new platform endpoint for {identity_id}, {brand}: {err}' ) if err.response['Error']['Code'] != 'InvalidParameter': raise err if ( 'already exists with the same Token, but different attributes' not in err.response['Error']['Message'] ): # noqa raise err # if it has diff data in SNS attributes, then just update the attributes. result = _update_sns_device_attributes( identity_id, device_data['push_token'], device_data['platform_type'], custom_user_data ) return result.get('EndpointArn') @tracer.wrap(name='logic.devices._update_sns_device_attributes') def _update_sns_device_attributes(identity_id, push_token, platform_type, custom_user_data): """Set attributes for a device endpoint.""" g.ows.log.info( f'Setting device endpoint attributes for identity: {identity_id} ' f'with device push token: {push_token}' ) device = devices.get_device_by_push_token(identity_id, push_token, platform_type) if not device: raise DeviceNotFoundError( f'Device not found for identity id {identity_id} with push_token {push_token}, ' f'platform type {platform_type}' ) params = dict( EndpointArn=device.message['endpoint_arn'], Attributes={'CustomUserData': custom_user_data} ) try: SNS_CLIENT.set_endpoint_attributes(**params) g.ows.log.info(f'Successfully set the attributes: {params} for a device endpoint') except ClientError as e: g.ows.log.error(f'Failed to set the attributes: {params} for a device endpoint: {e}') if e.response['Error']['Code'] != 'NotFound': raise e # match the result structure to create_platform_endpoint result return params @tracer.wrap(name='logic.devices._get_sns_subscriptions') def _get_sns_subscriptions(orchard_identity_id, brand=None): topic_name = _sns_topic_name(orchard_identity_id, brand) topic_arn = f'{config.SNS_ARN_PREFIX}:{topic_name}' g.ows.log.info( f'Getting topic {topic_arn} subscriptions for identity_id: {orchard_identity_id}' ) result = {} try: result = SNS_CLIENT.list_subscriptions_by_topic(TopicArn=topic_arn) g.ows.log.info( f'Successfully retrieved list of subscriptions by topic {topic_arn}: {result}' ) except ClientError as e: g.ows.log.error(f'Failed to retrieve list of subscriptions by topic {topic_arn}: {e}') if e.response['Error']['Code'] != 'NotFound': raise e return result.get('Subscriptions', []) @tracer.wrap(name='logic.devices._delete_sns_subscriptions') def _delete_sns_subscriptions(subscriptions): for subscription in subscriptions: subscription_arn = subscription['SubscriptionArn'] try: result = SNS_CLIENT.unsubscribe(SubscriptionArn=subscription_arn) g.ows.log.info(f'Subscription {subscription_arn} deleted successfully: {result}') except ClientError as e: g.ows.log.error(f'Failed to delete Subscription Arn {subscription_arn}: {e}') if e.response['Error']['Code'] != 'NotFound': raise e @tracer.wrap(name='logic.devices.unregister_device') def unregister_device(sns_endpoint_arn): """Unregister device by deleting SNS endpoint. Args: sns_endpoint_arn (str): identifier of SNS endpoint Returns: dict: result from delete_endpoint boto3 sns call """ result = {} try: result = SNS_CLIENT.delete_endpoint(EndpointArn=sns_endpoint_arn) g.ows.log.info(f'SNS endpoint {sns_endpoint_arn} deleted successfully: {result}') except ClientError as e: g.ows.log.error(f'Failed to delete SNS endpoint {sns_endpoint_arn}: {e}') if e.response['Error']['Code'] != 'NotFound': raise e return result @tracer.wrap(name='logic.devices.add_push_notification_device') def add_push_notification_device(orchard_identity_id, request_data, correlation_id): """Add push notification device node in graphdb. Args: orchard_identity_id (str): Identity identifier in graph. request_data (dict): Example data: { push_token: "abc123def", platform_type: "android", device_id: "1234-34", # optional localization: "en" # optional brand: "theorchard" # depricated } correlation_id (str): Unique ID for request Returns: dict: the newly created device node from graph. """ if request_data.get('brand'): g.ows.log.info( 'brand property no longer needed for device registration.' '\nregistering device using identity.defaultBrand' ) identity_result = identities.get_identity(orchard_identity_id) if not identity_result: return identity_result if not identity_result.message.get('default_brand'): g.ows.log.info(f'Identity {orchard_identity_id} does not have a default brand.') orchard_identity_id = identity_result.message['id'] brand = ( constants.ORCHARD_BRAND if identity_result.message.get('default_brand') == constants.AUTH0_ORCHARD_ORG_NAME else identity_result.message.get('default_brand') ) # noqa if brand not in config.SUPPORTED_DEVICE_REGISTRATION_BRANDS: message = ( f'Identity id {orchard_identity_id} can not register device. ' f'Brand {brand} is not supported for device registration.' ) return response.create_error_response(code=constants.BAD_PARAMS_ERROR_CODE, message=message) # remove previous stale registrations of this device if 'device_id' in request_data: g.ows.log.info(f'Removing previous device stale registrations: {json.dumps(request_data)}') delete_stale_registrations( orchard_identity_id, request_data['device_id'], request_data['platform_type'], request_data['push_token'], brand if brand != constants.ORCHARD_BRAND else constants.AUTH0_ORCHARD_ORG_NAME, # use constants.AUTH0_ORCHARD_ORG_NAME (orchard) to register orchard devices ) # if the device is already registered to SNS topic, dont redo it. Just update neo4j data. existing_device = devices.get_device_by_push_token( orchard_identity_id, request_data['push_token'], request_data['platform_type'] ) recreate_sns = True if ( existing_device and existing_device.message.get('endpoint_arn') and existing_device.message.get('brand') == brand and existing_device.message.get('device_id') == request_data.get('device_id') ): recreate_sns = False endpoint_arn = existing_device.message.get('endpoint_arn') if recreate_sns: # add default subscriptions per profile # @todo move this ows-notification calls to graphql itself. # also change DEFAULT_NOTIFICATION_PROFILE_TYPES to only be Insights. No label or artist. subscription_result = add_profile_subscriptions(orchard_identity_id, correlation_id) if subscription_result.status != 200: return subscription_result # step 1: Create a SNS topic for this identity topic_name = _sns_topic_name(orchard_identity_id, brand) topic_attrs = { 'ApplicationSuccessFeedbackRoleArn': config.DELIVERY_STATUS_LOGGING_ROLE, # noqa:E501 'ApplicationSuccessFeedbackSampleRate': '100', 'ApplicationFailureFeedbackRoleArn': config.DELIVERY_STATUS_LOGGING_ROLE, # noqa:E501 'KmsMasterKeyId': 'alias/aws/sns', } try: user_topic_arn = SNS_CLIENT.create_topic(Name=topic_name, Attributes=topic_attrs).get( 'TopicArn' ) g.ows.log.info( f'Successfully created a new SNS topic {topic_name} ' f'for identity {orchard_identity_id}, {brand}: {user_topic_arn}' ) except ClientError as e: g.ows.log.error( f'Failed to create a new SNS topic {topic_name} ' f'for identity {orchard_identity_id}: {e}' ) if e.response['Error']['Code'] != 'InvalidParameter': raise e user_topic_arn = f'{config.SNS_ARN_PREFIX}:{topic_name}' # step 2: Register this device to SNS > Push Notification Application g.ows.log.info( f'Registering device: {json.dumps(request_data)} for identity: {orchard_identity_id} ' f'and brand: {brand}' ) endpoint_arn = register_device( orchard_identity_id, request_data, brand if brand != constants.ORCHARD_BRAND else constants.AUTH0_ORCHARD_ORG_NAME, # use constants.AUTH0_ORCHARD_ORG_NAME (orchard) to register orchard devices ) # step 3: Add the device ARN to SNS topic so we can send message to SNS topic directly. try: result = SNS_CLIENT.subscribe( TopicArn=user_topic_arn, Protocol='application', Endpoint=endpoint_arn ) g.ows.log.info( f'Successfully created a new SNS subscription for topic {topic_name}: {result}' ) except ClientError as e: g.ows.log.error(f'Failed to create a new subscription for topic {topic_name}: {e}') if e.response['Error']['Code'] != 'NotFound': raise e # split data according to owner device_data = { 'endpoint_arn': endpoint_arn, 'device_id': request_data.get('device_id'), 'brand': brand, } identity_data = {} if 'localization' in request_data: identity_data['localization'] = request_data['localization'] device_data['localization'] = request_data['localization'] # step 4: Add Device node in neo4j for this identity. g.ows.log.info( f'Adding device: {json.dumps(device_data)} to database for identity {orchard_identity_id}' ) return devices.create_push_notification_device( orchard_identity_id, request_data['push_token'], request_data['platform_type'], device_data, identity_data, ) @tracer.wrap(name='logic.devices.add_profile_subscriptions') def add_profile_subscriptions(identity_id, correlation_id): """Create default notification subscriptions for every profile on identity. Args: identity_id (str): Identity identifier in graph correlation_id (str): Unique ID for request, pass to ows-notifications """ for profile in profiles.get_profiles(identity_id).message: if profile['profile_type'] in constants.DEFAULT_NOTIFICATION_PROFILE_TYPES: for setting in constants.DEFAULT_NOTIFICATION_SETTINGS: result = ows_notifications.create_notification_subscription( profile['profile_id'], profile['profile_type'], setting['notification_type'], setting['feed_type'], False, correlation_id, ) if result.status != 200: return response.create_fatal_response(message=result.errors['message']) return response.Response(status=200) @tracer.wrap(name='logic.devices.get_push_notification_device') def get_push_notification_device(orchard_identity_id): """Get push notification device node from graphdb. Args: orchard_identity_id (str): Identity identifier in graph. Returns: list: device objects that identity has. """ identity_result = identities.get_identity(orchard_identity_id) if not identity_result: return identity_result return devices.get_push_notification_device(orchard_identity_id) @tracer.wrap(name='logic.devices.delete_stale_registrations') def delete_stale_registrations(identity_id, device_id, platform_type, push_token, brand=None): """Remove previous registrations of a device. Use cases: 1. old identity logs out and new identity logs in 2. app re-installed, new push token, same identity logs in Args: identity_id (str): idenity registring new device device_id (str): device being registered platform_type (str): platform type of device being registered push_token (str): push token associated with new registration brand (str): app brand Returns: None """ registered_devices = devices.get_registrations(device_id, platform_type, brand) for device, identity in registered_devices.message: # if there is a device with same platform_type, brand and device_id then delete it, # as there can only be 1 app of a brand on the same device. # don't delete if this is a dupe request for same identity and push token. if not identity_id == identity['id'] or not device['push_token'] == push_token: delete_push_notification_device(identity['id'], device['device_id'], brand) @tracer.wrap(name='logic.devices.delete_push_notification_device') def delete_push_notification_device(orchard_identity_id, device_id, brand=None): """Remove push notification device node in graphdb and remove SNS endpoint. Args: orchard_identity_id (str): Identify identifier in graph. device_id (str): Device identifier brand (str): App brand Returns: Response: success response with no message """ devices_result = devices.get_push_notification_device(orchard_identity_id) devices_result = [ x for x in devices_result.message if x.get('device_id') == device_id and (not brand or x.get('brand') == brand) ] if not devices_result: return response.create_not_found_response('Devices not found') # get all push endpoint SNS topic subscriptions for identity subscriptions = _get_sns_subscriptions(orchard_identity_id, brand) for device in devices_result: # remove all SNS topic subscriptions for device's endpoint device_subscriptions = [x for x in subscriptions if x['Endpoint'] == device['endpoint_arn']] _delete_sns_subscriptions(device_subscriptions) # remove SNS endpoint in AWS unregister_device(device['endpoint_arn']) # remove device link in graphdb via soft delete g.ows.log.info( f'Deleting push notification device {device["device_id"]} from database' f'for identity {orchard_identity_id} with brand: {brand}' ) devices.delete_push_notification_device(orchard_identity_id, device['device_id'], brand) return response.Response(status=204)