217 lines
8.1 KiB
Python
217 lines
8.1 KiB
Python
|
|
"""
|
||
|
|
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"
|
||
|
|
)
|