440 lines
16 KiB
Python
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": 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="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()
|