gateway/modules/routes/routeGdpr.py
2026-02-08 14:26:01 +01:00

440 lines
16 KiB
Python

# 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
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": um.createdAt
})
# 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": inv.mandateId,
"createdAt": inv.createdAt,
"expiresAt": inv.expiresAt,
"maxUses": inv.maxUses,
"currentUses": inv.currentUses
}
for inv in invitationsCreated
]
# Invitations used by user using interface method
invitationsUsed = rootInterface.getInvitationsByUsedBy(str(currentUser.id))
invitationsUsedList = [
{
"id": inv.id,
"mandateId": inv.mandateId,
"usedAt": inv.usedAt
}
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="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="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()