import logging import uuid from typing import Any, NamedTuple, Optional from ddtrace import tracer from fastapi import HTTPException, Request from oto import response from owsrequest import access, error_response from pydantic import UUID1, UUID4 from python_pdp_sdk import ( ForwardKwargsGetter, ResourceWithAttributes, UnauthenticatedException, ) from product_staging.api import datasources from product_staging.api.schemas.tenant import Tenant from product_staging.connectors import ows_account, ows_users from product_staging.constants import error from product_staging.constants.error import ( ERROR_MESSAGE_CANNOT_ACCESS_VENDOR, ERROR_MESSAGE_NO_VENDOR_ID, ) from product_staging.logic import jwt from product_staging.logic import profile as profile_logic logger = logging.getLogger(__name__) def identity_uuid_from_scope(request: Request) -> Optional[UUID4]: """Fetch the authenticated principal's orchardIdentityId claim from JWT stored in 'token' scope.""" # noqa: E501 if "token" not in request.scope: message = "JWT not decoded by JWTAuthenticationMiddleware" logger.error(message) raise HTTPException( status_code=401, detail=message, ) token_claims = request.scope["token"] identity_uuid = jwt.get_identity_uuid(token_claims) if not identity_uuid: logger.error("Missing identity UUID") raise HTTPException( status_code=401, detail=error.ERROR_MESSAGE_BAD_IDENTITY_UUID, ) try: as_uuid = uuid.UUID(identity_uuid, version=4) except ValueError: logger.error("Bad format on identity UUID") raise HTTPException( status_code=401, detail=error.ERROR_MESSAGE_BAD_IDENTITY_UUID, ) else: return as_uuid def profiles_from_scope(request: Request) -> list[tuple[str, int]]: """Fetch the authenticated principal's profiles from ows-users.""" # noqa if "token" not in request.scope: message = "JWT not decoded by JWTAuthenticationMiddleware" logger.error(message) raise HTTPException( status_code=401, detail=message, ) token_claim = request.scope["token"] # Fetch all profiles for this identity from ows-users instead of reading # them from the JWT, because Workstation's account switching does not # include all LabelProfiles in the token. identity_uuid = jwt.get_identity_uuid(token_claim) if identity_uuid: profiles = ows_users.get_profiles_for_identity(uuid.UUID(identity_uuid)) if profiles: return profiles return [] def correlation_id_from_headers(request: Request) -> Optional[str]: """Fetch the correlation id from request headers, if present.""" correlation_id = request.headers.get("Correlation-Id") if not correlation_id: correlation_id = str(uuid.uuid4()) return correlation_id def verify_profile_headers( profile_type: str | None, profile_id: int | None, required: bool = False, allowed_types: tuple[str, ...] | None = None, ) -> response.Response: """Verify profile headers presence and completeness. Args: profile_type: Profile type value. profile_id: Profile id value. required: If True, headers must be presented to pass the check. allowed_types: Allowed profile types to match the profile_type with. Returns: Response: Response 200 - if the check has passed, 404 - if headers are incomplete, 403 - if required headers are absent or profile_type doesn't match allowed_types. """ allowed_types = ( tuple(_type.lower() for _type in allowed_types) if allowed_types else None ) completeness_response = access.verify_profile_headers_are_complete( profile_type, profile_id ) if not completeness_response: return completeness_response if ( required and not profile_type or allowed_types and profile_type and profile_type.lower() not in allowed_types ): return error_response.create_error_forbidden() return response.Response(status=200) @tracer.wrap() async def check_vendor_access_for_profiles( identity_id: UUID4, vendor_uuid: UUID1 | UUID4 | None, profiles: list[tuple[str, int]], allowed_types: tuple[str, ...], subaccount_id: int | None = None, ) -> int: """Check vendor access for profiles and return vendor_id when a valid profile is found. This function iterates through profiles, validates headers and access, and returns the vendor_id when at least one profile passes all checks. Args: identity_id: The UUID of the identity to check access for vendor_uuid: The UUID of the vendor to check access for profiles: List of (profile_type, profile_id) tuples from JWT allowed_types: Tuple of allowed profile types subaccount_id: The id of the subaccount to check access for Returns: int: The vendor_id if a profile has valid access Raises: HTTPException: 404 if vendor not found, 403 if no valid profile access """ if not vendor_uuid and not subaccount_id: raise HTTPException(status_code=404, detail=ERROR_MESSAGE_NO_VENDOR_ID) vendor_id = None if vendor_uuid: vendor_id = await ows_account.get_vendor_id(vendor_uuid) if subaccount_id: account_id = subaccount_id elif vendor_id: account_id = vendor_id else: raise HTTPException(status_code=404, detail=ERROR_MESSAGE_NO_VENDOR_ID) redis = datasources.get_redis_client() for profile in profiles: profile_type, profile_id = profile profile_headers_validation = verify_profile_headers( profile_type, profile_id, required=True, allowed_types=allowed_types, ) if profile_headers_validation.status != 200: continue # Check cache for profile access validation cache_key = f"/profile/access/{identity_id}/{profile_type}/{profile_id}/" if subaccount_id: cache_key += f"subaccount/{account_id}" else: cache_key += str(account_id) if redis: cached = await redis.get(cache_key) if cached is not None: # Cache hit - use cached validation result if cached.get("status") == 200: return account_id else: continue # Cache miss - perform validation profile_access_validation = profile_logic.check_profile_access( identity_id=identity_id, profile_id=profile_id, profile_type=profile_type, vendor_id=vendor_id, subaccount_id=subaccount_id, ) if redis: await redis.set(cache_key, {"status": profile_access_validation.status}) if profile_access_validation.status != 200: continue # At least one profile passed all checks return account_id # # No profile passed all checks error_msg = f"Access to vendor {vendor_uuid} denied for identity: {identity_id} profiles: {profiles}" if subaccount_id: error_msg = f"Access to subaccount {subaccount_id} denied for identity: {identity_id} profiles: {profiles}" logger.error(error_msg) raise HTTPException(status_code=403, detail=ERROR_MESSAGE_CANNOT_ACCESS_VENDOR) def _lookup_subaccount_to_tenant(subaccount: dict) -> Tenant: """Converts a subaccount dict object from ows_account.lookup_subaccounts_by_id into a Tenant object. This function assumes the input is non-null.""" # Since ows-account lookup endpoint has been called # pass along the tenant hierarchy details tenant_hierarchy = [ subaccount.get("parent_company_uuid"), subaccount.get("company_brand_uuid"), subaccount.get("vendor_uuid"), ] tenant_attributes = ( # However, if any of the tenant hierarchy elements are None # do not send that information along - leave it to ows-pdp to make the call again {"tenant_hierarchy": tenant_hierarchy} if all(tenant_hierarchy) else {} ) return Tenant( tenant_uuid=uuid.UUID(subaccount["uuid"]), tenant_type="subaccount", tenant_attributes=tenant_attributes, ) @tracer.wrap() async def get_tenant( vendor_uuid: UUID1 | UUID4 | None, subaccount_id: int | None ) -> Tenant | None: """Given a vendor_uuid or subaccount_uuid, build the Tenant. This will return None when one of the following occurred: - both vendor_uuid and subaccount_id were None - a subaccount id was provided but does not exist """ if subaccount_id: subaccounts = await ows_account.lookup_subaccounts_by_id([subaccount_id]) if subaccounts and subaccounts[0] and subaccounts[0].get("uuid"): return _lookup_subaccount_to_tenant(subaccounts[0]) if vendor_uuid: # tenant_attributes.tenant_hierarchy is not fetched/passed # because ows-account lookup endpoint has been called return Tenant( tenant_uuid=vendor_uuid, tenant_type="account", ) return None class TenantLookup(NamedTuple): """A (vendor_uuid, subaccount_id) pair for batch tenant resolution.""" vendor_uuid: UUID1 | UUID4 | None subaccount_id: int | None @tracer.wrap() async def get_tenants_many(tenant_lookups: list[TenantLookup]) -> list[Tenant | None]: """Batch version of get_tenant. Resolves all (vendor_uuid, subaccount_id) pairs with a single lookup_subaccounts_by_id call instead of one call per request. When a subaccount_id is provided but not found in ows-account, the request falls back to an account-type Tenant built from vendor_uuid (mirroring get_tenant's behavior). Returns None only when neither resolves. Returns a list of Tenant | None aligned by index with `lookups`. """ subaccount_ids = list( {tl.subaccount_id for tl in tenant_lookups if tl.subaccount_id} ) subaccount_by_id: dict[int, dict] = {} if subaccount_ids: # Fetch subaccounts from /lookup/subaccounts/subaccount-ids/ # Skip responses where s is None or is missing a `subaccount_id`. subaccount_by_id = { int(s["subaccount_id"]): s for s in await ows_account.lookup_subaccounts_by_id(subaccount_ids) if s and s.get("subaccount_id") } results: list[Tenant | None] = [] for lookup in tenant_lookups: if lookup.subaccount_id: subaccount = subaccount_by_id.get(lookup.subaccount_id) if subaccount and subaccount.get("uuid"): results.append(_lookup_subaccount_to_tenant(subaccount)) continue if lookup.vendor_uuid: results.append( Tenant(tenant_uuid=lookup.vendor_uuid, tenant_type="account") ) else: results.append(None) return results @tracer.wrap() def is_authorized_for_tenant( tenant_uuid: UUID1 | UUID4, tenant_type: str = "account", resource_type: str = "digital_audio", action: str = "bulk_create", tenant_attributes: dict[str, Any] = {}, ) -> bool: """Uses an authorization backend to determine if the user can bulk perform the resource type/action for the tenant.""" authorization_backend = datasources.get_authorization_backend() try: is_authorized = authorization_backend.is_authorized( action=action, resource_id=0, resource_type=resource_type, resource_getter=ForwardKwargsGetter(), tenant={ "tenant_type": tenant_type, "tenant_uuid": str(tenant_uuid), **tenant_attributes, }, ) except UnauthenticatedException: raise HTTPException( status_code=401, detail=error.ERROR_MESSAGE_NOT_AUTHENTICATED ) return is_authorized @tracer.wrap() def is_authorized_for_tenant_id( tenant_id: int | str, tenant_type: str = "account", resource_type: str = "digital_audio", action: str = "bulk_create", tenant_attributes: dict[str, Any] = {}, ) -> bool: """Uses an authorization backend to determine if the user can perform the resource type/action for the tenant, using tenant ID instead of UUID.""" authorization_backend = datasources.get_authorization_backend() try: is_authorized = authorization_backend.is_authorized( action=action, resource_id=0, resource_type=resource_type, resource_getter=ForwardKwargsGetter(), id_to_uuid_exchange_tenant={ "tenant_type": tenant_type, "tenant_id": tenant_id, **tenant_attributes, }, ) except UnauthenticatedException: raise HTTPException( status_code=401, detail=error.ERROR_MESSAGE_NOT_AUTHENTICATED ) return is_authorized @tracer.wrap() def is_authorized_many_for_tenant_id( *, tenant_ids: list[int], tenant_type: str, resource_type: str = "digital_audio", action: str = "bulk_create", ) -> list[bool]: """Batch version of is_authorized_for_tenant_id — makes a single PDP request for all tenants by ID.""" if not tenant_ids: return [] authorization_backend = datasources.get_authorization_backend() try: results = authorization_backend.is_authorized_many( action=action, resource_type=resource_type, resources_with_attributes=[ ResourceWithAttributes( resource_id=str(tenant_id), attributes={ "id_to_uuid_exchange_tenant": { "tenant_type": tenant_type, "tenant_id": str(tenant_id), } }, ) for tenant_id in tenant_ids ], ) except UnauthenticatedException: raise HTTPException( status_code=401, detail=error.ERROR_MESSAGE_NOT_AUTHENTICATED ) return results @tracer.wrap() def is_authorized_for_tenants( tenants: list[Tenant], bulk_session_ids: list[UUID4], resource_type: str = "digital_audio", action: str = "bulk_create", ) -> list[bool]: """Uses an authorization backend to determine if the user can bulk perform the resource type/action for each tenant. `tenants` and `bulk_session_ids` must be the same length and aligned by index — element i in the returned list corresponds to (tenants[i], bulk_session_ids[i]). The caller is responsible for ensuring every bulk_session_id exists in the DB before invoking this function. Unknown ids should be rejected upstream with a 404 (see `get_bulk_session` in logic/bulk_session.py for the established pattern), so this function can assume every tenant is non-None. Raises: HTTPException: 401 if the backend reports the principal is unauthenticated; 500 if `tenants` and `bulk_session_ids` differ in length (a programmer error — the mismatch detail is logged). """ authorization_backend = datasources.get_authorization_backend() if len(tenants) != len(bulk_session_ids): logger.error( f"tenants has length: {len(tenants)} but bulk_session_ids has length: {len(bulk_session_ids)}. Must be the same length." ) raise HTTPException(status_code=500, detail="Internal server error") try: results = authorization_backend.is_authorized_many( action=action, resource_type=resource_type, resources_with_attributes=[ ResourceWithAttributes( resource_id=f"bulk_session_for_{str(tenant.tenant_uuid)}", attributes={ "bulk_session_id": str(bulk_session_id), "tenant": { **tenant.tenant_attributes, "tenant_type": tenant.tenant_type, "tenant_uuid": str(tenant.tenant_uuid), }, }, ) for (tenant, bulk_session_id) in zip(tenants, bulk_session_ids) ], ) except UnauthenticatedException: raise HTTPException( status_code=401, detail=error.ERROR_MESSAGE_NOT_AUTHENTICATED ) return results