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

View File

@@ -6,7 +6,7 @@ from fastapi import FastAPI
from sqlalchemy import text
from app.core.config import settings
from app.core.database import database_manager
from app.api import auth_operations, users, onboarding_progress
from app.api import auth_operations, users, onboarding_progress, consent, data_export, account_deletion
from app.services.messaging import setup_messaging, cleanup_messaging
from shared.service_base import StandardFastAPIService
@@ -50,7 +50,8 @@ class AuthService(StandardFastAPIService):
# Define expected database tables for health checks
auth_expected_tables = [
'users', 'refresh_tokens', 'user_onboarding_progress',
'user_onboarding_summary', 'login_attempts'
'user_onboarding_summary', 'login_attempts', 'user_consents',
'consent_history', 'audit_logs'
]
# Define custom metrics for auth service
@@ -152,3 +153,6 @@ service.setup_standard_endpoints()
service.add_router(auth_operations.router, tags=["authentication"])
service.add_router(users.router, tags=["users"])
service.add_router(onboarding_progress.router, tags=["onboarding"])
service.add_router(consent.router, tags=["gdpr", "consent"])
service.add_router(data_export.router, tags=["gdpr", "data-export"])
service.add_router(account_deletion.router, tags=["gdpr", "account-deletion"])

View File

@@ -13,6 +13,7 @@ AuditLog = create_audit_log_model(Base)
from .users import User
from .tokens import RefreshToken, LoginAttempt
from .onboarding import UserOnboardingProgress, UserOnboardingSummary
from .consent import UserConsent, ConsentHistory
__all__ = [
'User',
@@ -20,5 +21,7 @@ __all__ = [
'LoginAttempt',
'UserOnboardingProgress',
'UserOnboardingSummary',
'UserConsent',
'ConsentHistory',
"AuditLog",
]

View File

@@ -0,0 +1,110 @@
"""
User consent tracking models for GDPR compliance
"""
from sqlalchemy import Column, String, Boolean, DateTime, Text, ForeignKey, Index
from sqlalchemy.dialects.postgresql import UUID, JSON
from datetime import datetime, timezone
import uuid
from shared.database.base import Base
class UserConsent(Base):
"""
Tracks user consent for various data processing activities
GDPR Article 7 - Conditions for consent
"""
__tablename__ = "user_consents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
# Consent types
terms_accepted = Column(Boolean, nullable=False, default=False)
privacy_accepted = Column(Boolean, nullable=False, default=False)
marketing_consent = Column(Boolean, nullable=False, default=False)
analytics_consent = Column(Boolean, nullable=False, default=False)
# Consent metadata
consent_version = Column(String(20), nullable=False, default="1.0")
consent_method = Column(String(50), nullable=False) # registration, settings_update, cookie_banner
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
# Consent text at time of acceptance
terms_text_hash = Column(String(64), nullable=True)
privacy_text_hash = Column(String(64), nullable=True)
# Timestamps
consented_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
withdrawn_at = Column(DateTime(timezone=True), nullable=True)
# Additional metadata (renamed from 'metadata' to avoid SQLAlchemy reserved word)
extra_data = Column(JSON, nullable=True)
__table_args__ = (
Index('idx_user_consent_user_id', 'user_id'),
Index('idx_user_consent_consented_at', 'consented_at'),
)
def __repr__(self):
return f"<UserConsent(user_id={self.user_id}, version={self.consent_version})>"
def to_dict(self):
return {
"id": str(self.id),
"user_id": str(self.user_id),
"terms_accepted": self.terms_accepted,
"privacy_accepted": self.privacy_accepted,
"marketing_consent": self.marketing_consent,
"analytics_consent": self.analytics_consent,
"consent_version": self.consent_version,
"consent_method": self.consent_method,
"consented_at": self.consented_at.isoformat() if self.consented_at else None,
"withdrawn_at": self.withdrawn_at.isoformat() if self.withdrawn_at else None,
}
class ConsentHistory(Base):
"""
Historical record of all consent changes
Provides audit trail for GDPR compliance
"""
__tablename__ = "consent_history"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
consent_id = Column(UUID(as_uuid=True), ForeignKey("user_consents.id", ondelete="SET NULL"), nullable=True)
# Action type
action = Column(String(50), nullable=False) # granted, updated, withdrawn, revoked
# Consent state at time of action
consent_snapshot = Column(JSON, nullable=False)
# Context
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
consent_method = Column(String(50), nullable=True)
# Timestamp
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), index=True)
__table_args__ = (
Index('idx_consent_history_user_id', 'user_id'),
Index('idx_consent_history_created_at', 'created_at'),
Index('idx_consent_history_action', 'action'),
)
def __repr__(self):
return f"<ConsentHistory(user_id={self.user_id}, action={self.action})>"
def to_dict(self):
return {
"id": str(self.id),
"user_id": str(self.user_id),
"action": self.action,
"consent_snapshot": self.consent_snapshot,
"created_at": self.created_at.isoformat() if self.created_at else None,
}

