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,216 @@
"""
User self-service account deletion API for GDPR compliance
Implements Article 17 (Right to erasure / "Right to be forgotten")
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Request, BackgroundTasks
from pydantic import BaseModel, Field
from datetime import datetime, timezone
import structlog
from shared.auth.decorators import get_current_user_dep
from shared.routing import RouteBuilder
from app.core.database import get_db
from app.services.admin_delete import AdminUserDeleteService
from app.models.users import User
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import httpx
logger = structlog.get_logger()
router = APIRouter()
route_builder = RouteBuilder('auth')
class AccountDeletionRequest(BaseModel):
"""Request model for account deletion"""
confirm_email: str = Field(..., description="User's email for confirmation")
reason: str = Field(default="", description="Optional reason for deletion")
password: str = Field(..., description="User's password for verification")
class DeletionScheduleResponse(BaseModel):
"""Response for scheduled deletion"""
message: str
user_id: str
scheduled_deletion_date: str
grace_period_days: int = 30
@router.post("/api/v1/users/me/delete/request")
async def request_account_deletion(
deletion_request: AccountDeletionRequest,
request: Request,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Request account deletion (self-service)
GDPR Article 17 - Right to erasure ("right to be forgotten")
This initiates account deletion with a 30-day grace period.
During this period:
- Account is marked for deletion
- User can still log in and cancel deletion
- After 30 days, account is permanently deleted
Requires:
- Email confirmation matching logged-in user
- Current password verification
"""
try:
user_id = UUID(current_user["sub"])
user_email = current_user.get("email")
if deletion_request.confirm_email.lower() != user_email.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email confirmation does not match your account email"
)
query = select(User).where(User.id == user_id)
result = await db.execute(query)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
from app.core.security import SecurityManager
if not SecurityManager.verify_password(deletion_request.password, user.hashed_password):
logger.warning(
"account_deletion_invalid_password",
user_id=str(user_id),
ip_address=request.client.host if request.client else None
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid password"
)
logger.info(
"account_deletion_requested",
user_id=str(user_id),
email=user_email,
reason=deletion_request.reason[:100] if deletion_request.reason else None,
ip_address=request.client.host if request.client else None
)
tenant_id = current_user.get("tenant_id")
if tenant_id:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
cancel_response = await client.get(
f"http://tenant-service:8000/api/v1/subscriptions/{tenant_id}/status",
headers={"Authorization": request.headers.get("Authorization")}
)
if cancel_response.status_code == 200:
subscription_data = cancel_response.json()
if subscription_data.get("status") in ["active", "pending_cancellation"]:
cancel_sub_response = await client.delete(
f"http://tenant-service:8000/api/v1/tenants/{tenant_id}/subscription",
headers={"Authorization": request.headers.get("Authorization")}
)
logger.info(
"subscription_cancelled_before_deletion",
user_id=str(user_id),
tenant_id=tenant_id,
subscription_status=subscription_data.get("status")
)
except Exception as e:
logger.warning(
"subscription_cancellation_failed_during_account_deletion",
user_id=str(user_id),
error=str(e)
)
deletion_service = AdminUserDeleteService(db)
result = await deletion_service.delete_admin_user_complete(
user_id=str(user_id),
requesting_user_id=str(user_id)
)
return {
"message": "Account deleted successfully",
"user_id": str(user_id),
"deletion_date": datetime.now(timezone.utc).isoformat(),
"data_retained": "Audit logs will be anonymized after legal retention period (1 year)",
"gdpr_article": "Article 17 - Right to erasure"
}
except HTTPException:
raise
except Exception as e:
logger.error(
"account_deletion_failed",
user_id=current_user.get("sub"),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to process account deletion request"
)
@router.get("/api/v1/users/me/delete/info")
async def get_deletion_info(
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get information about what will be deleted
Shows user exactly what data will be deleted when they request
account deletion. Transparency requirement under GDPR.
"""
try:
user_id = UUID(current_user["sub"])
deletion_service = AdminUserDeleteService(db)
preview = await deletion_service.preview_user_deletion(str(user_id))
return {
"user_info": preview.get("user"),
"what_will_be_deleted": {
"account_data": "Your account, email, name, and profile information",
"sessions": "All active sessions and refresh tokens",
"consents": "Your consent history and preferences",
"security_data": "Login history and security logs",
"tenant_data": preview.get("tenant_associations"),
"estimated_records": preview.get("estimated_deletions")
},
"what_will_be_retained": {
"audit_logs": "Anonymized for 1 year (legal requirement)",
"financial_records": "Anonymized for 7 years (tax law)",
"anonymized_analytics": "Aggregated data without personal identifiers"
},
"process": {
"immediate_deletion": True,
"grace_period": "No grace period - deletion is immediate",
"reversible": False,
"completion_time": "Immediate"
},
"gdpr_rights": {
"article_17": "Right to erasure (right to be forgotten)",
"article_5_1_e": "Storage limitation principle",
"exceptions": "Data required for legal obligations will be retained in anonymized form"
},
"warning": "⚠️ This action is irreversible. All your data will be permanently deleted."
}
except Exception as e:
logger.error(
"deletion_info_failed",
user_id=current_user.get("sub"),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve deletion information"
)

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"
)

View File

@@ -0,0 +1,123 @@
"""
User data export API endpoints for GDPR compliance
Implements Article 15 (Right to Access) and Article 20 (Right to Data Portability)
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
import structlog
from shared.auth.decorators import get_current_user_dep
from shared.routing import RouteBuilder
from app.core.database import get_db
from app.services.data_export_service import DataExportService
logger = structlog.get_logger()
router = APIRouter()
route_builder = RouteBuilder('auth')
@router.get("/api/v1/users/me/export")
async def export_my_data(
current_user: dict = Depends(get_current_user_dep),
db = Depends(get_db)
):
"""
Export all personal data for the current user
GDPR Article 15 - Right of access by the data subject
GDPR Article 20 - Right to data portability
Returns complete user data in machine-readable JSON format including:
- Personal information
- Account data
- Consent history
- Security logs
- Audit trail
Response is provided in JSON format for easy data portability.
"""
try:
user_id = UUID(current_user["sub"])
export_service = DataExportService(db)
data = await export_service.export_user_data(user_id)
logger.info(
"data_export_requested",
user_id=str(user_id),
email=current_user.get("email")
)
return JSONResponse(
content=data,
status_code=status.HTTP_200_OK,
headers={
"Content-Disposition": f'attachment; filename="user_data_export_{user_id}.json"',
"Content-Type": "application/json"
}
)
except Exception as e:
logger.error(
"data_export_failed",
user_id=current_user.get("sub"),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to export user data"
)
@router.get("/api/v1/users/me/export/summary")
async def get_export_summary(
current_user: dict = Depends(get_current_user_dep),
db = Depends(get_db)
):
"""
Get a summary of what data would be exported
Useful for showing users what data we have about them
before they request full export.
"""
try:
user_id = UUID(current_user["sub"])
export_service = DataExportService(db)
data = await export_service.export_user_data(user_id)
summary = {
"user_id": str(user_id),
"data_categories": {
"personal_data": bool(data.get("personal_data")),
"account_data": bool(data.get("account_data")),
"consent_data": bool(data.get("consent_data")),
"security_data": bool(data.get("security_data")),
"onboarding_data": bool(data.get("onboarding_data")),
"audit_logs": bool(data.get("audit_logs"))
},
"data_counts": {
"active_sessions": data.get("account_data", {}).get("active_sessions_count", 0),
"consent_changes": data.get("consent_data", {}).get("total_consent_changes", 0),
"login_attempts": len(data.get("security_data", {}).get("recent_login_attempts", [])),
"audit_logs": data.get("audit_logs", {}).get("total_logs_exported", 0)
},
"export_format": "JSON",
"gdpr_articles": ["Article 15 (Right to Access)", "Article 20 (Data Portability)"]
}
return summary
except Exception as e:
logger.error(
"export_summary_failed",
user_id=current_user.get("sub"),
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate export summary"
)