"""Helper methods to retrieve values from the auth token.""" import logging import uuid from typing import Dict, List, Optional from cerbos.sdk.client import AsyncCerbosClient from ddtrace.trace import tracer from fastapi import Depends, HTTPException from fastapi.requests import Request from fastapi.routing import APIRoute from httpx import HTTPStatusError from pydantic import UUID4 from pdp.connectors.dynamo import DynamoDbConnector from pdp.connectors.features import BooleanFeature, SplitioClient from pdp.connectors.ows_account import OwsAccountClient from pdp.connectors.ows_participant import OwsParticipantClient from pdp.connectors.ows_permissions import OwsPermissionsClient from pdp.connectors.redis_client import PydanticSchemaSerializer, RedisConnector from pdp.constants import constants, error from pdp.constants.features import FEATURE_FLAG_PP_SEND_IMPERSONATED_BY_IDENTITY_UUID from pdp.fastapi.datasources import ( get_async_cerbos_client, get_boto_connector, get_ows_account_client, get_ows_participant_client, get_ows_permissions_client, get_redis_connector, get_splitio_client, ) from pdp.fastapi.schemas.cache import PrincipalPdpCacheObject from pdp.fastapi.schemas.check_resources import CheckResourcesRequest from pdp.fastapi.schemas.identity import ( CheckResourceAction, Resource, Role, TenantRoles, ) from pdp.fastapi.schemas.principal import Principal from pdp.fastapi.schemas.tenant import Tenant from pdp.logic import cerbos, jwt from pdp.logic.cache import ( get_object_from_cache, save_cache_object, ) from pdp.logic.identity import get_roles_by_identity from pdp.utils.auth import ( build_identity_resource_from_tenant_role, get_tenant_uuid_from_identity_resource_id, ) logger = logging.getLogger(__name__) API_ROUTE_ACTION_MAPPING = { "attach_detach_roles_by_identity_tenant": "attach_and_detach_role", "get_roles_by_identity": "list_tenants", "check_identity_resources": "check_resources", "deactivate_one": "deactivate", "deactivate_all": "deactivate_all", } INFRA_API_ROUTE_ACTION_MAPPING = { "cache_list": "list_cache", "cache_bust": "bust_cache", "cache_bludgeon": "bludgeon_cache", "uuid_to_id_exchange": "uuid_to_id_exchange", "id_to_uuid_exchange": "id_to_uuid_exchange", } @tracer.wrap() 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: # JWT not decoded by JWTAuthenticationMiddleware raise HTTPException( status_code=401, detail="JWT not decoded by JWTAuthenticationMiddleware", ) token_claims = request.scope["token"] identity_uuid = jwt.get_identity_uuid(token_claims) if not identity_uuid: raise HTTPException( status_code=401, detail=error.ERROR_MESSAGE_IDENTITY_NOT_FOUND, ) try: as_uuid = uuid.UUID(identity_uuid, version=4) except ValueError: raise HTTPException( status_code=401, detail=error.ERROR_MESSAGE_BAD_IDENTITY_UUID, ) else: return as_uuid def impersonated_by_identity_uuid_from_scope(request: Request) -> Optional[UUID4]: """Fetch the impersonated_by_identity_uuid from the request scope, if present.""" # noqa: E501 if "token" not in request.scope: # JWT not decoded by JWTAuthenticationMiddleware raise HTTPException( status_code=401, detail="JWT not decoded by JWTAuthenticationMiddleware", ) token_claims = request.scope["token"] impersonated_by_identity_uuid = jwt.get_impersonated_by_identity_uuid(token_claims) if not impersonated_by_identity_uuid: return None try: as_uuid = uuid.UUID(impersonated_by_identity_uuid, version=4) except ValueError: raise HTTPException( status_code=401, detail=error.ERROR_MESSAGE_BAD_IMPERSONATED_BY_IDENTITY_UUID, ) else: return as_uuid @tracer.wrap() def user_type_from_scope(request: Request) -> str: """Fetch the authenticated principal's isMachine claim from JWT stored in 'token' scope.""" # noqa: E501 if "token" not in request.scope: # JWT not decoded by JWTAuthenticationMiddleware raise HTTPException( status_code=401, detail="JWT not decoded by JWTAuthenticationMiddleware", ) token_claims = request.scope["token"] return jwt.get_user_type(token_claims) @tracer.wrap() async def get_identity_pdp_tenant_roles_from_scope( request: Request, identity_uuid: uuid.UUID, identity_ddb_connector: DynamoDbConnector = Depends(get_boto_connector), redis_connector: RedisConnector = Depends(get_redis_connector), ) -> Dict[uuid.UUID, TenantRoles]: """Get an identity's full PDP tenant roles. Paginate over DynamoDB to get all the roles available for the identity_uuid. Note, the identity_uuid may not be the same as the authenticated identity_uuid. Use the request scope to store this identity's full PDP tenant roles. """ principal_pdp_cache_object = PrincipalPdpCacheObject( identity_uuid=identity_uuid, ) principal_cache_key = principal_pdp_cache_object.to_cache_key() if principal_cache_key in request.scope: return request.scope[principal_cache_key] # type: ignore[no-any-return] tenants_cached: Dict[uuid.UUID, TenantRoles] | None = await get_object_from_cache( key=principal_cache_key, redis_connector=redis_connector, serializer=PydanticSchemaSerializer(PrincipalPdpCacheObject), cache_attribute_name="tenant_roles", ) if tenants_cached is not None: request.scope[principal_cache_key] = tenants_cached return tenants_cached result = get_roles_by_identity( str(identity_uuid), identity_ddb_connector=identity_ddb_connector ) tenants = result.tenants cursor = result.cursor.cursor while cursor: result = get_roles_by_identity( str(identity_uuid), identity_ddb_connector=identity_ddb_connector, cursor=cursor, ) tenants.update(result.tenants) cursor = result.cursor.cursor request.scope[principal_cache_key] = tenants principal_pdp_cache_object.tenant_roles = tenants await save_cache_object( cache_object=principal_pdp_cache_object, cache_model_type=PrincipalPdpCacheObject, redis_connector=redis_connector, ) return tenants @tracer.wrap() async def get_principal_pdp_tenant_roles_from_scope( request: Request, principal_identity_uuid: UUID4 = Depends(identity_uuid_from_scope), identity_ddb_connector: DynamoDbConnector = Depends(get_boto_connector), redis_connector: RedisConnector = Depends(get_redis_connector), ) -> Dict[uuid.UUID, TenantRoles]: """Get the authenticated principal's tenant roles.""" tenants = await get_identity_pdp_tenant_roles_from_scope( request, principal_identity_uuid, identity_ddb_connector=identity_ddb_connector, redis_connector=redis_connector, ) return tenants @tracer.wrap() async def get_principal_ows_permissions_from_scope( request: Request, ows_permissions_client: OwsPermissionsClient = Depends(get_ows_permissions_client), ) -> Dict[uuid.UUID, TenantRoles]: """Get the authenticated principal's tenant roles from ows-permissions. Args: request: starlette/fastapi request, where we will store/fetch from scope ows_permissions_client: dependency on ows-permissions, which should proxy the Authorization header of this request Typical CheckResources op should not require interacting with ows-permissions. However, until we migrate all "ADMIN ACCESS" into ows-pdp's datastore, we will use ows-permissions as a Policy Information Point (PIP) to determine which tenants the authenticated user has access to admin users. """ if constants.SCOPE_PRINCIPAL_OWS_PERMISSIONS in request.scope: return request.scope[ # type: ignore[no-any-return] constants.SCOPE_PRINCIPAL_OWS_PERMISSIONS ] tenant_roles: Dict[uuid.UUID, TenantRoles] = {} try: adminable_resources = ( await ows_permissions_client.collect_get_my_adminable_resources() ) except HTTPStatusError as e: if e.response.status_code >= 400 and e.response.status_code < 500: logger.warning(f"Bad request to ows-permissions {e}") else: logger.error(f"Server error from ows-permissions {e}") except Exception as e: logger.error( f"Failed to fetch additional auth principal data {e}", exc_info=True ) else: # Transform adminable resources for resource in adminable_resources: if not resource.is_supported_resource_type(): continue tenant_roles[uuid.UUID(resource.uuid)] = TenantRoles( tenant_type=resource.get_tenant_type(), tenant_uuid=uuid.UUID(resource.uuid), roles=[Role(role=constants.RAP_ADMIN_PER_OWS_PERMISSIONS)], ) finally: request.scope[constants.SCOPE_PRINCIPAL_OWS_PERMISSIONS] = tenant_roles return tenant_roles @tracer.wrap() async def _build_identity_resources( request: Request, api_route: APIRoute, identity_uuid: UUID4, ) -> List[Resource]: """Build identity resource for check_authorization operation.""" attrs = {} # an api_route must be added here to enforce the identity_owned_resource policy if api_route.name in [ "get_roles_by_identity", "check_identity_resources", "deactivate_all", ]: return [ Resource( resource_id=str(identity_uuid), resource_type="identity", attributes={ "identity_uuid": str(identity_uuid), }, ), ] if ( api_route.name in ["attach_detach_roles_by_identity_tenant", "deactivate_one"] and "tenant_uuid" in request.path_params ): try: request_data = await request.json() rap_by_tenant_request = Tenant.model_validate(request_data) attrs = { "tenant": { "tenant_uuid": request.path_params["tenant_uuid"], "tenant_type": rap_by_tenant_request.tenant_type, }, "identity_uuid": str(identity_uuid), } except ValueError: logger.warning( error.ERROR_MESSAGE_BUILD_IDENTITY_RESOURCE, exc_info=True, ) return [ Resource( resource_id=str(identity_uuid), resource_type="identity", attributes=attrs, ) ] return [] @tracer.wrap() def _build_infra_resources( api_route: APIRoute, ) -> List[Resource]: """Build infra resource for check_authorization_infra operation.""" if api_route.name in [ "cache_list", "cache_bust", "cache_bludgeon", "id_to_uuid_exchange", "uuid_to_id_exchange", ]: return [ Resource( resource_id=constants.DEFAULT_RESOURCE_ID, resource_type=constants.INFRA, attributes={}, ), ] return [] @tracer.wrap() async def check_authorization( request: Request, identity_uuid: UUID4, principal_identity_uuid: UUID4 = Depends(identity_uuid_from_scope), pdp_tenant_roles: Dict[uuid.UUID, TenantRoles] = Depends( get_principal_pdp_tenant_roles_from_scope ), ows_permissions_tenant_roles: Dict[uuid.UUID, TenantRoles] = Depends( get_principal_ows_permissions_from_scope ), impersonated_by_identity_uuid: UUID4 | None = Depends( impersonated_by_identity_uuid_from_scope ), user_type: str = Depends(user_type_from_scope), cerbos_client: AsyncCerbosClient = Depends(get_async_cerbos_client), ows_account_client: OwsAccountClient = Depends(get_ows_account_client), ows_participant_client: OwsParticipantClient = Depends(get_ows_participant_client), redis_connector: RedisConnector = Depends(get_redis_connector), splitio_client: SplitioClient = Depends(get_splitio_client), ) -> None: """Check the authenticated principal is authorized to perform role administration. Args: request: the API request being processed identity_uuid: the uuid of the identity being administered principal_identity_uuid: the authenticated identity's uuid, who made the request pdp_tenant_roles: information about the authenticated identity, from DynamoDB pp_identity ows_permissions_tenant_roles: information about the authenticated identity, from ows-permissions """ api_route: Optional[APIRoute] = request.get("route") action: Optional[str] = None if not api_route: raise HTTPException( status_code=400, detail="Request is not for a valid api route", ) action = API_ROUTE_ACTION_MAPPING.get(api_route.name, None) if not action: raise HTTPException( status_code=403, detail=f"Principal {principal_identity_uuid} not authorized for {api_route}", # noqa: E501 ) identities = await _build_identity_resources(request, api_route, identity_uuid) if not identities: raise HTTPException( status_code=403, detail=f"Principal {principal_identity_uuid} not authorized to {action} on identity {identity_uuid}", # noqa: E501 ) pp_send_impersonated_by_identity_uuid_feature = BooleanFeature( client=splitio_client, feature_name=FEATURE_FLAG_PP_SEND_IMPERSONATED_BY_IDENTITY_UUID, ) if pp_send_impersonated_by_identity_uuid_feature.is_on_for_identity( str(impersonated_by_identity_uuid or principal_identity_uuid) ): principal = Principal( identity_uuid=principal_identity_uuid, user_type=user_type, pdp_tenant_roles=pdp_tenant_roles, ows_permissions_tenant_roles=ows_permissions_tenant_roles, impersonated_by_identity_uuid=impersonated_by_identity_uuid, ) check_result = await cerbos.check_resources( identity_uuid=str(principal_identity_uuid), check_resources_request=CheckResourcesRequest( resources=[ CheckResourceAction( action=action, resource=identity, ) for identity in identities ], ), pdp_tenant_roles=pdp_tenant_roles, ows_permissions_tenant_roles=ows_permissions_tenant_roles, cerbos_client=cerbos_client, ows_account_client=ows_account_client, ows_participant_client=ows_participant_client, redis_connector=redis_connector, authenticated_identity_uuid=principal_identity_uuid, principal=principal, splitio_client=splitio_client, ) else: check_result = await cerbos.check_resources( identity_uuid=str(principal_identity_uuid), check_resources_request=CheckResourcesRequest( resources=[ CheckResourceAction( action=action, resource=identity, ) for identity in identities ], ), pdp_tenant_roles=pdp_tenant_roles, ows_permissions_tenant_roles=ows_permissions_tenant_roles, cerbos_client=cerbos_client, ows_account_client=ows_account_client, ows_participant_client=ows_participant_client, redis_connector=redis_connector, authenticated_identity_uuid=principal_identity_uuid, splitio_client=splitio_client, ) # PP-425: https://theorchard.atlassian.net/browse/PP-425 # Once we have a stance on this, we should handle each item # in the check_result.resources array if check_result.resources[0].effect != constants.AUTH_EFFECT_ALLOW: raise HTTPException( status_code=403, detail=f"Principal {principal_identity_uuid} not authorized to {action} on identity {identity_uuid}", # noqa: E501 ) @tracer.wrap() async def check_authorization_infra( request: Request, principal_identity_uuid: UUID4 = Depends(identity_uuid_from_scope), impersonated_by_identity_uuid: UUID4 | None = Depends( impersonated_by_identity_uuid_from_scope ), user_type: str = Depends(user_type_from_scope), cerbos_client: AsyncCerbosClient = Depends(get_async_cerbos_client), ows_account_client: OwsAccountClient = Depends(get_ows_account_client), ows_participant_client: OwsParticipantClient = Depends(get_ows_participant_client), redis_connector: RedisConnector = Depends(get_redis_connector), splitio_client: SplitioClient = Depends(get_splitio_client), ) -> None: """Check the authenticated principal is authorized to perform infrastructure operations.""" # noqa: E501 api_route: Optional[APIRoute] = request.get("route") action: Optional[str] = None if not api_route: raise HTTPException( status_code=400, detail="Request is not for a valid api route", ) action = INFRA_API_ROUTE_ACTION_MAPPING.get(api_route.name, None) if not action: raise HTTPException( status_code=403, detail=f"Principal {principal_identity_uuid} not authorized for {api_route}", # noqa: E501 ) resources = _build_infra_resources(api_route) pp_send_impersonated_by_identity_uuid_feature = BooleanFeature( client=splitio_client, feature_name=FEATURE_FLAG_PP_SEND_IMPERSONATED_BY_IDENTITY_UUID, ) if pp_send_impersonated_by_identity_uuid_feature.is_on_for_identity( str(impersonated_by_identity_uuid or principal_identity_uuid) ): principal = Principal( identity_uuid=principal_identity_uuid, user_type=user_type, pdp_tenant_roles={}, impersonated_by_identity_uuid=impersonated_by_identity_uuid, ) check_result = await cerbos.check_resources( identity_uuid=str(principal_identity_uuid), check_resources_request=CheckResourcesRequest( resources=[ CheckResourceAction( action=action, resource=infra_resource, ) for infra_resource in resources ], ), pdp_tenant_roles={}, ows_permissions_tenant_roles={}, cerbos_client=cerbos_client, ows_account_client=ows_account_client, ows_participant_client=ows_participant_client, redis_connector=redis_connector, authenticated_identity_uuid=principal_identity_uuid, principal=principal, splitio_client=splitio_client, ) else: check_result = await cerbos.check_resources( identity_uuid=str(principal_identity_uuid), check_resources_request=CheckResourcesRequest( resources=[ CheckResourceAction( action=action, resource=infra_resource, ) for infra_resource in resources ], ), pdp_tenant_roles={}, ows_permissions_tenant_roles={}, cerbos_client=cerbos_client, ows_account_client=ows_account_client, ows_participant_client=ows_participant_client, redis_connector=redis_connector, authenticated_identity_uuid=principal_identity_uuid, splitio_client=splitio_client, ) if check_result.resources[0].effect != constants.AUTH_EFFECT_ALLOW: raise HTTPException( status_code=403, detail=f"Principal {principal_identity_uuid} not authorized to {action}.", ) # PP-1212: This function is not a fastapi dependency function # so we should remove the `Depends()` methods from the args. @tracer.wrap() async def filter_for_identity_tenants( identity_uuid: uuid.UUID, tenants: Dict[uuid.UUID, TenantRoles], cerbos_client: AsyncCerbosClient, splitio_client: SplitioClient, user_type: str, impersonated_by_identity_uuid: UUID4 | None = None, action: str = "view", principal_identity_uuid: UUID4 = Depends(identity_uuid_from_scope), pdp_tenant_roles: Dict[uuid.UUID, TenantRoles] = Depends( get_principal_pdp_tenant_roles_from_scope ), ows_permissions_tenant_roles: Dict[uuid.UUID, TenantRoles] = Depends( get_principal_ows_permissions_from_scope ), ows_account_client: OwsAccountClient = Depends(get_ows_account_client), ows_participant_client: OwsParticipantClient = Depends(get_ows_participant_client), redis_connector: RedisConnector = Depends(get_redis_connector), ) -> Dict[uuid.UUID, TenantRoles]: """Filter the identity's tenants for what the authenticated principal is allowed. Args: identity_uuid: the uuid of the identity whose TenantRoles are being administered tenants: the identity's TenantRoles that should be filtered, based on the authenticated identity's roles action: the action the authenticated principal wants to do principal_identity_uuid: the authenticated identity's uuid, who made the request pdp_tenant_roles: information about the authenticated identity, from DynamoDB pp_identity ows_permissions_tenant_roles: information about the authenticated identity, from ows-permissions """ # Return right away if tenants is empty if len(tenants) == 0: return tenants keepers: Dict[uuid.UUID, TenantRoles] = {} losers: List[str] = [] # Convert tenants into a CheckResourceRequest identities: List[Resource] = [] for tenant_role in tenants.values(): identities.append( build_identity_resource_from_tenant_role( identity_uuid, tenant_role, ) ) pp_send_impersonated_by_identity_uuid_feature = BooleanFeature( client=splitio_client, feature_name=FEATURE_FLAG_PP_SEND_IMPERSONATED_BY_IDENTITY_UUID, ) ff_value = pp_send_impersonated_by_identity_uuid_feature.is_on_for_identity( str(impersonated_by_identity_uuid or principal_identity_uuid) ) if ff_value: principal = Principal( identity_uuid=principal_identity_uuid, user_type=user_type, pdp_tenant_roles=pdp_tenant_roles, ows_permissions_tenant_roles=ows_permissions_tenant_roles, impersonated_by_identity_uuid=impersonated_by_identity_uuid, ) # Get check_resources response from Cerbos check_result = await cerbos.check_resources( str(principal_identity_uuid), CheckResourcesRequest( resources=[ CheckResourceAction( action=action, resource=identity, ) for identity in identities ], ), pdp_tenant_roles=pdp_tenant_roles, ows_permissions_tenant_roles=ows_permissions_tenant_roles, cerbos_client=cerbos_client, ows_account_client=ows_account_client, ows_participant_client=ows_participant_client, redis_connector=redis_connector, authenticated_identity_uuid=principal_identity_uuid, principal=principal, splitio_client=splitio_client, ) else: # Get check_resources response from Cerbos check_result = await cerbos.check_resources( str(principal_identity_uuid), CheckResourcesRequest( resources=[ CheckResourceAction( action=action, resource=identity, ) for identity in identities ], ), pdp_tenant_roles=pdp_tenant_roles, ows_permissions_tenant_roles=ows_permissions_tenant_roles, cerbos_client=cerbos_client, ows_account_client=ows_account_client, ows_participant_client=ows_participant_client, redis_connector=redis_connector, authenticated_identity_uuid=principal_identity_uuid, splitio_client=splitio_client, ) # Turn the allowed resources back into the Dict[UUID, TenantRoles] to return for resource in check_result.resources: # Get the original tenant_uuid tenant_uuid = get_tenant_uuid_from_identity_resource_id( str(resource.resource.resource_id) ) if resource.effect == constants.AUTH_EFFECT_ALLOW: # Use the tenant_uuid to get the original TenantRoles # from the tenant dict and put into keepers keepers[tenant_uuid] = tenants[tenant_uuid] else: # Track the denied tenant_uuids losers.append(str(tenant_uuid)) if losers: logger.info( "List of tenants that the principal was not allowed to view:", extra={ "resources": { "principal_identity_uuid": str(principal_identity_uuid), "identity_uuid": str(identity_uuid), "losers": losers, } }, ) return keepers