diff --git a/README.md b/README.md index 49592bffc..7478da032 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,12 @@ OpenAPI: `backend/app/openapi.yaml` - Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` - Reminders: CRUD `/reminders`, trigger `/reminders/run` - Insights: `/insights/monthly`, `/insights/budget-suggestion` +- Privacy: ZIP export `/privacy/export`, confirmed account deletion `/privacy/me` + +## Privacy Workflow +- `GET /privacy/export` creates a ZIP archive for the authenticated user with `manifest.json`, `profile.json`, `data.json`, and per-table CSV files. The package includes profile, categories, expenses, recurring expenses, bills, reminders, ad impressions, subscriptions, and audit logs, but excludes password hashes and other users' records. +- `DELETE /privacy/me` requires `{"confirm":"DELETE_MY_DATA"}` and permanently removes the requesting user's owned records in dependency order. Existing audit logs are anonymized, a deletion completion audit entry is retained, user-scoped cache keys are removed, and matching refresh sessions are revoked from Redis. +- Backend coverage: `REDIS_URL=redis://localhost:6379/15 python -m pytest tests/test_privacy.py tests/test_auth.py` from `packages/backend`. ## MVP UI/UX Plan - Auth screens: register/login. @@ -186,6 +192,7 @@ finmind/ ## Security & Scalability - JWT access/refresh, secure cookies OR Authorization header. - RBAC-ready via roles on `users.role`. +- GDPR-ready account export/delete endpoints with audit logs and confirmation guard. - N+1 avoided via SQLAlchemy eager loading. - Redis caching for hot paths to cut DB load. - 12-factor app env config; stateless API. diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts index 79001053a..9c1306403 100644 --- a/app/src/api/auth.ts +++ b/app/src/api/auth.ts @@ -1,4 +1,5 @@ -import { api } from './client'; +import { api, baseURL } from './client'; +import { getToken } from '../lib/auth'; export type LoginResponse = { access_token: string; refresh_token?: string }; @@ -35,3 +36,36 @@ export async function updateMe(payload: { }): Promise { return api('/auth/me', { method: 'PATCH', body: payload }); } + +export async function exportPrivacyData(): Promise { + const token = getToken(); + const res = await fetch(`${baseURL}/privacy/export`, { + method: 'GET', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + credentials: 'include', + }); + if (!res.ok) { + let message = `HTTP ${res.status}`; + try { + const payload = (await res.json()) as { error?: string; message?: string }; + message = payload.error || payload.message || message; + } catch { + // Keep status-based fallback for non-JSON failures. + } + throw new Error(message); + } + return res.blob(); +} + +export async function deleteAccount(confirm: string): Promise<{ + message: string; + deleted_records: Record; + anonymized_audit_logs: number; + deleted_cache_keys: number; + revoked_refresh_sessions: number; +}> { + return api('/privacy/me', { + method: 'DELETE', + body: { confirm }, + }); +} diff --git a/app/src/pages/Account.tsx b/app/src/pages/Account.tsx index 0c07d66ba..cce009ef2 100644 --- a/app/src/pages/Account.tsx +++ b/app/src/pages/Account.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { useToast } from '@/hooks/use-toast'; -import { me, updateMe } from '@/api/auth'; -import { setCurrency } from '@/lib/auth'; +import { deleteAccount, exportPrivacyData, me, updateMe } from '@/api/auth'; +import { clearRefreshToken, clearToken, setCurrency } from '@/lib/auth'; const SUPPORTED_CURRENCIES = [ { code: 'INR', label: 'Indian Rupee (INR)' }, @@ -23,6 +23,9 @@ export default function Account() { const [currency, setCurrencyState] = useState('INR'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [exporting, setExporting] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deleteConfirmation, setDeleteConfirmation] = useState(''); useEffect(() => { const load = async () => { @@ -60,6 +63,58 @@ export default function Account() { } }; + const onExport = async () => { + setExporting(true); + try { + const blob = await exportPrivacyData(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + link.href = url; + link.download = `finmind-data-export-${timestamp}.zip`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + toast({ + title: 'Export ready', + description: 'Your personal data export has been downloaded.', + }); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : 'Failed to export account data'; + toast({ title: 'Export failed', description: message }); + } finally { + setExporting(false); + } + }; + + const onDelete = async () => { + if (deleteConfirmation !== 'DELETE_MY_DATA') { + toast({ + title: 'Confirmation required', + description: 'Enter DELETE_MY_DATA before deleting this account.', + }); + return; + } + setDeleting(true); + try { + await deleteAccount(deleteConfirmation); + clearToken(); + clearRefreshToken(); + toast({ + title: 'Account deleted', + description: 'Your FinMind account and personal data were deleted.', + }); + window.location.assign('/'); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : 'Failed to delete account'; + toast({ title: 'Delete failed', description: message }); + setDeleting(false); + } + }; + return (
@@ -108,6 +163,52 @@ export default function Account() { )}
+ +
+
+

