"""Logic for permissions.""" import json from hashlib import sha1 from flask import request from owsrequest import context from owsrequest.constants import headers as owsrequest_headers from analytics.config import FEED_IDS from analytics.connectors import redis from analytics.connectors.snowflake import SnowflakeQuery from analytics.constants import cache from analytics.constants import cache as cache_constants from analytics.queries.schema import ( CrossAttributionPairsSchema, QueryWithPermissionsSchema, ) from analytics.services import ows_permissions from analytics.services.ows_permissions import ( ARTIST_INFO_RESOURCE, LABEL_RESOURCE, SUBACCOUNT_RESOURCE_TYPE, VENDOR_RESOURCE_TYPE, ) from analytics.utils.cache import ( _deserialize_types, _serialize_types, create_permissions_key, ) ENCODING = "utf-8" DELIMITER = "," def _encode_int_array(array): """Decode an array of integers from a byte array.""" return DELIMITER.join(map(lambda x: str(x), array)).encode(ENCODING) def _decode_int_array(bytes_): """Encode an array of integers into a byte array.""" return list( map( lambda x: None if x == "None" else int(x), bytes_.decode(ENCODING).split(DELIMITER), ) ) def get_artist_info_ids(profile_type, profile_id): """Return artist info ids for a given profile type and id. Args: profile_type (str): The profile type. profile_id (int): The profile id. Returns: array (int): An array of accessible artist profile ids """ if profile_type is None or profile_id is None: return [] redis_key = create_permissions_key(profile_type, profile_id, ARTIST_INFO_RESOURCE) result = redis.client.get(redis_key) if result: return _decode_int_array(result) response = ows_permissions.get_artist_info_resources(profile_type, profile_id) if not response: return [] artist_info_ids = list(map(lambda item: item["id"], response.message["items"])) redis.client.set( redis_key, _encode_int_array(artist_info_ids), ex=cache_constants.PERMISSIONS_CACHE_TTL, ) return artist_info_ids def get_vendor_and_subaccount(profile_type, profile_id): """Return vendor_id and subaccount_id for a provided profile. Args: profile_type (str): The profile type. profile_id (int): The profile id. Returns: (int, int): An tuple of ordered vendor_id, subaccount_id """ if profile_type is None or profile_id is None: return None, None redis_key = create_permissions_key(profile_type, profile_id, LABEL_RESOURCE) result = redis.client.get(redis_key) if result: vendor_subaccount_pair = _decode_int_array(result) return vendor_subaccount_pair[0], vendor_subaccount_pair[1] response = ows_permissions.get_label_resources(profile_type, profile_id) # Empty response, so no vendor_id, subaccount_id if not response: return None, None # Pull the first reference to a vendor or subaccount resource from result resource = next( ( item for item in response.message["items"] if item["type"].lower() in (SUBACCOUNT_RESOURCE_TYPE.lower(), VENDOR_RESOURCE_TYPE.lower()) ), None, ) # If no vendor or subaccount resources, return no vendor_id, subaccount_id if not resource: return None, None # If the resource is a vendor, return only vendor_id if resource["type"] == VENDOR_RESOURCE_TYPE: vendor_subaccount_pair = [resource["id"], None] redis.client.set( redis_key, _encode_int_array(vendor_subaccount_pair), ex=cache_constants.PERMISSIONS_CACHE_TTL, ) return vendor_subaccount_pair[0], vendor_subaccount_pair[1] # Return vendor_id, subaccount_id vendor_subaccount_pair = [None, resource["id"]] redis.client.set( redis_key, _encode_int_array(vendor_subaccount_pair), ex=cache_constants.PERMISSIONS_CACHE_TTL, ) return vendor_subaccount_pair[0], vendor_subaccount_pair[1] def get_permissions_filter( request_context, filter_by_feed_ids=True, video_endpoint=False, **kwargs ): """Return a dict containing resource permissions based on request context. Args: request_context (RequestContext): request context filter_by_feed_ids (bool): If True, add a list of available to this user feed ids to the permissions filter. video_endpoint (bool): If True, do not exclude YouTube from the feeds, available to the user. Returns: dict: a dict containing resources the user has access to """ feed_ids = [] # New-style non-video endpoints filter via the # analytics/queries/sql/macros/permissions_filter.sql macro. # New-style video endpoints filter via # analytics/queries/sql/macros/permissions_filter_video_or_channel.sql, # which omits the feed id filter. if filter_by_feed_ids: feed_ids = FEED_IDS[:] result = { "artist_ids": None, "label_ids": None, "subaccount_ids": None, "label_participant_ids": None, "feed_ids": feed_ids, } service_access_profile_types = [ owsrequest_headers.PROFILE_TYPE_INSIGHTS, owsrequest_headers.PROFILE_TYPE_PUBLISHING, owsrequest_headers.PROFILE_TYPE_CONTENT, "SongwhipProfile", ] if request_context.context_type != owsrequest_headers.CONTEXT_TYPE_PROFILE: return result profile_type = request_context.profile_type profile_id = request_context.profile_id if profile_type == owsrequest_headers.PROFILE_TYPE_ARTIST: result["artist_ids"] = get_artist_info_ids(profile_type, profile_id) return result elif profile_type == owsrequest_headers.PROFILE_TYPE_LABEL: vendor_id, subaccount_id = get_vendor_and_subaccount(profile_type, profile_id) result["label_ids"] = [vendor_id] result["subaccount_ids"] = [subaccount_id] if subaccount_id else None return result elif profile_type in service_access_profile_types: profile_permissions = get_all_permissions_for_profile(profile_type, profile_id) profile_permissions.update({"feed_ids": feed_ids}) return {**result, **profile_permissions} return result def _parse_profile_permissions(permissions): if not permissions or not permissions.get("items"): return None, None, None, None artist_ids = [] label_ids = [] subaccount_ids = [] label_participant_ids = [] for item in permissions.get("items"): item_type = item.get("type") if item_type == "Vendor": if item.get("id") == "*": return [], [], [], [] label_ids.append(item.get("id")) elif item_type in ["Subaccount", "SubAccount"]: subaccount_ids.append(item.get("id")) elif item_type == "LabelParticipant": label_participant_ids.append(item.get("id")) if not any([artist_ids, label_ids, subaccount_ids, label_participant_ids]): return None, None, None, None return artist_ids, label_ids, subaccount_ids, label_participant_ids def get_all_permissions_for_profile(profile_type, profile_id): """Return a dict containing resource permissions based on insighs profile. Args: profile_type (str): The profile type. profile_id (int): The profile id. Returns: dict: a dict containing resources the user has access to """ if profile_id is None or profile_type is None: return { "artist_ids": None, "label_ids": None, "subaccount_ids": None, "label_participant_ids": None, } response = ows_permissions.get_all_resources(profile_type, profile_id) ( artist_ids, label_ids, subaccount_ids, label_participant_ids, ) = _parse_profile_permissions(response.message) return { "artist_ids": artist_ids, "label_ids": label_ids, "subaccount_ids": subaccount_ids, "label_participant_ids": label_participant_ids, } def has_full_access_on_permissions(permissions): """Return True when permissions explicitly grant unrestricted access. Full access is signalled by every permission scope being an empty list (the sentinel produced by `_parse_profile_permissions` for a "*" Vendor item). `None` values are NOT treated as full access. """ return ( permissions.get("permission_label_ids") == [] and permissions.get("permission_subaccount_ids") == [] and permissions.get("permission_artist_ids") == [] and permissions.get("permission_label_participant_ids") == [] ) def get_permission_values(product_ids=False): """Get permission values from headers, but with clearer permission_ prefix to make the purpose of the values clearer when used in SQL""" request_context = context.get_request_context_from_headers( request.headers, label_profile=True ) permissions = get_permissions_filter(request_context) permission_values = { "permission_label_ids": permissions.get("label_ids"), "permission_artist_ids": permissions.get("artist_ids"), "permission_subaccount_ids": permissions.get("subaccount_ids"), "permission_label_participant_ids": permissions.get("label_participant_ids"), "permission_feed_ids": permissions.get("feed_ids"), } if product_ids: pids = get_product_ids(permission_values) if product_ids: permission_values["permission_product_ids"] = pids return permission_values def product_ids_cache_key(permissions): values = sha1(json.dumps(permissions, sort_keys=True).encode("utf-8")).hexdigest() return f"permissions:product_ids:{values}" def get_product_ids_query(permissions): pids = [] if ( permissions.get("permission_label_ids") or permissions.get("permission_subaccount_ids") or permissions.get("permission_artist_ids") ): product_ids = SnowflakeQuery( "permissions/permissions_product_id_values.sql", QueryWithPermissionsSchema, permissions, ).execute() for pid in product_ids: pids.append(pid[0]) return pids def get_product_ids(permissions): redis_key = product_ids_cache_key(permissions) result = redis.client.get(redis_key) if result: return _decode_int_array(result) product_ids = get_product_ids_query(permissions) redis.client.set( redis_key, _encode_int_array(product_ids), ex=cache.SECONDS_PER_HOUR, ) return product_ids def get_cross_attribution_pairs(account_id, account_type): """Return (label_id, subaccount_id) pairs that ever co-owned a product with the given account, excluding the asker's own pair. BY_ACCOUNT _DAILY tables are clustered LINEAR(label_id, subaccount_id, ...); inlining these pairs as constants in the WHERE clause is what lets Snowflake prune to the right micro-partitions when transfer-product-ownership lookups need to fetch rows attributed to a former or current co-owner. """ redis_key = f"cross_attribution_pairs:{account_type}:{account_id}" cached = redis.client.get(redis_key) if cached is not None: return [tuple(pair) for pair in json.loads(cached)] rows = SnowflakeQuery( "permissions/cross_attribution_pairs.sql", CrossAttributionPairsSchema, {"account_id": account_id, "account_type": account_type}, ).execute() pairs = [(row[0], row[1]) for row in rows] redis.client.set(redis_key, json.dumps(pairs), ex=cache.SECONDS_PER_HOUR) return pairs def cache_json_redis(fn, key, params, ttl=cache.SECONDS_PER_HOUR): cached_result = redis.client.get(key) if cached_result: return json.loads(cached_result, object_hook=_deserialize_types) result = fn(params) redis.client.set(key, json.dumps(result, default=_serialize_types), ex=ttl) return result