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:
Benjamin Admin
2026-05-02 19:41:22 +02:00
parent c3f8e19e92
commit 44acd68c96
12 changed files with 1522 additions and 5 deletions
@@ -0,0 +1,515 @@
import { test, expect } from '@playwright/test'
/**
* Banner Consent API Integration Tests
*
* Tests the complete lifecycle of cookie banner consents:
* - Record/retrieve/withdraw consent
* - Email linking for DSR integration
* - Consent export (Art. 15/20 DSGVO)
* - Consent deletion (Art. 17 DSGVO erasure)
* - Consent sync to Einwilligungen
* - Vendor sync from service registry
* - Site config with config_version for consent proof
*/
const API_BASE = process.env.PLAYWRIGHT_API_URL || 'https://macmini:8093'
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const HEADERS = {
'Content-Type': 'application/json',
'X-Tenant-ID': TENANT_ID,
}
// Test data
const TEST_SITE_ID = `e2e-banner-test-${Date.now()}`
const TEST_DEVICE_FP = `e2e-device-${Date.now()}`
const TEST_EMAIL = `e2e-test-${Date.now()}@example.com`
test.describe('Banner Consent API — Full Lifecycle', () => {
let siteConfigId: string | null = null
let consentId: string | null = null
// ─── Setup: Create site config ───────────────────────────
test('01 — Create site config', async ({ request }) => {
const res = await request.post(`${API_BASE}/banner/admin/sites`, {
headers: HEADERS,
data: {
site_id: TEST_SITE_ID,
site_name: 'E2E Test Site',
site_url: 'https://e2e-test.example.com',
banner_title: 'Cookie-Einstellungen',
banner_description: 'Wir verwenden Cookies fuer E2E Tests.',
privacy_url: '/datenschutz',
imprint_url: '/impressum',
},
})
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.site_id).toBe(TEST_SITE_ID)
expect(body.config_version).toBe(1)
siteConfigId = body.id
})
test('02 — Create categories for site', async ({ request }) => {
const categories = [
{ category_key: 'necessary', name_de: 'Notwendig', is_required: true, sort_order: 0 },
{ category_key: 'statistics', name_de: 'Statistik', is_required: false, sort_order: 1 },
{ category_key: 'marketing', name_de: 'Marketing', is_required: false, sort_order: 2 },
]
for (const cat of categories) {
const res = await request.post(`${API_BASE}/banner/admin/sites/${TEST_SITE_ID}/categories`, {
headers: HEADERS,
data: cat,
})
expect(res.ok()).toBeTruthy()
}
})
test('03 — Create vendor configs', async ({ request }) => {
const vendors = [
{ vendor_name: 'Google Analytics', category_key: 'statistics', retention_days: 790, cookie_names: ['_ga', '_gid'] },
{ vendor_name: 'Facebook Pixel', category_key: 'marketing', retention_days: 90, cookie_names: ['_fbp'] },
]
for (const v of vendors) {
const res = await request.post(`${API_BASE}/banner/admin/sites/${TEST_SITE_ID}/vendors`, {
headers: HEADERS,
data: v,
})
expect(res.ok()).toBeTruthy()
}
})
// ─── Core Consent CRUD ───────────────────────────────────
test('04 — Get site config for banner display', async ({ request }) => {
const res = await request.get(`${API_BASE}/banner/config/${TEST_SITE_ID}`, { headers: HEADERS })
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.site_id).toBe(TEST_SITE_ID)
expect(body.banner_title).toBe('Cookie-Einstellungen')
expect(body.categories.length).toBe(3)
expect(body.vendors.length).toBe(2)
expect(body.config_version).toBe(1)
})
test('05 — Record consent (accept statistics only)', async ({ request }) => {
const res = await request.post(`${API_BASE}/banner/consent`, {
headers: HEADERS,
data: {
site_id: TEST_SITE_ID,
device_fingerprint: TEST_DEVICE_FP,
categories: ['necessary', 'statistics'],
vendors: ['Google Analytics'],
ip_address: '192.168.1.100',
user_agent: 'Playwright E2E Test',
},
})
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.device_fingerprint).toBe(TEST_DEVICE_FP)
expect(body.categories).toEqual(['necessary', 'statistics'])
expect(body.vendors).toEqual(['Google Analytics'])
expect(body.ip_hash).toBeTruthy() // IP is hashed
expect(body.linked_email).toBeNull()
consentId = body.id
})
test('06 — Retrieve consent for device', async ({ request }) => {
const res = await request.get(
`${API_BASE}/banner/consent?site_id=${TEST_SITE_ID}&device_fingerprint=${TEST_DEVICE_FP}`,
{ headers: HEADERS },
)
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.has_consent).toBe(true)
expect(body.consent.categories).toEqual(['necessary', 'statistics'])
})
test('07 — Update consent (accept all categories)', async ({ request }) => {
const res = await request.post(`${API_BASE}/banner/consent`, {
headers: HEADERS,
data: {
site_id: TEST_SITE_ID,
device_fingerprint: TEST_DEVICE_FP,
categories: ['necessary', 'statistics', 'marketing'],
vendors: ['Google Analytics', 'Facebook Pixel'],
ip_address: '192.168.1.100',
user_agent: 'Playwright E2E Test',
},
})
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.categories).toEqual(['necessary', 'statistics', 'marketing'])
expect(body.id).toBe(consentId) // Same consent row (upsert)
})
// ─── Phase 3: Email Linking ──────────────────────────────
test('08 — Link email to device fingerprint', async ({ request }) => {
const res = await request.post(`${API_BASE}/banner/consent/link-email`, {
headers: HEADERS,
data: {
site_id: TEST_SITE_ID,
device_fingerprint: TEST_DEVICE_FP,
email: TEST_EMAIL,
},
})
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.linked_email).toBe(TEST_EMAIL.toLowerCase())
expect(body.device_fingerprint).toBe(TEST_DEVICE_FP)
})
test('09 — Find consents by email (Art. 15)', async ({ request }) => {
const res = await request.get(
`${API_BASE}/banner/consent/by-email/${encodeURIComponent(TEST_EMAIL)}`,
{ headers: HEADERS },
)
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.length).toBe(1)
expect(body[0].linked_email).toBe(TEST_EMAIL.toLowerCase())
expect(body[0].device_fingerprint).toBe(TEST_DEVICE_FP)
})
test('10 — Export consent data for DSR (Art. 15/20)', async ({ request }) => {
const res = await request.get(
`${API_BASE}/banner/consent/dsr-export/${encodeURIComponent(TEST_EMAIL)}`,
{ headers: HEADERS },
)
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.email).toBe(TEST_EMAIL.toLowerCase())
expect(body.banner_consents.length).toBe(1)
expect(body.audit_trail.length).toBeGreaterThan(0)
// Verify consent proof fields in audit trail
const lastAudit = body.audit_trail[0]
expect(lastAudit.action).toBeTruthy()
expect(lastAudit.site_id).toBe(TEST_SITE_ID)
})
// ─── Phase 4: Consent Sync ──────────────────────────────
test('11 — Sync banner consent to Einwilligungen', async ({ request }) => {
const res = await request.post(`${API_BASE}/banner/consent/sync`, {
headers: HEADERS,
data: {
site_id: TEST_SITE_ID,
device_fingerprint: TEST_DEVICE_FP,
email: TEST_EMAIL,
},
})
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.synced).toBeGreaterThan(0)
expect(body.categories).toContain('necessary')
expect(body.categories).toContain('statistics')
expect(body.categories).toContain('marketing')
expect(body.email).toBe(TEST_EMAIL.toLowerCase())
})
// ─── DSGVO Export ────────────────────────────────────────
test('12 — Export consent per device (existing endpoint)', async ({ request }) => {
const res = await request.get(
`${API_BASE}/banner/consent/export?site_id=${TEST_SITE_ID}&device_fingerprint=${TEST_DEVICE_FP}`,
{ headers: HEADERS },
)
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.device_fingerprint).toBe(TEST_DEVICE_FP)
expect(body.consents.length).toBe(1)
expect(body.audit_trail.length).toBeGreaterThan(0)
})
// ─── Stats ───────────────────────────────────────────────
test('13 — Get site consent statistics', async ({ request }) => {
const res = await request.get(`${API_BASE}/banner/admin/stats/${TEST_SITE_ID}`, {
headers: HEADERS,
})
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.site_id).toBe(TEST_SITE_ID)
expect(body.total_consents).toBeGreaterThan(0)
expect(body.category_acceptance.necessary).toBeTruthy()
expect(body.category_acceptance.statistics).toBeTruthy()
})
// ─── Withdraw + Cleanup ─────────────────────────────────
test('14 — Withdraw consent', async ({ request }) => {
expect(consentId).toBeTruthy()
const res = await request.delete(`${API_BASE}/banner/consent/${consentId}`, {
headers: HEADERS,
})
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.success).toBe(true)
})
test('15 — Verify consent withdrawn (no consent found)', async ({ request }) => {
const res = await request.get(
`${API_BASE}/banner/consent?site_id=${TEST_SITE_ID}&device_fingerprint=${TEST_DEVICE_FP}`,
{ headers: HEADERS },
)
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.has_consent).toBe(false)
})
// ─── Cleanup: Delete site config ────────────────────────
test('16 — Cleanup: Delete test site', async ({ request }) => {
const res = await request.delete(`${API_BASE}/banner/admin/sites/${TEST_SITE_ID}`, {
headers: HEADERS,
})
expect(res.status()).toBe(204)
})
})
test.describe('Banner Consent API — Art. 17 Erasure via Email', () => {
const ERASURE_SITE = `e2e-erasure-${Date.now()}`
const ERASURE_DEVICE_1 = `e2e-dev1-${Date.now()}`
const ERASURE_DEVICE_2 = `e2e-dev2-${Date.now()}`
const ERASURE_EMAIL = `erasure-${Date.now()}@example.com`
test.beforeAll(async ({ request }) => {
// Create site
await request.post(`${API_BASE}/banner/admin/sites`, {
headers: HEADERS,
data: { site_id: ERASURE_SITE, site_name: 'Erasure Test Site' },
})
// Record consent on two devices with same email
for (const fp of [ERASURE_DEVICE_1, ERASURE_DEVICE_2]) {
await request.post(`${API_BASE}/banner/consent`, {
headers: HEADERS,
data: {
site_id: ERASURE_SITE,
device_fingerprint: fp,
categories: ['necessary', 'statistics'],
vendors: [],
},
})
await request.post(`${API_BASE}/banner/consent/link-email`, {
headers: HEADERS,
data: { site_id: ERASURE_SITE, device_fingerprint: fp, email: ERASURE_EMAIL },
})
}
})
test('should find 2 consents for email', async ({ request }) => {
const res = await request.get(
`${API_BASE}/banner/consent/by-email/${encodeURIComponent(ERASURE_EMAIL)}`,
{ headers: HEADERS },
)
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.length).toBe(2)
})
test('should delete all consents by email (Art. 17)', async ({ request }) => {
const res = await request.delete(
`${API_BASE}/banner/consent/by-email/${encodeURIComponent(ERASURE_EMAIL)}`,
{ headers: HEADERS },
)
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.deleted).toBe(2)
expect(body.email).toBe(ERASURE_EMAIL.toLowerCase())
})
test('should find 0 consents after erasure', async ({ request }) => {
const res = await request.get(
`${API_BASE}/banner/consent/by-email/${encodeURIComponent(ERASURE_EMAIL)}`,
{ headers: HEADERS },
)
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.length).toBe(0)
})
test.afterAll(async ({ request }) => {
await request.delete(`${API_BASE}/banner/admin/sites/${ERASURE_SITE}`, {
headers: HEADERS,
})
})
})
test.describe('Banner Consent API — Vendor Sync from Registry', () => {
const SYNC_SITE = `e2e-sync-${Date.now()}`
test.beforeAll(async ({ request }) => {
await request.post(`${API_BASE}/banner/admin/sites`, {
headers: HEADERS,
data: { site_id: SYNC_SITE, site_name: 'Vendor Sync Test' },
})
})
test('should sync vendors from service registry', async ({ request }) => {
const res = await request.post(
`${API_BASE}/banner/admin/sites/${SYNC_SITE}/sync-vendors`,
{ headers: HEADERS },
)
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.total).toBeGreaterThan(0)
expect(body.created).toBeGreaterThan(0)
})
test('should list synced vendors', async ({ request }) => {
const res = await request.get(
`${API_BASE}/banner/admin/sites/${SYNC_SITE}/vendors`,
{ headers: HEADERS },
)
expect(res.ok()).toBeTruthy()
const vendors = await res.json()
expect(vendors.length).toBeGreaterThan(0)
// Verify vendor structure
const ga = vendors.find((v: any) => v.vendor_name === 'Google Analytics')
if (ga) {
expect(ga.category_key).toBe('statistics')
expect(ga.retention_days).toBe(790) // 26 months
}
})
test.afterAll(async ({ request }) => {
await request.delete(`${API_BASE}/banner/admin/sites/${SYNC_SITE}`, {
headers: HEADERS,
})
})
})
test.describe('Banner Consent API — Edge Cases & Validation', () => {
test('should return has_consent=false for unknown device', async ({ request }) => {
const res = await request.get(
`${API_BASE}/banner/consent?site_id=nonexistent&device_fingerprint=unknown`,
{ headers: HEADERS },
)
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.has_consent).toBe(false)
})
test('should return default config for unconfigured site', async ({ request }) => {
const res = await request.get(`${API_BASE}/banner/config/nonexistent-site`, {
headers: HEADERS,
})
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.site_id).toBe('nonexistent-site')
expect(body.banner_title).toBe('Cookie-Einstellungen')
expect(body.categories).toEqual([])
})
test('should reject invalid email for link-email', async ({ request }) => {
const res = await request.post(`${API_BASE}/banner/consent/link-email`, {
headers: HEADERS,
data: {
site_id: 'test',
device_fingerprint: 'test',
email: 'not-an-email',
},
})
// Should fail — either 400 or 404 (no consent found)
expect(res.status()).toBeGreaterThanOrEqual(400)
})
test('should return empty list for unknown email in by-email', async ({ request }) => {
const res = await request.get(
`${API_BASE}/banner/consent/by-email/nobody@nowhere.test`,
{ headers: HEADERS },
)
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body).toEqual([])
})
test('should hash IP address (never store plain IP)', async ({ request }) => {
const siteId = `e2e-ip-test-${Date.now()}`
const fp = `e2e-ip-fp-${Date.now()}`
// Create site
await request.post(`${API_BASE}/banner/admin/sites`, {
headers: HEADERS,
data: { site_id: siteId, site_name: 'IP Test' },
})
// Record consent with IP
const res = await request.post(`${API_BASE}/banner/consent`, {
headers: HEADERS,
data: {
site_id: siteId,
device_fingerprint: fp,
categories: ['necessary'],
vendors: [],
ip_address: '10.0.0.42',
},
})
expect(res.ok()).toBeTruthy()
const body = await res.json()
// IP should be hashed, not stored plain
expect(body.ip_hash).toBeTruthy()
expect(body.ip_hash).not.toBe('10.0.0.42')
expect(body.ip_hash.length).toBe(16) // SHA256[:16]
// Cleanup
await request.delete(`${API_BASE}/banner/consent/${body.id}`, { headers: HEADERS })
await request.delete(`${API_BASE}/banner/admin/sites/${siteId}`, { headers: HEADERS })
})
})
test.describe('Banner Consent API — Retention per Category', () => {
const RET_SITE = `e2e-retention-${Date.now()}`
test.beforeAll(async ({ request }) => {
// Create site with categories and vendors
await request.post(`${API_BASE}/banner/admin/sites`, {
headers: HEADERS,
data: { site_id: RET_SITE, site_name: 'Retention Test' },
})
await request.post(`${API_BASE}/banner/admin/sites/${RET_SITE}/categories`, {
headers: HEADERS,
data: { category_key: 'necessary', name_de: 'Notwendig', is_required: true },
})
await request.post(`${API_BASE}/banner/admin/sites/${RET_SITE}/categories`, {
headers: HEADERS,
data: { category_key: 'marketing', name_de: 'Marketing', is_required: false },
})
await request.post(`${API_BASE}/banner/admin/sites/${RET_SITE}/vendors`, {
headers: HEADERS,
data: { vendor_name: 'FB Pixel', category_key: 'marketing', retention_days: 90 },
})
})
test('consent expiry should match max vendor retention', async ({ request }) => {
const fp = `e2e-ret-fp-${Date.now()}`
const res = await request.post(`${API_BASE}/banner/consent`, {
headers: HEADERS,
data: {
site_id: RET_SITE,
device_fingerprint: fp,
categories: ['necessary', 'marketing'],
vendors: ['FB Pixel'],
},
})
expect(res.ok()).toBeTruthy()
const body = await res.json()
// Expiry should be based on max vendor retention (90 days for marketing)
const expiresAt = new Date(body.expires_at)
const createdAt = new Date(body.created_at)
const diffDays = Math.round((expiresAt.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24))
// Should be 90 days (marketing) not 365 (default)
expect(diffDays).toBeGreaterThanOrEqual(89)
expect(diffDays).toBeLessThanOrEqual(91)
// Cleanup
await request.delete(`${API_BASE}/banner/consent/${body.id}`, { headers: HEADERS })
})
test.afterAll(async ({ request }) => {
await request.delete(`${API_BASE}/banner/admin/sites/${RET_SITE}`, {
headers: HEADERS,
})
})
})
@@ -0,0 +1,139 @@
import { test, expect } from '@playwright/test'
import { navigateToSDK, waitForPageLoad } from '../utils/test-helpers'
/**
* Cookie Banner UI E2E Tests
*
* Tests the cookie banner configuration page and admin UI.
* Verifies that the banner builder, category management,
* and code export work correctly.
*/
test.describe('Cookie Banner Configuration Page', () => {
test.beforeEach(async ({ page }) => {
await navigateToSDK(page, '/cookie-banner')
})
test('should load cookie banner page', async ({ page }) => {
// The page should load with the step header
await expect(page.getByText('Cookie-Banner')).toBeVisible({ timeout: 10000 })
})
test('should display category cards', async ({ page }) => {
// Wait for content to load
await page.waitForTimeout(1000)
// Check for standard cookie categories
const pageContent = await page.textContent('body')
expect(pageContent).toBeTruthy()
// At minimum the "Notwendig" category should exist
const hasCategories = pageContent?.includes('Notwendig') ||
pageContent?.includes('necessary') ||
pageContent?.includes('Kategorie')
expect(hasCategories).toBeTruthy()
})
test('should have banner preview section', async ({ page }) => {
await page.waitForTimeout(1000)
// Check for preview or banner-related UI elements
const hasPreview = await page.locator('text=Vorschau').isVisible().catch(() => false) ||
await page.locator('text=Preview').isVisible().catch(() => false) ||
await page.locator('text=Banner').isVisible().catch(() => false)
expect(hasPreview).toBeTruthy()
})
test('should have export/publish buttons', async ({ page }) => {
// Check for action buttons
const exportBtn = page.getByRole('button', { name: /Code exportieren|Export/ })
const publishBtn = page.getByRole('button', { name: /Veroeffentlichen|Speichern|Publish/ })
const hasExport = await exportBtn.isVisible().catch(() => false)
const hasPublish = await publishBtn.isVisible().catch(() => false)
// At least one action button should be present
expect(hasExport || hasPublish).toBeTruthy()
})
test('should display cookie statistics', async ({ page }) => {
await page.waitForTimeout(1000)
// Check for statistics display (cookie count, third-party count)
const pageContent = await page.textContent('body')
const hasStats = pageContent?.includes('Cookie') || pageContent?.includes('Vendor')
expect(hasStats).toBeTruthy()
})
})
test.describe('Cookie Banner Navigation', () => {
test('should be reachable from SDK sidebar', async ({ page }) => {
// Navigate to SDK dashboard first
await navigateToSDK(page, '')
await page.waitForTimeout(1000)
// Look for cookie banner link in sidebar or navigation
const bannerLink = page.locator('a[href*="cookie-banner"]').first()
if (await bannerLink.isVisible().catch(() => false)) {
await bannerLink.click()
await waitForPageLoad(page)
await expect(page).toHaveURL(/cookie-banner/)
}
})
test('should navigate between consent management pages', async ({ page }) => {
await navigateToSDK(page, '/einwilligungen')
await page.waitForTimeout(1000)
// Check if tabs/links to cookie-banner exist
const cookieBannerTab = page.locator('a[href*="cookie-banner"], button:has-text("Cookie")')
const isVisible = await cookieBannerTab.first().isVisible().catch(() => false)
if (isVisible) {
await cookieBannerTab.first().click()
await waitForPageLoad(page)
}
})
})
test.describe('Consent Management Page', () => {
test.beforeEach(async ({ page }) => {
await navigateToSDK(page, '/consent-management')
})
test('should load consent management page', async ({ page }) => {
await page.waitForTimeout(1000)
const pageContent = await page.textContent('body')
// Page should have consent-related content
const hasContent = pageContent?.includes('Consent') ||
pageContent?.includes('Einwilligung') ||
pageContent?.includes('consent')
expect(hasContent).toBeTruthy()
})
})
test.describe('DSR Module — Banner Integration', () => {
test.beforeEach(async ({ page }) => {
await navigateToSDK(page, '/dsr')
})
test('should load DSR portal', async ({ page }) => {
await expect(page.getByRole('heading', { name: /DSR|Betroffenen/ })).toBeVisible({
timeout: 10000,
})
})
test('should have tab navigation for DSR workflow', async ({ page }) => {
await page.waitForTimeout(1000)
// DSR should have workflow tabs
const pageContent = await page.textContent('body')
const hasTabs = pageContent?.includes('Eingang') ||
pageContent?.includes('Bearbeitung') ||
pageContent?.includes('Abgeschlossen')
expect(hasTabs).toBeTruthy()
})
})
@@ -20,12 +20,16 @@ from compliance.api._http_errors import translate_domain_errors
from compliance.schemas.banner import ( from compliance.schemas.banner import (
CategoryConfigCreate, CategoryConfigCreate,
ConsentCreate, ConsentCreate,
ConsentSyncRequest,
LinkEmailRequest,
SiteConfigCreate, SiteConfigCreate,
SiteConfigUpdate, SiteConfigUpdate,
VendorConfigCreate, VendorConfigCreate,
) )
from compliance.services.banner_admin_service import BannerAdminService from compliance.services.banner_admin_service import BannerAdminService
from compliance.services.banner_consent_service import BannerConsentService 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"]) router = APIRouter(prefix="/banner", tags=["compliance-banner"])
@@ -48,6 +52,10 @@ def get_admin_service(db: Session = Depends(get_db)) -> BannerAdminService:
return BannerAdminService(db) return BannerAdminService(db)
def get_dsr_service(db: Session = Depends(get_db)) -> BannerDSRService:
return BannerDSRService(db)
# ============================================================================= # =============================================================================
# Public SDK Endpoints (fuer Einbettung in Kunden-Websites) # Public SDK Endpoints (fuer Einbettung in Kunden-Websites)
# ============================================================================= # =============================================================================
@@ -118,6 +126,69 @@ async def export_consent(
return service.export_consent(tenant_id, site_id, device_fingerprint) 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 # Admin — Stats
# ============================================================================= # =============================================================================
@@ -253,3 +324,43 @@ async def delete_vendor(
"""Delete a vendor.""" """Delete a vendor."""
with translate_domain_errors(): with translate_domain_errors():
service.delete_vendor(vendor_id) 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)}
@@ -34,6 +34,7 @@ class BannerConsentDB(Base):
ip_hash = Column(Text) ip_hash = Column(Text)
user_agent = Column(Text) user_agent = Column(Text)
consent_string = Column(Text) consent_string = Column(Text)
linked_email = Column(Text)
expires_at = Column(DateTime) expires_at = Column(DateTime)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -42,6 +43,8 @@ class BannerConsentDB(Base):
Index('idx_banner_consent_tenant', 'tenant_id'), Index('idx_banner_consent_tenant', 'tenant_id'),
Index('idx_banner_consent_site', 'site_id'), Index('idx_banner_consent_site', 'site_id'),
Index('idx_banner_consent_device', 'device_fingerprint'), Index('idx_banner_consent_device', 'device_fingerprint'),
Index('idx_banner_consent_email', 'linked_email',
postgresql_where='linked_email IS NOT NULL'),
) )
@@ -58,6 +61,8 @@ class BannerConsentAuditLogDB(Base):
device_fingerprint = Column(Text) device_fingerprint = Column(Text)
categories = Column(JSON, default=list) categories = Column(JSON, default=list)
ip_hash = Column(Text) ip_hash = Column(Text)
banner_config_hash = Column(Text)
consent_version = Column(Integer)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = ( __table_args__ = (
@@ -85,6 +90,7 @@ class BannerSiteConfigDB(Base):
dsb_email = Column(Text) dsb_email = Column(Text)
theme = Column(JSON, default=dict) theme = Column(JSON, default=dict)
tcf_enabled = Column(Boolean, default=False) tcf_enabled = Column(Boolean, default=False)
config_version = Column(Integer, nullable=False, default=1)
is_active = Column(Boolean, nullable=False, default=True) is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -76,10 +76,26 @@ class VendorConfigCreate(BaseModel):
retention_days: int = 365 retention_days: int = 365
class LinkEmailRequest(BaseModel):
"""Request body for linking an email to a device fingerprint."""
site_id: str
device_fingerprint: str
email: str
class ConsentSyncRequest(BaseModel):
"""Request body for syncing banner consent to Einwilligungen."""
site_id: str
device_fingerprint: str
email: str
__all__ = [ __all__ = [
"ConsentCreate", "ConsentCreate",
"SiteConfigCreate", "SiteConfigCreate",
"SiteConfigUpdate", "SiteConfigUpdate",
"CategoryConfigCreate", "CategoryConfigCreate",
"VendorConfigCreate", "VendorConfigCreate",
"LinkEmailRequest",
"ConsentSyncRequest",
] ]
@@ -25,6 +25,7 @@ def consent_to_dict(c: BannerConsentDB) -> dict[str, Any]:
"vendors": c.vendors or [], "vendors": c.vendors or [],
"ip_hash": c.ip_hash, "ip_hash": c.ip_hash,
"consent_string": c.consent_string, "consent_string": c.consent_string,
"linked_email": c.linked_email,
"expires_at": c.expires_at.isoformat() if c.expires_at else None, "expires_at": c.expires_at.isoformat() if c.expires_at else None,
"created_at": c.created_at.isoformat() if c.created_at else None, "created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None, "updated_at": c.updated_at.isoformat() if c.updated_at else None,
@@ -45,6 +46,7 @@ def site_config_to_dict(s: BannerSiteConfigDB) -> dict[str, Any]:
"dsb_email": s.dsb_email, "dsb_email": s.dsb_email,
"theme": s.theme or {}, "theme": s.theme or {},
"tcf_enabled": s.tcf_enabled, "tcf_enabled": s.tcf_enabled,
"config_version": s.config_version,
"is_active": s.is_active, "is_active": s.is_active,
"created_at": s.created_at.isoformat() if s.created_at else None, "created_at": s.created_at.isoformat() if s.created_at else None,
"updated_at": s.updated_at.isoformat() if s.updated_at else None, "updated_at": s.updated_at.isoformat() if s.updated_at else None,
@@ -9,9 +9,12 @@ display), export, and per-site consent statistics.
Admin-side site/category/vendor management lives in Admin-side site/category/vendor management lives in
``compliance.services.banner_admin_service.BannerAdminService``. ``compliance.services.banner_admin_service.BannerAdminService``.
DSR-facing email linking lives in
``compliance.services.banner_dsr_service.BannerDSRService``.
""" """
import hashlib import hashlib
import json
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Optional from typing import Any, Optional
@@ -33,6 +36,15 @@ from compliance.services._banner_serializers import (
vendor_to_dict, 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: class BannerConsentService:
"""Business logic for public SDK banner consent endpoints.""" """Business logic for public SDK banner consent endpoints."""
@@ -59,6 +71,8 @@ class BannerConsentService:
device_fingerprint: Optional[str] = None, device_fingerprint: Optional[str] = None,
categories: Optional[list[str]] = None, categories: Optional[list[str]] = None,
ip_hash: Optional[str] = None, ip_hash: Optional[str] = None,
banner_config_hash: Optional[str] = None,
consent_version: Optional[int] = None,
) -> None: ) -> None:
entry = BannerConsentAuditLogDB( entry = BannerConsentAuditLogDB(
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -68,9 +82,53 @@ class BannerConsentService:
device_fingerprint=device_fingerprint, device_fingerprint=device_fingerprint,
categories=categories or [], categories=categories or [],
ip_hash=ip_hash, ip_hash=ip_hash,
banner_config_hash=banner_config_hash,
consent_version=consent_version,
) )
self.db.add(entry) 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) # Consent CRUD (public SDK)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -86,11 +144,19 @@ class BannerConsentService:
user_agent: Optional[str], user_agent: Optional[str],
consent_string: Optional[str], consent_string: Optional[str],
) -> dict[str, Any]: ) -> 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) tid = uuid.UUID(tenant_id)
ip_hash = self._hash_ip(ip_address) ip_hash = self._hash_ip(ip_address)
now = datetime.now(timezone.utc) 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 = ( existing = (
self.db.query(BannerConsentDB) self.db.query(BannerConsentDB)
@@ -113,7 +179,7 @@ class BannerConsentService:
self.db.flush() self.db.flush()
self._log( self._log(
tid, existing.id, "consent_updated", site_id, device_fingerprint, tid, existing.id, "consent_updated", site_id, device_fingerprint,
categories, ip_hash, categories, ip_hash, config_hash, config_ver,
) )
self.db.commit() self.db.commit()
self.db.refresh(existing) self.db.refresh(existing)
@@ -134,7 +200,7 @@ class BannerConsentService:
self.db.flush() self.db.flush()
self._log( self._log(
tid, consent.id, "consent_given", site_id, device_fingerprint, tid, consent.id, "consent_given", site_id, device_fingerprint,
categories, ip_hash, categories, ip_hash, config_hash, config_ver,
) )
self.db.commit() self.db.commit()
self.db.refresh(consent) self.db.refresh(consent)
@@ -0,0 +1,266 @@
# mypy: disable-error-code="arg-type,assignment"
"""
Banner DSR service bridges device-based banner consents with
user-based DSR (Data Subject Request) processing.
Phase 3: Email linking allows correlating anonymous device fingerprints
with user emails (e.g. after newsletter signup or account creation).
This enables Art. 15 (access), Art. 17 (erasure), and Art. 20
(portability) requests to include/delete banner consent data.
Phase 4: Consent sync bridges banner consents (device-based) with
Einwilligungen (user-based) for unified consent management.
"""
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy.orm import Session
from compliance.db.banner_models import (
BannerConsentAuditLogDB,
BannerConsentDB,
)
from compliance.db.einwilligungen_models import (
EinwilligungenConsentDB,
EinwilligungenConsentHistoryDB,
)
from compliance.domain import NotFoundError, ValidationError
from compliance.services._banner_serializers import consent_to_dict
class BannerDSRService:
"""Email linking + DSR integration for banner consents."""
def __init__(self, db: Session) -> None:
self.db = db
# ------------------------------------------------------------------
# Phase 3: Email linking
# ------------------------------------------------------------------
def link_email(
self,
tenant_id: str,
site_id: str,
device_fingerprint: str,
email: str,
) -> dict[str, Any]:
"""Link an email address to a device fingerprint's consent.
Typically called after newsletter signup, account creation, or
login any point where the user's email becomes known.
"""
tid = uuid.UUID(tenant_id)
if not email or "@" not in email:
raise ValidationError("Invalid email address")
consent = (
self.db.query(BannerConsentDB)
.filter(
BannerConsentDB.tenant_id == tid,
BannerConsentDB.site_id == site_id,
BannerConsentDB.device_fingerprint == device_fingerprint,
)
.first()
)
if not consent:
raise NotFoundError("No consent found for this device")
consent.linked_email = email.lower().strip()
consent.updated_at = datetime.now(timezone.utc)
# Audit the linking
self.db.add(BannerConsentAuditLogDB(
tenant_id=tid,
consent_id=consent.id,
action="email_linked",
site_id=site_id,
device_fingerprint=device_fingerprint,
categories=consent.categories or [],
))
self.db.commit()
self.db.refresh(consent)
return consent_to_dict(consent)
def get_consents_by_email(
self, tenant_id: str, email: str,
) -> list[dict[str, Any]]:
"""Find all banner consents linked to an email (Art. 15 DSGVO)."""
tid = uuid.UUID(tenant_id)
consents = (
self.db.query(BannerConsentDB)
.filter(
BannerConsentDB.tenant_id == tid,
BannerConsentDB.linked_email == email.lower().strip(),
)
.all()
)
return [consent_to_dict(c) for c in consents]
def delete_consents_by_email(
self, tenant_id: str, email: str,
) -> dict[str, Any]:
"""Delete all banner consents for an email (Art. 17 DSGVO erasure).
Creates audit log entries before deletion for compliance proof.
"""
tid = uuid.UUID(tenant_id)
normalized = email.lower().strip()
consents = (
self.db.query(BannerConsentDB)
.filter(
BannerConsentDB.tenant_id == tid,
BannerConsentDB.linked_email == normalized,
)
.all()
)
deleted = 0
for c in consents:
self.db.add(BannerConsentAuditLogDB(
tenant_id=tid,
consent_id=c.id,
action="consent_deleted_dsr",
site_id=c.site_id,
device_fingerprint=c.device_fingerprint,
categories=c.categories or [],
))
self.db.delete(c)
deleted += 1
self.db.commit()
return {"deleted": deleted, "email": normalized}
def export_for_dsr(
self, tenant_id: str, email: str,
) -> dict[str, Any]:
"""Export all banner consent data for a DSR (Art. 15/20 DSGVO).
Returns consent records + audit trail for the email.
"""
tid = uuid.UUID(tenant_id)
normalized = email.lower().strip()
consents = (
self.db.query(BannerConsentDB)
.filter(
BannerConsentDB.tenant_id == tid,
BannerConsentDB.linked_email == normalized,
)
.all()
)
consent_ids = [c.id for c in consents]
audit = []
if consent_ids:
audit = (
self.db.query(BannerConsentAuditLogDB)
.filter(BannerConsentAuditLogDB.consent_id.in_(consent_ids))
.order_by(BannerConsentAuditLogDB.created_at.desc())
.all()
)
return {
"email": normalized,
"banner_consents": [consent_to_dict(c) for c in consents],
"audit_trail": [
{
"id": str(a.id),
"action": a.action,
"site_id": a.site_id,
"categories": a.categories or [],
"banner_config_hash": a.banner_config_hash,
"consent_version": a.consent_version,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
for a in audit
],
}
# ------------------------------------------------------------------
# Phase 4: Consent sync (Banner ↔ Einwilligungen)
# ------------------------------------------------------------------
def sync_consent_to_einwilligungen(
self,
tenant_id: str,
device_fingerprint: str,
email: str,
site_id: str,
) -> dict[str, Any]:
"""Sync banner consent categories to user-based Einwilligungen.
Called when a user logs in and their email becomes known.
Creates/updates EinwilligungenConsent entries for each accepted
banner category, bridging device-based and user-based systems.
"""
tid = uuid.UUID(tenant_id)
consent = (
self.db.query(BannerConsentDB)
.filter(
BannerConsentDB.tenant_id == tid,
BannerConsentDB.site_id == site_id,
BannerConsentDB.device_fingerprint == device_fingerprint,
)
.first()
)
if not consent:
raise NotFoundError("No banner consent found for this device")
# Link email if not already linked
normalized = email.lower().strip()
if not consent.linked_email:
consent.linked_email = normalized
consent.updated_at = datetime.now(timezone.utc)
synced = 0
categories = consent.categories or []
for cat in categories:
data_point_id = f"banner_{cat}"
existing = (
self.db.query(EinwilligungenConsentDB)
.filter(
EinwilligungenConsentDB.tenant_id == tid,
EinwilligungenConsentDB.user_id == normalized,
EinwilligungenConsentDB.data_point_id == data_point_id,
)
.first()
)
now = datetime.now(timezone.utc)
if existing:
if not existing.granted:
existing.granted = True
existing.granted_at = now
existing.revoked_at = None
existing.source = "banner_sync"
self.db.add(EinwilligungenConsentHistoryDB(
consent_id=existing.id,
tenant_id=tid,
action="granted",
source="banner_sync",
))
synced += 1
else:
new_consent = EinwilligungenConsentDB(
tenant_id=tid,
user_id=normalized,
data_point_id=data_point_id,
granted=True,
granted_at=now,
consent_version="1",
source="banner_sync",
)
self.db.add(new_consent)
self.db.flush()
self.db.add(EinwilligungenConsentHistoryDB(
consent_id=new_consent.id,
tenant_id=tid,
action="granted",
source="banner_sync",
))
synced += 1
self.db.commit()
return {
"synced": synced,
"categories": categories,
"email": normalized,
}
@@ -142,9 +142,33 @@ class DSRWorkflowService:
if body.result_data: if body.result_data:
dsr.data_export = body.result_data dsr.data_export = body.result_data
dsr.updated_at = now dsr.updated_at = now
# Phase 3: Auto-delete banner consents on Art. 17 erasure
banner_result = None
if dsr.request_type == "erasure" and dsr.requester_email:
from compliance.services.banner_dsr_service import BannerDSRService
banner_svc = BannerDSRService(self._db)
banner_result = banner_svc.delete_consents_by_email(
tenant_id, dsr.requester_email,
)
# Phase 3: Include banner consents in data export for access/portability
if dsr.request_type in ("access", "portability") and dsr.requester_email:
from compliance.services.banner_dsr_service import BannerDSRService
banner_svc = BannerDSRService(self._db)
export = banner_svc.export_for_dsr(tenant_id, dsr.requester_email)
if export.get("banner_consents"):
existing_export = dsr.data_export or {}
if isinstance(existing_export, dict):
existing_export["banner_consents"] = export
dsr.data_export = existing_export
self._db.commit() self._db.commit()
self._db.refresh(dsr) self._db.refresh(dsr)
return _dsr_to_dict(dsr) result = _dsr_to_dict(dsr)
if banner_result:
result["banner_consents_deleted"] = banner_result["deleted"]
return result
# -- Reject -------------------------------------------------------------- # -- Reject --------------------------------------------------------------
@@ -0,0 +1,127 @@
"""
Vendor-Banner Sync maps the 82-service registry to banner vendor configs.
Automatically creates vendor entries in the cookie banner with correct
category assignment and legally required retention periods.
"""
import logging
import os
import uuid
logger = logging.getLogger(__name__)
# Service category → Banner category mapping
CATEGORY_MAP = {
"tracking": "statistics",
"heatmap": "statistics",
"tag_manager": "statistics",
"marketing": "marketing",
"social": "marketing",
"push": "marketing",
"crm": "marketing",
"chatbot": "functional",
"support": "functional",
"video": "functional",
"testing": "functional",
"cdn": "necessary",
"payment": "necessary",
"error_tracking": "necessary",
"accessibility": "necessary",
"cmp": "necessary",
"other": "functional",
}
# Legally required max retention per category (in days)
# Based on: DSGVO Art. 5(1)(e), CNIL guidelines, EDPB recommendations
RETENTION_DEFAULTS = {
"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
}
# Specific service retention overrides
SERVICE_RETENTION = {
"google_analytics": 790, # 26 months (GA4 default)
"matomo": 790, # 26 months
"hotjar": 365, # 12 months
"facebook_pixel": 90, # 90 days (Meta default)
"google_ads": 90, # 90 days
"stripe": 0, # Session only (payment)
"paypal": 0, # Session only
"klarna": 0, # Session only
}
def get_banner_vendors_from_registry() -> list[dict]:
"""Convert service registry entries to banner vendor configs."""
from compliance.services.service_registry import SERVICE_REGISTRY
vendors = []
for pattern, meta in SERVICE_REGISTRY.items():
service_id = meta.get("id", "")
category = meta.get("category", "other")
banner_category = CATEGORY_MAP.get(category, "functional")
# Skip CMP — consent managers are not vendor entries
if service_id == "cmp":
continue
retention = SERVICE_RETENTION.get(service_id, RETENTION_DEFAULTS.get(banner_category, 365))
vendors.append({
"vendor_name": meta["name"],
"vendor_url": "", # Would need manual entry
"category_key": banner_category,
"description_de": f"{meta['name']} ({meta.get('provider', '')})",
"description_en": f"{meta['name']} ({meta.get('provider', '')})",
"cookie_names": [], # Service-specific, populated later
"retention_days": retention,
"is_active": True,
"country": meta.get("country", ""),
"eu_adequate": meta.get("eu_adequate", False),
"requires_consent": meta.get("requires_consent", True),
"legal_ref": meta.get("legal_ref", ""),
"service_id": service_id,
})
logger.info("Generated %d banner vendors from service registry", len(vendors))
return vendors
async def sync_vendors_to_site(pool, site_config_id: str, tenant_id: str) -> dict:
"""Sync service registry vendors to a site's banner vendor configs."""
vendors = get_banner_vendors_from_registry()
created = 0
updated = 0
async with pool.acquire() as conn:
for v in vendors:
# Check if vendor already exists for this site
existing = await conn.fetchrow("""
SELECT id FROM compliance_banner_vendor_configs
WHERE site_config_id = $1 AND vendor_name = $2
""", uuid.UUID(site_config_id), v["vendor_name"])
if existing:
await conn.execute("""
UPDATE compliance_banner_vendor_configs
SET category_key = $1, retention_days = $2, is_active = $3
WHERE id = $4
""", v["category_key"], v["retention_days"], v["is_active"], existing["id"])
updated += 1
else:
import json
await conn.execute("""
INSERT INTO compliance_banner_vendor_configs
(site_config_id, vendor_name, category_key, description_de,
description_en, cookie_names, retention_days, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
""", uuid.UUID(site_config_id), v["vendor_name"], v["category_key"],
v["description_de"], v["description_en"],
json.dumps(v["cookie_names"]), v["retention_days"], v["is_active"])
created += 1
logger.info("Synced vendors to site %s: %d created, %d updated", site_config_id, created, updated)
return {"created": created, "updated": updated, "total": len(vendors)}
@@ -0,0 +1,23 @@
-- Migration 106: Banner Email Linking + Consent Proof
-- Phase 3: linked_email for DSR ↔ Banner-Consent correlation
-- Phase 6: banner_config_hash + consent_version for Art. 7(1) DSGVO proof
-- 1. Add linked_email to banner consents (optional, nullable)
-- Allows correlating device-based consents with user email for DSR processing
ALTER TABLE compliance_banner_consents
ADD COLUMN IF NOT EXISTS linked_email TEXT;
CREATE INDEX IF NOT EXISTS idx_banner_consent_email
ON compliance_banner_consents (linked_email)
WHERE linked_email IS NOT NULL;
-- 2. Add consent proof columns to audit log
-- banner_config_hash: SHA256 of the site config at consent time (Art. 7(1) DSGVO)
-- consent_version: incremented per site on config change, tracks which banner version was shown
ALTER TABLE compliance_banner_consent_audit_log
ADD COLUMN IF NOT EXISTS banner_config_hash TEXT,
ADD COLUMN IF NOT EXISTS consent_version INTEGER;
-- 3. Add config_version counter to site configs (auto-incremented on config change)
ALTER TABLE compliance_banner_site_configs
ADD COLUMN IF NOT EXISTS config_version INTEGER NOT NULL DEFAULT 1;
@@ -312,3 +312,225 @@ class TestStats:
assert data["category_acceptance"]["necessary"]["count"] == 3 assert data["category_acceptance"]["necessary"]["count"] == 3
assert data["category_acceptance"]["analytics"]["count"] == 2 assert data["category_acceptance"]["analytics"]["count"] == 2
assert data["category_acceptance"]["marketing"]["count"] == 1 assert data["category_acceptance"]["marketing"]["count"] == 1
# =============================================================================
# Phase 3: Email Linking
# =============================================================================
class TestEmailLinking:
def test_link_email(self):
_record_consent()
r = client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": "fp-123",
"email": "user@example.com",
}, headers=HEADERS)
assert r.status_code == 200
assert r.json()["linked_email"] == "user@example.com"
def test_link_email_normalizes(self):
_record_consent()
r = client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": "fp-123",
"email": " User@Example.COM ",
}, headers=HEADERS)
assert r.status_code == 200
assert r.json()["linked_email"] == "user@example.com"
def test_link_email_invalid(self):
_record_consent()
r = client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": "fp-123",
"email": "not-an-email",
}, headers=HEADERS)
assert r.status_code == 400
def test_link_email_no_consent(self):
r = client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": "nonexistent",
"email": "user@example.com",
}, headers=HEADERS)
assert r.status_code == 404
def test_get_consents_by_email(self):
_record_consent(fingerprint="dev-a")
_record_consent(fingerprint="dev-b")
# Link both to same email
for fp in ["dev-a", "dev-b"]:
client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": fp,
"email": "multi@example.com",
}, headers=HEADERS)
r = client.get("/api/compliance/banner/consent/by-email/multi@example.com", headers=HEADERS)
assert r.status_code == 200
assert len(r.json()) == 2
def test_get_consents_by_email_empty(self):
r = client.get("/api/compliance/banner/consent/by-email/nobody@nowhere.com", headers=HEADERS)
assert r.status_code == 200
assert r.json() == []
def test_delete_consents_by_email_art17(self):
_record_consent(fingerprint="del-a")
_record_consent(fingerprint="del-b")
for fp in ["del-a", "del-b"]:
client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": fp,
"email": "erasure@example.com",
}, headers=HEADERS)
r = client.delete("/api/compliance/banner/consent/by-email/erasure@example.com", headers=HEADERS)
assert r.status_code == 200
data = r.json()
assert data["deleted"] == 2
assert data["email"] == "erasure@example.com"
# Verify gone
r2 = client.get("/api/compliance/banner/consent/by-email/erasure@example.com", headers=HEADERS)
assert r2.json() == []
def test_dsr_export_by_email(self):
_record_consent(fingerprint="exp-1")
client.post("/api/compliance/banner/consent/link-email", json={
"site_id": "example.com",
"device_fingerprint": "exp-1",
"email": "export@example.com",
}, headers=HEADERS)
r = client.get("/api/compliance/banner/consent/dsr-export/export@example.com", headers=HEADERS)
assert r.status_code == 200
data = r.json()
assert data["email"] == "export@example.com"
assert len(data["banner_consents"]) == 1
assert len(data["audit_trail"]) >= 1
# =============================================================================
# Phase 4: Consent Sync (Banner → Einwilligungen)
# =============================================================================
class TestConsentSync:
def test_sync_consent(self):
_record_consent(categories=["necessary", "analytics"])
r = client.post("/api/compliance/banner/consent/sync", json={
"site_id": "example.com",
"device_fingerprint": "fp-123",
"email": "sync@example.com",
}, headers=HEADERS)
assert r.status_code == 200
data = r.json()
assert data["synced"] >= 1
assert "necessary" in data["categories"]
assert data["email"] == "sync@example.com"
def test_sync_consent_links_email(self):
_record_consent()
# Sync should auto-link email
client.post("/api/compliance/banner/consent/sync", json={
"site_id": "example.com",
"device_fingerprint": "fp-123",
"email": "autolink@example.com",
}, headers=HEADERS)
r = client.get("/api/compliance/banner/consent/by-email/autolink@example.com", headers=HEADERS)
assert len(r.json()) == 1
def test_sync_no_consent(self):
r = client.post("/api/compliance/banner/consent/sync", json={
"site_id": "example.com",
"device_fingerprint": "nonexistent",
"email": "test@example.com",
}, headers=HEADERS)
assert r.status_code == 404
# =============================================================================
# Phase 2: Retention per Category
# =============================================================================
class TestRetention:
def test_retention_uses_vendor_max(self):
"""Consent expiry should use max vendor retention, not hardcoded 365."""
_create_site()
# Add marketing vendor with 90 days retention
client.post("/api/compliance/banner/admin/sites/example.com/vendors", json={
"vendor_name": "FB Pixel",
"category_key": "marketing",
"retention_days": 90,
}, headers=HEADERS)
c = _record_consent(categories=["necessary", "marketing"])
# Consent should expire in 90 days (max of vendor retentions)
from datetime import datetime
created = datetime.fromisoformat(c["created_at"])
expires = datetime.fromisoformat(c["expires_at"])
diff_days = (expires - created).days
assert 89 <= diff_days <= 91, f"Expected ~90 days, got {diff_days}"
def test_retention_default_365(self):
"""Without vendor config, should use category default."""
c = _record_consent(categories=["necessary"])
from datetime import datetime
created = datetime.fromisoformat(c["created_at"])
expires = datetime.fromisoformat(c["expires_at"])
diff_days = (expires - created).days
assert 364 <= diff_days <= 366, f"Expected ~365 days, got {diff_days}"
# =============================================================================
# Phase 6: Consent Proof (Art. 7(1) DSGVO)
# =============================================================================
class TestConsentProof:
def test_consent_has_linked_email_field(self):
"""New consent should include linked_email in response."""
c = _record_consent()
assert "linked_email" in c
assert c["linked_email"] is None # Not linked yet
def test_site_config_has_version(self):
"""Site config should have config_version field."""
s = _create_site()
assert "config_version" in s
assert s["config_version"] == 1
def test_audit_trail_after_consent(self):
"""Audit trail should exist after recording consent."""
_record_consent()
r = client.get(
"/api/compliance/banner/consent/export?site_id=example.com&device_fingerprint=fp-123",
headers=HEADERS,
)
data = r.json()
assert len(data["audit_trail"]) >= 1
audit = data["audit_trail"][0]
assert audit["action"] in ("consent_given", "consent_updated")
# =============================================================================
# IP Hashing
# =============================================================================
class TestIPHashing:
def test_ip_is_hashed(self):
"""IP address should never be stored plain — only SHA256[:16]."""
c = _record_consent()
assert c["ip_hash"] is not None
assert c["ip_hash"] != "1.2.3.4"
assert len(c["ip_hash"]) == 16
def test_no_ip_returns_none(self):
r = client.post("/api/compliance/banner/consent", json={
"site_id": "example.com",
"device_fingerprint": "fp-no-ip",
"categories": ["necessary"],
}, headers=HEADERS)
assert r.status_code == 200
assert r.json()["ip_hash"] is None