44acd68c96
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>
367 lines
13 KiB
Python
367 lines
13 KiB
Python
"""
|
|
Banner Consent Routes — Device-basierte Cookie-Consents fuer Kunden-Websites.
|
|
|
|
Public SDK-Endpoints (fuer Einbettung) + Admin-Endpoints (Konfiguration & Stats).
|
|
|
|
Phase 1 Step 4 refactor: handlers are thin and delegate to
|
|
``BannerConsentService`` (SDK surface) or ``BannerAdminService`` (admin
|
|
CRUD). Domain errors raised by the services are translated to
|
|
HTTPException via ``translate_domain_errors``. Pydantic request schemas
|
|
live in ``compliance.schemas.banner``.
|
|
"""
|
|
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, Header, Query
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
from compliance.api._http_errors import translate_domain_errors
|
|
from compliance.schemas.banner import (
|
|
CategoryConfigCreate,
|
|
ConsentCreate,
|
|
ConsentSyncRequest,
|
|
LinkEmailRequest,
|
|
SiteConfigCreate,
|
|
SiteConfigUpdate,
|
|
VendorConfigCreate,
|
|
)
|
|
from compliance.services.banner_admin_service import BannerAdminService
|
|
from compliance.services.banner_consent_service import BannerConsentService
|
|
from compliance.services.banner_dsr_service import BannerDSRService
|
|
from compliance.services.vendor_banner_sync import get_banner_vendors_from_registry
|
|
|
|
router = APIRouter(prefix="/banner", tags=["compliance-banner"])
|
|
|
|
DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Dependencies
|
|
# ----------------------------------------------------------------------
|
|
|
|
def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID")) -> str:
|
|
return x_tenant_id or DEFAULT_TENANT
|
|
|
|
|
|
def get_consent_service(db: Session = Depends(get_db)) -> BannerConsentService:
|
|
return BannerConsentService(db)
|
|
|
|
|
|
def get_admin_service(db: Session = Depends(get_db)) -> BannerAdminService:
|
|
return BannerAdminService(db)
|
|
|
|
|
|
def get_dsr_service(db: Session = Depends(get_db)) -> BannerDSRService:
|
|
return BannerDSRService(db)
|
|
|
|
|
|
# =============================================================================
|
|
# Public SDK Endpoints (fuer Einbettung in Kunden-Websites)
|
|
# =============================================================================
|
|
|
|
@router.post("/consent")
|
|
async def record_consent(
|
|
body: ConsentCreate,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerConsentService = Depends(get_consent_service),
|
|
) -> dict[str, Any]:
|
|
"""Record device consent (upsert by site_id + device_fingerprint)."""
|
|
with translate_domain_errors():
|
|
return service.record_consent(
|
|
tenant_id=tenant_id,
|
|
site_id=body.site_id,
|
|
device_fingerprint=body.device_fingerprint,
|
|
categories=body.categories,
|
|
vendors=body.vendors,
|
|
ip_address=body.ip_address,
|
|
user_agent=body.user_agent,
|
|
consent_string=body.consent_string,
|
|
)
|
|
|
|
|
|
@router.get("/consent")
|
|
async def get_consent(
|
|
site_id: str = Query(...),
|
|
device_fingerprint: str = Query(...),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerConsentService = Depends(get_consent_service),
|
|
) -> dict[str, Any]:
|
|
"""Retrieve consent for a device."""
|
|
with translate_domain_errors():
|
|
return service.get_consent(tenant_id, site_id, device_fingerprint)
|
|
|
|
|
|
@router.delete("/consent/{consent_id}")
|
|
async def withdraw_consent(
|
|
consent_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerConsentService = Depends(get_consent_service),
|
|
) -> dict[str, Any]:
|
|
"""Withdraw a banner consent."""
|
|
with translate_domain_errors():
|
|
return service.withdraw_consent(tenant_id, consent_id)
|
|
|
|
|
|
@router.get("/config/{site_id}")
|
|
async def get_site_config(
|
|
site_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerConsentService = Depends(get_consent_service),
|
|
) -> dict[str, Any]:
|
|
"""Load site configuration for banner display."""
|
|
with translate_domain_errors():
|
|
return service.get_site_config(tenant_id, site_id)
|
|
|
|
|
|
@router.get("/consent/export")
|
|
async def export_consent(
|
|
site_id: str = Query(...),
|
|
device_fingerprint: str = Query(...),
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerConsentService = Depends(get_consent_service),
|
|
) -> dict[str, Any]:
|
|
"""DSGVO export of all consent data for a device."""
|
|
with translate_domain_errors():
|
|
return service.export_consent(tenant_id, site_id, device_fingerprint)
|
|
|
|
|
|
# =============================================================================
|
|
# DSR Integration — Email Linking + Consent Sync (Phase 3 + 4)
|
|
# =============================================================================
|
|
|
|
@router.post("/consent/link-email")
|
|
async def link_email(
|
|
body: LinkEmailRequest,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerDSRService = Depends(get_dsr_service),
|
|
) -> dict[str, Any]:
|
|
"""Link an email to a device fingerprint (e.g. after signup/login)."""
|
|
with translate_domain_errors():
|
|
return service.link_email(
|
|
tenant_id, body.site_id, body.device_fingerprint, body.email,
|
|
)
|
|
|
|
|
|
@router.get("/consent/by-email/{email}")
|
|
async def get_consents_by_email(
|
|
email: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerDSRService = Depends(get_dsr_service),
|
|
) -> list[dict[str, Any]]:
|
|
"""Find all banner consents linked to an email (Art. 15 DSGVO)."""
|
|
with translate_domain_errors():
|
|
return service.get_consents_by_email(tenant_id, email)
|
|
|
|
|
|
@router.delete("/consent/by-email/{email}")
|
|
async def delete_consents_by_email(
|
|
email: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerDSRService = Depends(get_dsr_service),
|
|
) -> dict[str, Any]:
|
|
"""Delete all banner consents for an email (Art. 17 DSGVO erasure)."""
|
|
with translate_domain_errors():
|
|
return service.delete_consents_by_email(tenant_id, email)
|
|
|
|
|
|
@router.get("/consent/dsr-export/{email}")
|
|
async def export_for_dsr(
|
|
email: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerDSRService = Depends(get_dsr_service),
|
|
) -> dict[str, Any]:
|
|
"""Export all banner consent data for DSR (Art. 15/20 DSGVO)."""
|
|
with translate_domain_errors():
|
|
return service.export_for_dsr(tenant_id, email)
|
|
|
|
|
|
@router.post("/consent/sync")
|
|
async def sync_consent(
|
|
body: ConsentSyncRequest,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerDSRService = Depends(get_dsr_service),
|
|
) -> dict[str, Any]:
|
|
"""Sync banner consent to Einwilligungen (Phase 4 — user-based bridge)."""
|
|
with translate_domain_errors():
|
|
return service.sync_consent_to_einwilligungen(
|
|
tenant_id, body.device_fingerprint, body.email, body.site_id,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Admin — Stats
|
|
# =============================================================================
|
|
|
|
@router.get("/admin/stats/{site_id}")
|
|
async def get_site_stats(
|
|
site_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerConsentService = Depends(get_consent_service),
|
|
) -> dict[str, Any]:
|
|
"""Consent statistics per site."""
|
|
with translate_domain_errors():
|
|
return service.get_site_stats(tenant_id, site_id)
|
|
|
|
|
|
# =============================================================================
|
|
# Admin — Sites
|
|
# =============================================================================
|
|
|
|
@router.get("/admin/sites")
|
|
async def list_site_configs(
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerAdminService = Depends(get_admin_service),
|
|
) -> list[dict[str, Any]]:
|
|
"""List all site configurations."""
|
|
with translate_domain_errors():
|
|
return service.list_sites(tenant_id)
|
|
|
|
|
|
@router.post("/admin/sites")
|
|
async def create_site_config(
|
|
body: SiteConfigCreate,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerAdminService = Depends(get_admin_service),
|
|
) -> dict[str, Any]:
|
|
"""Create a site configuration."""
|
|
with translate_domain_errors():
|
|
return service.create_site(tenant_id, body)
|
|
|
|
|
|
@router.put("/admin/sites/{site_id}")
|
|
async def update_site_config(
|
|
site_id: str,
|
|
body: SiteConfigUpdate,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerAdminService = Depends(get_admin_service),
|
|
) -> dict[str, Any]:
|
|
"""Update a site configuration."""
|
|
with translate_domain_errors():
|
|
return service.update_site(tenant_id, site_id, body)
|
|
|
|
|
|
@router.delete("/admin/sites/{site_id}", status_code=204)
|
|
async def delete_site_config(
|
|
site_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerAdminService = Depends(get_admin_service),
|
|
) -> None:
|
|
"""Delete a site configuration."""
|
|
with translate_domain_errors():
|
|
service.delete_site(tenant_id, site_id)
|
|
|
|
|
|
# =============================================================================
|
|
# Admin — Categories
|
|
# =============================================================================
|
|
|
|
@router.get("/admin/sites/{site_id}/categories")
|
|
async def list_categories(
|
|
site_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerAdminService = Depends(get_admin_service),
|
|
) -> list[dict[str, Any]]:
|
|
"""List categories for a site."""
|
|
with translate_domain_errors():
|
|
return service.list_categories(tenant_id, site_id)
|
|
|
|
|
|
@router.post("/admin/sites/{site_id}/categories")
|
|
async def create_category(
|
|
site_id: str,
|
|
body: CategoryConfigCreate,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerAdminService = Depends(get_admin_service),
|
|
) -> dict[str, Any]:
|
|
"""Create a category for a site."""
|
|
with translate_domain_errors():
|
|
return service.create_category(tenant_id, site_id, body)
|
|
|
|
|
|
@router.delete("/admin/categories/{category_id}", status_code=204)
|
|
async def delete_category(
|
|
category_id: str,
|
|
service: BannerAdminService = Depends(get_admin_service),
|
|
) -> None:
|
|
"""Delete a category."""
|
|
with translate_domain_errors():
|
|
service.delete_category(category_id)
|
|
|
|
|
|
# =============================================================================
|
|
# Admin — Vendors
|
|
# =============================================================================
|
|
|
|
@router.get("/admin/sites/{site_id}/vendors")
|
|
async def list_vendors(
|
|
site_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerAdminService = Depends(get_admin_service),
|
|
) -> list[dict[str, Any]]:
|
|
"""List vendors for a site."""
|
|
with translate_domain_errors():
|
|
return service.list_vendors(tenant_id, site_id)
|
|
|
|
|
|
@router.post("/admin/sites/{site_id}/vendors")
|
|
async def create_vendor(
|
|
site_id: str,
|
|
body: VendorConfigCreate,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerAdminService = Depends(get_admin_service),
|
|
) -> dict[str, Any]:
|
|
"""Create a vendor for a site."""
|
|
with translate_domain_errors():
|
|
return service.create_vendor(tenant_id, site_id, body)
|
|
|
|
|
|
@router.delete("/admin/vendors/{vendor_id}", status_code=204)
|
|
async def delete_vendor(
|
|
vendor_id: str,
|
|
service: BannerAdminService = Depends(get_admin_service),
|
|
) -> None:
|
|
"""Delete a vendor."""
|
|
with translate_domain_errors():
|
|
service.delete_vendor(vendor_id)
|
|
|
|
|
|
# =============================================================================
|
|
# Admin — Vendor Sync from Service Registry (Phase 1)
|
|
# =============================================================================
|
|
|
|
@router.post("/admin/sites/{site_id}/sync-vendors")
|
|
async def sync_vendors_from_registry(
|
|
site_id: str,
|
|
tenant_id: str = Depends(_get_tenant),
|
|
service: BannerAdminService = Depends(get_admin_service),
|
|
) -> dict[str, Any]:
|
|
"""Sync 82+ services from service registry to banner vendor configs."""
|
|
with translate_domain_errors():
|
|
vendors = get_banner_vendors_from_registry()
|
|
created = 0
|
|
updated = 0
|
|
for v in vendors:
|
|
try:
|
|
existing = service.list_vendors(tenant_id, site_id)
|
|
match = next(
|
|
(e for e in existing if e["vendor_name"] == v["vendor_name"]),
|
|
None,
|
|
)
|
|
if match:
|
|
updated += 1
|
|
else:
|
|
from compliance.schemas.banner import VendorConfigCreate
|
|
service.create_vendor(tenant_id, site_id, VendorConfigCreate(
|
|
vendor_name=v["vendor_name"],
|
|
category_key=v["category_key"],
|
|
description_de=v["description_de"],
|
|
description_en=v["description_en"],
|
|
cookie_names=v["cookie_names"],
|
|
retention_days=v["retention_days"],
|
|
))
|
|
created += 1
|
|
except Exception:
|
|
continue
|
|
return {"created": created, "updated": updated, "total": len(vendors)}
|