mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-17 23:09:35 +00:00
Phase 1-6 implementation for Dealix AI Revenue OS: - AI Arabic Engine: NLP (arabic_nlp.py), lead scoring (lead_scoring.py) - PDPL Compliance: consent manager, data rights handler, consent model - Sequence Engine: multi-channel sequences with WhatsApp/Email/SMS - CPQ System: quote engine, AI proposal generator - Security Gate: pre-release checks, PDPL message validation - Tool Verification: agent action audit trail - Project Operating Files: AGENTS.md, CLAUDE.md - Project Memory: architecture, ADRs, provider routing, PDPL checklist - Design System: IBM Plex Sans Arabic tokens, RTL-safe components - Sequence/Consent models for database https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
302 lines
11 KiB
Python
302 lines
11 KiB
Python
"""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()
|