From 7e73af31bec1a62db90a932322b308c54304ca62 Mon Sep 17 00:00:00 2001 From: xppert9 Date: Tue, 10 Mar 2026 12:40:07 -0300 Subject: [PATCH] feat: add GDPR PII export, account deletion, and audit trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements three endpoints under /gdpr: - GET /gdpr/export — collects all user-linked data (profile, categories, expenses, recurring expenses, bills, reminders, subscriptions) into a structured JSON response. Excludes password_hash. Logs a PII_EXPORT audit event. - POST /gdpr/delete — permanently deletes the user account after password re-verification. Leverages existing ON DELETE CASCADE foreign keys to remove all associated records. Audit log entry survives via ON DELETE SET NULL, preserving compliance records after deletion. - GET /gdpr/audit — returns the authenticated user's audit trail sorted by most recent first. Includes 18 tests covering: auth guards, export completeness, password verification, cascade deletion, audit log persistence, and edge cases. Closes #76 --- packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/gdpr.py | 218 ++++++++++++++++++++++ packages/backend/tests/test_gdpr.py | 229 ++++++++++++++++++++++++ 3 files changed, 449 insertions(+) create mode 100644 packages/backend/app/routes/gdpr.py create mode 100644 packages/backend/tests/test_gdpr.py diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f8..288b981 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .gdpr import bp as gdpr_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(gdpr_bp, url_prefix="/gdpr") diff --git a/packages/backend/app/routes/gdpr.py b/packages/backend/app/routes/gdpr.py new file mode 100644 index 0000000..deef7f2 --- /dev/null +++ b/packages/backend/app/routes/gdpr.py @@ -0,0 +1,218 @@ +from flask import Blueprint, request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from werkzeug.security import check_password_hash +from ..extensions import db +from ..models import ( + User, + Category, + Expense, + RecurringExpense, + Bill, + Reminder, + UserSubscription, + AuditLog, +) +import logging + +bp = Blueprint("gdpr", __name__) +logger = logging.getLogger("finmind.gdpr") + + +def _serialize_date(val): + if val is None: + return None + return val.isoformat() + + +def _serialize_decimal(val): + if val is None: + return None + return float(val) + + +def _collect_user_data(user): + """Gather all PII-linked records for a user into a structured dict.""" + categories = ( + db.session.query(Category).filter_by(user_id=user.id).all() + ) + expenses = ( + db.session.query(Expense).filter_by(user_id=user.id).all() + ) + recurring = ( + db.session.query(RecurringExpense).filter_by(user_id=user.id).all() + ) + bills = db.session.query(Bill).filter_by(user_id=user.id).all() + reminders = ( + db.session.query(Reminder).filter_by(user_id=user.id).all() + ) + subscriptions = ( + db.session.query(UserSubscription).filter_by(user_id=user.id).all() + ) + + return { + "user": { + "id": user.id, + "email": user.email, + "preferred_currency": user.preferred_currency, + "role": user.role, + "created_at": _serialize_date(user.created_at), + }, + "categories": [ + { + "id": c.id, + "name": c.name, + "created_at": _serialize_date(c.created_at), + } + for c in categories + ], + "expenses": [ + { + "id": e.id, + "category_id": e.category_id, + "amount": _serialize_decimal(e.amount), + "currency": e.currency, + "expense_type": e.expense_type, + "notes": e.notes, + "spent_at": _serialize_date(e.spent_at), + "source_recurring_id": e.source_recurring_id, + "created_at": _serialize_date(e.created_at), + } + for e in expenses + ], + "recurring_expenses": [ + { + "id": r.id, + "category_id": r.category_id, + "amount": _serialize_decimal(r.amount), + "currency": r.currency, + "expense_type": r.expense_type, + "notes": r.notes, + "cadence": r.cadence.value if r.cadence else None, + "start_date": _serialize_date(r.start_date), + "end_date": _serialize_date(r.end_date), + "active": r.active, + "created_at": _serialize_date(r.created_at), + } + for r in recurring + ], + "bills": [ + { + "id": b.id, + "name": b.name, + "amount": _serialize_decimal(b.amount), + "currency": b.currency, + "next_due_date": _serialize_date(b.next_due_date), + "cadence": b.cadence.value if b.cadence else None, + "autopay_enabled": b.autopay_enabled, + "active": b.active, + "created_at": _serialize_date(b.created_at), + } + for b in bills + ], + "reminders": [ + { + "id": rm.id, + "bill_id": rm.bill_id, + "message": rm.message, + "send_at": _serialize_date(rm.send_at), + "sent": rm.sent, + "channel": rm.channel, + } + for rm in reminders + ], + "subscriptions": [ + { + "id": s.id, + "plan_id": s.plan_id, + "active": s.active, + "started_at": _serialize_date(s.started_at), + } + for s in subscriptions + ], + } + + +@bp.get("/export") +@jwt_required() +def export_pii(): + """Export all personal data for the authenticated user (GDPR Art. 20).""" + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="user not found"), 404 + + data = _collect_user_data(user) + + audit = AuditLog(user_id=uid, action="PII_EXPORT") + db.session.add(audit) + db.session.commit() + + logger.info("PII export completed for user_id=%s", uid) + return jsonify(data=data), 200 + + +@bp.post("/delete") +@jwt_required() +def delete_account(): + """Permanently delete user account and all associated data (GDPR Art. 17). + + Requires password confirmation in the request body. + """ + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="user not found"), 404 + + body = request.get_json() or {} + password = body.get("password") + if not password: + return jsonify(error="password required for account deletion"), 400 + + if not check_password_hash(user.password_hash, password): + logger.warning("Delete account failed: wrong password user_id=%s", uid) + return jsonify(error="invalid password"), 403 + + # Log the deletion event before removing the user. + # audit_logs.user_id uses ON DELETE SET NULL, so the log + # row survives user deletion with user_id becoming NULL. + audit = AuditLog(user_id=uid, action="ACCOUNT_DELETED") + db.session.add(audit) + db.session.flush() + + # Delete the user row. Cascading FKs handle related data: + # categories, expenses, recurring_expenses, bills, + # reminders, user_subscriptions -> ON DELETE CASCADE + # ad_impressions, audit_logs -> ON DELETE SET NULL + db.session.delete(user) + db.session.commit() + + logger.info("Account deleted for former user_id=%s", uid) + return jsonify(message="account and all associated data deleted"), 200 + + +@bp.get("/audit") +@jwt_required() +def audit_trail(): + """Retrieve audit log entries for the authenticated user.""" + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="user not found"), 404 + + logs = ( + db.session.query(AuditLog) + .filter_by(user_id=uid) + .order_by(AuditLog.created_at.desc()) + .all() + ) + + return jsonify( + audit_logs=[ + { + "id": log.id, + "action": log.action, + "created_at": _serialize_date(log.created_at), + } + for log in logs + ] + ), 200 diff --git a/packages/backend/tests/test_gdpr.py b/packages/backend/tests/test_gdpr.py new file mode 100644 index 0000000..2338b0e --- /dev/null +++ b/packages/backend/tests/test_gdpr.py @@ -0,0 +1,229 @@ +import pytest +from app.extensions import db +from app.models import ( + User, + Category, + Expense, + Bill, + BillCadence, + RecurringExpense, + RecurringCadence, + Reminder, + AuditLog, +) +from datetime import date, datetime + + +@pytest.fixture() +def seeded_user(client, auth_header): + """Create sample data for the default test user.""" + with client.application.app_context(): + user = db.session.query(User).filter_by( + email="test@example.com" + ).first() + uid = user.id + + cat = Category(user_id=uid, name="Groceries") + db.session.add(cat) + db.session.flush() + + exp = Expense( + user_id=uid, + category_id=cat.id, + amount=42.50, + currency="INR", + notes="weekly shopping", + spent_at=date(2026, 3, 1), + ) + db.session.add(exp) + + rec = RecurringExpense( + user_id=uid, + amount=9.99, + notes="streaming", + cadence=RecurringCadence.MONTHLY, + start_date=date(2026, 1, 1), + ) + db.session.add(rec) + + bill = Bill( + user_id=uid, + name="Electricity", + amount=80.00, + next_due_date=date(2026, 4, 1), + cadence=BillCadence.MONTHLY, + ) + db.session.add(bill) + db.session.flush() + + rem = Reminder( + user_id=uid, + bill_id=bill.id, + message="pay electricity", + send_at=datetime(2026, 3, 30, 9, 0), + ) + db.session.add(rem) + db.session.commit() + return auth_header + + +# ── Export Tests ──────────────────────────────────────────── + + +def test_export_returns_user_data(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + assert r.status_code == 200 + data = r.get_json()["data"] + assert data["user"]["email"] == "test@example.com" + + +def test_export_includes_categories(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + data = r.get_json()["data"] + names = [c["name"] for c in data["categories"]] + assert "Groceries" in names + + +def test_export_includes_expenses(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + data = r.get_json()["data"] + assert len(data["expenses"]) >= 1 + assert data["expenses"][0]["notes"] == "weekly shopping" + + +def test_export_includes_recurring(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + data = r.get_json()["data"] + assert len(data["recurring_expenses"]) >= 1 + + +def test_export_includes_bills(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + data = r.get_json()["data"] + assert any(b["name"] == "Electricity" for b in data["bills"]) + + +def test_export_includes_reminders(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + data = r.get_json()["data"] + assert len(data["reminders"]) >= 1 + + +def test_export_excludes_password_hash(client, seeded_user): + r = client.get("/gdpr/export", headers=seeded_user) + data = r.get_json()["data"] + assert "password_hash" not in data["user"] + + +def test_export_creates_audit_log(client, seeded_user): + client.get("/gdpr/export", headers=seeded_user) + with client.application.app_context(): + logs = db.session.query(AuditLog).filter_by( + action="PII_EXPORT" + ).all() + assert len(logs) >= 1 + + +def test_export_requires_auth(client): + r = client.get("/gdpr/export") + assert r.status_code == 401 + + +# ── Delete Tests ──────────────────────────────────────────── + + +def test_delete_requires_password(client, seeded_user): + r = client.post("/gdpr/delete", headers=seeded_user, json={}) + assert r.status_code == 400 + assert "password required" in r.get_json()["error"] + + +def test_delete_rejects_wrong_password(client, seeded_user): + r = client.post( + "/gdpr/delete", headers=seeded_user, json={"password": "wrong"} + ) + assert r.status_code == 403 + + +def test_delete_removes_user(client, seeded_user): + r = client.post( + "/gdpr/delete", headers=seeded_user, json={"password": "password123"} + ) + assert r.status_code == 200 + with client.application.app_context(): + user = db.session.query(User).filter_by( + email="test@example.com" + ).first() + assert user is None + + +def test_delete_cascades_expenses(client, seeded_user): + with client.application.app_context(): + user = db.session.query(User).filter_by( + email="test@example.com" + ).first() + uid = user.id + + client.post( + "/gdpr/delete", headers=seeded_user, json={"password": "password123"} + ) + + with client.application.app_context(): + remaining = db.session.query(Expense).filter_by( + user_id=uid + ).count() + assert remaining == 0 + + +def test_delete_cascades_categories(client, seeded_user): + with client.application.app_context(): + user = db.session.query(User).filter_by( + email="test@example.com" + ).first() + uid = user.id + + client.post( + "/gdpr/delete", headers=seeded_user, json={"password": "password123"} + ) + + with client.application.app_context(): + remaining = db.session.query(Category).filter_by( + user_id=uid + ).count() + assert remaining == 0 + + +def test_delete_preserves_audit_log(client, seeded_user): + client.post( + "/gdpr/delete", headers=seeded_user, json={"password": "password123"} + ) + with client.application.app_context(): + logs = db.session.query(AuditLog).filter_by( + action="ACCOUNT_DELETED" + ).all() + assert len(logs) >= 1 + # user_id should be NULL after cascade + assert logs[0].user_id is None + + +def test_delete_requires_auth(client): + r = client.post("/gdpr/delete", json={"password": "password123"}) + assert r.status_code == 401 + + +# ── Audit Tests ───────────────────────────────────────────── + + +def test_audit_returns_logs(client, seeded_user): + # Trigger an export to create an audit entry + client.get("/gdpr/export", headers=seeded_user) + r = client.get("/gdpr/audit", headers=seeded_user) + assert r.status_code == 200 + logs = r.get_json()["audit_logs"] + assert len(logs) >= 1 + assert logs[0]["action"] == "PII_EXPORT" + + +def test_audit_requires_auth(client): + r = client.get("/gdpr/audit") + assert r.status_code == 401