Improve GDPR implementation
This commit is contained in:
372
services/auth/app/api/consent.py
Normal file
372
services/auth/app/api/consent.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user