View File

@@ -22,6 +22,11 @@ class UserRegistration(BaseModel):
subscription_plan: Optional[str] = Field("starter", description="Selected subscription plan (starter, professional, enterprise)")
use_trial: Optional[bool] = Field(False, description="Whether to use trial period")
payment_method_id: Optional[str] = Field(None, description="Stripe payment method ID")
# GDPR Consent fields
terms_accepted: Optional[bool] = Field(True, description="Accept terms of service")
privacy_accepted: Optional[bool] = Field(True, description="Accept privacy policy")
marketing_consent: Optional[bool] = Field(False, description="Consent to marketing communications")
analytics_consent: Optional[bool] = Field(False, description="Consent to analytics cookies")
class UserLogin(BaseModel):
"""User login request"""

View File

@@ -109,6 +109,64 @@ class EnhancedAuthService:
await token_repo.create_token(token_data)
# Record GDPR consent if provided
if (user_data.terms_accepted or user_data.privacy_accepted or
user_data.marketing_consent or user_data.analytics_consent):
try:
from app.models.consent import UserConsent, ConsentHistory
ip_address = None # Would need to pass from request context
user_agent = None # Would need to pass from request context
consent = UserConsent(
user_id=new_user.id,
terms_accepted=user_data.terms_accepted if user_data.terms_accepted is not None else True,
privacy_accepted=user_data.privacy_accepted if user_data.privacy_accepted is not None else True,
marketing_consent=user_data.marketing_consent if user_data.marketing_consent is not None else False,
analytics_consent=user_data.analytics_consent if user_data.analytics_consent is not None else False,
consent_version="1.0",
consent_method="registration",
ip_address=ip_address,
user_agent=user_agent,
consented_at=datetime.now(timezone.utc)
)
db_session.add(consent)
await db_session.flush()
# Create consent history entry
history = ConsentHistory(
user_id=new_user.id,
consent_id=consent.id,
action="granted",
consent_snapshot={
"terms_accepted": consent.terms_accepted,
"privacy_accepted": consent.privacy_accepted,
"marketing_consent": consent.marketing_consent,
"analytics_consent": consent.analytics_consent,
"consent_version": "1.0",
"consent_method": "registration"
},
ip_address=ip_address,
user_agent=user_agent,
consent_method="registration",
created_at=datetime.now(timezone.utc)
)
db_session.add(history)
logger.info("User consent recorded during registration",
user_id=new_user.id,
terms_accepted=consent.terms_accepted,
privacy_accepted=consent.privacy_accepted,
marketing_consent=consent.marketing_consent,
analytics_consent=consent.analytics_consent)
except Exception as e:
logger.error("Failed to record user consent during registration",
user_id=new_user.id,
error=str(e))
# Re-raise to ensure registration fails if consent can't be recorded
raise
# Store subscription plan selection in onboarding progress BEFORE committing
# This ensures it's part of the same transaction
if user_data.subscription_plan or user_data.use_trial or user_data.payment_method_id:
@@ -146,7 +204,7 @@ class EnhancedAuthService:
# Re-raise to ensure registration fails if onboarding data can't be saved
raise
# Commit transaction (includes user, tokens, and onboarding data)
# Commit transaction (includes user, tokens, consent, and onboarding data)
await uow.commit()
# Publish registration event (non-blocking)

View File

