From d77536ff90246eb8a8845edf035ba40af751a64c Mon Sep 17 00:00:00 2001 From: yuliuyi717-ux <264093635+yuliuyi717-ux@users.noreply.github.com> Date: Tue, 10 Mar 2026 06:34:47 +0800 Subject: [PATCH] feat(auth): add PII export and account deletion workflows --- AUTODEV_REPORT.md | 33 ++++ packages/backend/app/routes/auth.py | 297 +++++++++++++++++++++++++++- packages/backend/tests/test_auth.py | 196 ++++++++++++++++++ 3 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 AUTODEV_REPORT.md diff --git a/AUTODEV_REPORT.md b/AUTODEV_REPORT.md new file mode 100644 index 0000000..a6431f4 --- /dev/null +++ b/AUTODEV_REPORT.md @@ -0,0 +1,33 @@ +# AUTODEV Report + +## Changed Files +- `packages/backend/app/routes/auth.py` +- `packages/backend/tests/test_auth.py` + +## What Was Implemented (Issue #76) +- Added authenticated PII export workflow: + - `GET /auth/export-data` (also available as `GET /auth/export`) + - Exports a JSON package including profile, categories, expenses, recurring expenses, bills, reminders, subscriptions, ad impressions, and audit logs. +- Added irreversible account deletion workflow: + - `DELETE /auth/delete-account` (also available as `DELETE /auth/delete`) + - Requires explicit confirmation (`confirm=true`, `confirmed=true`, or `confirmation="DELETE"`). + - Deletes user-owned data rows and then deletes the user account. + - Revokes refresh-token sessions for the deleted user. +- Added audit-trail logging: + - Records `USER_DATA_EXPORTED` on export. + - Records `USER_ACCOUNT_DELETED` on deletion. + +## Validation Commands +1. `cd packages/backend && REDIS_URL=redis://localhost:6379/15 PYTHONPATH=. ../../.venv/bin/pytest tests/test_auth.py -q` +2. `cd packages/backend && REDIS_URL=redis://localhost:6379/15 PYTHONPATH=. ../../.venv/bin/pytest tests -q` +3. `./.venv/bin/flake8 packages/backend/app/routes/auth.py packages/backend/tests/test_auth.py` + +## Validation Results +- `tests/test_auth.py`: **5 passed** +- Full backend tests: **24 passed** +- `flake8` on touched files: **passed** + +## Risks / Follow-ups +- Test/runtime setup depends on Redis availability. In this workspace, tests required overriding `REDIS_URL` to localhost and running a Redis container. +- Access JWTs already issued before deletion are not centrally revoked (current codebase tracks refresh sessions, not access-token blocklists). +- Export response currently returns JSON payload directly; if large datasets are expected, async file generation/streaming could be considered later. diff --git a/packages/backend/app/routes/auth.py b/packages/backend/app/routes/auth.py index 05a3937..ed44ac6 100644 --- a/packages/backend/app/routes/auth.py +++ b/packages/backend/app/routes/auth.py @@ -1,3 +1,4 @@ +from datetime import date, datetime from flask import Blueprint, request, jsonify from werkzeug.security import generate_password_hash, check_password_hash from flask_jwt_extended import ( @@ -9,12 +10,25 @@ get_jwt_identity, ) from ..extensions import db, redis_client -from ..models import User +from ..models import ( + AdImpression, + AuditLog, + Bill, + Category, + Expense, + RecurringExpense, + Reminder, + SubscriptionPlan, + User, + UserSubscription, +) import logging import time bp = Blueprint("auth", __name__) logger = logging.getLogger("finmind.auth") +AUDIT_EXPORT_ACTION = "USER_DATA_EXPORTED" +AUDIT_DELETE_ACTION = "USER_ACCOUNT_DELETED" SUPPORTED_CURRENCIES = { "USD", "INR", @@ -101,6 +115,138 @@ def update_me(): ) +@bp.get("/export") +@bp.get("/export-data") +@jwt_required() +def export_data(): + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="not found"), 404 + + categories = ( + db.session.query(Category) + .filter(Category.user_id == uid) + .order_by(Category.created_at.desc()) + .all() + ) + expenses = ( + db.session.query(Expense) + .filter(Expense.user_id == uid) + .order_by(Expense.spent_at.desc(), Expense.created_at.desc()) + .all() + ) + recurring = ( + db.session.query(RecurringExpense) + .filter(RecurringExpense.user_id == uid) + .order_by(RecurringExpense.created_at.desc()) + .all() + ) + bills = ( + db.session.query(Bill) + .filter(Bill.user_id == uid) + .order_by(Bill.next_due_date.asc()) + .all() + ) + reminders = ( + db.session.query(Reminder) + .filter(Reminder.user_id == uid) + .order_by(Reminder.send_at.asc()) + .all() + ) + subscriptions = ( + db.session.query(UserSubscription, SubscriptionPlan) + .outerjoin(SubscriptionPlan, SubscriptionPlan.id == UserSubscription.plan_id) + .filter(UserSubscription.user_id == uid) + .order_by(UserSubscription.started_at.desc()) + .all() + ) + ad_impressions = ( + db.session.query(AdImpression) + .filter(AdImpression.user_id == uid) + .order_by(AdImpression.created_at.desc()) + .all() + ) + + _write_audit_log(uid, AUDIT_EXPORT_ACTION) + db.session.commit() + + audit_logs = ( + db.session.query(AuditLog) + .filter(AuditLog.user_id == uid) + .order_by(AuditLog.created_at.desc()) + .all() + ) + logger.info("Generated data export user_id=%s", uid) + return jsonify( + generated_at=datetime.utcnow().isoformat(), + profile={ + "id": user.id, + "email": user.email, + "preferred_currency": user.preferred_currency, + "role": user.role, + "created_at": _to_iso(user.created_at), + }, + categories=[_category_to_dict(item) for item in categories], + expenses=[_expense_to_dict(item) for item in expenses], + recurring_expenses=[_recurring_to_dict(item) for item in recurring], + bills=[_bill_to_dict(item) for item in bills], + reminders=[_reminder_to_dict(item) for item in reminders], + subscriptions=[ + _subscription_to_dict(subscription, plan) + for subscription, plan in subscriptions + ], + ad_impressions=[_ad_impression_to_dict(item) for item in ad_impressions], + audit_logs=[_audit_to_dict(item) for item in audit_logs], + ) + + +@bp.delete("/delete") +@bp.delete("/delete-account") +@jwt_required() +def delete_account(): + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="not found"), 404 + payload = request.get_json(silent=True) or {} + if not _is_delete_confirmed(payload): + return jsonify(error="confirmation required"), 400 + + _write_audit_log(uid, AUDIT_DELETE_ACTION) + db.session.query(Reminder).filter(Reminder.user_id == uid).delete( + synchronize_session=False + ) + db.session.query(Expense).filter(Expense.user_id == uid).delete( + synchronize_session=False + ) + db.session.query(RecurringExpense).filter(RecurringExpense.user_id == uid).delete( + synchronize_session=False + ) + db.session.query(Bill).filter(Bill.user_id == uid).delete(synchronize_session=False) + db.session.query(Category).filter(Category.user_id == uid).delete( + synchronize_session=False + ) + db.session.query(UserSubscription).filter(UserSubscription.user_id == uid).delete( + synchronize_session=False + ) + db.session.query(AdImpression).filter(AdImpression.user_id == uid).delete( + synchronize_session=False + ) + db.session.query(AuditLog).filter(AuditLog.user_id == uid).update( + {AuditLog.user_id: None}, + synchronize_session=False, + ) + db.session.delete(user) + db.session.commit() + + revoked_count = _revoke_refresh_sessions(uid) + logger.info( + "Deleted account user_id=%s revoked_refresh_sessions=%s", uid, revoked_count + ) + return jsonify(message="account deleted"), 200 + + @bp.post("/refresh") @jwt_required(refresh=True) def refresh(): @@ -137,3 +283,152 @@ def _store_refresh_session(refresh_token: str, uid: str): return ttl = max(int(exp - time.time()), 1) redis_client.setex(_refresh_key(jti), ttl, uid) + + +def _revoke_refresh_sessions(uid: int) -> int: + uid_str = str(uid) + keys_to_delete: list[str] = [] + try: + for key in redis_client.scan_iter(match=_refresh_key("*")): + if redis_client.get(key) == uid_str: + keys_to_delete.append(key) + if keys_to_delete: + redis_client.delete(*keys_to_delete) + return len(keys_to_delete) + except Exception: + logger.exception("Failed to revoke refresh sessions for user_id=%s", uid) + return 0 + + +def _write_audit_log(uid: int, action: str) -> None: + db.session.add(AuditLog(user_id=uid, action=action)) + + +def _is_delete_confirmed(payload: dict) -> bool: + if _as_bool(payload.get("confirm")) or _as_bool(payload.get("confirmed")): + return True + confirmation = payload.get("confirmation") + if isinstance(confirmation, str) and confirmation.strip().upper() == "DELETE": + return True + query_confirm = request.args.get("confirm") + return _as_bool(query_confirm) + + +def _as_bool(value) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "y", "delete"} + if isinstance(value, (int, float)): + return bool(value) + return False + + +def _to_iso(value) -> str | None: + if value is None: + return None + if isinstance(value, (datetime, date)): + return value.isoformat() + return str(value) + + +def _enum_value(value): + return getattr(value, "value", value) + + +def _category_to_dict(item: Category) -> dict: + return { + "id": item.id, + "name": item.name, + "created_at": _to_iso(item.created_at), + } + + +def _expense_to_dict(item: Expense) -> dict: + return { + "id": item.id, + "category_id": item.category_id, + "amount": float(item.amount), + "currency": item.currency, + "expense_type": item.expense_type, + "notes": item.notes or "", + "spent_at": _to_iso(item.spent_at), + "created_at": _to_iso(item.created_at), + } + + +def _recurring_to_dict(item: RecurringExpense) -> dict: + return { + "id": item.id, + "category_id": item.category_id, + "amount": float(item.amount), + "currency": item.currency, + "expense_type": item.expense_type, + "notes": item.notes, + "cadence": _enum_value(item.cadence), + "start_date": _to_iso(item.start_date), + "end_date": _to_iso(item.end_date), + "active": bool(item.active), + "created_at": _to_iso(item.created_at), + } + + +def _bill_to_dict(item: Bill) -> dict: + return { + "id": item.id, + "name": item.name, + "amount": float(item.amount), + "currency": item.currency, + "next_due_date": _to_iso(item.next_due_date), + "cadence": _enum_value(item.cadence), + "autopay_enabled": bool(item.autopay_enabled), + "channel_whatsapp": bool(item.channel_whatsapp), + "channel_email": bool(item.channel_email), + "active": bool(item.active), + "created_at": _to_iso(item.created_at), + } + + +def _reminder_to_dict(item: Reminder) -> dict: + return { + "id": item.id, + "bill_id": item.bill_id, + "message": item.message, + "send_at": _to_iso(item.send_at), + "sent": bool(item.sent), + "channel": item.channel, + } + + +def _subscription_to_dict( + item: UserSubscription, plan: SubscriptionPlan | None +) -> dict: + return { + "id": item.id, + "plan_id": item.plan_id, + "active": bool(item.active), + "started_at": _to_iso(item.started_at), + "plan": { + "name": plan.name, + "price_cents": plan.price_cents, + "interval": plan.interval, + } + if plan + else None, + } + + +def _ad_impression_to_dict(item: AdImpression) -> dict: + return { + "id": item.id, + "placement": item.placement, + "created_at": _to_iso(item.created_at), + } + + +def _audit_to_dict(item: AuditLog) -> dict: + return { + "id": item.id, + "action": item.action, + "created_at": _to_iso(item.created_at), + } diff --git a/packages/backend/tests/test_auth.py b/packages/backend/tests/test_auth.py index 7b22b0e..06b95ba 100644 --- a/packages/backend/tests/test_auth.py +++ b/packages/backend/tests/test_auth.py @@ -1,3 +1,30 @@ +from app.extensions import db +from app.models import ( + AdImpression, + AuditLog, + Bill, + Category, + Expense, + RecurringExpense, + SubscriptionPlan, + User, + UserSubscription, +) + + +def _register_and_login(client, *, email: str, password: str = "secret123"): + r = client.post("/auth/register", json={"email": email, "password": password}) + assert r.status_code in (201, 409) + + r = client.post("/auth/login", json={"email": email, "password": password}) + assert r.status_code == 200 + payload = r.get_json() + return ( + {"Authorization": f"Bearer {payload['access_token']}"}, + payload["refresh_token"], + ) + + def test_auth_refresh_flow(client): # Register user email = "refresh@test.com" @@ -66,3 +93,172 @@ def test_auth_me_and_update_preferred_currency(client): r = client.patch("/auth/me", json={"preferred_currency": "ZZZ"}, headers=auth) assert r.status_code == 400 + + +def test_auth_export_data_returns_full_user_package(client, auth_header, app_fixture): + me = client.get("/auth/me", headers=auth_header) + assert me.status_code == 200 + user_id = me.get_json()["id"] + + r = client.post("/categories", json={"name": "Utilities"}, headers=auth_header) + assert r.status_code == 201 + category_id = r.get_json()["id"] + + r = client.post( + "/expenses", + json={ + "amount": 199.5, + "category_id": category_id, + "description": "Electricity bill", + "date": "2026-03-01", + }, + headers=auth_header, + ) + assert r.status_code == 201 + expense_id = r.get_json()["id"] + + r = client.post( + "/expenses/recurring", + json={ + "amount": 49.0, + "description": "Gym membership", + "start_date": "2026-03-01", + "cadence": "MONTHLY", + "category_id": category_id, + }, + headers=auth_header, + ) + assert r.status_code == 201 + recurring_id = r.get_json()["id"] + + r = client.post( + "/bills", + json={ + "name": "Internet", + "amount": 89.0, + "next_due_date": "2026-03-20", + "cadence": "MONTHLY", + }, + headers=auth_header, + ) + assert r.status_code == 201 + bill_id = r.get_json()["id"] + + r = client.post( + "/reminders", + json={ + "message": "Pay internet bill", + "send_at": "2026-03-15T09:00:00", + "channel": "email", + }, + headers=auth_header, + ) + assert r.status_code == 201 + reminder_id = r.get_json()["id"] + + with app_fixture.app_context(): + plan = SubscriptionPlan(name="Pro", price_cents=999, interval="monthly") + db.session.add(plan) + db.session.flush() + db.session.add(UserSubscription(user_id=user_id, plan_id=plan.id, active=True)) + db.session.add(AdImpression(user_id=user_id, placement="dashboard_top")) + db.session.commit() + + r = client.get("/auth/export-data", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + assert payload["profile"]["id"] == user_id + assert payload["profile"]["email"] == "test@example.com" + assert payload["profile"]["preferred_currency"] == "INR" + assert any(item["id"] == category_id for item in payload["categories"]) + assert any(item["id"] == expense_id for item in payload["expenses"]) + assert any(item["id"] == recurring_id for item in payload["recurring_expenses"]) + assert any(item["id"] == bill_id for item in payload["bills"]) + assert any(item["id"] == reminder_id for item in payload["reminders"]) + assert len(payload["subscriptions"]) == 1 + assert len(payload["ad_impressions"]) == 1 + + with app_fixture.app_context(): + audit = ( + db.session.query(AuditLog) + .filter( + AuditLog.action == "USER_DATA_EXPORTED", + AuditLog.user_id == user_id, + ) + .all() + ) + assert len(audit) == 1 + + +def test_auth_delete_account_is_confirmed_and_irreversible(client, app_fixture): + email = "erase-me@test.com" + password = "secret123" + auth, refresh_token = _register_and_login(client, email=email, password=password) + + me = client.get("/auth/me", headers=auth) + assert me.status_code == 200 + user_id = me.get_json()["id"] + + r = client.post("/categories", json={"name": "Temporary"}, headers=auth) + assert r.status_code == 201 + category_id = r.get_json()["id"] + + r = client.post( + "/expenses", + json={ + "amount": 10, + "category_id": category_id, + "description": "Temp record", + "date": "2026-03-01", + }, + headers=auth, + ) + assert r.status_code == 201 + + with app_fixture.app_context(): + db.session.add(AdImpression(user_id=user_id, placement="account_page")) + db.session.commit() + + r = client.delete("/auth/delete-account", headers=auth, json={}) + assert r.status_code == 400 + + r = client.get("/auth/me", headers=auth) + assert r.status_code == 200 + + r = client.delete("/auth/delete-account", headers=auth, json={"confirm": True}) + assert r.status_code == 200 + assert r.get_json()["message"] == "account deleted" + + r = client.get("/auth/me", headers=auth) + assert r.status_code == 404 + + r = client.post("/auth/login", json={"email": email, "password": password}) + assert r.status_code == 401 + + r = client.post( + "/auth/refresh", headers={"Authorization": f"Bearer {refresh_token}"} + ) + assert r.status_code == 401 + + with app_fixture.app_context(): + assert db.session.get(User, user_id) is None + assert db.session.query(Category).filter_by(user_id=user_id).count() == 0 + assert db.session.query(Expense).filter_by(user_id=user_id).count() == 0 + assert db.session.query(Bill).filter_by(user_id=user_id).count() == 0 + assert ( + db.session.query(RecurringExpense).filter_by(user_id=user_id).count() == 0 + ) + assert ( + db.session.query(UserSubscription).filter_by(user_id=user_id).count() == 0 + ) + assert db.session.query(AdImpression).filter_by(user_id=user_id).count() == 0 + deleted_logs = ( + db.session.query(AuditLog) + .filter(AuditLog.action == "USER_ACCOUNT_DELETED") + .all() + ) + assert len(deleted_logs) == 1 + + r = client.post("/auth/register", json={"email": email, "password": password}) + assert r.status_code == 201