"""Application Handlers. Requests are redirected to handlers, which are responsible for getting information from the URL and passing it down to the logic layer. The way each layer talks to each other is through Response objects which defines the type status of the data and the data itself. Please note: the Orchard uses the term handlers over views as convention for clarity See: oto.response for more details. """ from flask import g, jsonify, request 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 from permissions import config from permissions.api import app from permissions.connectors import redis from permissions.constants import constants from permissions.logic import hello, identity, profile, resource from permissions.models import owsusers from permissions.utils import api_utils from permissions.utils.api_utils import validate_request_data from permissions.utils.cache_utils import bust_by_identity, get_resources_key from permissions.validations.schemas.add_identity_profile_resources import ( AddIdentityProfileResourcesSchema, ) from permissions.validations.schemas.artist_resource import ArtistResourceSchema from permissions.validations.schemas.edit_profile_resources import EditProfileResourcesSchema from permissions.validations.schemas.get_profiles_for_admin import GetProfilesForAdminSchema from permissions.validations.schemas.get_user_resources_for_admin import ( GetUserResourcesForAdminSchema, ) from permissions.validations.schemas.resource import ResourceSchema from permissions.validations.schemas.resource_to_profile import ResourceToProfileSchema @app.route('/', methods=['GET']) def hello_world(): """Hello World with an optional GET param "name".""" name = request.args.get('name', '') return flaskify(hello.say_hello(name)) @app.route('/', methods=['GET']) def hello_world_username(username): """Hello World on /. Args: username (str): the user's username. """ return flaskify(hello.say_hello(username)) @app.route(config.HEALTH_CHECK, methods=['GET']) def health(): """Check the health of the application.""" return jsonify({'status': 'ok'}) @app.route('/profile/uuid//resource/label/id/', methods=['HEAD']) def check_profile_access(profile_uuid, label_id): """Check if a profile has access to a specific resource.""" redis_key = '_'.join(['profile_resource_access_check', profile_uuid, 'label', label_id]) status_code = redis.get(redis_key) if status_code is None: status_code = resource.check_label_access_for_profile(profile_uuid, label_id) redis.set(redis_key, str(status_code)) return flaskify(response.Response(status=int(status_code))) @app.route( '/admin/profile-type//profile//resource/', # NOQA methods=['GET'], ) @app.route( '/admin/profile-type//profile//resource/', methods=['GET'], defaults={'profile_id': 0}, ) @app.route( '/admin/profile//resource/', methods=['GET'], defaults={'profile_id': 0, 'profile_type': 'InsightsProfile'}, ) def get_resources_for_profile(profile_type, profile_id, resource_type, profile_uuid=None): """GET all resources or type resource_type for this profile. This is meant to be called from other microservices. Args: profile_type (str): Type of profile. profile_id (int): Profile Identifier. resource_type (str): Type of resource eg: product, artistinfo or all. profile_uuid (str): Optionally you can send only profile's 'uuid. """ redis_key = get_resources_key(profile_type, profile_uuid or profile_id, resource_type) cached_data = redis.get(redis_key) if cached_data: return cached_data if profile_uuid: result = resource.get_resources_for_profile_uuid(profile_uuid, resource_type) else: g.log.info(f'Using get_resources_for_profile with profile_id: {profile_type}:{profile_id}') result = resource.get_resources_for_profile(profile_type, profile_id, resource_type) if not result: return flaskify(result) return_result = { 'items': result.message, 'pagination': {'type': 'none', 'total_records': len(result.message)}, } redis.set(redis_key, return_result, config.REDIS_CACHE_TTL) return flaskify(response.Response(return_result)) @app.route('/associated-labels//', methods=['GET']) @validate_request_data(ArtistResourceSchema()) def get_labels_for_artist_id(resource_type, artist_id, deserialize_schema): """GET label for an artistInfo or labelParticipant id. Used by global settings to show associated label info when administering user artist access. Args: resource_type (str): ArtistInfo or LabelParticipant support only. artist_id (int): ArtistInfo or LabelParticipant neo4j id. """ return flaskify(resource.get_labels_for_artist_id(resource_type, artist_id)) @app.route('/deactivate/identity/', methods=['DELETE']) def deactivate_user(identity_id): """Deactivate a user and delete access to its resources. When Profile headers are sent for admin user, it will delete resources access that are common with this admin. If all the resources are deleted, then it disables/blocks the user in auth0. Args: identity_id(str): Identity uuid for user that is being viewed/edited. """ if any( [ g.request_context.context_type != constants.PROFILE_CONTEXT_TYPE, not g.request_context.profile_type, ] ): return flaskify(error_response.create_error_incomplete_profile_headers()) admin_context = { 'identity_id': g.request_context.identity_id, 'profile_type': g.request_context.profile_type, 'profile_id': int(g.request_context.profile_id), } result = resource.deactivate_resources_common_with_admin(admin_context, identity_id) return flaskify(result) @app.route('/activate/identity/', methods=['PATCH']) def activate_user(identity_id): """Activate a user and unblock the auth0 user too. An admin must have either vendor * access or direct access to one of the user's past tenants in order to reactivate that user. Args: identity_id(str): Identity uuid for user that is being viewed/edited. """ if any( [ g.request_context.context_type != constants.PROFILE_CONTEXT_TYPE, not g.request_context.profile_type, ] ): return flaskify(error_response.create_error_incomplete_profile_headers()) admin_context = { 'identity_id': g.request_context.identity_id, 'profile_type': g.request_context.profile_type, 'profile_id': int(g.request_context.profile_id), } result = identity.activate_user(admin_context, identity_id) return flaskify(result) @app.route( '/ows/profile///has-access-to/resource//', # NOQA methods=['POST'], ) @app.route( '/ows/profile//has-access-to/resource//', methods=['POST'], defaults={'profile_id': 0, 'profile_type': 'InsightsProfile'}, ) @validate_request_data(ResourceToProfileSchema()) def add_resource_to_profile( profile_type, profile_id, resource_type, resource_id, deserialize_schema, profile_uuid=None ): """Give an existing profile access to an existing resources. Args: profile_type (str): Type of profile eg: ArtistProfile, LabelProfile. profile_id (int): Profile Identifier. resource_type (str): Type of resource eg: ArtistInfo, Product. resource_id (int): Resource Identifier. deserialize_schema (dict): field names mapped to deserialized values. profile_uuid (str): Optionally you can send only profile's 'uuid. Json body: dict: containing access roles for that relationship. Eg: { "roles": ["analytics"] } """ if not deserialize_schema['resource_id'] == '*': deserialize_schema['resource_id'] = int(resource_id) admin_id = g.request_context.identity_id result = resource.add_resource_to_profile(deserialize_schema, admin_id) if result: try: identity_uuids = profile.get_identities_by_profile( profile_type, profile_id, profile_uuid, ) if identity_uuids: bust_by_identity(identity_uuids, resource_types=['all_admin']) except Exception as e: g.log.warning( f'Failed to bust identity all_admin cache for Profile Type {profile_type}, Profile Id {profile_id}, Profile UUID {profile_uuid}: {e}' ) # noqa: E501 return flaskify(result) @app.route( '/ows/profile///has-access-to/resource//', # NOQA methods=['DELETE'], ) @app.route( '/ows/profile//has-access-to/resource//', methods=['DELETE'], defaults={'profile_id': 0, 'profile_type': 'InsightsProfile'}, ) @validate_request_data(ResourceToProfileSchema()) def delete_resource_to_profile( profile_type, profile_id, resource_type, resource_id, deserialize_schema, profile_uuid=None ): """Delete an existing relationship between a profile and a resources. Args: profile_type (str): Type of profile eg: ArtistProfile, LabelProfile. profile_id (int): Profile Identifier. resource_type (str): Type of resource eg: ArtistInfo, Product. resource_id (int): Resource Identifier. deserialize_schema (dict): field names mapped to deserialized values. profile_uuid (str): Optionally you can send only profile's 'uuid. """ if not deserialize_schema['resource_id'] == '*': deserialize_schema['resource_id'] = int(resource_id) return flaskify(resource.delete_resource_to_profile(deserialize_schema)) @app.route('/e2e/resource//', methods=['GET']) def get_resources(resource_type, resource_id): """Get node of type/label resource Type. Args: resource_type (str): Type of resource eg: product, artistinfo or all. resource_id (str): Resource identifier. """ return flaskify(resource.get_resources(resource_type, resource_id)) @app.route('/e2e/resource//', methods=['POST']) @validate_request_data(ResourceSchema()) def create_resource(resource_type, resource_id, deserialize_schema): """Create node of type/label resource Type. Args: resource_type (str): Type of resource eg: product, artistinfo or all. resource_id (str): Resource identifier. deserialize_schema (dict): field names mapped to deserialized values. Json body: dict: containing additional details like { name: "My Test Artist" } """ return flaskify(resource.create_resource(resource_type, resource_id, request.json)) @app.route('/e2e/resource//', methods=['DELETE']) @validate_request_data(ResourceSchema()) def delete_resource(resource_type, resource_id, deserialize_schema): """Delete node of type/label resource Type. Args: resource_type (str): Type of resource eg: product, artistinfo or all. resource_id (str): Resource identifier. deserialize_schema (dict): field names mapped to deserialized values. """ return flaskify(resource.delete_resource(resource_type, resource_id)) @app.route('/identity/add-resources-profiles', methods=['POST']) @validate_request_data(AddIdentityProfileResourcesSchema()) def add_identity_profiles(deserialize_schema): """Add identity, profiles and add a list of resources to each. Json body: dict: containing emails, profileTypes, resources and roles. Eg: { "identity": { "name": "foo@bar.com", # will be also be reused as profile name. "email": "foo@bar.com", "first_name": "Foo", # optional "last_name": "Bar" # optional }, "resource_access": [ # same resources will get added to all supported profile_types. { "resource_type": "Vendor", # required "uuid": "dffedd4d-b88d-444d-a9eb-6ce89aa4d2f6", # required "roles": ["analytics"], # required atleast one. }, { "resource_type": "LabelParticipant", "uuid": "5eb8caaf-78df-4a99-929b-887170ede534", "roles": ["analytics"], } ], "send_password_reset": true, # optional. Default is true. "set_email_verified": true, # optional. Default is true. "overwrite_existing_access": true, # optional. Default is true. "create_auth0_user": true, # optional. Default is true. This param is used to determine if settings should create the auth0 user or will the user be created via auth0 invite email. "brand": # optional. Ex. awal or theorchard. "master_contact": false # optional. Default is false. This param is used for gda users created from the gda-account-creation step function. "user_metadata": # optional. Ex. {"should_send_collaborator_access_email": True} } """ if g.request_context.profile_id is None: return flaskify(error_response.create_error_forbidden_user()) admin_context = { 'identity_id': g.request_context.identity_id, 'profile_type': g.request_context.profile_type, 'profile_id': int(g.request_context.profile_id), } if admin_context['profile_type'] == constants.COLLABORATORSPROFILE: settings_profile = owsusers.get_settings_profile_for_identity(admin_context['identity_id']) if settings_profile is not None: admin_context['profile_type'] = settings_profile['profile_type'] admin_context['profile_id'] = settings_profile['profile_id'] if admin_context['profile_type'] not in constants.SETTINGS_SUPPORT_MAPPING['adminProfileTypes']: # noqa return flaskify(error_response.create_error_forbidden_user()) result = profile.create_identity_profiles_resources(admin_context, **deserialize_schema) if result: try: identity_uuids = [] for identity in result.message.get('identities_affected', []): if 'id' in identity: identity_uuids.append(identity['id']) if identity_uuids: bust_by_identity(identity_uuids, resource_types=['all_admin']) except Exception as e: g.log.warning(f'Failed to bust identity all_admin cache for {identity_uuids}: {e}') return flaskify(result) @app.route('/identity//edit-resources-profiles', methods=['POST']) @validate_request_data(EditProfileResourcesSchema()) def edit_identity_profiles(identity_id, deserialize_schema): """Edit identity access to resources. Json body: dict: containing resources and roles. Eg: { "resource_access": [ # same resources will get added to all supported profile_types. { "resource_type": "Vendor", # required "uuid": "497611", # required "roles": ["analytics"], # required atleast one., "overwrite_existing_access": false, # optional. Default is false. }, { "resource_type": "LabelParticipant", "uuid": "497611", "roles": ["analytics"], "overwrite_existing_access": false, # optional. Default is false. } ], } """ if g.request_context.profile_id is None: return flaskify(error_response.create_error_forbidden_user()) admin_context = { 'identity_id': g.request_context.identity_id, 'profile_type': g.request_context.profile_type, 'profile_id': int(g.request_context.profile_id), } if admin_context['profile_type'] == constants.COLLABORATORSPROFILE: settings_profile = owsusers.get_settings_profile_for_identity(admin_context['identity_id']) if settings_profile is not None: admin_context['profile_type'] = settings_profile['profile_type'] admin_context['profile_id'] = settings_profile['profile_id'] if admin_context['profile_type'] not in constants.SETTINGS_SUPPORT_MAPPING['adminProfileTypes']: # noqa return flaskify(error_response.create_error_forbidden_user()) result = profile.edit_identity_profiles_resources(admin_context, **deserialize_schema) if result: try: bust_by_identity([identity_id], resource_types=['all_admin']) except Exception as e: g.log.warning(f'Failed to bust identity all_admin cache for {identity_id}: {e}') return flaskify(result) @app.route('/identity/admin/resources/all/', methods=['GET']) def get_my_adminable_resources(): """GET all resources which this authenticated user has admin access.""" identity_uuid = g.request_context.jwt_identity_id limit = int(request.args.get('limit', constants.DEFAULT_LIMIT)) offset = int(request.args.get('offset', constants.DEFAULT_OFFSET)) if not identity_uuid: return flaskify( response.create_error_response( code='UNAUTHORIZED', message='Request context has no identity uuid.', status=401 ) ) redis_key = get_resources_key('identity', identity_uuid, resource_type='all_admin') cached_data = redis.get(redis_key) # If there is a limit or an offset other than default, do not pull from cached data if cached_data and limit is constants.DEFAULT_LIMIT and offset is constants.DEFAULT_OFFSET: return flaskify(response.Response(cached_data)) result = resource.get_resources_for_identity_uuid(identity_uuid, limit, offset) # Only cache when the result is successful and the requested # limit and offset are the default values if result and limit is constants.DEFAULT_LIMIT and offset is constants.DEFAULT_OFFSET: redis.set(redis_key, result.message, config.REDIS_CACHE_TTL) return flaskify(result) @app.route('/identity/admin/profiles', methods=['GET']) @api_utils.validate_request_data(GetProfilesForAdminSchema()) def get_profiles_for_admin(deserialize_schema): """GET all profiles an admin can administer. This will return all profiles that have access to the resources the given identity has admin rights to. Ex: /identity/admin/profiles?profile_type=LabelProfile returns profiles that have access to the resources the given Identity's LabelProfile(s) have admin access to. /identity/admin/profiles returns profiles that have access to the resources the given Identity (and all of its profiles) has admin access to. Params: profile_type (str): Optional. Type of profile the given identity has access to eg. LabelProfile, InsightsProfile, etc. limit (int): Optional. Limit number of records. Default 50. offset (int): Optional. Offset result set. Default 0. term (str): Optional. Search term. Search is case-insensitive. active (str): Optional. Filter by active/inactive users. Eg. Y or N. pending (str): Optional. Filter by awaiting auth0 org invite accepted. Eg. Y (waiting for invite accpetion) or N (invite already accepted). label_participant (int): Optional. Filter by access to LabelParticipant node ids. @deprecated Use resource_access parameter. resource_access (str): Optional. Filter by uuid of a node. parent_vendor_filter(list[str]): Optional. Filter results to parent vendor uuids. include_subaccount_users (bool): Optional. Whether to include subaccount users for D3 admins when "resource_access" is specified. Default is True. """ if any( [ g.request_context.context_type != constants.PROFILE_CONTEXT_TYPE, not g.request_context.profile_type, ] ): return flaskify(error_response.create_error_incomplete_profile_headers()) if g.request_context.profile_type not in constants.SETTINGS_SUPPORT_MAPPING['profileTypes']: return flaskify(error_response.create_error_forbidden_user()) edit_super_admins_enabled = ( pythonfeatures.get_single_feature(constants.EDIT_SUPER_ADMINS, g.request_context).message == split_constants.FEATURE_ENABLED ) admin_context = { 'identity_id': g.request_context.identity_id, 'profile_type': g.request_context.profile_type, 'profile_id': int(g.request_context.profile_id), } profile_types = request.args.getlist('profile_type') limit = int(request.args.get('limit', constants.DEFAULT_USERS_LIMIT)) offset = int(request.args.get('offset', constants.DEFAULT_OFFSET)) search_term = request.args.get('term') active = request.args.get('active', None) pending = request.args.get('pending', None) label_participants = request.args.getlist('label_participant') resource_access = request.args.getlist('resource_access') parent_vendor_filter = deserialize_schema.get('parent_vendor_filter') include_subaccount_users = deserialize_schema.get('include_subaccount_users', [True])[0] include_requester = deserialize_schema.get('include_requester', [False])[0] result = resource.get_user_profiles_for_admin( admin_context, profile_types, limit, offset, search_term, label_participants, active, pending, resource_access, parent_vendor_filter, edit_super_admins_enabled, include_subaccount_users, include_requester, ) if not result: return flaskify(result) return flaskify( response.Response( { 'items': result.message['data'], 'pagination': { 'type': constants.PAGINATION_TYPE_STANDARD, 'total_records': result.message['total'], 'limit': limit, 'offset': offset, }, } ) ) @app.route('/identity//direct-access/resources/', methods=['GET']) @validate_request_data(GetUserResourcesForAdminSchema()) def get_user_resources_for_admin(identity_id, resource_type, deserialize_schema): """GET resources that identity_id has direct access to and the admin can administer. When Profile headers are sent for admin user, then it will filter the resources to what is common with this admin. Args: identity_id(str): Identity uuid for user that is being viewed/edited. resource_type(str): Type of resource. eg: vendor, label, subaccount, vendor star. Params: limit (int): Limit number of records. Default 200. offset (int): Offset result set. Default 0. active (bool): Users active state. Default True. pending (bool):Users pending state. Default False. """ if any( [ g.request_context.context_type != constants.PROFILE_CONTEXT_TYPE, not g.request_context.profile_type, ] ): return flaskify(error_response.create_error_incomplete_profile_headers()) admin_context = { 'identity_id': g.request_context.identity_id, 'profile_type': g.request_context.profile_type, 'profile_id': int(g.request_context.profile_id), } if admin_context['profile_type'] == constants.COLLABORATORSPROFILE: settings_profile = owsusers.get_settings_profile_for_identity(admin_context['identity_id']) if settings_profile is not None: admin_context['profile_type'] = settings_profile['profile_type'] admin_context['profile_id'] = settings_profile['profile_id'] # removing this as this endpoint is now being called by all suite frontends, # when displaying the user modal. # https://github.com/theorchard/orchard-suite/blob/master/packages/suite-frontend/src/components/mainNav/mainNavUserModal/mainNavUserModalAccounts.tsx#L14 # if admin_context['profile_type'] not in constants.SETTINGS_SUPPORT_MAPPING['adminProfileTypes']: # noqa # return flaskify( # error_response.create_error_forbidden_user()) limit = int(request.args.get('limit', constants.DEFAULT_LIMIT)) offset = int(request.args.get('offset', constants.DEFAULT_OFFSET)) active = request.args.get('active', constants.DEFAULT_ACTIVE_STATUS) if active == 'false': active = False result = resource.get_user_resources_for_admin( admin_context, identity_id, resource_type, limit, offset, active ) if not result: return flaskify(result) return flaskify( response.Response( { 'items': result.message['data'], 'pagination': { 'type': constants.PAGINATION_TYPE_STANDARD, 'total_records': result.message['total'], 'limit': limit, 'offset': offset, 'active': active, }, } ) )