diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f8..0617622 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 .pii import bp as pii_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(pii_bp, url_prefix="/pii") diff --git a/packages/backend/app/routes/pii.py b/packages/backend/app/routes/pii.py new file mode 100644 index 0000000..2a1ec4a --- /dev/null +++ b/packages/backend/app/routes/pii.py @@ -0,0 +1,85 @@ +""" +PII Export & Delete routes — GDPR-ready endpoints. + +Endpoints: + GET /pii/export — Download a JSON export of all personal data + POST /pii/delete — Irreversibly delete all personal data (requires confirmation) +""" + +import json +import logging +from flask import Blueprint, jsonify, request, Response +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..services.pii import export_user_data, delete_user_data + +bp = Blueprint("pii", __name__) +logger = logging.getLogger("finmind.pii") + + +@bp.get("/export") +@jwt_required() +def export_data(): + """ + Export all personal data as a downloadable JSON file. + + Returns a JSON package containing: + - User profile (excluding password hash) + - Categories, expenses, recurring expenses + - Bills, reminders + - Ad impressions, subscriptions + - Audit logs + + Response: 200 with JSON attachment, or 404 if user not found. + """ + user_id = get_jwt_identity() + data = export_user_data(user_id) + + if data is None: + return jsonify(error="User not found"), 404 + + # Return as downloadable JSON file + json_bytes = json.dumps(data, indent=2).encode("utf-8") + return Response( + json_bytes, + mimetype="application/json", + headers={ + "Content-Disposition": f"attachment; filename=finmind-export-user-{user_id}.json", + "Content-Length": str(len(json_bytes)), + }, + ) + + +@bp.post("/delete") +@jwt_required() +def delete_data(): + """ + Irreversibly delete all personal data. + + Requires a JSON body with: + {"confirm": true} + + This action cannot be undone. All user data including the account itself + will be permanently removed. An anonymized audit trail entry is preserved + for compliance purposes. + + Response: 200 with deletion summary, 400 if not confirmed, 404 if user not found. + """ + user_id = get_jwt_identity() + + body = request.get_json(silent=True) or {} + if not body.get("confirm"): + return jsonify( + error="Deletion requires explicit confirmation. Send {\"confirm\": true} to proceed." + ), 400 + + summary = delete_user_data(user_id) + + if summary is None: + return jsonify(error="User not found"), 404 + + logger.info("User %d data deletion completed", user_id) + return jsonify( + message="All personal data has been permanently deleted.", + deleted=summary, + ), 200 diff --git a/packages/backend/app/services/pii.py b/packages/backend/app/services/pii.py new file mode 100644 index 0000000..60cd6ad --- /dev/null +++ b/packages/backend/app/services/pii.py @@ -0,0 +1,169 @@ +""" +PII Export & Delete Service — GDPR-ready data portability and erasure. + +Provides: +- Export: generates a JSON package of all user PII and associated data +- Delete: irreversibly removes all user data with audit trail +""" + +import json +import logging +from datetime import datetime, date +from decimal import Decimal + +from ..extensions import db +from ..models import ( + User, + Category, + Expense, + RecurringExpense, + Bill, + Reminder, + AdImpression, + UserSubscription, + AuditLog, +) + +logger = logging.getLogger("finmind.pii") + + +class _JSONEncoder(json.JSONEncoder): + """Handle datetime, date, and Decimal serialization.""" + + def default(self, obj): + if isinstance(obj, datetime): + return obj.isoformat() + if isinstance(obj, date): + return obj.isoformat() + if isinstance(obj, Decimal): + return str(obj) + return super().default(obj) + + +def _serialize_row(row, exclude_cols=None): + """Convert a SQLAlchemy model instance to a dict.""" + exclude = set(exclude_cols or []) + result = {} + for col in row.__table__.columns: + if col.name in exclude: + continue + result[col.name] = getattr(row, col.name) + return result + + +def export_user_data(user_id: int) -> dict: + """ + Generate a complete PII export package for the given user. + + Returns a dict containing all user data, suitable for JSON serialization. + The export includes: profile, categories, expenses, recurring expenses, + bills, reminders, ad impressions, subscriptions, and audit logs. + """ + user = db.session.get(User, user_id) + if not user: + return None + + # Collect all user data + categories = Category.query.filter_by(user_id=user_id).all() + expenses = Expense.query.filter_by(user_id=user_id).all() + recurring = RecurringExpense.query.filter_by(user_id=user_id).all() + bills = Bill.query.filter_by(user_id=user_id).all() + reminders = Reminder.query.filter_by(user_id=user_id).all() + ad_impressions = AdImpression.query.filter_by(user_id=user_id).all() + subscriptions = UserSubscription.query.filter_by(user_id=user_id).all() + audit_logs = AuditLog.query.filter_by(user_id=user_id).all() + + export = { + "export_version": "1.0", + "exported_at": datetime.utcnow().isoformat(), + "user": _serialize_row(user, exclude_cols=["password_hash"]), + "categories": [_serialize_row(c) for c in categories], + "expenses": [_serialize_row(e) for e in expenses], + "recurring_expenses": [_serialize_row(r) for r in recurring], + "bills": [_serialize_row(b) for b in bills], + "reminders": [_serialize_row(r) for r in reminders], + "ad_impressions": [_serialize_row(a) for a in ad_impressions], + "subscriptions": [_serialize_row(s) for s in subscriptions], + "audit_logs": [_serialize_row(a) for a in audit_logs], + } + + # Log the export action + db.session.add( + AuditLog(user_id=user_id, action="pii_export") + ) + db.session.commit() + + logger.info("PII export generated for user %d", user_id) + return json.loads(json.dumps(export, cls=_JSONEncoder)) + + +def delete_user_data(user_id: int) -> dict: + """ + Irreversibly delete all personal data for the given user. + + Deletion order respects foreign key constraints. + An audit trail entry is created *before* user deletion (with user_id=None + post-deletion to preserve the log). + + Returns a summary dict with counts of deleted records per table. + """ + user = db.session.get(User, user_id) + if not user: + return None + + summary = {} + + # Delete in dependency order (children first) + # 1. Reminders (depends on bills) + count = Reminder.query.filter_by(user_id=user_id).delete() + summary["reminders"] = count + + # 2. Expenses (depends on categories, recurring_expenses) + count = Expense.query.filter_by(user_id=user_id).delete() + summary["expenses"] = count + + # 3. Recurring expenses (depends on categories) + count = RecurringExpense.query.filter_by(user_id=user_id).delete() + summary["recurring_expenses"] = count + + # 4. Bills + count = Bill.query.filter_by(user_id=user_id).delete() + summary["bills"] = count + + # 5. Categories + count = Category.query.filter_by(user_id=user_id).delete() + summary["categories"] = count + + # 6. Ad impressions + count = AdImpression.query.filter_by(user_id=user_id).delete() + summary["ad_impressions"] = count + + # 7. User subscriptions + count = UserSubscription.query.filter_by(user_id=user_id).delete() + summary["subscriptions"] = count + + # 8. Audit logs for this user (except the deletion record we're about to create) + count = AuditLog.query.filter_by(user_id=user_id).delete() + summary["audit_logs"] = count + + # 9. Create deletion audit trail (user_id=None since user will be gone) + email_hash = str(hash(user.email)) # pseudonymized reference + db.session.add( + AuditLog( + user_id=None, + action=f"pii_delete:user_hash={email_hash}", + ) + ) + + # 10. Delete the user record itself + db.session.delete(user) + summary["user"] = 1 + + db.session.commit() + + logger.info( + "PII deletion completed for user %d: %s", + user_id, + summary, + ) + return summary diff --git a/packages/backend/tests/test_pii.py b/packages/backend/tests/test_pii.py new file mode 100644 index 0000000..965c31d --- /dev/null +++ b/packages/backend/tests/test_pii.py @@ -0,0 +1,180 @@ +""" +Tests for PII Export & Delete endpoints (GDPR-ready). +""" + +import json +import pytest + + +# ── helpers ────────────────────────────────────────────────────────── + + +def _seed_user_data(client, auth_header): + """Create some sample data for the test user.""" + # Create a category + r = client.post( + "/categories", + json={"name": "Groceries"}, + headers=auth_header, + ) + assert r.status_code in (200, 201) + cat_id = r.get_json().get("id") or r.get_json().get("category", {}).get("id") + + # Create an expense + r = client.post( + "/expenses", + json={ + "amount": 42.50, + "notes": "Weekly shopping", + "category_id": cat_id, + "spent_at": "2025-03-01", + }, + headers=auth_header, + ) + assert r.status_code in (200, 201) + + # Create a bill + r = client.post( + "/bills", + json={ + "name": "Electric bill", + "amount": 120.00, + "next_due_date": "2025-04-01", + "cadence": "MONTHLY", + }, + headers=auth_header, + ) + assert r.status_code in (200, 201) + + return cat_id + + +# ── export tests ───────────────────────────────────────────────────── + + +class TestPIIExport: + def test_export_requires_auth(self, client): + r = client.get("/pii/export") + assert r.status_code in (401, 422) + + def test_export_returns_json_attachment(self, client, auth_header): + _seed_user_data(client, auth_header) + r = client.get("/pii/export", headers=auth_header) + assert r.status_code == 200 + assert "application/json" in r.content_type + assert "attachment" in r.headers.get("Content-Disposition", "") + + def test_export_contains_user_profile(self, client, auth_header): + r = client.get("/pii/export", headers=auth_header) + assert r.status_code == 200 + data = json.loads(r.data) + assert "user" in data + assert data["user"]["email"] == "test@example.com" + # Password hash must NOT be in export + assert "password_hash" not in data["user"] + + def test_export_contains_expenses(self, client, auth_header): + _seed_user_data(client, auth_header) + r = client.get("/pii/export", headers=auth_header) + data = json.loads(r.data) + assert len(data["expenses"]) >= 1 + assert data["expenses"][0]["notes"] == "Weekly shopping" + + def test_export_contains_categories(self, client, auth_header): + _seed_user_data(client, auth_header) + r = client.get("/pii/export", headers=auth_header) + data = json.loads(r.data) + assert len(data["categories"]) >= 1 + + def test_export_contains_bills(self, client, auth_header): + _seed_user_data(client, auth_header) + r = client.get("/pii/export", headers=auth_header) + data = json.loads(r.data) + assert len(data["bills"]) >= 1 + + def test_export_has_metadata(self, client, auth_header): + r = client.get("/pii/export", headers=auth_header) + data = json.loads(r.data) + assert data["export_version"] == "1.0" + assert "exported_at" in data + + def test_export_creates_audit_log(self, client, auth_header): + r = client.get("/pii/export", headers=auth_header) + assert r.status_code == 200 + # Export again and check audit logs are in export + r2 = client.get("/pii/export", headers=auth_header) + data = json.loads(r2.data) + actions = [log["action"] for log in data.get("audit_logs", [])] + assert "pii_export" in actions + + +# ── delete tests ───────────────────────────────────────────────────── + + +class TestPIIDelete: + def test_delete_requires_auth(self, client): + r = client.post("/pii/delete", json={"confirm": True}) + assert r.status_code in (401, 422) + + def test_delete_requires_confirmation(self, client, auth_header): + r = client.post("/pii/delete", json={}, headers=auth_header) + assert r.status_code == 400 + assert "confirm" in r.get_json().get("error", "").lower() + + def test_delete_without_confirm_flag(self, client, auth_header): + r = client.post("/pii/delete", json={"confirm": False}, headers=auth_header) + assert r.status_code == 400 + + def test_delete_removes_all_data(self, client, auth_header): + _seed_user_data(client, auth_header) + + # Perform deletion + r = client.post( + "/pii/delete", + json={"confirm": True}, + headers=auth_header, + ) + assert r.status_code == 200 + body = r.get_json() + assert body["message"] == "All personal data has been permanently deleted." + assert body["deleted"]["user"] == 1 + assert body["deleted"]["expenses"] >= 1 + assert body["deleted"]["categories"] >= 1 + assert body["deleted"]["bills"] >= 1 + + def test_delete_invalidates_session(self, client, auth_header): + """After deletion, the user's JWT should no longer work.""" + r = client.post( + "/pii/delete", + json={"confirm": True}, + headers=auth_header, + ) + assert r.status_code == 200 + + # Subsequent requests should fail (user gone) + r = client.get("/pii/export", headers=auth_header) + # Could be 404 (user not found) or 401/422 depending on JWT validation + assert r.status_code in (401, 404, 422, 500) + + def test_delete_returns_summary(self, client, auth_header): + _seed_user_data(client, auth_header) + r = client.post( + "/pii/delete", + json={"confirm": True}, + headers=auth_header, + ) + body = r.get_json() + # Verify all expected keys are present + expected_keys = [ + "user", + "expenses", + "categories", + "bills", + "reminders", + "recurring_expenses", + "ad_impressions", + "subscriptions", + "audit_logs", + ] + for key in expected_keys: + assert key in body["deleted"], f"Missing key: {key}"