@@ -0,0 +1,187 @@
"""
User data export service for GDPR compliance
Implements Article 15 (Right to Access) and Article 20 (Right to Data Portability)
"""
from typing import Dict, Any, List
from uuid import UUID
from datetime import datetime, timezone
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.users import User
from app.models.tokens import RefreshToken, LoginAttempt
from app.models.consent import UserConsent, ConsentHistory
from app.models.onboarding import UserOnboardingProgress
from app.models import AuditLog
logger = structlog.get_logger()
class DataExportService:
"""Service to export all user data in machine-readable format"""
def __init__(self, db: AsyncSession):
self.db = db
async def export_user_data(self, user_id: UUID) -> Dict[str, Any]:
"""
Export all user data from auth service
Returns data in structured JSON format
"""
try:
export_data = {
"export_metadata": {
"user_id": str(user_id),
"export_date": datetime.now(timezone.utc).isoformat(),
"data_controller": "Panadería IA",
"format_version": "1.0",
"gdpr_article": "Article 15 (Right to Access) & Article 20 (Data Portability)"
},
"personal_data": await self._export_personal_data(user_id),
"account_data": await self._export_account_data(user_id),
"consent_data": await self._export_consent_data(user_id),
"security_data": await self._export_security_data(user_id),
"onboarding_data": await self._export_onboarding_data(user_id),
"audit_logs": await self._export_audit_logs(user_id)
}
logger.info("data_export_completed", user_id=str(user_id))
return export_data
except Exception as e:
logger.error("data_export_failed", user_id=str(user_id), error=str(e))
raise
async def _export_personal_data(self, user_id: UUID) -> Dict[str, Any]:
"""Export personal identifiable information"""
query = select(User).where(User.id == user_id)
result = await self.db.execute(query)
user = result.scalar_one_or_none()
if not user:
return {}
return {
"user_id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"phone": user.phone,
"language": user.language,
"timezone": user.timezone,
"is_active": user.is_active,
"is_verified": user.is_verified,
"role": user.role,
"created_at": user.created_at.isoformat() if user.created_at else None,
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
"last_login": user.last_login.isoformat() if user.last_login else None
}
async def _export_account_data(self, user_id: UUID) -> Dict[str, Any]:
"""Export account-related data"""
query = select(RefreshToken).where(RefreshToken.user_id == user_id)
result = await self.db.execute(query)
tokens = result.scalars().all()
active_sessions = []
for token in tokens:
if token.expires_at > datetime.now(timezone.utc) and not token.revoked:
active_sessions.append({
"token_id": str(token.id),
"created_at": token.created_at.isoformat() if token.created_at else None,
"expires_at": token.expires_at.isoformat() if token.expires_at else None,
"device_info": token.device_info
})
return {
"active_sessions_count": len(active_sessions),
"active_sessions": active_sessions,
"total_tokens_issued": len(tokens)
}
async def _export_consent_data(self, user_id: UUID) -> Dict[str, Any]:
"""Export consent history"""
consent_query = select(UserConsent).where(UserConsent.user_id == user_id)
consent_result = await self.db.execute(consent_query)
consents = consent_result.scalars().all()
history_query = select(ConsentHistory).where(ConsentHistory.user_id == user_id)
history_result = await self.db.execute(history_query)
history = history_result.scalars().all()
return {
"current_consent": consents[0].to_dict() if consents else None,
"consent_history": [h.to_dict() for h in history],
"total_consent_changes": len(history)
}
async def _export_security_data(self, user_id: UUID) -> Dict[str, Any]:
"""Export security-related data"""
query = select(LoginAttempt).where(
LoginAttempt.user_id == user_id
).order_by(LoginAttempt.attempted_at.desc()).limit(50)
result = await self.db.execute(query)
attempts = result.scalars().all()
login_attempts = []
for attempt in attempts:
login_attempts.append({
"attempted_at": attempt.attempted_at.isoformat() if attempt.attempted_at else None,
"success": attempt.success,
"ip_address": attempt.ip_address,
"user_agent": attempt.user_agent,
"failure_reason": attempt.failure_reason
})
return {
"recent_login_attempts": login_attempts,
"total_attempts_exported": len(login_attempts),
"note": "Only last 50 login attempts included for data minimization"
}
async def _export_onboarding_data(self, user_id: UUID) -> Dict[str, Any]:
"""Export onboarding progress"""
query = select(UserOnboardingProgress).where(UserOnboardingProgress.user_id == user_id)
result = await self.db.execute(query)
progress = result.scalars().all()
return {
"onboarding_steps": [
{
"step_id": str(p.id),
"step_name": p.step_name,
"completed": p.completed,
"completed_at": p.completed_at.isoformat() if p.completed_at else None
}
for p in progress
]
}
async def _export_audit_logs(self, user_id: UUID) -> Dict[str, Any]:
"""Export audit logs related to user"""
query = select(AuditLog).where(
AuditLog.user_id == user_id
).order_by(AuditLog.created_at.desc()).limit(100)
result = await self.db.execute(query)
logs = result.scalars().all()
return {
"audit_trail": [
{
"log_id": str(log.id),
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"severity": log.severity,
"description": log.description,
"ip_address": log.ip_address,
"created_at": log.created_at.isoformat() if log.created_at else None
}
for log in logs
],
"total_logs_exported": len(logs),
"note": "Only last 100 audit logs included for data minimization"
}