"""PDPL consent engine -- tracks, validates, and audits consent per Saudi data protection law. Penalty for violations: up to 5,000,000 SAR per incident. """ import logging from datetime import datetime, timedelta, timezone from typing import Optional from uuid import UUID from pydantic import BaseModel as Schema from sqlalchemy import select, and_ from sqlalchemy.ext.asyncio import AsyncSession from app.models.consent import ( PDPLConsent, PDPLConsentAudit, DataRequest, ConsentStatusEnum, ConsentPurpose, ConsentChannel, DataRequestType, DataRequestStatus, ) logger = logging.getLogger(__name__) DEFAULT_EXPIRY_MONTHS = 12 CROSS_BORDER_ALLOWED_COUNTRIES = {"SA", "AE", "BH", "KW", "OM", "QA"} # --------------------------------------------------------------------------- # Pydantic schemas # --------------------------------------------------------------------------- class ConsentGrantInput(Schema): contact_id: UUID tenant_id: UUID purpose: str channel: str consent_text: Optional[str] = None ip_address: Optional[str] = None actor_id: Optional[UUID] = None expiry_months: int = DEFAULT_EXPIRY_MONTHS class ConsentRevokeInput(Schema): consent_id: UUID actor_id: Optional[UUID] = None reason: Optional[str] = None ip_address: Optional[str] = None class ConsentCheckResult(Schema): allowed: bool consent_id: Optional[UUID] = None status: Optional[str] = None expires_at: Optional[datetime] = None message: str = "" message_ar: str = "" class DataRequestInput(Schema): contact_id: UUID tenant_id: UUID request_type: str notes: Optional[str] = None actor_id: Optional[UUID] = None class AuditEntry(Schema): id: UUID consent_id: UUID contact_id: UUID action: str actor_id: Optional[UUID] = None channel: str purpose: str details: dict created_at: datetime model_config = {"from_attributes": True} # --------------------------------------------------------------------------- # ConsentManager # --------------------------------------------------------------------------- class ConsentManager: """Core PDPL consent engine for Dealix CRM.""" def __init__(self, db: AsyncSession): self.db = db # -- grant --------------------------------------------------------------- async def grant_consent(self, data: ConsentGrantInput) -> PDPLConsent: """Record a new consent grant. Existing active consent for same contact+purpose+channel is revoked first (re-consent flow).""" now = datetime.now(timezone.utc) # Revoke any existing active consent for same triplet (re-consent) existing = await self._find_active(data.contact_id, data.purpose, data.channel) if existing: existing.status = ConsentStatusEnum.REVOKED.value existing.revoked_at = now await self._audit( consent_id=existing.id, contact_id=data.contact_id, action="revoked_for_renewal", actor_id=data.actor_id, channel=data.channel, purpose=data.purpose, details={"reason": "re-consent on purpose change"}, ip_address=data.ip_address, tenant_id=data.tenant_id, ) consent = PDPLConsent( contact_id=data.contact_id, tenant_id=data.tenant_id, purpose=data.purpose, channel=data.channel, status=ConsentStatusEnum.GRANTED.value, granted_at=now, expires_at=now + timedelta(days=30 * data.expiry_months), ip_address=data.ip_address, consent_text=data.consent_text, granted_by=data.actor_id, ) self.db.add(consent) await self.db.flush() await self.db.refresh(consent) await self._audit( consent_id=consent.id, contact_id=data.contact_id, action="granted", actor_id=data.actor_id, channel=data.channel, purpose=data.purpose, details={"expiry_months": data.expiry_months, "consent_text": data.consent_text or ""}, ip_address=data.ip_address, tenant_id=data.tenant_id, ) logger.info("PDPL consent granted: contact=%s purpose=%s channel=%s", data.contact_id, data.purpose, data.channel) return consent # -- revoke -------------------------------------------------------------- async def revoke_consent(self, data: ConsentRevokeInput) -> PDPLConsent: """Revoke an existing consent immediately.""" result = await self.db.execute( select(PDPLConsent).where(PDPLConsent.id == data.consent_id) ) consent = result.scalar_one_or_none() if not consent: raise ValueError("سجل الموافقة غير موجود") # Consent record not found now = datetime.now(timezone.utc) consent.status = ConsentStatusEnum.REVOKED.value consent.revoked_at = now await self._audit( consent_id=consent.id, contact_id=consent.contact_id, action="revoked", actor_id=data.actor_id, channel=consent.channel, purpose=consent.purpose, details={"reason": data.reason or "user_request"}, ip_address=data.ip_address, tenant_id=consent.tenant_id, ) logger.info("PDPL consent revoked: id=%s contact=%s", consent.id, consent.contact_id) return consent # -- check --------------------------------------------------------------- async def check_consent( self, contact_id: UUID, purpose: str, channel: str, ) -> ConsentCheckResult: """Validate consent before any outbound message. Must be called before every send -- violations carry up to 5M SAR penalty.""" consent = await self._find_active(contact_id, purpose, channel) if not consent: return ConsentCheckResult( allowed=False, message=f"No active consent for {purpose}/{channel}", message_ar="لا توجد موافقة فعالة لهذا الغرض والقناة", ) now = datetime.now(timezone.utc) if consent.expires_at and consent.expires_at <= now: consent.status = ConsentStatusEnum.EXPIRED.value await self.db.flush() return ConsentCheckResult( allowed=False, consent_id=consent.id, status=ConsentStatusEnum.EXPIRED.value, expires_at=consent.expires_at, message="Consent expired -- re-consent required", message_ar="انتهت صلاحية الموافقة -- يلزم تجديد الموافقة", ) return ConsentCheckResult( allowed=True, consent_id=consent.id, status=consent.status, expires_at=consent.expires_at, message="Consent valid", message_ar="الموافقة صالحة", ) # -- data request -------------------------------------------------------- async def process_data_request(self, data: DataRequestInput) -> DataRequest: """Submit a PDPL data subject rights request.""" request = DataRequest( contact_id=data.contact_id, tenant_id=data.tenant_id, request_type=data.request_type, status=DataRequestStatus.PENDING.value, requested_at=datetime.now(timezone.utc), notes=data.notes, handled_by=data.actor_id, ) self.db.add(request) await self.db.flush() await self.db.refresh(request) logger.info("PDPL data request created: type=%s contact=%s", data.request_type, data.contact_id) return request # -- audit --------------------------------------------------------------- async def get_consent_audit( self, tenant_id: UUID, contact_id: Optional[UUID] = None, limit: int = 100, offset: int = 0, ) -> list[AuditEntry]: """Return consent audit trail filtered by tenant and optionally contact.""" query = ( select(PDPLConsentAudit) .where(PDPLConsentAudit.tenant_id == tenant_id) .order_by(PDPLConsentAudit.created_at.desc()) ) if contact_id: query = query.where(PDPLConsentAudit.contact_id == contact_id) query = query.offset(offset).limit(limit) result = await self.db.execute(query) return [AuditEntry.model_validate(row) for row in result.scalars().all()] # -- cross-border -------------------------------------------------------- @staticmethod def check_cross_border_transfer(destination_country: str) -> ConsentCheckResult: """Check if data transfer to destination country is PDPL-compliant. SDAIA requires adequate protection level or explicit consent.""" code = destination_country.upper().strip() if code in CROSS_BORDER_ALLOWED_COUNTRIES: return ConsentCheckResult( allowed=True, message=f"Transfer to {code} permitted under GCC adequacy", message_ar=f"النقل إلى {code} مسموح بموجب كفاية دول الخليج", ) return ConsentCheckResult( allowed=False, message=f"Transfer to {code} requires explicit consent and SDAIA approval", message_ar=f"النقل إلى {code} يتطلب موافقة صريحة وموافقة الهيئة", ) # -- private helpers ----------------------------------------------------- async def _find_active( self, contact_id: UUID, purpose: str, channel: str ) -> Optional[PDPLConsent]: result = await self.db.execute( select(PDPLConsent).where( and_( PDPLConsent.contact_id == contact_id, PDPLConsent.purpose == purpose, PDPLConsent.channel == channel, PDPLConsent.status == ConsentStatusEnum.GRANTED.value, ) ).order_by(PDPLConsent.granted_at.desc()).limit(1) ) return result.scalar_one_or_none() async def _audit( self, *, consent_id, contact_id, action, actor_id, channel, purpose, details, ip_address, tenant_id, ) -> None: entry = PDPLConsentAudit( consent_id=consent_id, contact_id=contact_id, tenant_id=tenant_id, action=action, actor_id=actor_id, channel=channel, purpose=purpose, details=details or {}, ip_address=ip_address, ) self.db.add(entry) await self.db.flush()