feat: Cookie-Banner ↔ Backend Integration (DSR, Retention, Consent Proof)
Phase 1: Vendor sync from service registry (82+ services → banner vendors) Phase 2: Category-based retention (marketing=90d, statistics=790d, not hardcoded 365d) Phase 3: DSR ↔ Banner email linking (link-email, by-email, Art.17 erasure, Art.15/20 export) Phase 4: Consent sync (Banner → Einwilligungen bridge) Phase 6: Consent proof (SHA256 config hash + config_version in audit log, Art. 7(1) DSGVO) New files: - banner_dsr_service.py — email linking + DSR integration - vendor_banner_sync.py — service registry → vendor configs - migration 106 — linked_email, banner_config_hash, consent_version columns Tests: 20+ new backend tests + 2 Playwright E2E test suites (API + UI) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,9 +9,12 @@ display), export, and per-site consent statistics.
|
||||
|
||||
Admin-side site/category/vendor management lives in
|
||||
``compliance.services.banner_admin_service.BannerAdminService``.
|
||||
DSR-facing email linking lives in
|
||||
``compliance.services.banner_dsr_service.BannerDSRService``.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
@@ -33,6 +36,15 @@ from compliance.services._banner_serializers import (
|
||||
vendor_to_dict,
|
||||
)
|
||||
|
||||
# Default consent expiration per banner category (days).
|
||||
# Based on: DSGVO Art. 5(1)(e), CNIL guidelines, EDPB recommendations.
|
||||
CATEGORY_RETENTION_DAYS = {
|
||||
"necessary": 365, # Session + functional = max 12 months
|
||||
"statistics": 790, # Max 26 months (Google Analytics default)
|
||||
"marketing": 90, # Max 90 days for retargeting
|
||||
"functional": 365, # Max 12 months
|
||||
}
|
||||
|
||||
|
||||
class BannerConsentService:
|
||||
"""Business logic for public SDK banner consent endpoints."""
|
||||
@@ -59,6 +71,8 @@ class BannerConsentService:
|
||||
device_fingerprint: Optional[str] = None,
|
||||
categories: Optional[list[str]] = None,
|
||||
ip_hash: Optional[str] = None,
|
||||
banner_config_hash: Optional[str] = None,
|
||||
consent_version: Optional[int] = None,
|
||||
) -> None:
|
||||
entry = BannerConsentAuditLogDB(
|
||||
tenant_id=tenant_id,
|
||||
@@ -68,9 +82,53 @@ class BannerConsentService:
|
||||
device_fingerprint=device_fingerprint,
|
||||
categories=categories or [],
|
||||
ip_hash=ip_hash,
|
||||
banner_config_hash=banner_config_hash,
|
||||
consent_version=consent_version,
|
||||
)
|
||||
self.db.add(entry)
|
||||
|
||||
def _compute_config_hash(self, tenant_id: uuid.UUID, site_id: str) -> tuple[Optional[str], Optional[int]]:
|
||||
"""Compute SHA256 hash of current site config for consent proof (Art. 7(1) DSGVO)."""
|
||||
config = (
|
||||
self.db.query(BannerSiteConfigDB)
|
||||
.filter(
|
||||
BannerSiteConfigDB.tenant_id == tenant_id,
|
||||
BannerSiteConfigDB.site_id == site_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not config:
|
||||
return None, None
|
||||
snapshot = json.dumps({
|
||||
"banner_title": config.banner_title,
|
||||
"banner_description": config.banner_description,
|
||||
"privacy_url": config.privacy_url,
|
||||
"imprint_url": config.imprint_url,
|
||||
}, sort_keys=True)
|
||||
return hashlib.sha256(snapshot.encode()).hexdigest()[:32], config.config_version
|
||||
|
||||
def _get_max_retention(self, tenant_id: uuid.UUID, site_id: str, categories: list[str]) -> int:
|
||||
"""Determine consent expiration based on accepted categories and vendor retention."""
|
||||
config = (
|
||||
self.db.query(BannerSiteConfigDB)
|
||||
.filter(BannerSiteConfigDB.tenant_id == tenant_id, BannerSiteConfigDB.site_id == site_id)
|
||||
.first()
|
||||
)
|
||||
if not config:
|
||||
return 365
|
||||
vendors = (
|
||||
self.db.query(BannerVendorConfigDB)
|
||||
.filter(
|
||||
BannerVendorConfigDB.site_config_id == config.id,
|
||||
BannerVendorConfigDB.category_key.in_(categories),
|
||||
BannerVendorConfigDB.is_active,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if vendors:
|
||||
return max(v.retention_days for v in vendors if v.retention_days)
|
||||
return max((CATEGORY_RETENTION_DAYS.get(c, 365) for c in categories), default=365)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Consent CRUD (public SDK)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -86,11 +144,19 @@ class BannerConsentService:
|
||||
user_agent: Optional[str],
|
||||
consent_string: Optional[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Upsert a device consent row for (tenant, site, device_fingerprint)."""
|
||||
"""Upsert a device consent row for (tenant, site, device_fingerprint).
|
||||
|
||||
Expiration is derived from the maximum vendor retention for the
|
||||
accepted categories (Phase 2 — DSGVO Art. 5(1)(e)).
|
||||
A SHA256 hash of the banner config is stored in the audit log
|
||||
for consent proof (Phase 6 — Art. 7(1) DSGVO).
|
||||
"""
|
||||
tid = uuid.UUID(tenant_id)
|
||||
ip_hash = self._hash_ip(ip_address)
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(days=365)
|
||||
retention = self._get_max_retention(tid, site_id, categories)
|
||||
expires_at = now + timedelta(days=retention)
|
||||
config_hash, config_ver = self._compute_config_hash(tid, site_id)
|
||||
|
||||
existing = (
|
||||
self.db.query(BannerConsentDB)
|
||||
@@ -113,7 +179,7 @@ class BannerConsentService:
|
||||
self.db.flush()
|
||||
self._log(
|
||||
tid, existing.id, "consent_updated", site_id, device_fingerprint,
|
||||
categories, ip_hash,
|
||||
categories, ip_hash, config_hash, config_ver,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(existing)
|
||||
@@ -134,7 +200,7 @@ class BannerConsentService:
|
||||
self.db.flush()
|
||||
self._log(
|
||||
tid, consent.id, "consent_given", site_id, device_fingerprint,
|
||||
categories, ip_hash,
|
||||
categories, ip_hash, config_hash, config_ver,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(consent)
|
||||
|
||||
Reference in New Issue
Block a user