# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ GDPR compliance routes for the backend API. Implements data subject rights according to GDPR regulations. GDPR Articles implemented: - Article 15: Right of access (data export) - Article 16: Right to rectification (via existing update endpoints) - Article 17: Right to erasure (account deletion) - Article 20: Right to data portability (machine-readable export) """ from fastapi import APIRouter, HTTPException, Depends, Request from fastapi.responses import JSONResponse from typing import List, Dict, Any, Optional from fastapi import status import logging import json from pydantic import BaseModel, Field from modules.auth import limiter, getCurrentUser from modules.datamodels.datamodelUam import User, UserInDB, Mandate, UserConnection from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.timeUtils import getUtcTimestamp from modules.shared.auditLogger import audit_logger from modules.shared.gdprDeletion import deleteUserDataAcrossAllDatabases, buildDeletionSummary from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeGdpr") logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/user/me", tags=["GDPR"], responses={404: {"description": "Not found"}} ) # ============================================================================= # Response Models # ============================================================================= class DataExportResponse(BaseModel): """Response model for GDPR data export""" exportedAt: float userId: str userData: Dict[str, Any] mandates: List[Dict[str, Any]] featureAccesses: List[Dict[str, Any]] invitationsCreated: List[Dict[str, Any]] invitationsUsed: List[Dict[str, Any]] class DataPortabilityResponse(BaseModel): """Machine-readable data portability response (JSON-LD format)""" context: str = Field(alias="@context") type: str = Field(alias="@type") identifier: str exportDate: str data: Dict[str, Any] class DeletionResult(BaseModel): """Result of account deletion""" success: bool userId: str deletedAt: float deletedData: List[str] message: str # ============================================================================= # Article 15: Right of Access # ============================================================================= @router.get("/data-export", response_model=DataExportResponse) @limiter.limit("5/minute") def export_user_data( request: Request, currentUser: User = Depends(getCurrentUser) ) -> DataExportResponse: """ Export all personal data (GDPR Article 15). Returns all data associated with the authenticated user including: - User profile data - Mandate memberships - Feature access records - Invitations created and used Note: This exports Gateway-level data only. Feature-specific data (e.g., chat workflows, trustee contracts) should be exported via feature-specific endpoints. """ try: rootInterface = getRootInterface() # User data (exclude sensitive fields) userData = { "id": str(currentUser.id), "username": currentUser.username, "email": currentUser.email, "fullName": getattr(currentUser, "fullName", None), "enabled": currentUser.enabled, "isSysAdmin": getattr(currentUser, "isSysAdmin", False), "createdAt": getattr(currentUser, "createdAt", None), "updatedAt": getattr(currentUser, "updatedAt", None), "lastLogin": getattr(currentUser, "lastLogin", None), "language": getattr(currentUser, "language", None), "authenticationAuthority": str(getattr(currentUser, "authenticationAuthority", "")) } # Mandate memberships using interface method userMandates = rootInterface.getUserMandates(str(currentUser.id)) mandates = [] for um in userMandates: mandateId = um.mandateId # Get mandate details using interface method mandate = rootInterface.getMandate(mandateId) mandateName = mandate.name if mandate else "Unknown" # Get roles for this membership roleIds = rootInterface.getRoleIdsForUserMandate(um.id) mandates.append({ "userMandateId": um.id, "mandateId": mandateId, "mandateName": mandateName, "enabled": um.enabled, "roleIds": roleIds, "joinedAt": getattr(um, 'createdAt', None) }) # Feature access records using interface method featureAccesses = rootInterface.getFeatureAccessesForUser(str(currentUser.id)) featureAccessList = [] for fa in featureAccesses: instanceId = fa.featureInstanceId # Get instance details using interface method instance = rootInterface.getFeatureInstance(instanceId) roleIds = rootInterface.getRoleIdsForFeatureAccess(fa.id) featureAccessList.append({ "featureAccessId": fa.id, "featureInstanceId": instanceId, "featureCode": instance.featureCode if instance else None, "instanceLabel": instance.label if instance else None, "enabled": fa.enabled, "roleIds": roleIds }) # Invitations created by user using interface method invitationsCreated = rootInterface.getInvitationsByCreator(str(currentUser.id)) invitationsCreatedList = [ { "id": inv.id, "mandateId": getattr(inv, 'mandateId', None), "createdAt": getattr(inv, 'createdAt', None), "expiresAt": getattr(inv, 'expiresAt', None), "maxUses": getattr(inv, 'maxUses', None), "currentUses": getattr(inv, 'currentUses', None) } for inv in invitationsCreated ] # Invitations used by user using interface method invitationsUsed = rootInterface.getInvitationsByUsedBy(str(currentUser.id)) invitationsUsedList = [ { "id": inv.id, "mandateId": getattr(inv, 'mandateId', None), "usedAt": getattr(inv, 'usedAt', None) } for inv in invitationsUsed ] # Audit log - GDPR Article 15 data export audit_logger.logGdprEvent( userId=str(currentUser.id), mandateId="system", action="gdpr_data_export", details="User requested data export (GDPR Article 15 - Right of Access)", ipAddress=request.client.host if request.client else None ) logger.info(f"User {currentUser.id} exported personal data (GDPR Art. 15)") return DataExportResponse( exportedAt=getUtcTimestamp(), userId=str(currentUser.id), userData=userData, mandates=mandates, featureAccesses=featureAccessList, invitationsCreated=invitationsCreatedList, invitationsUsed=invitationsUsedList ) except Exception as e: logger.error(f"Error exporting user data: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to export data: {str(e)}" ) # ============================================================================= # Article 20: Right to Data Portability # ============================================================================= @router.get("/data-portability") @limiter.limit("5/minute") def export_portable_data( request: Request, currentUser: User = Depends(getCurrentUser) ) -> JSONResponse: """ Export data in portable, machine-readable format (GDPR Article 20). Returns data in JSON-LD format suitable for transfer to another service. This is a structured format that can be easily parsed by machines. """ try: # Get full export data first rootInterface = getRootInterface() # Build portable data structure portableData = { "@context": "https://schema.org", "@type": "Person", "identifier": str(currentUser.id), "name": getattr(currentUser, "fullName", None) or currentUser.username, "email": currentUser.email, "additionalProperty": [] } # Add mandate memberships as organization affiliations using interface method userMandates = rootInterface.getUserMandates(str(currentUser.id)) affiliations = [] for um in userMandates: mandate = rootInterface.getMandate(um.mandateId) if mandate: affiliations.append({ "@type": "Organization", "identifier": um.mandateId, "name": mandate.name, "membershipActive": um.enabled }) if affiliations: portableData["affiliation"] = affiliations # Wrap in export envelope exportEnvelope = { "@context": "https://schema.org", "@type": "DataDownload", "identifier": f"export-{currentUser.id}-{int(getUtcTimestamp())}", "dateCreated": _timestampToIso(getUtcTimestamp()), "encodingFormat": "application/ld+json", "about": portableData } # Audit log - GDPR Article 20 data portability audit_logger.logGdprEvent( userId=str(currentUser.id), mandateId="system", action="gdpr_data_portability", details="User requested portable data export (GDPR Article 20 - Right to Data Portability)", ipAddress=request.client.host if request.client else None ) logger.info(f"User {currentUser.id} exported portable data (GDPR Art. 20)") return JSONResponse( content=exportEnvelope, media_type="application/ld+json" ) except Exception as e: logger.error(f"Error exporting portable data: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to export data: {str(e)}" ) # ============================================================================= # Article 17: Right to Erasure # ============================================================================= @router.delete("/", response_model=DeletionResult) @limiter.limit("1/hour") def delete_account( request: Request, confirmDeletion: bool = False, currentUser: User = Depends(getCurrentUser) ) -> DeletionResult: """ Delete own account and all associated data (GDPR Article 17). IMPORTANT: This action is irreversible! - All user data will be permanently deleted - All mandate memberships will be removed - All feature accesses will be removed - All created invitations will be revoked Args: confirmDeletion: Must be True to confirm deletion """ if not confirmDeletion: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("Deletion not confirmed. Set confirmDeletion=true to proceed.") ) # Prevent SysAdmin self-deletion (safety measure) if getattr(currentUser, "isSysAdmin", False): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("SysAdmin accounts cannot be self-deleted. Contact another SysAdmin.") ) try: # Step 1: Audit log BEFORE deletion (audit needs userId) audit_logger.logGdprEvent( userId=str(currentUser.id), mandateId="system", action="gdpr_account_deletion_started", details=f"User initiated account deletion (GDPR Article 17 - Right to Erasure)", ipAddress=request.client.host if request.client else None ) # Step 2: Revoke invitations BEFORE generic deletion (business logic) rootInterface = getRootInterface() from modules.datamodels.datamodelInvitation import Invitation userInvitations = rootInterface.getInvitationsByCreator(str(currentUser.id)) for inv in userInvitations: rootInterface.db.recordModify( Invitation, inv.id, {"revokedAt": getUtcTimestamp()} ) logger.info(f"Revoked {len(userInvitations)} invitations for user {currentUser.id}") # Step 3: Generic deletion across ALL databases deletionStats = deleteUserDataAcrossAllDatabases(str(currentUser.id), currentUser) # Step 4: Delete the user account from UserInDB (authentication table) # This must be done AFTER all other deletions to maintain audit trail deletedAt = getUtcTimestamp() rootInterface.db.recordDelete(UserInDB, str(currentUser.id)) # Build summary for response deletedData = buildDeletionSummary(deletionStats) deletedData.insert(0, f"Invitations revoked: {len(userInvitations)}") deletedData.append("User account deleted from authentication system") logger.info(f"User {currentUser.id} deleted own account (GDPR Art. 17). " f"Stats: {deletionStats['totalRecordsDeleted']} deleted, " f"{deletionStats['totalRecordsAnonymized']} anonymized") return DeletionResult( success=True, userId=str(currentUser.id), deletedAt=deletedAt, deletedData=deletedData, message="Account and all associated data have been permanently deleted or anonymized." ) except HTTPException: raise except Exception as e: logger.error(f"Error deleting account: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete account: {str(e)}" ) # ============================================================================= # Consent Information Endpoint # ============================================================================= @router.get("/consent-info", response_model=Dict[str, Any]) @limiter.limit("30/minute") def get_consent_info( request: Request, currentUser: User = Depends(getCurrentUser) ) -> Dict[str, Any]: """ Get information about data processing and user rights (GDPR transparency). Returns information about: - What data is collected - How data is processed - User rights under GDPR - Contact information for data protection inquiries """ return { "dataCollected": { "profile": "Name, email, username, language preferences", "authentication": "Login timestamps, authentication provider", "memberships": "Mandate and feature access records", "activity": "Audit logs for security-relevant actions" }, "dataProcessing": { "purpose": "Providing multi-tenant platform services", "legalBasis": "Contract fulfillment and legitimate interest", "retention": "Data retained while account is active, deleted upon account deletion" }, "userRights": { "access": "GET /api/user/me/data-export (Article 15)", "portability": "GET /api/user/me/data-portability (Article 20)", "erasure": "DELETE /api/user/me (Article 17)", "rectification": "PUT /api/local/me (Article 16)" }, "contact": { "email": "privacy@example.com", "note": "For data protection inquiries, please contact us with your user ID" } } # ============================================================================= # Helper Functions # ============================================================================= def _timestampToIso(timestamp: float) -> str: """Convert Unix timestamp to ISO 8601 format""" from datetime import datetime, timezone dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) return dt.isoformat()