Improve GDPR implementation

This commit is contained in:
Urtzi Alfaro
2025-10-16 07:28:04 +02:00
parent dbb48d8e2c
commit b6cb800758
37 changed files with 4876 additions and 307 deletions

View File

@@ -0,0 +1,372 @@
"""
User consent management API endpoints for GDPR compliance
"""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Request
from pydantic import BaseModel, Field
from datetime import datetime, timezone
import structlog
import hashlib
from shared.auth.decorators import get_current_user_dep
from app.core.database import get_db
from app.models.consent import UserConsent, ConsentHistory
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
logger = structlog.get_logger()
router = APIRouter()
class ConsentRequest(BaseModel):
"""Request model for granting/updating consent"""
terms_accepted: bool = Field(..., description="Accept terms of service")
privacy_accepted: bool = Field(..., description="Accept privacy policy")
marketing_consent: bool = Field(default=False, description="Consent to marketing communications")
analytics_consent: bool = Field(default=False, description="Consent to analytics cookies")
consent_method: str = Field(..., description="How consent was given (registration, settings, cookie_banner)")
consent_version: str = Field(default="1.0", description="Version of terms/privacy policy")
class ConsentResponse(BaseModel):
"""Response model for consent data"""
id: str
user_id: str
terms_accepted: bool
privacy_accepted: bool
marketing_consent: bool
analytics_consent: bool
consent_version: str
consent_method: str
consented_at: str
withdrawn_at: Optional[str]
class ConsentHistoryResponse(BaseModel):
"""Response model for consent history"""
id: str
user_id: str
action: str
consent_snapshot: dict
created_at: str
def hash_text(text: str) -> str:
"""Create hash of consent text for verification"""
return hashlib.sha256(text.encode()).hexdigest()
@router.post("/consent", response_model=ConsentResponse, status_code=status.HTTP_201_CREATED)
async def record_consent(
consent_data: ConsentRequest,
request: Request,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Record user consent for data processing
GDPR Article 7 - Conditions for consent
"""
try:
user_id = UUID(current_user["sub"])
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")
consent = UserConsent(
user_id=user_id,
terms_accepted=consent_data.terms_accepted,
privacy_accepted=consent_data.privacy_accepted,
marketing_consent=consent_data.marketing_consent,
analytics_consent=consent_data.analytics_consent,
consent_version=consent_data.consent_version,
consent_method=consent_data.consent_method,
ip_address=ip_address,
user_agent=user_agent,
consented_at=datetime.now(timezone.utc)
)
db.add(consent)
await db.flush()
history = ConsentHistory(
user_id=user_id,
consent_id=consent.id,
action="granted",
consent_snapshot=consent_data.dict(),
ip_address=ip_address,
user_agent=user_agent,
consent_method=consent_data.consent_method,
created_at=datetime.now(timezone.utc)
)
db.add(history)
await db.commit()
await db.refresh(consent)
logger.info(
"consent_recorded",
user_id=str(user_id),
consent_version=consent_data.consent_version,
method=consent_data.consent_method
)
return ConsentResponse(
id=str(consent.id),
user_id=str(consent.user_id),
terms_accepted=consent.terms_accepted,
privacy_accepted=consent.privacy_accepted,
marketing_consent=consent.marketing_consent,
analytics_consent=consent.analytics_consent,
consent_version=consent.consent_version,
consent_method=consent.consent_method,
consented_at=consent.consented_at.isoformat(),
withdrawn_at=consent.withdrawn_at.isoformat() if consent.withdrawn_at else None
)
except Exception as e:
await db.rollback()
logger.error("error_recording_consent", error=str(e), user_id=current_user.get("sub"))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to record consent"
)
@router.get("/consent/current", response_model=Optional[ConsentResponse])
async def get_current_consent(
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get current active consent for user
"""
try:
user_id = UUID(current_user["sub"])
query = select(UserConsent).where(
and_(
UserConsent.user_id == user_id,
UserConsent.withdrawn_at.is_(None)
)
).order_by(UserConsent.consented_at.desc())
result = await db.execute(query)
consent = result.scalar_one_or_none()
if not consent:
return None
return ConsentResponse(
id=str(consent.id),
user_id=str(consent.user_id),
terms_accepted=consent.terms_accepted,
privacy_accepted=consent.privacy_accepted,
marketing_consent=consent.marketing_consent,
analytics_consent=consent.analytics_consent,
consent_version=consent.consent_version,
consent_method=consent.consent_method,
consented_at=consent.consented_at.isoformat(),
withdrawn_at=consent.withdrawn_at.isoformat() if consent.withdrawn_at else None
)
except Exception as e:
logger.error("error_getting_consent", error=str(e), user_id=current_user.get("sub"))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve consent"
)
@router.get("/consent/history", response_model=List[ConsentHistoryResponse])
async def get_consent_history(
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get complete consent history for user
GDPR Article 7(1) - Demonstrating consent
"""
try:
user_id = UUID(current_user["sub"])
query = select(ConsentHistory).where(
ConsentHistory.user_id == user_id
).order_by(ConsentHistory.created_at.desc())
result = await db.execute(query)
history = result.scalars().all()
return [
ConsentHistoryResponse(
id=str(h.id),
user_id=str(h.user_id),
action=h.action,
consent_snapshot=h.consent_snapshot,
created_at=h.created_at.isoformat()
)
for h in history
]
except Exception as e:
logger.error("error_getting_consent_history", error=str(e), user_id=current_user.get("sub"))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve consent history"
)
@router.put("/consent", response_model=ConsentResponse)
async def update_consent(
consent_data: ConsentRequest,
request: Request,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Update user consent preferences
GDPR Article 7(3) - Withdrawal of consent
"""
try:
user_id = UUID(current_user["sub"])
query = select(UserConsent).where(
and_(
UserConsent.user_id == user_id,
UserConsent.withdrawn_at.is_(None)
)
).order_by(UserConsent.consented_at.desc())
result = await db.execute(query)
current_consent = result.scalar_one_or_none()
if current_consent:
current_consent.withdrawn_at = datetime.now(timezone.utc)
history = ConsentHistory(
user_id=user_id,
consent_id=current_consent.id,
action="updated",
consent_snapshot=current_consent.to_dict(),
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
consent_method=consent_data.consent_method,
created_at=datetime.now(timezone.utc)
)
db.add(history)
new_consent = UserConsent(
user_id=user_id,
terms_accepted=consent_data.terms_accepted,
privacy_accepted=consent_data.privacy_accepted,
marketing_consent=consent_data.marketing_consent,
analytics_consent=consent_data.analytics_consent,
consent_version=consent_data.consent_version,
consent_method=consent_data.consent_method,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
consented_at=datetime.now(timezone.utc)
)
db.add(new_consent)
await db.flush()
history = ConsentHistory(
user_id=user_id,
consent_id=new_consent.id,
action="granted" if not current_consent else "updated",
consent_snapshot=consent_data.dict(),
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
consent_method=consent_data.consent_method,
created_at=datetime.now(timezone.utc)
)
db.add(history)
await db.commit()
await db.refresh(new_consent)
logger.info(
"consent_updated",
user_id=str(user_id),
consent_version=consent_data.consent_version
)
return ConsentResponse(
id=str(new_consent.id),
user_id=str(new_consent.user_id),
terms_accepted=new_consent.terms_accepted,
privacy_accepted=new_consent.privacy_accepted,
marketing_consent=new_consent.marketing_consent,
analytics_consent=new_consent.analytics_consent,
consent_version=new_consent.consent_version,
consent_method=new_consent.consent_method,
consented_at=new_consent.consented_at.isoformat(),
withdrawn_at=None
)
except Exception as e:
await db.rollback()
logger.error("error_updating_consent", error=str(e), user_id=current_user.get("sub"))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update consent"
)
@router.post("/consent/withdraw", status_code=status.HTTP_200_OK)
async def withdraw_consent(
request: Request,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Withdraw all consent
GDPR Article 7(3) - Right to withdraw consent
"""
try:
user_id = UUID(current_user["sub"])
query = select(UserConsent).where(
and_(
UserConsent.user_id == user_id,
UserConsent.withdrawn_at.is_(None)
)
)
result = await db.execute(query)
consents = result.scalars().all()
for consent in consents:
consent.withdrawn_at = datetime.now(timezone.utc)
history = ConsentHistory(
user_id=user_id,
consent_id=consent.id,
action="withdrawn",
consent_snapshot=consent.to_dict(),
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
consent_method="user_withdrawal",
created_at=datetime.now(timezone.utc)
)
db.add(history)
await db.commit()
logger.info("consent_withdrawn", user_id=str(user_id), count=len(consents))
return {
"message": "Consent withdrawn successfully",
"withdrawn_count": len(consents)
}
except Exception as e:
await db.rollback()
logger.error("error_withdrawing_consent", error=str(e), user_id=current_user.get("sub"))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to withdraw consent"
)