"""DynamoDB Identity Table Model.""" import datetime import json import logging import time from typing import Any, Dict, List, Optional from uuid import UUID from pdp import config from pdp.connectors.dynamo import DynamoDbConnector from pdp.constants.constants import ( TenantType, ) from pdp.fastapi.schemas.identity import ( IdentityTenant, TenantRoles, TombstoneIdentityTenant, TombstoneIdentityTenantValidator, ) from pdp.fastapi.schemas.principal import Principal from pdp.fastapi.schemas.tenant import Tenant from pdp.utils.dynamo import ( deserialize_dynamo_item_array, deserialize_dynamo_item_to_dict, serialize_dict_to_dynamo_item, ) HASH_KEY = config.IDENTITY_HASH_KEY # also known as Partition key RANGE_KEY = config.IDENTITY_RANGE_KEY # also known as Sort key TABLE_NAME = config.DYNAMODB_TABLE_IDENTITY TOMBSTONE_ID_FIELD_PREFIX = "TOMBSTONE" TOMBSTONE_DELIMITER = ":" logger = logging.getLogger(__name__) class Identity: """Identity model.""" def __init__(self, identity_uuid: str, identity_ddb_connector: DynamoDbConnector): """Init method. Args: identity_uuid (str): Universally unique identifier for identity. """ self._connector = identity_ddb_connector self._identity_uuid = identity_uuid def get_roles(self, cursor: Optional[str] = None) -> Dict[str, Any]: """Get all permissions. Args: cursor (str): Pagination cursor. Optional. Returns: dict: Containing items array and cursor dict. For example: { "items": [{...}], "cursor": { "cursor": "some string", ...} } """ return self._connector.query_by_hash_key(self._identity_uuid, cursor=cursor) def get_roles_by_tenant(self, tenant_uuid: UUID) -> Dict[str, Any]: """Get all permissions for an identity/tenant pair.""" return self._connector.query_by_hash_key( hash_key=self._identity_uuid, range_key=str(tenant_uuid) ) def update_roles(self, tenant_uuid: UUID, roles: Dict[str, Any]) -> Dict[str, Any]: """Update roles for an identity/tenant pair.""" return self._connector.update_item( hash_key=self._identity_uuid, item=roles, range_key=str(tenant_uuid) ) def get_empty_tenant_permissions_state( self, tenant_uuid: UUID, tenant_type: TenantType, created_at: str, created_by: str, created_impersonated_by: str | None = None, ) -> IdentityTenant: """Return an empty permissions state for an Identity Tenant object.""" return IdentityTenant( identity_uuid=self._identity_uuid, tenant_uuid=tenant_uuid, tenant_type=tenant_type, roles=[], created_by=created_by, created_impersonated_by=created_impersonated_by, created_at=created_at, updated_by=created_by, updated_impersonated_by=created_impersonated_by, updated_at=created_at, ) def get_permissions_by_tenant(self, tenant_uuid: UUID) -> Optional[IdentityTenant]: """Get an identity/tenant item, as an Identity Tenant object.""" response = self.get_roles_by_tenant(tenant_uuid) if response.get("items"): item = deserialize_dynamo_item_array(response.get("items", {}))[0] return IdentityTenant(**item) return None def update_tenant_permissions( self, tenant_uuid: UUID, tenant_state: IdentityTenant ) -> IdentityTenant: """Update an identity's permissions for a given tenant and return.""" # Transform the IdentityTenant object into a dynamo item tenant_state_dict = tenant_state.model_dump() tenant_state_dict.pop(HASH_KEY, None) tenant_state_dict.pop(RANGE_KEY, None) tenant_state_dynamo_item = serialize_dict_to_dynamo_item(tenant_state_dict) # Update roles response = self.update_roles(tenant_state.tenant_uuid, tenant_state_dynamo_item) # Return the IdentityTenant object return IdentityTenant( identity_uuid=self._identity_uuid, tenant_uuid=tenant_uuid, **deserialize_dynamo_item_to_dict(response), ) async def deactivate_many( self, tenants: Dict[UUID, TenantRoles], ) -> List[Dict[str, Any]]: """Delete multiple identity/tenant items.""" if not tenants: return [] delete_keys = [ { HASH_KEY: self._identity_uuid, RANGE_KEY: tenant_uuid, } for tenant_uuid in tenants.keys() ] return await self._connector.delete_items(delete_keys) def get_tombstone_state( self, tenant: Tenant, deleted_at: str, deleted_by: str, deleted_impersonated_by: str | None = None, ttl: int = 1, ) -> TombstoneIdentityTenant: """Create a TombstoneIdentityTenant object. Args: tenant: Tenant schema object deleted_at: timestamp for the deletion deleted_by: Authorized Principal identity_uuid that sent the deactivation request. ttl: Seconds in the future to set the `expires_at` field for deletion. Returns: A TombstoneIdentityTenant object with fields required to write the tombstone record to the `pp_identity` table. """ # Create UUID values with TOMBSTONE_ID_FIELD_PREFIX identity_uuid = TOMBSTONE_DELIMITER.join( [TOMBSTONE_ID_FIELD_PREFIX, self._identity_uuid, str(int(time.time()))] ) tenant_uuid = TOMBSTONE_DELIMITER.join( [TOMBSTONE_ID_FIELD_PREFIX, str(tenant.tenant_uuid)] ) expires_at = int(time.time()) + ttl return TombstoneIdentityTenant( identity_uuid=identity_uuid, tenant_uuid=tenant_uuid, tenant_type=tenant.tenant_type, expires_at=expires_at, is_tombstone=True, updated_at=deleted_at, updated_by=deleted_by, updated_impersonated_by=deleted_impersonated_by, created_at=deleted_at, created_by=deleted_by, created_impersonated_by=deleted_impersonated_by, roles=[], ) async def write_tombstone_records_for_deactivations( self, authenticated_identity_uuid: str, request_tenants: Dict[UUID, TenantRoles], unprocessed_items: List[Dict[str, Any]], principal: Principal | None = None, ) -> List[Dict[str, Any]]: """Write tombstone records for successfully deactivated tenants.""" if not request_tenants: return [] if principal: tombstone_records = self._generate_tombstone_records( authenticated_identity_uuid=str(principal.identity_uuid), request_tenants=request_tenants, unprocessed_items=unprocessed_items, impersonated_by_identity_uuid=str( principal.impersonated_by_identity_uuid ) if principal.impersonated_by_identity_uuid else None, ) else: tombstone_records = self._generate_tombstone_records( authenticated_identity_uuid=authenticated_identity_uuid, request_tenants=request_tenants, unprocessed_items=unprocessed_items, ) return await self._write_tombstone_records(tombstone_records) async def _write_tombstone_records( self, tombstone_records: List[TombstoneIdentityTenant] ) -> List[Dict[str, Any]]: """Write the tombstone records to DDB.""" if not tombstone_records: return [] unprocessed_records = await self._connector.put_items( TombstoneIdentityTenantValidator.dump_python(tombstone_records) ) for rec in unprocessed_records: # Alert the team so we can manually create the record, if needed # There's no PII in the record, so it should be safe to log it. logger.error("Failed to write tombstone record: %s", json.dumps(rec)) return unprocessed_records def _generate_tombstone_records( self, authenticated_identity_uuid: str, request_tenants: Dict[UUID, TenantRoles], unprocessed_items: List[Dict[str, Any]], impersonated_by_identity_uuid: str | None = None, ) -> List[TombstoneIdentityTenant]: """Find the sucessfully deleted items using the request tenants and the unprocessed items. Return a list of tombstone objects for successfully deleted items.""" # Get identity and tenant uuids from the unprocessed_items list. unprocessed_tenants = { (upi.get(HASH_KEY), upi.get(RANGE_KEY)) for upi in unprocessed_items } # Create TombstoneIdentityTenant records for processed tenants. tombstone_states = [] for request_tenant_uuid, tenant in request_tenants.items(): if (self._identity_uuid, request_tenant_uuid) in unprocessed_tenants: # This (identity, tenant) was unprocessed, # so don't create a tombstone record. continue tombstone_states.append( self.get_tombstone_state( tenant=tenant, deleted_at=datetime.datetime.now(datetime.UTC).isoformat(), deleted_by=authenticated_identity_uuid, deleted_impersonated_by=impersonated_by_identity_uuid, ttl=config.TOMBSTONE_TTL_SECONDS, ) ) return tombstone_states