373 lines
12 KiB
Python
373 lines
12 KiB
Python
|
|
"""
|
||
|
|
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("/api/v1/auth/me/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["user_id"])
|
||
|
|
|
||
|
|
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("user_id"))
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
|
|
detail="Failed to record consent"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/api/v1/auth/me/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["user_id"])
|
||
|
|
|
||
|
|
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("user_id"))
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
|
|
detail="Failed to retrieve consent"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/api/v1/auth/me/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["user_id"])
|
||
|
|
|
||
|
|
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("user_id"))
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
|
|
detail="Failed to retrieve consent history"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.put("/api/v1/auth/me/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["user_id"])
|
||
|
|
|
||
|
|
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("user_id"))
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
|
|
detail="Failed to update consent"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/api/v1/auth/me/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["user_id"])
|
||
|
|
|
||
|
|
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("user_id"))
|
||
|
|
raise HTTPException(
|
||
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
|
|
detail="Failed to withdraw consent"
|
||
|
|
)
|