From 81e6bcfd3970e9dafd888aeb74ecc698d3787070 Mon Sep 17 00:00:00 2001 From: Moyuchiiii Date: Tue, 10 Mar 2026 16:32:01 +0900 Subject: [PATCH 1/2] feat: add PII export, deletion workflow, and audit trail endpoints (#76) --- packages/backend/app/db/schema.sql | 14 ++ packages/backend/app/models.py | 7 +- packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/privacy.py | 279 ++++++++++++++++++++++++ packages/backend/tests/test_privacy.py | 143 ++++++++++++ 5 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 packages/backend/app/routes/privacy.py create mode 100644 packages/backend/tests/test_privacy.py diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189d..c64ad92 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -121,5 +121,19 @@ CREATE TABLE IF NOT EXISTS audit_logs ( id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE SET NULL, action VARCHAR(100) NOT NULL, + detail VARCHAR(1000), + ip_address VARCHAR(45), + user_agent VARCHAR(500), created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +-- PII deletion support +ALTER TABLE users + ADD COLUMN IF NOT EXISTS deletion_requested_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS deletion_scheduled_for TIMESTAMP; + +-- Audit log extended columns +ALTER TABLE audit_logs + ADD COLUMN IF NOT EXISTS detail VARCHAR(1000), + ADD COLUMN IF NOT EXISTS ip_address VARCHAR(45), + ADD COLUMN IF NOT EXISTS user_agent VARCHAR(500); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d4481..36c41b7 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -17,6 +17,8 @@ class User(db.Model): preferred_currency = db.Column(db.String(10), default="INR", nullable=False) role = db.Column(db.String(20), default=Role.USER.value, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + deletion_requested_at = db.Column(db.DateTime, nullable=True) + deletion_scheduled_for = db.Column(db.DateTime, nullable=True) class Category(db.Model): @@ -130,6 +132,9 @@ class UserSubscription(db.Model): class AuditLog(db.Model): __tablename__ = "audit_logs" id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True) action = db.Column(db.String(100), nullable=False) + detail = db.Column(db.String(1000), nullable=True) + ip_address = db.Column(db.String(45), nullable=True) + user_agent = db.Column(db.String(500), nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f8..87d6dea 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 .privacy import bp as privacy_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(privacy_bp, url_prefix="/privacy") diff --git a/packages/backend/app/routes/privacy.py b/packages/backend/app/routes/privacy.py new file mode 100644 index 0000000..fb5f97f --- /dev/null +++ b/packages/backend/app/routes/privacy.py @@ -0,0 +1,279 @@ +from datetime import datetime, timedelta +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import ( + AuditLog, + Bill, + Category, + Expense, + RecurringExpense, + Reminder, + User, + UserSubscription, + AdImpression, +) +import logging + +bp = Blueprint("privacy", __name__) +logger = logging.getLogger("finmind.privacy") + +DELETION_GRACE_DAYS = 30 + + +def _log_audit(user_id, action, detail=None): + """Record a GDPR audit event with request metadata.""" + entry = AuditLog( + user_id=user_id, + action=action, + detail=detail, + ip_address=request.remote_addr, + user_agent=(request.user_agent.string or "")[:500], + ) + db.session.add(entry) + db.session.flush() + + +@bp.get("/export") +@jwt_required() +def export_data(): + """Export all PII associated with the authenticated user as JSON.""" + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="not found"), 404 + + profile = { + "id": user.id, + "email": user.email, + "preferred_currency": user.preferred_currency, + "role": user.role, + "created_at": user.created_at.isoformat(), + "deletion_requested_at": ( + user.deletion_requested_at.isoformat() + if user.deletion_requested_at + else None + ), + } + + expenses = [ + { + "id": e.id, + "amount": float(e.amount), + "currency": e.currency, + "expense_type": e.expense_type, + "notes": e.notes, + "date": e.spent_at.isoformat(), + "category_id": e.category_id, + "created_at": e.created_at.isoformat(), + } + for e in db.session.query(Expense).filter_by(user_id=uid).all() + ] + + recurring = [ + { + "id": r.id, + "amount": float(r.amount), + "currency": r.currency, + "expense_type": r.expense_type, + "notes": r.notes, + "cadence": r.cadence.value, + "start_date": r.start_date.isoformat(), + "end_date": r.end_date.isoformat() if r.end_date else None, + "active": r.active, + } + for r in db.session.query(RecurringExpense).filter_by(user_id=uid).all() + ] + + categories = [ + {"id": c.id, "name": c.name} + for c in db.session.query(Category).filter_by(user_id=uid).all() + ] + + bills = [ + { + "id": b.id, + "name": b.name, + "amount": float(b.amount), + "currency": b.currency, + "next_due_date": b.next_due_date.isoformat(), + "cadence": b.cadence.value, + "autopay_enabled": b.autopay_enabled, + "active": b.active, + } + for b in db.session.query(Bill).filter_by(user_id=uid).all() + ] + + reminders = [ + { + "id": r.id, + "bill_id": r.bill_id, + "message": r.message, + "send_at": r.send_at.isoformat(), + "sent": r.sent, + "channel": r.channel, + } + for r in db.session.query(Reminder).filter_by(user_id=uid).all() + ] + + subscriptions = [ + { + "id": s.id, + "plan_id": s.plan_id, + "active": s.active, + "started_at": s.started_at.isoformat(), + } + for s in db.session.query(UserSubscription).filter_by(user_id=uid).all() + ] + + _log_audit(uid, "pii_export") + db.session.commit() + + logger.info("PII export generated for user_id=%s", uid) + return jsonify( + profile=profile, + expenses=expenses, + recurring_expenses=recurring, + categories=categories, + bills=bills, + reminders=reminders, + subscriptions=subscriptions, + exported_at=datetime.utcnow().isoformat(), + ) + + +@bp.post("/delete-request") +@jwt_required() +def request_deletion(): + """Initiate account deletion with a 30-day grace period.""" + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="not found"), 404 + + if user.deletion_requested_at: + return jsonify( + error="deletion already requested", + deletion_scheduled_for=user.deletion_scheduled_for.isoformat(), + ), 409 + + now = datetime.utcnow() + scheduled = now + timedelta(days=DELETION_GRACE_DAYS) + user.deletion_requested_at = now + user.deletion_scheduled_for = scheduled + + _log_audit(uid, "deletion_requested", f"scheduled_for={scheduled.isoformat()}") + db.session.commit() + + logger.info("Deletion requested for user_id=%s scheduled=%s", uid, scheduled) + return jsonify( + message="deletion scheduled", + deletion_requested_at=now.isoformat(), + deletion_scheduled_for=scheduled.isoformat(), + grace_period_days=DELETION_GRACE_DAYS, + ) + + +@bp.post("/delete-cancel") +@jwt_required() +def cancel_deletion(): + """Cancel a pending deletion request during the grace period.""" + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="not found"), 404 + + if not user.deletion_requested_at: + return jsonify(error="no pending deletion request"), 400 + + user.deletion_requested_at = None + user.deletion_scheduled_for = None + + _log_audit(uid, "deletion_cancelled") + db.session.commit() + + logger.info("Deletion cancelled for user_id=%s", uid) + return jsonify(message="deletion cancelled") + + +@bp.post("/delete-confirm") +@jwt_required() +def confirm_deletion(): + """Permanently delete all user data. Requires an active deletion request.""" + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="not found"), 404 + + if not user.deletion_requested_at: + return jsonify(error="no pending deletion request"), 400 + + # Record audit BEFORE deleting (audit logs are retained with user_id=NULL) + _log_audit(uid, "deletion_confirmed", f"email={user.email}") + + # Cascade delete all user data + db.session.query(Reminder).filter_by(user_id=uid).delete() + db.session.query(Expense).filter_by(user_id=uid).delete() + db.session.query(RecurringExpense).filter_by(user_id=uid).delete() + db.session.query(Bill).filter_by(user_id=uid).delete() + db.session.query(Category).filter_by(user_id=uid).delete() + db.session.query(UserSubscription).filter_by(user_id=uid).delete() + db.session.query(AdImpression).filter_by(user_id=uid).delete() + + # Nullify user references in audit logs (retain for compliance) + db.session.query(AuditLog).filter_by(user_id=uid).update({"user_id": None}) + + db.session.delete(user) + db.session.commit() + + logger.info("User permanently deleted user_id=%s", uid) + return jsonify(message="account permanently deleted") + + +@bp.get("/audit-log") +@jwt_required() +def get_audit_log(): + """Return the authenticated user's GDPR audit trail.""" + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="not found"), 404 + + logs = ( + db.session.query(AuditLog) + .filter_by(user_id=uid) + .order_by(AuditLog.created_at.desc()) + .limit(100) + .all() + ) + return jsonify( + [ + { + "id": log.id, + "action": log.action, + "detail": log.detail, + "ip_address": log.ip_address, + "created_at": log.created_at.isoformat(), + } + for log in logs + ] + ) + + +@bp.get("/deletion-status") +@jwt_required() +def deletion_status(): + """Check whether a deletion request is pending.""" + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="not found"), 404 + + if not user.deletion_requested_at: + return jsonify(pending=False) + + return jsonify( + pending=True, + deletion_requested_at=user.deletion_requested_at.isoformat(), + deletion_scheduled_for=user.deletion_scheduled_for.isoformat(), + ) diff --git a/packages/backend/tests/test_privacy.py b/packages/backend/tests/test_privacy.py new file mode 100644 index 0000000..33d8422 --- /dev/null +++ b/packages/backend/tests/test_privacy.py @@ -0,0 +1,143 @@ +from datetime import date + + +def _auth(client, email="privacy@test.com", password="secret123"): + """Register + login and return auth header.""" + client.post("/auth/register", json={"email": email, "password": password}) + r = client.post("/auth/login", json={"email": email, "password": password}) + token = r.get_json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def _seed_data(client, auth): + """Create some sample data for export tests.""" + client.post( + "/categories", + json={"name": "Food"}, + headers=auth, + ) + client.post( + "/expenses", + json={"amount": 42.50, "description": "Lunch", "date": "2025-01-15"}, + headers=auth, + ) + + +def test_export_returns_all_user_data(client): + auth = _auth(client) + _seed_data(client, auth) + + r = client.get("/privacy/export", headers=auth) + assert r.status_code == 200 + data = r.get_json() + + assert "profile" in data + assert data["profile"]["email"] == "privacy@test.com" + assert "exported_at" in data + assert isinstance(data["expenses"], list) + assert isinstance(data["categories"], list) + assert isinstance(data["bills"], list) + assert isinstance(data["reminders"], list) + assert isinstance(data["recurring_expenses"], list) + assert isinstance(data["subscriptions"], list) + + +def test_export_creates_audit_log(client): + auth = _auth(client) + client.get("/privacy/export", headers=auth) + + r = client.get("/privacy/audit-log", headers=auth) + assert r.status_code == 200 + logs = r.get_json() + assert any(log["action"] == "pii_export" for log in logs) + + +def test_delete_request_and_cancel(client): + auth = _auth(client) + + # Request deletion + r = client.post("/privacy/delete-request", headers=auth) + assert r.status_code == 200 + data = r.get_json() + assert data["grace_period_days"] == 30 + assert "deletion_scheduled_for" in data + + # Check status + r = client.get("/privacy/deletion-status", headers=auth) + assert r.status_code == 200 + assert r.get_json()["pending"] is True + + # Duplicate request should fail + r = client.post("/privacy/delete-request", headers=auth) + assert r.status_code == 409 + + # Cancel + r = client.post("/privacy/delete-cancel", headers=auth) + assert r.status_code == 200 + + # Status should be cleared + r = client.get("/privacy/deletion-status", headers=auth) + assert r.get_json()["pending"] is False + + +def test_delete_cancel_without_request(client): + auth = _auth(client) + r = client.post("/privacy/delete-cancel", headers=auth) + assert r.status_code == 400 + + +def test_delete_confirm_removes_all_data(client): + auth = _auth(client, email="doomed@test.com") + _seed_data(client, auth) + + # Must request first + r = client.post("/privacy/delete-confirm", headers=auth) + assert r.status_code == 400 + + # Request then confirm + client.post("/privacy/delete-request", headers=auth) + r = client.post("/privacy/delete-confirm", headers=auth) + assert r.status_code == 200 + assert r.get_json()["message"] == "account permanently deleted" + + # Token should no longer work (user gone) + r = client.get("/privacy/export", headers=auth) + assert r.status_code == 404 + + +def test_delete_confirm_preserves_audit_logs(client, app_fixture): + auth = _auth(client, email="audit@test.com") + client.post("/privacy/delete-request", headers=auth) + client.post("/privacy/delete-confirm", headers=auth) + + # Audit logs should still exist with user_id=NULL + from app.models import AuditLog + from app.extensions import db + + with app_fixture.app_context(): + logs = ( + db.session.query(AuditLog) + .filter(AuditLog.action.in_(["deletion_requested", "deletion_confirmed"])) + .all() + ) + assert len(logs) >= 2 + for log in logs: + assert log.user_id is None + + +def test_audit_log_records_ip_and_action(client): + auth = _auth(client, email="auditip@test.com") + client.get("/privacy/export", headers=auth) + + r = client.get("/privacy/audit-log", headers=auth) + logs = r.get_json() + assert len(logs) >= 1 + assert logs[0]["action"] == "pii_export" + assert "ip_address" in logs[0] + + +def test_deletion_status_no_request(client): + auth = _auth(client, email="nostatus@test.com") + r = client.get("/privacy/deletion-status", headers=auth) + assert r.status_code == 200 + assert r.get_json()["pending"] is False From 2cfa4192285f12c91b6d5591958188cedab11f77 Mon Sep 17 00:00:00 2001 From: Moyuchiiii Date: Tue, 10 Mar 2026 18:59:01 +0900 Subject: [PATCH 2/2] style: apply black formatting to models.py and privacy.py --- packages/backend/app/models.py | 4 +++- packages/backend/app/routes/privacy.py | 11 +++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 36c41b7..c3cc1ef 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -132,7 +132,9 @@ class UserSubscription(db.Model): class AuditLog(db.Model): __tablename__ = "audit_logs" id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + user_id = db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) action = db.Column(db.String(100), nullable=False) detail = db.Column(db.String(1000), nullable=True) ip_address = db.Column(db.String(45), nullable=True) diff --git a/packages/backend/app/routes/privacy.py b/packages/backend/app/routes/privacy.py index fb5f97f..1df430b 100644 --- a/packages/backend/app/routes/privacy.py +++ b/packages/backend/app/routes/privacy.py @@ -152,10 +152,13 @@ def request_deletion(): return jsonify(error="not found"), 404 if user.deletion_requested_at: - return jsonify( - error="deletion already requested", - deletion_scheduled_for=user.deletion_scheduled_for.isoformat(), - ), 409 + return ( + jsonify( + error="deletion already requested", + deletion_scheduled_for=user.deletion_scheduled_for.isoformat(), + ), + 409, + ) now = datetime.utcnow() scheduled = now + timedelta(days=DELETION_GRACE_DAYS)