Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
359
backend-core/consent_client.py
Normal file
359
backend-core/consent_client.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Consent Service Client für BreakPilot
|
||||
Kommuniziert mit dem Consent Management Service für GDPR-Compliance
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import os
|
||||
import uuid
|
||||
|
||||
# Consent Service URL (aus Umgebungsvariable oder Standard für lokale Entwicklung)
|
||||
CONSENT_SERVICE_URL = os.getenv("CONSENT_SERVICE_URL", "http://localhost:8081")
|
||||
|
||||
# JWT Secret - MUSS mit dem Go Consent Service übereinstimmen!
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "breakpilot-dev-jwt-secret-2024")
|
||||
|
||||
|
||||
def generate_jwt_token(
|
||||
user_id: str = None,
|
||||
email: str = "demo@breakpilot.app",
|
||||
role: str = "user",
|
||||
expires_hours: int = 24
|
||||
) -> str:
|
||||
"""
|
||||
Generiert einen JWT Token für die Authentifizierung beim Consent Service.
|
||||
|
||||
Args:
|
||||
user_id: Die User-ID (wird generiert falls nicht angegeben)
|
||||
email: Die E-Mail-Adresse des Benutzers
|
||||
role: Die Rolle (user, admin, super_admin)
|
||||
expires_hours: Gültigkeitsdauer in Stunden
|
||||
|
||||
Returns:
|
||||
JWT Token als String
|
||||
"""
|
||||
if user_id is None:
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"role": role,
|
||||
"exp": datetime.utcnow() + timedelta(hours=expires_hours),
|
||||
"iat": datetime.utcnow(),
|
||||
}
|
||||
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def generate_demo_token() -> str:
|
||||
"""Generiert einen Demo-Token für nicht-authentifizierte Benutzer"""
|
||||
return generate_jwt_token(
|
||||
user_id="demo-user-" + str(uuid.uuid4())[:8],
|
||||
email="demo@breakpilot.app",
|
||||
role="user"
|
||||
)
|
||||
|
||||
|
||||
class DocumentType(str, Enum):
|
||||
TERMS = "terms"
|
||||
PRIVACY = "privacy"
|
||||
COOKIES = "cookies"
|
||||
COMMUNITY = "community"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsentStatus:
|
||||
has_consent: bool
|
||||
current_version_id: Optional[str] = None
|
||||
consented_version: Optional[str] = None
|
||||
needs_update: bool = False
|
||||
consented_at: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentVersion:
|
||||
id: str
|
||||
document_id: str
|
||||
version: str
|
||||
language: str
|
||||
title: str
|
||||
content: str
|
||||
summary: Optional[str] = None
|
||||
|
||||
|
||||
class ConsentClient:
|
||||
"""Client für die Kommunikation mit dem Consent Service"""
|
||||
|
||||
def __init__(self, base_url: str = CONSENT_SERVICE_URL):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_url = f"{self.base_url}/api/v1"
|
||||
|
||||
def _get_headers(self, jwt_token: str) -> Dict[str, str]:
|
||||
"""Erstellt die Header mit JWT Token"""
|
||||
return {
|
||||
"Authorization": f"Bearer {jwt_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
async def check_consent(
|
||||
self,
|
||||
jwt_token: str,
|
||||
document_type: DocumentType,
|
||||
language: str = "de"
|
||||
) -> ConsentStatus:
|
||||
"""
|
||||
Prüft ob der Benutzer dem Dokument zugestimmt hat.
|
||||
Gibt zurück ob eine Zustimmung vorliegt und ob sie aktuell ist.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/consent/check/{document_type.value}",
|
||||
headers=self._get_headers(jwt_token),
|
||||
params={"language": language},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return ConsentStatus(
|
||||
has_consent=data.get("has_consent", False),
|
||||
current_version_id=data.get("current_version_id"),
|
||||
consented_version=data.get("consented_version"),
|
||||
needs_update=data.get("needs_update", False),
|
||||
consented_at=data.get("consented_at")
|
||||
)
|
||||
else:
|
||||
return ConsentStatus(has_consent=False, needs_update=True)
|
||||
|
||||
except httpx.RequestError:
|
||||
# Bei Verbindungsproblemen: Consent nicht erzwingen
|
||||
return ConsentStatus(has_consent=True, needs_update=False)
|
||||
|
||||
async def check_all_mandatory_consents(
|
||||
self,
|
||||
jwt_token: str,
|
||||
language: str = "de"
|
||||
) -> Dict[str, ConsentStatus]:
|
||||
"""
|
||||
Prüft alle verpflichtenden Dokumente (Terms, Privacy).
|
||||
Gibt ein Dictionary mit dem Status für jedes Dokument zurück.
|
||||
"""
|
||||
mandatory_docs = [DocumentType.TERMS, DocumentType.PRIVACY]
|
||||
results = {}
|
||||
|
||||
for doc_type in mandatory_docs:
|
||||
results[doc_type.value] = await self.check_consent(jwt_token, doc_type, language)
|
||||
|
||||
return results
|
||||
|
||||
async def get_pending_consents(
|
||||
self,
|
||||
jwt_token: str,
|
||||
language: str = "de"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Gibt eine Liste aller Dokumente zurück, die noch Zustimmung benötigen.
|
||||
Nützlich für die Anzeige beim Login/Registration.
|
||||
"""
|
||||
pending = []
|
||||
statuses = await self.check_all_mandatory_consents(jwt_token, language)
|
||||
|
||||
for doc_type, status in statuses.items():
|
||||
if not status.has_consent or status.needs_update:
|
||||
# Hole das aktuelle Dokument
|
||||
doc = await self.get_latest_document(jwt_token, doc_type, language)
|
||||
if doc:
|
||||
pending.append({
|
||||
"type": doc_type,
|
||||
"version_id": status.current_version_id,
|
||||
"title": doc.title,
|
||||
"content": doc.content,
|
||||
"summary": doc.summary,
|
||||
"is_update": status.has_consent and status.needs_update
|
||||
})
|
||||
|
||||
return pending
|
||||
|
||||
async def get_latest_document(
|
||||
self,
|
||||
jwt_token: str,
|
||||
document_type: str,
|
||||
language: str = "de"
|
||||
) -> Optional[DocumentVersion]:
|
||||
"""Holt die aktuellste Version eines Dokuments"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/documents/{document_type}/latest",
|
||||
headers=self._get_headers(jwt_token),
|
||||
params={"language": language},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return DocumentVersion(
|
||||
id=data["id"],
|
||||
document_id=data["document_id"],
|
||||
version=data["version"],
|
||||
language=data["language"],
|
||||
title=data["title"],
|
||||
content=data["content"],
|
||||
summary=data.get("summary")
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
async def give_consent(
|
||||
self,
|
||||
jwt_token: str,
|
||||
document_type: str,
|
||||
version_id: str,
|
||||
consented: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Speichert die Zustimmung des Benutzers.
|
||||
Gibt True zurück bei Erfolg.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/consent",
|
||||
headers=self._get_headers(jwt_token),
|
||||
json={
|
||||
"document_type": document_type,
|
||||
"version_id": version_id,
|
||||
"consented": consented
|
||||
},
|
||||
timeout=10.0
|
||||
)
|
||||
return response.status_code == 201
|
||||
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
async def get_cookie_categories(
|
||||
self,
|
||||
jwt_token: str,
|
||||
language: str = "de"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Holt alle Cookie-Kategorien für das Cookie-Banner"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/cookies/categories",
|
||||
headers=self._get_headers(jwt_token),
|
||||
params={"language": language},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json().get("categories", [])
|
||||
return []
|
||||
|
||||
except httpx.RequestError:
|
||||
return []
|
||||
|
||||
async def set_cookie_consent(
|
||||
self,
|
||||
jwt_token: str,
|
||||
categories: List[Dict[str, Any]]
|
||||
) -> bool:
|
||||
"""
|
||||
Speichert die Cookie-Präferenzen.
|
||||
categories: [{"category_id": "...", "consented": true/false}, ...]
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/cookies/consent",
|
||||
headers=self._get_headers(jwt_token),
|
||||
json={"categories": categories},
|
||||
timeout=10.0
|
||||
)
|
||||
return response.status_code == 200
|
||||
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
async def get_my_data(self, jwt_token: str) -> Optional[Dict[str, Any]]:
|
||||
"""GDPR Art. 15: Holt alle Daten des Benutzers"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/privacy/my-data",
|
||||
headers=self._get_headers(jwt_token),
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return None
|
||||
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
async def request_data_export(self, jwt_token: str) -> Optional[str]:
|
||||
"""GDPR Art. 20: Fordert einen Datenexport an"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/privacy/export",
|
||||
headers=self._get_headers(jwt_token),
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 202:
|
||||
return response.json().get("request_id")
|
||||
return None
|
||||
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
async def request_data_deletion(
|
||||
self,
|
||||
jwt_token: str,
|
||||
reason: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""GDPR Art. 17: Fordert Löschung aller Daten an"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/privacy/delete",
|
||||
headers=self._get_headers(jwt_token),
|
||||
json={"reason": reason} if reason else {},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 202:
|
||||
return response.json().get("request_id")
|
||||
return None
|
||||
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Prüft ob der Consent Service erreichbar ist"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/health",
|
||||
timeout=5.0
|
||||
)
|
||||
return response.status_code == 200
|
||||
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
|
||||
# Singleton-Instanz für einfachen Zugriff
|
||||
consent_client = ConsentClient()
|
||||
Reference in New Issue
Block a user