feat: IAB TCF 2.2 — TC String encoder + purpose mapping + UI

- TCFEncoderService: generates base64url-encoded TC Strings per IAB spec
  with 12 purposes, vendor consent bitfield, CMP metadata
- Category-to-purpose mapping (necessary→none, statistics→1,7,8,9,10,
  marketing→1,2,3,4,5,6,7,12, functional→1,11)
- tcf_routes: 5 endpoints (purposes, features, mapping, encode, encode-categories)
- banner_consent_service: auto-generates TC String when tcf_enabled=true
- TCFSettings.tsx: enable/disable toggle, purpose grid with category mapping,
  TC String test generator, CMP registration info
- New "TCF/IAB" tab in cookie-banner page (7 tabs total)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-04 07:01:37 +02:00
parent c89a68e59e
commit d3c8811fdb
6 changed files with 505 additions and 1 deletions
@@ -69,6 +69,7 @@ _ROUTER_MODULES = [
"banner_ab_routes",
"compliance_report_routes",
"whistleblower_routes",
"tcf_routes",
]
_loaded_count = 0
@@ -0,0 +1,95 @@
"""
FastAPI routes for IAB TCF 2.2 (Transparency & Consent Framework).
Endpoints:
GET /tcf/purposes — list 12 IAB purposes with translations
GET /tcf/special-features — list 2 IAB special features
GET /tcf/category-mapping — banner category → IAB purpose mapping
POST /tcf/encode — generate TC String from consent decisions
POST /tcf/encode-categories — generate TC String from banner categories
"""
import logging
from typing import Optional, List, Dict
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
from .tenant_utils import get_tenant_id as _get_tenant_id
from compliance.services.tcf_encoder_service import TCFEncoderService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tcf", tags=["tcf"])
class TCFEncodeRequest(BaseModel):
purpose_consents: Dict[int, bool] = {}
vendor_consents: Dict[int, bool] = {}
purpose_li: Optional[Dict[int, bool]] = None
special_features: Optional[Dict[int, bool]] = None
cmp_id: int = 1
cmp_version: int = 1
consent_language: str = "DE"
class TCFCategoryEncodeRequest(BaseModel):
categories: List[str] = []
vendor_consents: Optional[Dict[int, bool]] = None
cmp_id: int = 1
consent_language: str = "DE"
@router.get("/purposes")
def list_purposes():
return TCFEncoderService.get_purposes()
@router.get("/special-features")
def list_special_features():
return TCFEncoderService.get_special_features()
@router.get("/category-mapping")
def get_category_mapping():
return TCFEncoderService.get_category_purpose_map()
@router.post("/encode")
def encode_tc_string(body: TCFEncodeRequest):
encoder = TCFEncoderService(
cmp_id=body.cmp_id,
cmp_version=body.cmp_version,
consent_language=body.consent_language,
)
tc_string = encoder.encode(
purpose_consents=body.purpose_consents,
vendor_consents=body.vendor_consents,
purpose_li=body.purpose_li,
special_features=body.special_features,
)
return {"tc_string": tc_string, "version": 2}
@router.post("/encode-categories")
def encode_from_categories(body: TCFCategoryEncodeRequest):
encoder = TCFEncoderService(
cmp_id=body.cmp_id,
consent_language=body.consent_language,
)
tc_string = encoder.encode_from_categories(
categories=body.categories,
vendor_consents=body.vendor_consents,
)
# Also return which purposes were set
from compliance.services.tcf_encoder_service import CATEGORY_PURPOSE_MAP
purpose_ids = set()
for cat in body.categories:
purpose_ids.update(CATEGORY_PURPOSE_MAP.get(cat, []))
return {
"tc_string": tc_string,
"version": 2,
"purposes_consented": sorted(purpose_ids),
"categories": body.categories,
}
@@ -133,6 +133,24 @@ class BannerConsentService:
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)
def _maybe_generate_tc_string(
self, tenant_id: uuid.UUID, site_id: str, categories: list[str],
) -> Optional[str]:
"""Generate TC String if TCF is enabled for this site."""
config = (
self.db.query(BannerSiteConfigDB)
.filter(BannerSiteConfigDB.tenant_id == tenant_id, BannerSiteConfigDB.site_id == site_id)
.first()
)
if not config or not config.tcf_enabled:
return None
try:
from compliance.services.tcf_encoder_service import TCFEncoderService
encoder = TCFEncoderService()
return encoder.encode_from_categories(categories)
except Exception:
return None
# ------------------------------------------------------------------
# Consent CRUD (public SDK)
# ------------------------------------------------------------------
@@ -163,6 +181,10 @@ class BannerConsentService:
expires_at = now + timedelta(days=retention)
config_hash, config_ver = self._compute_config_hash(tid, site_id)
# Auto-generate TC String if TCF is enabled for this site
if not consent_string:
consent_string = self._maybe_generate_tc_string(tid, site_id, categories)
existing = (
self.db.query(BannerConsentDB)
.filter(
@@ -0,0 +1,209 @@
"""
TCF 2.2 TC String Encoder — generates IAB Transparency & Consent strings.
Implements the TC String v2.2 format per IAB specification.
The TC String is a base64url-encoded bitfield containing:
- CMP metadata (ID, version, screen, consent language)
- Purpose consents (12 standard IAB purposes)
- Vendor consents (per IAB vendor ID)
- Legitimate interest signals
Reference: https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework
NOTE: This is a simplified encoder for CMP integration. For full GVL
(Global Vendor List) support, integrate with the IAB GVL API.
"""
import base64
import math
from datetime import datetime, timezone
from typing import Any
# IAB TCF 2.2 Standard Purposes
IAB_PURPOSES = {
1: {"name": "Store and/or access information on a device", "name_de": "Informationen auf Geraet speichern/abrufen"},
2: {"name": "Select basic ads", "name_de": "Einfache Anzeigen auswaehlen"},
3: {"name": "Create a personalised ads profile", "name_de": "Personalisiertes Anzeigenprofil erstellen"},
4: {"name": "Select personalised ads", "name_de": "Personalisierte Anzeigen auswaehlen"},
5: {"name": "Create a personalised content profile", "name_de": "Personalisiertes Inhaltsprofil erstellen"},
6: {"name": "Select personalised content", "name_de": "Personalisierte Inhalte auswaehlen"},
7: {"name": "Measure ad performance", "name_de": "Anzeigen-Leistung messen"},
8: {"name": "Measure content performance", "name_de": "Inhalte-Leistung messen"},
9: {"name": "Apply market research to generate audience insights", "name_de": "Marktforschung fuer Zielgruppen"},
10: {"name": "Develop and improve products", "name_de": "Produkte entwickeln und verbessern"},
11: {"name": "Use limited data to select content", "name_de": "Eingeschraenkte Daten fuer Inhalte nutzen"},
12: {"name": "Use limited data to select ads", "name_de": "Eingeschraenkte Daten fuer Anzeigen nutzen"},
}
# IAB Special Features
IAB_SPECIAL_FEATURES = {
1: {"name": "Use precise geolocation data", "name_de": "Praezise Standortdaten verwenden"},
2: {"name": "Actively scan device characteristics for identification", "name_de": "Geraetemerkmale aktiv scannen"},
}
# Category-to-Purpose mapping (how our banner categories map to IAB purposes)
CATEGORY_PURPOSE_MAP = {
"necessary": [], # No consent needed
"functional": [1, 11], # Device access + limited data for content
"statistics": [1, 7, 8, 9, 10], # Device access + measurement + research
"marketing": [1, 2, 3, 4, 5, 6, 7, 12], # Most purposes
}
def _int_to_bits(value: int, length: int) -> str:
"""Convert integer to fixed-length bit string."""
return bin(value)[2:].zfill(length)
def _datetime_to_deciseconds(dt: datetime) -> int:
"""Convert datetime to deciseconds since epoch (IAB format)."""
epoch = datetime(2000, 1, 1, tzinfo=timezone.utc)
return int((dt - epoch).total_seconds() * 10)
def _bits_to_base64url(bits: str) -> str:
"""Convert bit string to base64url encoding (TC String format)."""
# Pad to multiple of 8
padding = (8 - len(bits) % 8) % 8
bits += "0" * padding
# Convert to bytes
byte_array = bytearray()
for i in range(0, len(bits), 8):
byte_array.append(int(bits[i:i+8], 2))
# Base64url encode (no padding)
return base64.urlsafe_b64encode(bytes(byte_array)).rstrip(b"=").decode("ascii")
class TCFEncoderService:
"""Generates TC Strings per IAB TCF 2.2 specification."""
def __init__(
self,
cmp_id: int = 1,
cmp_version: int = 1,
consent_screen: int = 1,
consent_language: str = "DE",
):
self.cmp_id = cmp_id
self.cmp_version = cmp_version
self.consent_screen = consent_screen
self.consent_language = consent_language
def encode(
self,
purpose_consents: dict[int, bool],
vendor_consents: dict[int, bool],
purpose_li: dict[int, bool] | None = None,
special_features: dict[int, bool] | None = None,
) -> str:
"""Generate a TC String from consent decisions.
Args:
purpose_consents: {purpose_id: True/False} for purposes 1-12
vendor_consents: {vendor_id: True/False} for IAB vendor IDs
purpose_li: Legitimate interest signals per purpose
special_features: Special feature opt-ins
Returns:
Base64url-encoded TC String
"""
now = datetime.now(timezone.utc)
created = _datetime_to_deciseconds(now)
updated = created
bits = ""
# Core TC String v2 fields
bits += _int_to_bits(2, 6) # Version (6 bits) = 2
bits += _int_to_bits(created, 36) # Created (36 bits)
bits += _int_to_bits(updated, 36) # LastUpdated (36 bits)
bits += _int_to_bits(self.cmp_id, 12) # CmpId (12 bits)
bits += _int_to_bits(self.cmp_version, 12) # CmpVersion (12 bits)
bits += _int_to_bits(self.consent_screen, 6) # ConsentScreen (6 bits)
# ConsentLanguage (12 bits = 2 × 6-bit letters)
lang = self.consent_language.upper()[:2]
bits += _int_to_bits(ord(lang[0]) - ord("A"), 6)
bits += _int_to_bits(ord(lang[1]) - ord("A"), 6)
# VendorListVersion (12 bits) — use 0 if not fetching GVL
bits += _int_to_bits(0, 12)
# TcfPolicyVersion (6 bits) = 4 for TCF 2.2
bits += _int_to_bits(4, 6)
# IsServiceSpecific (1 bit) = 1
bits += "1"
# UseNonStandardTexts (1 bit) = 0
bits += "0"
# SpecialFeatureOptIns (12 bits)
sf = special_features or {}
for i in range(1, 13):
bits += "1" if sf.get(i, False) else "0"
# PurposesConsent (24 bits)
for i in range(1, 25):
bits += "1" if purpose_consents.get(i, False) else "0"
# PurposesLITransparency (24 bits)
li = purpose_li or {}
for i in range(1, 25):
bits += "1" if li.get(i, False) else "0"
# Purpose one treatment (1 bit) = 0, PublisherCC (12 bits) = DE
bits += "0"
bits += _int_to_bits(ord("D") - ord("A"), 6)
bits += _int_to_bits(ord("E") - ord("A"), 6)
# Vendor consents — Range encoding
max_vendor = max(vendor_consents.keys()) if vendor_consents else 0
bits += _int_to_bits(max_vendor, 16) # MaxVendorId
# Use bitfield encoding (simpler than range)
bits += "0" # IsRangeEncoding = 0 (bitfield)
for i in range(1, max_vendor + 1):
bits += "1" if vendor_consents.get(i, False) else "0"
# Vendor legitimate interests (same pattern)
bits += _int_to_bits(max_vendor, 16)
bits += "0"
for i in range(1, max_vendor + 1):
bits += "1" if vendor_consents.get(i, False) else "0" # Simplified: same as consent
return _bits_to_base64url(bits)
def encode_from_categories(
self,
categories: list[str],
vendor_consents: dict[int, bool] | None = None,
) -> str:
"""Generate TC String from banner category selections.
Maps our banner categories (necessary, statistics, marketing, functional)
to IAB purposes and generates the TC String.
"""
purpose_consents: dict[int, bool] = {}
for cat in categories:
for purpose_id in CATEGORY_PURPOSE_MAP.get(cat, []):
purpose_consents[purpose_id] = True
return self.encode(
purpose_consents=purpose_consents,
vendor_consents=vendor_consents or {},
)
@staticmethod
def get_purposes() -> list[dict[str, Any]]:
"""Return all 12 IAB purposes with translations."""
return [
{"id": pid, "name": info["name"], "name_de": info["name_de"]}
for pid, info in IAB_PURPOSES.items()
]
@staticmethod
def get_special_features() -> list[dict[str, Any]]:
return [
{"id": fid, "name": info["name"], "name_de": info["name_de"]}
for fid, info in IAB_SPECIAL_FEATURES.items()
]
@staticmethod
def get_category_purpose_map() -> dict[str, list[int]]:
return CATEGORY_PURPOSE_MAP