"""Models used to serialize API requests and responses.""" from typing import Annotated, Any, Dict, List, Optional, Union from uuid import UUID from pydantic import ( BaseModel, ConfigDict, StringConstraints, TypeAdapter, field_validator, model_validator, ) from pdp.connectors.cerbos_policy_parser import PolicyMetadataDatabase from pdp.constants.constants import AuthEffect from pdp.fastapi.schemas.resource_attributes import TenantOwnedResourceAttributes from pdp.fastapi.schemas.tenant import ( IdToUuidExchangeTenant, IdToUuidExchangeTenantResourceAttributes, Tenant, TenantWithMaybeHierarchy, TenantWithMaybeHierarchyResourceAttributes, ) class BonjourResponse(BaseModel): """Object for demo bonjour endpoint.""" message: Union[str, None] = None class CachePingResponse(BaseModel): """Object for Redis Ping response.""" status: bool redis_url: str class HelloResponse(BaseModel): """Simple model with OK status.""" status: str = "ok" class PaginationCursor(BaseModel): """Response object for pagination cursor.""" cursor: Union[str, None] = None shorthand: Union[str, None] = None class ImmutablePaginationCursor(PaginationCursor): """Immutable variant of the pagination cursor.""" # Prevent code from accidentally mutating # DEFAULT_PAGINATION_CURSOR. model_config = ConfigDict( frozen=True, ) DEFAULT_PAGINATION_CURSOR = ImmutablePaginationCursor(cursor=None, shorthand=None) class Role(BaseModel): """Object for Role. `role` key is required, but extra attributes are allowed. """ role: Annotated[str, StringConstraints(min_length=1)] model_config = ConfigDict( extra="allow", str_strip_whitespace=True, validate_default=True, ) @model_validator(mode="before") @classmethod def strip_whitespace_from_keys_and_values(cls, values: Any) -> Any: """Strip whitespace from role and extra attributes.""" if isinstance(values, dict): return { str(k).strip(): v.strip() if isinstance(v, str) else v for k, v in values.items() } return values RoleListValidator = TypeAdapter(List[Role]) class TenantRoles(Tenant): """Object for TenantRoles.""" roles: List[Role] def as_cerbos_principal_tenants_attribute(self) -> Dict[str, Any]: """Transform TenantRoles into a dict for a cerbos check_resources request.""" return { "roles": {role.role: role.model_dump() for role in self.roles}, "tenant_type": self.tenant_type.value, "tenant_uuid": str(self.tenant_uuid), } def __eq__(self, other: object) -> bool: assert isinstance(other, TenantRoles) return super().__eq__(other) and repr(self.roles) == repr(other.roles) def __repr__(self) -> str: return f"{super().__repr__()}{repr(self.roles)}" def __hash__(self) -> int: """Hash TenantRoles using its representation.""" return hash(repr(self)) TenantRolesMapValidator = TypeAdapter(Dict[UUID, TenantRoles]) class IdentityTenant(TenantRoles): """Object for an IdentityTenant. This represent's an Identity's permissions with respect to a single tenant. """ identity_uuid: str # Version must be an integer string. version: Annotated[str, StringConstraints(pattern=r"^\d+$")] = "0" updated_at: Optional[str] = None updated_by: Optional[str] = None updated_impersonated_by: Optional[str] = None created_at: Optional[str] = None created_by: Optional[str] = None created_impersonated_by: Optional[str] = None # IdentityTenant inherits TenantRoles which inherits Tenant # Tenant is treated as Immutable, but IdentityTenant can be mutated model_config = ConfigDict( frozen=False, ) def increment_version(self) -> Any: """Increment version to the next version number.""" self.version = str(int(self.version) + 1) return self def __eq__(self, other: object) -> bool: if not isinstance(other, IdentityTenant): return False return ( super().__eq__(other) and repr(self.version) == repr(other.version) and repr(self.updated_at) == repr(other.updated_at) and repr(self.updated_by) == repr(other.updated_by) and repr(self.updated_impersonated_by) == repr(other.updated_impersonated_by) and repr(self.created_at) == repr(other.created_at) and repr(self.created_by) == repr(other.created_by) and repr(self.created_impersonated_by) == repr(other.created_impersonated_by) ) def __repr__(self) -> str: return f"""{super().__repr__()}#{repr(self.version)}#{repr(self.updated_at)}# {repr(self.updated_by)}#{repr(self.updated_impersonated_by)} {repr(self.created_at)}#{repr(self.created_by)}#{repr(self.created_impersonated_by)}""" def __hash__(self) -> int: """Hash TenantRoles using its representation.""" return hash(repr(self)) class RolesResponse(BaseModel): """Response object for role data.""" cursor: PaginationCursor = DEFAULT_PAGINATION_CURSOR errors: Dict[str, Any] = {} tenants: Dict[UUID, TenantRoles] = {} class AttachDetachRolesRequest(Tenant): """Request object for creating a request to attach & detach roles. { "tenant_type": "account", "tenant_uuid": "c87b9586-03dc-47be-a3b0-53ca84aa4145", "roles_to_attach": [ {"role": "audience_development_client"}, {"role": "content_reviewer", "content_types": ["physical"]} ], "roles_to_detach": [ {"role": "audience_development_admin"} ] } """ roles_to_attach: List[Role] roles_to_detach: List[Role] class Resource(BaseModel): """Representation of a resource.""" resource_id: Union[Annotated[str, StringConstraints(min_length=1)], int] resource_type: Annotated[str, StringConstraints(min_length=1)] attributes: Dict[str, Any] = {} model_config = ConfigDict( str_strip_whitespace=True, ) @field_validator("resource_id") @classmethod def coerce_resource_id_to_string(cls, v: Union[str, int]) -> Any: """resource_id can be an int, but always cast to str.""" if isinstance(v, int): return str(v) return v def get_tenant(self) -> Optional[Tenant]: """Get Tenant associated with the resource, if any.""" try: tenant_attr = TenantOwnedResourceAttributes.model_validate( self.attributes, ) except Exception: return None return tenant_attr.tenant def get_tenant_with_maybe_hierarchy(self) -> Optional[TenantWithMaybeHierarchy]: """Get Tenant with possible hierarchy, if any.""" try: tenant_attr = TenantWithMaybeHierarchyResourceAttributes.model_validate( self.attributes, ) except Exception: return None return tenant_attr.tenant def get_id_to_uuid_exchange_tenant(self) -> Optional[IdToUuidExchangeTenant]: """Get IdToUuidExchangeTenant, if any.""" try: id_to_uuid_exchange_tenant_attr = ( IdToUuidExchangeTenantResourceAttributes.model_validate(self.attributes) ) except Exception: return None return id_to_uuid_exchange_tenant_attr.id_to_uuid_exchange_tenant def needs_account_feature_controls_lookup( self, policy_db: PolicyMetadataDatabase, ) -> bool: """ Return True if this resource type requires account feature controls lookup. """ return policy_db.requires_account_feature_controls(self.resource_type) class CheckResourceAction(BaseModel): """Representation for checking resource action authorization.""" resource: Resource action: Annotated[str, StringConstraints(min_length=1)] model_config = ConfigDict( str_strip_whitespace=True, ) def get_tenant(self) -> Optional[Tenant]: """Get Tenant associated with the CheckResourceAction, if any.""" return self.resource.get_tenant() def get_tenant_with_maybe_hierarchy(self) -> Optional[TenantWithMaybeHierarchy]: """Get tenant, maybe with hierachy, associated with CheckResourceAction.""" return self.resource.get_tenant_with_maybe_hierarchy() def get_id_to_uuid_exchange_tenant(self) -> Optional[IdToUuidExchangeTenant]: """Get IdToUuidExchangeTenant, if any.""" return self.resource.get_id_to_uuid_exchange_tenant() class CheckResourceActionResult(CheckResourceAction): """Representation of result of checking resource action authorization.""" effect: AuthEffect errors: Dict[str, Any] = {} class CheckResourcesResponse(BaseModel): """Response object for checking resources actions.""" request_id: str resources: List[CheckResourceActionResult] def filter_for_allowed_tenants(self) -> List[Tenant]: """Returns unique list of Tenants with ALLOW effect. If a CheckResourceActionResult has a Tenant and the effect is ALLOW, return it in the list, regardless of the resource type or the action. """ allowed_tenants: Dict[UUID, Tenant] = {} for result in self.resources: if result.effect == AuthEffect.AUTH_EFFECT_ALLOW: tenant = result.get_tenant() if tenant: allowed_tenants[tenant.tenant_uuid] = tenant return list(allowed_tenants.values()) class OwsError(BaseModel): """Representation of ows error content.""" code: str message: str class TombstoneIdentityTenant(IdentityTenant): """An object for a tombstone pp_identity record to represent a deactivation event. """ # Tombstone specific fields is_tombstone: bool expires_at: int # A tombstone record will have a tenant_uuid and identity_uuid # with the format `TOMBSTONE:UUID[:EPOCH_TS]`. This value is not a valid # UUID, so we're "overriding" the field types here. identity_uuid: str tenant_uuid: str # type: ignore[assignment] def __eq__(self, other: object) -> bool: if not isinstance(other, TombstoneIdentityTenant): return False return ( super().__eq__(other) and repr(self.is_tombstone) == repr(other.is_tombstone) and repr(self.expires_at) == repr(other.expires_at) ) def __repr__(self) -> str: return f"{super().__repr__()}#{repr(self.is_tombstone)}#{repr(self.expires_at)}" def __hash__(self) -> int: """Hash TombstoneIdentityTenant using its representation.""" return hash(repr(self)) TombstoneIdentityTenantValidator = TypeAdapter(List[TombstoneIdentityTenant])