From 939b080cc132d957f8d9b6fd8f96f8a227470e66 Mon Sep 17 00:00:00 2001 From: klawgulp-ship-it Date: Wed, 11 Mar 2026 00:30:35 -0500 Subject: [PATCH 1/3] feat: implement GDPR PII export and irreversible deletion workflow with audit trail --- backend/app/models/user.py | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 backend/app/models/user.py diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..a9bd726 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,44 @@ +from datetime import datetime +from sqlalchemy import Column, Integer, String, DateTime, Boolean +from sqlalchemy.orm import relationship +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + username = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + full_name = Column(String, nullable=True) + phone = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + is_active = Column(Boolean, default=True) + is_deleted = Column(Boolean, default=False) + deleted_at = Column(DateTime, nullable=True) + + expenses = relationship("Expense", back_populates="user", cascade="all, delete-orphan") + bills = relationship("Bill", back_populates="user", cascade="all, delete-orphan") + reminders = relationship("Reminder", back_populates="user", cascade="all, delete-orphan") + categories = relationship("Category", back_populates="user", cascade="all, delete-orphan") + audit_logs = relationship("AuditLog", back_populates="user") + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, nullable=True) + user_email = Column(String, nullable=True) + action = Column(String, nullable=False) + entity = Column(String, nullable=True) + entity_id = Column(Integer, nullable=True) + metadata = Column(String, nullable=True) + ip_address = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + from sqlalchemy import ForeignKey + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + user = relationship("User", back_populates="audit_logs") From d6abac55870b2934e14e84a9862de24912115da7 Mon Sep 17 00:00:00 2001 From: klawgulp-ship-it Date: Wed, 11 Mar 2026 00:30:35 -0500 Subject: [PATCH 2/3] feat: implement GDPR PII export and irreversible deletion workflow with audit trail --- backend/app/services/audit_logger.py | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/app/services/audit_logger.py diff --git a/backend/app/services/audit_logger.py b/backend/app/services/audit_logger.py new file mode 100644 index 0000000..4b0c8ea --- /dev/null +++ b/backend/app/services/audit_logger.py @@ -0,0 +1,31 @@ +import json +from datetime import datetime +from typing import Optional +from sqlalchemy.orm import Session +from app.models.user import AuditLog + + +def log_action( + db: Session, + action: str, + user_id: Optional[int] = None, + user_email: Optional[str] = None, + entity: Optional[str] = None, + entity_id: Optional[int] = None, + metadata: Optional[dict] = None, + ip_address: Optional[str] = None, +) -> AuditLog: + entry = AuditLog( + user_id=user_id, + user_email=user_email, + action=action, + entity=entity, + entity_id=entity_id, + metadata=json.dumps(metadata) if metadata else None, + ip_address=ip_address, + created_at=datetime.utcnow(), + ) + db.add(entry) + db.commit() + db.refresh(entry) + return entry From 84122af02310e7ce00366d53ffcae470073cba08 Mon Sep 17 00:00:00 2001 From: klawgulp-ship-it Date: Wed, 11 Mar 2026 00:30:36 -0500 Subject: [PATCH 3/3] feat: implement GDPR PII export and irreversible deletion workflow with audit trail --- backend/app/routes/user.py | 136 +++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 backend/app/routes/user.py diff --git a/backend/app/routes/user.py b/backend/app/routes/user.py new file mode 100644 index 0000000..7eee411 --- /dev/null +++ b/backend/app/routes/user.py @@ -0,0 +1,136 @@ +import io +import json +import zipfile +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.user import User +from app.services.audit_logger import log_action +from app.dependencies import get_current_user + +router = APIRouter(prefix="/users", tags=["users"]) + + +def _serialize_row(obj) -> dict: + result = {} + for col in obj.__table__.columns: + val = getattr(obj, col.name) + if isinstance(val, datetime): + val = val.isoformat() + result[col.name] = val + return result + + +@router.get("/me/export", summary="Export all personal data (GDPR)") +def export_user_data( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + user_data = _serialize_row(current_user) + user_data.pop("hashed_password", None) + + expenses = [ + _serialize_row(e) + for e in getattr(current_user, "expenses", []) + ] + bills = [ + _serialize_row(b) + for b in getattr(current_user, "bills", []) + ] + reminders = [ + _serialize_row(r) + for r in getattr(current_user, "reminders", []) + ] + categories = [ + _serialize_row(c) + for c in getattr(current_user, "categories", []) + ] + + package = { + "exported_at": datetime.utcnow().isoformat(), + "user": user_data, + "expenses": expenses, + "bills": bills, + "reminders": reminders, + "categories": categories, + } + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr("personal_data.json", json.dumps(package, indent=2)) + zip_buffer.seek(0) + + log_action( + db=db, + action="PII_EXPORT", + user_id=current_user.id, + user_email=current_user.email, + entity="user", + entity_id=current_user.id, + metadata={"exported_at": datetime.utcnow().isoformat()}, + ip_address=request.client.host if request.client else None, + ) + + filename = f"personal_data_{current_user.id}_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}.zip" + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.delete( + "/me", + status_code=status.HTTP_200_OK, + summary="Permanently delete account and all personal data (GDPR)", +) +def delete_user_account( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + user_id = current_user.id + user_email = current_user.email + ip = request.client.host if request.client else None + + for expense in list(getattr(current_user, "expenses", [])): + db.delete(expense) + + for bill in list(getattr(current_user, "bills", [])): + db.delete(bill) + + for reminder in list(getattr(current_user, "reminders", [])): + db.delete(reminder) + + for category in list(getattr(current_user, "categories", [])): + db.delete(category) + + db.delete(current_user) + db.flush() + + log_action( + db=db, + action="PII_DELETE", + user_id=None, + user_email=user_email, + entity="user", + entity_id=user_id, + metadata={ + "deleted_at": datetime.utcnow().isoformat(), + "deleted_user_id": user_id, + "deleted_user_email": user_email, + }, + ip_address=ip, + ) + + db.commit() + + return { + "detail": "Account and all associated personal data have been permanently deleted." + }