Improve GDPR implementation
This commit is contained in:
216
services/auth/app/api/account_deletion.py
Normal file
216
services/auth/app/api/account_deletion.py
Normal 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"
|
||||
)
|
||||
372
services/auth/app/api/consent.py
Normal file
372
services/auth/app/api/consent.py
Normal 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"
|
||||
)
|
||||
123
services/auth/app/api/data_export.py
Normal file
123
services/auth/app/api/data_export.py
Normal 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"
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
110
services/auth/app/models/consent.py
Normal file
110
services/auth/app/models/consent.py
Normal 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,
|
||||
}
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
187
services/auth/app/services/data_export_service.py
Normal file
187
services/auth/app/services/data_export_service.py
Normal 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"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"""add_gdpr_consent_tables
|
||||
|
||||
Revision ID: 510cf1184e0b
|
||||
Revises: 13327ad46a4d
|
||||
Create Date: 2025-10-15 21:55:40.584671+02:00
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '510cf1184e0b'
|
||||
down_revision: Union[str, None] = '13327ad46a4d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('user_consents',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('terms_accepted', sa.Boolean(), nullable=False),
|
||||
sa.Column('privacy_accepted', sa.Boolean(), nullable=False),
|
||||
sa.Column('marketing_consent', sa.Boolean(), nullable=False),
|
||||
sa.Column('analytics_consent', sa.Boolean(), nullable=False),
|
||||
sa.Column('consent_version', sa.String(length=20), nullable=False),
|
||||
sa.Column('consent_method', sa.String(length=50), nullable=False),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', sa.Text(), nullable=True),
|
||||
sa.Column('terms_text_hash', sa.String(length=64), nullable=True),
|
||||
sa.Column('privacy_text_hash', sa.String(length=64), nullable=True),
|
||||
sa.Column('consented_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('withdrawn_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('extra_data', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_user_consent_consented_at', 'user_consents', ['consented_at'], unique=False)
|
||||
op.create_index('idx_user_consent_user_id', 'user_consents', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_user_consents_user_id'), 'user_consents', ['user_id'], unique=False)
|
||||
op.create_table('consent_history',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('consent_id', sa.UUID(), nullable=True),
|
||||
sa.Column('action', sa.String(length=50), nullable=False),
|
||||
sa.Column('consent_snapshot', postgresql.JSON(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', sa.Text(), nullable=True),
|
||||
sa.Column('consent_method', sa.String(length=50), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['consent_id'], ['user_consents.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_consent_history_action', 'consent_history', ['action'], unique=False)
|
||||
op.create_index('idx_consent_history_created_at', 'consent_history', ['created_at'], unique=False)
|
||||
op.create_index('idx_consent_history_user_id', 'consent_history', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_consent_history_created_at'), 'consent_history', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_consent_history_user_id'), 'consent_history', ['user_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_consent_history_user_id'), table_name='consent_history')
|
||||
op.drop_index(op.f('ix_consent_history_created_at'), table_name='consent_history')
|
||||
op.drop_index('idx_consent_history_user_id', table_name='consent_history')
|
||||
op.drop_index('idx_consent_history_created_at', table_name='consent_history')
|
||||
op.drop_index('idx_consent_history_action', table_name='consent_history')
|
||||
op.drop_table('consent_history')
|
||||
op.drop_index(op.f('ix_user_consents_user_id'), table_name='user_consents')
|
||||
op.drop_index('idx_user_consent_user_id', table_name='user_consents')
|
||||
op.drop_index('idx_user_consent_consented_at', table_name='user_consents')
|
||||
op.drop_table('user_consents')
|
||||
# ### end Alembic commands ###
|
||||
Reference in New Issue
Block a user