Privacy Controls

+

+ Download a ZIP export of your account data or permanently delete the + account. +

+
+
+
+ +

+ Includes profile, expenses, bills, reminders, subscriptions, and + audit entries. +

+
+ +
+
+
+ +

+ Enter DELETE_MY_DATA to remove your profile and owned records. +

+
+
+ setDeleteConfirmation(event.target.value)} + disabled={deleting || loading} + /> + +
+
+
); } diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0f..7073e3846 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -12,6 +12,7 @@ tags: - name: Bills - name: Reminders - name: Insights + - name: Privacy paths: /auth/register: post: @@ -481,6 +482,79 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /privacy/export: + get: + summary: Export authenticated user's personal data + tags: [Privacy] + security: [{ bearerAuth: [] }] + responses: + '200': + description: ZIP archive containing manifest, JSON, and CSV files + content: + application/zip: + schema: + type: string + format: binary + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '404': + description: User not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /privacy/me: + delete: + summary: Permanently delete authenticated user's account and owned records + tags: [Privacy] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [confirm] + properties: + confirm: + type: string + enum: [DELETE_MY_DATA] + example: + confirm: DELETE_MY_DATA + responses: + '200': + description: Deletion result and removed record counts + content: + application/json: + schema: + type: object + properties: + message: { type: string } + deleted_records: + type: object + additionalProperties: { type: integer } + anonymized_audit_logs: { type: integer } + deleted_cache_keys: { type: integer } + revoked_refresh_sessions: { type: integer } + '400': + description: Missing or invalid confirmation + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '404': + description: User not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + components: securitySchemes: bearerAuth: diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f897..87d6dead2 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 000000000..a7d812a5e --- /dev/null +++ b/packages/backend/app/routes/privacy.py @@ -0,0 +1,300 @@ +import csv +import io +import json +import zipfile +from datetime import date, datetime +from decimal import Decimal + +from flask import Blueprint, jsonify, request, send_file +from flask_jwt_extended import get_jwt_identity, jwt_required + +from ..extensions import db, redis_client +from ..models import ( + AdImpression, + AuditLog, + Bill, + Category, + Expense, + RecurringExpense, + Reminder, + SubscriptionPlan, + User, + UserSubscription, +) + +bp = Blueprint("privacy", __name__) + +DELETE_CONFIRMATION = "DELETE_MY_DATA" + + +@bp.get("/export") +@jwt_required() +def export_personal_data(): + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + if not user: + return jsonify(error="not found"), 404 + + db.session.add(AuditLog(user_id=uid, action="privacy.export")) + db.session.commit() + + generated_at = datetime.utcnow() + archive = _build_export_archive(user, generated_at) + filename = f"finmind-data-export-{generated_at.strftime('%Y%m%dT%H%M%SZ')}.zip" + return send_file( + io.BytesIO(archive), + mimetype="application/zip", + as_attachment=True, + download_name=filename, + max_age=0, + ) + + +@bp.delete("/me") +@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 + + data = request.get_json(silent=True) or {} + if data.get("confirm") != DELETE_CONFIRMATION: + return ( + jsonify(error=f"confirm must be {DELETE_CONFIRMATION}"), + 400, + ) + + deleted_cache_keys = _delete_user_cache_keys(uid) + revoked_refresh_sessions = _revoke_refresh_sessions(uid) + deleted_records = _delete_user_owned_records(uid) + anonymized_audits = ( + db.session.query(AuditLog) + .filter(AuditLog.user_id == uid) + .update({AuditLog.user_id: None}, synchronize_session=False) + ) + db.session.delete(user) + db.session.add( + AuditLog( + user_id=None, + action=( + "privacy.delete.completed " + f"records={sum(deleted_records.values())} " + f"anonymized_audit_logs={anonymized_audits}" + ), + ) + ) + db.session.commit() + + return jsonify( + message="account permanently deleted", + deleted_records=deleted_records, + anonymized_audit_logs=anonymized_audits, + deleted_cache_keys=deleted_cache_keys, + revoked_refresh_sessions=revoked_refresh_sessions, + ) + + +def _build_export_archive(user: User, generated_at: datetime) -> bytes: + tables = _collect_user_tables(user.id) + profile = { + "id": user.id, + "email": user.email, + "preferred_currency": user.preferred_currency, + "role": user.role, + "created_at": _export_value(user.created_at), + } + manifest = { + "format_version": 1, + "generated_at": generated_at.isoformat() + "Z", + "profile_fields": sorted(profile.keys()), + "tables": {name: len(rows) for name, rows in tables.items()}, + } + export_json = { + "manifest": manifest, + "profile": profile, + "tables": tables, + } + + out = io.BytesIO() + with zipfile.ZipFile(out, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "manifest.json", json.dumps(manifest, indent=2, sort_keys=True) + "\n" + ) + archive.writestr( + "profile.json", json.dumps(profile, indent=2, sort_keys=True) + "\n" + ) + archive.writestr( + "data.json", json.dumps(export_json, indent=2, sort_keys=True) + "\n" + ) + for table_name, rows in tables.items(): + archive.writestr(f"{table_name}.csv", _rows_to_csv(rows)) + return out.getvalue() + + +def _collect_user_tables(uid: int) -> dict[str, list[dict]]: + return { + "categories": _model_rows( + Category, uid, ["id", "name", "created_at"], order_by=Category.id + ), + "expenses": _model_rows( + Expense, + uid, + [ + "id", + "category_id", + "amount", + "currency", + "expense_type", + "notes", + "spent_at", + "source_recurring_id", + "created_at", + ], + order_by=Expense.id, + ), + "recurring_expenses": _model_rows( + RecurringExpense, + uid, + [ + "id", + "category_id", + "amount", + "currency", + "expense_type", + "notes", + "cadence", + "start_date", + "end_date", + "active", + "created_at", + ], + order_by=RecurringExpense.id, + ), + "bills": _model_rows( + Bill, + uid, + [ + "id", + "name", + "amount", + "currency", + "next_due_date", + "cadence", + "autopay_enabled", + "channel_whatsapp", + "channel_email", + "active", + "created_at", + ], + order_by=Bill.id, + ), + "reminders": _model_rows( + Reminder, + uid, + ["id", "bill_id", "message", "send_at", "sent", "channel"], + order_by=Reminder.id, + ), + "ad_impressions": _model_rows( + AdImpression, + uid, + ["id", "placement", "created_at"], + order_by=AdImpression.id, + ), + "user_subscriptions": _subscription_rows(uid), + "audit_logs": _model_rows( + AuditLog, + uid, + ["id", "action", "created_at"], + order_by=AuditLog.id, + ), + } + + +def _model_rows(model, uid: int, fields: list[str], *, order_by) -> list[dict]: + rows = db.session.query(model).filter(model.user_id == uid).order_by(order_by).all() + return [ + {field: _export_value(getattr(row, field)) for field in fields} for row in rows + ] + + +def _subscription_rows(uid: int) -> list[dict]: + rows = ( + db.session.query(UserSubscription, SubscriptionPlan) + .join(SubscriptionPlan, UserSubscription.plan_id == SubscriptionPlan.id) + .filter(UserSubscription.user_id == uid) + .order_by(UserSubscription.id) + .all() + ) + return [ + { + "id": subscription.id, + "plan_id": subscription.plan_id, + "plan_name": plan.name, + "plan_price_cents": plan.price_cents, + "plan_interval": plan.interval, + "active": subscription.active, + "started_at": _export_value(subscription.started_at), + } + for subscription, plan in rows + ] + + +def _rows_to_csv(rows: list[dict]) -> str: + if not rows: + return "" + out = io.StringIO() + writer = csv.DictWriter(out, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + return out.getvalue() + + +def _export_value(value): + if isinstance(value, datetime): + return value.isoformat() + "Z" + if isinstance(value, date): + return value.isoformat() + if isinstance(value, Decimal): + return str(value) + if hasattr(value, "value"): + return value.value + return value + + +def _delete_user_owned_records(uid: int) -> dict[str, int]: + deleted: dict[str, int] = {} + for table_name, model in [ + ("reminders", Reminder), + ("expenses", Expense), + ("recurring_expenses", RecurringExpense), + ("bills", Bill), + ("categories", Category), + ("ad_impressions", AdImpression), + ("user_subscriptions", UserSubscription), + ]: + deleted[table_name] = ( + db.session.query(model) + .filter(model.user_id == uid) + .delete(synchronize_session=False) + ) + return deleted + + +def _revoke_refresh_sessions(uid: int) -> int: + target_uid = str(uid) + revoked = 0 + for key in redis_client.scan_iter(match="auth:refresh:*", count=100): + if redis_client.get(key) == target_uid: + redis_client.delete(key) + revoked += 1 + return revoked + + +def _delete_user_cache_keys(uid: int) -> int: + deleted = 0 + for pattern in [f"user:{uid}:*", f"insights:{uid}:*"]: + for key in redis_client.scan_iter(match=pattern, count=100): + redis_client.delete(key) + deleted += 1 + return deleted diff --git a/packages/backend/tests/test_privacy.py b/packages/backend/tests/test_privacy.py new file mode 100644 index 000000000..45b883c55 --- /dev/null +++ b/packages/backend/tests/test_privacy.py @@ -0,0 +1,210 @@ +import json +import zipfile +from datetime import date +from io import BytesIO + +from app.extensions import db, redis_client +from app.models import ( + AdImpression, + AuditLog, + Bill, + Category, + Expense, + RecurringCadence, + RecurringExpense, + Reminder, + SubscriptionPlan, + User, + UserSubscription, +) + + +def _create_category(client, auth_header, name="General"): + r = client.post("/categories", json={"name": name}, headers=auth_header) + assert r.status_code == 201 + return r.get_json()["id"] + + +def _create_expense(client, auth_header, category_id, description="Groceries"): + r = client.post( + "/expenses", + json={ + "amount": 12.5, + "currency": "USD", + "category_id": category_id, + "description": description, + "date": "2026-02-12", + }, + headers=auth_header, + ) + assert r.status_code == 201 + return r.get_json()["id"] + + +def _create_bill(client, auth_header): + r = client.post( + "/bills", + json={ + "name": "Internet", + "amount": 49.99, + "currency": "USD", + "next_due_date": date.today().isoformat(), + "cadence": "MONTHLY", + "channel_email": True, + "channel_whatsapp": True, + }, + headers=auth_header, + ) + assert r.status_code == 201 + return r.get_json()["id"] + + +def _register_and_login(client, email): + password = "password123" + r = client.post("/auth/register", json={"email": email, "password": password}) + assert r.status_code == 201 + r = client.post("/auth/login", json={"email": email, "password": password}) + assert r.status_code == 200 + return {"Authorization": f"Bearer {r.get_json()['access_token']}"} + + +def _current_user_id(client, auth_header): + r = client.get("/auth/me", headers=auth_header) + assert r.status_code == 200 + return r.get_json()["id"] + + +def test_privacy_export_returns_zip_with_owned_data_and_audit_log( + client, auth_header, app_fixture +): + uid = _current_user_id(client, auth_header) + category_id = _create_category(client, auth_header, "Food") + _create_expense(client, auth_header, category_id) + bill_id = _create_bill(client, auth_header) + r = client.post( + f"/reminders/bills/{bill_id}/schedule", + json={"offsets_days": [1]}, + headers=auth_header, + ) + assert r.status_code == 200 + + r = client.get("/privacy/export", headers=auth_header) + assert r.status_code == 200 + assert r.headers["Content-Type"] == "application/zip" + assert "test@example.com" not in r.headers["Content-Disposition"] + + with zipfile.ZipFile(BytesIO(r.data)) as archive: + names = set(archive.namelist()) + assert { + "manifest.json", + "profile.json", + "data.json", + "expenses.csv", + "audit_logs.csv", + }.issubset(names) + profile = json.loads(archive.read("profile.json")) + data = json.loads(archive.read("data.json")) + expenses_csv = archive.read("expenses.csv").decode("utf-8") + + assert profile["email"] == "test@example.com" + assert "password_hash" not in profile + assert data["manifest"]["tables"]["expenses"] == 1 + assert data["manifest"]["tables"]["audit_logs"] == 1 + assert "Groceries" in expenses_csv + + with app_fixture.app_context(): + audit = db.session.query(AuditLog).filter_by(user_id=uid).one() + assert audit.action == "privacy.export" + + +def test_privacy_delete_requires_confirmation_and_removes_only_requesting_user_data( + client, auth_header, app_fixture +): + uid = _current_user_id(client, auth_header) + category_id = _create_category(client, auth_header, "Food") + _create_expense(client, auth_header, category_id) + bill_id = _create_bill(client, auth_header) + r = client.post( + f"/reminders/bills/{bill_id}/schedule", + json={"offsets_days": [1]}, + headers=auth_header, + ) + assert r.status_code == 200 + + second_auth = _register_and_login(client, "other@example.com") + second_uid = _current_user_id(client, second_auth) + second_category_id = _create_category(client, second_auth, "Other") + _create_expense(client, second_auth, second_category_id, "Other groceries") + + with app_fixture.app_context(): + plan = SubscriptionPlan(name="Premium", price_cents=999, interval="monthly") + db.session.add(plan) + db.session.flush() + db.session.add(UserSubscription(user_id=uid, plan_id=plan.id, active=True)) + db.session.add(AdImpression(user_id=uid, placement="dashboard")) + db.session.add( + RecurringExpense( + user_id=uid, + category_id=category_id, + amount=15, + currency="USD", + notes="Coffee", + cadence=RecurringCadence.MONTHLY, + start_date=date(2026, 1, 1), + ) + ) + db.session.add(AuditLog(user_id=uid, action="privacy.export")) + db.session.commit() + redis_client.setex("auth:refresh:owned", 600, str(uid)) + redis_client.setex("auth:refresh:other", 600, str(second_uid)) + redis_client.setex(f"user:{uid}:categories", 600, "[]") + redis_client.setex(f"insights:{uid}:2026-01", 600, "{}") + redis_client.setex(f"user:{second_uid}:categories", 600, "[]") + + r = client.delete("/privacy/me", json={"confirm": "wrong"}, headers=auth_header) + assert r.status_code == 400 + + r = client.delete( + "/privacy/me", json={"confirm": "DELETE_MY_DATA"}, headers=auth_header + ) + assert r.status_code == 200 + payload = r.get_json() + assert payload["message"] == "account permanently deleted" + assert payload["deleted_records"]["expenses"] == 1 + assert payload["deleted_records"]["reminders"] == 2 + assert payload["anonymized_audit_logs"] == 1 + assert payload["deleted_cache_keys"] == 2 + assert payload["revoked_refresh_sessions"] == 2 + + r = client.get("/auth/me", headers=auth_header) + assert r.status_code == 404 + + with app_fixture.app_context(): + assert db.session.get(User, uid) is None + for model in [ + Category, + Expense, + RecurringExpense, + Bill, + Reminder, + AdImpression, + UserSubscription, + ]: + assert db.session.query(model).filter_by(user_id=uid).count() == 0 + assert db.session.get(User, second_uid) is not None + assert db.session.query(Expense).filter_by(user_id=second_uid).count() == 1 + assert db.session.query(AuditLog).filter_by(user_id=uid).count() == 0 + completion = ( + db.session.query(AuditLog) + .filter( + AuditLog.user_id.is_(None), + AuditLog.action.like("privacy.delete.completed%"), + ) + .one() + ) + assert "records=" in completion.action + assert redis_client.exists("auth:refresh:owned") == 0 + assert redis_client.exists("auth:refresh:other") == 1 + assert redis_client.exists(f"user:{uid}:categories") == 0 + assert redis_client.exists(f"insights:{uid}:2026-01") == 0 + assert redis_client.exists(f"user:{second_uid}:categories") == 1