From 367a64b59d47f617be89429490a314c6746717ed Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Tue, 1 Sep 2026 21:01:58 +0500 Subject: [PATCH] Audit trail: record who uploads, processes, exports, deletes; admin-only log view Every business action now lands in a new append-only audit_log table with the verified signed-in identity: logins, closing create/delete/reopen, file upload (incl. replacements) and delete, processing runs, export generation and downloads. Rows carry no FK so history survives a closing's deletion. Admins (new users.is_admin flag, granted via `manage.py set-admin `) can read it at /api/audit and in a new Audit Log page in the sidebar; everyone else gets 403 and no nav entry. login/me responses now carry is_admin. Co-Authored-By: Claude Fable 5 --- ar-aging-app/backend/app/api/auth.py | 27 ++- ar-aging-app/backend/app/api/main.py | 3 +- ar-aging-app/backend/app/api/routes/audit.py | 41 +++++ ar-aging-app/backend/app/api/routes/export.py | 14 +- ar-aging-app/backend/app/api/routes/files.py | 12 +- .../backend/app/api/routes/processing.py | 7 +- .../backend/app/api/routes/sessions.py | 21 ++- ar-aging-app/backend/app/db/database.py | 1 + ar-aging-app/backend/app/db/models.py | 21 +++ ar-aging-app/backend/app/services/audit.py | 34 ++++ ar-aging-app/backend/manage.py | 26 ++- ar-aging-app/backend/tests/test_audit.py | 114 +++++++++++++ ar-aging-app/frontend/src/App.tsx | 5 +- ar-aging-app/frontend/src/api/client.ts | 25 ++- ar-aging-app/frontend/src/auth.tsx | 3 +- ar-aging-app/frontend/src/pages/AuditLog.tsx | 156 ++++++++++++++++++ 16 files changed, 489 insertions(+), 21 deletions(-) create mode 100644 ar-aging-app/backend/app/api/routes/audit.py create mode 100644 ar-aging-app/backend/app/services/audit.py create mode 100644 ar-aging-app/backend/tests/test_audit.py create mode 100644 ar-aging-app/frontend/src/pages/AuditLog.tsx diff --git a/ar-aging-app/backend/app/api/auth.py b/ar-aging-app/backend/app/api/auth.py index dfcf5b5..73a3fa4 100644 --- a/ar-aging-app/backend/app/api/auth.py +++ b/ar-aging-app/backend/app/api/auth.py @@ -223,22 +223,43 @@ def login(body: LoginIn, db: OrmSession = Depends(db_dep)) -> dict: # One message for both wrong-user and wrong-password: don't confirm usernames. raise HTTPException(401, "Wrong username or password.") logger.info("login: %s", user.username) + db.add(models.AuditLog(username=user.username, display_name=user.display_name, + action="login")) + db.commit() return { "token": create_token(user), - "user": {"username": user.username, "display_name": user.display_name}, + "user": {"username": user.username, "display_name": user.display_name, + "is_admin": bool(user.is_admin)}, "expires_in_hours": AUTH_TOKEN_HOURS, } +def is_admin(request: Request, db: OrmSession) -> bool: + """Whether the signed-in user holds the admin flag — read from the DB every time, so a + revoke takes effect immediately rather than at token expiry. With auth off (dev/tests + before the first user) everyone counts as admin, matching AR_AUTH=auto's philosophy.""" + user = current_user(request) + if user is None: + return not auth_required() + row = db.get(models.User, user.id) + return bool(row is not None and row.is_active and row.is_admin) + + +def require_admin(request: Request, db: OrmSession) -> None: + if not is_admin(request, db): + raise HTTPException(403, "Admin access required.") + + @router.get("/me") -def me(request: Request) -> dict: +def me(request: Request, db: OrmSession = Depends(db_dep)) -> dict: user = current_user(request) if user is None: if auth_required(): raise HTTPException(401, "Not signed in.") return {"authenticated": False, "auth_required": False} return {"authenticated": True, "auth_required": True, - "username": user.username, "display_name": user.display_name} + "username": user.username, "display_name": user.display_name, + "is_admin": is_admin(request, db)} # ------------------------------------------------------------- emailed password codes diff --git a/ar-aging-app/backend/app/api/main.py b/ar-aging-app/backend/app/api/main.py index 90ec1aa..c79a47e 100644 --- a/ar-aging-app/backend/app/api/main.py +++ b/ar-aging-app/backend/app/api/main.py @@ -16,7 +16,7 @@ from ..db.database import ENGINE, init_db from . import auth from .routes import ( sessions, files, processing, results, settings as settings_routes, export, ar, control, - analytics, controls, payouts, accounts_summary, fx, + analytics, controls, payouts, accounts_summary, fx, audit, ) logging.basicConfig( @@ -108,3 +108,4 @@ app.include_router(controls.router) app.include_router(payouts.router) app.include_router(accounts_summary.router) app.include_router(fx.router) +app.include_router(audit.router) diff --git a/ar-aging-app/backend/app/api/routes/audit.py b/ar-aging-app/backend/app/api/routes/audit.py new file mode 100644 index 0000000..0fcc076 --- /dev/null +++ b/ar-aging-app/backend/app/api/routes/audit.py @@ -0,0 +1,41 @@ +"""Audit-log read API — admins only (users.is_admin, granted via `manage.py set-admin`).""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session as OrmSession + +from ...db import models +from ..auth import require_admin +from ..deps import db_dep + +router = APIRouter(prefix="/api/audit", tags=["audit"]) + + +@router.get("") +def list_audit(request: Request, limit: int = 100, offset: int = 0, + session_id: int | None = None, action: str = "", + db: OrmSession = Depends(db_dep)) -> dict: + """Newest first. Filter by closing and/or action; page with limit/offset.""" + require_admin(request, db) + limit = max(1, min(limit, 500)) + q = db.query(models.AuditLog) + if session_id is not None: + q = q.filter(models.AuditLog.session_id == session_id) + if action: + q = q.filter(models.AuditLog.action == action) + total = q.count() + rows = (q.order_by(models.AuditLog.at.desc(), models.AuditLog.id.desc()) + .offset(offset).limit(limit).all()) + return { + "total": total, + "entries": [{ + "id": r.id, + "at": r.at.isoformat() if r.at else None, + "username": r.username or "", + "display_name": r.display_name or "", + "action": r.action, + "session_id": r.session_id, + "session_name": r.session_name or "", + "detail": r.detail or "", + } for r in rows], + } diff --git a/ar-aging-app/backend/app/api/routes/export.py b/ar-aging-app/backend/app/api/routes/export.py index dd30355..95ff985 100644 --- a/ar-aging-app/backend/app/api/routes/export.py +++ b/ar-aging-app/backend/app/api/routes/export.py @@ -3,11 +3,12 @@ from __future__ import annotations import os -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi.responses import FileResponse from sqlalchemy.orm import Session as OrmSession from ...db import models +from ...services.audit import record as audit from ...services.jobs import run_export, run_summary_export from ..deps import db_dep, ensure_not_blocked, get_session_or_404 @@ -15,8 +16,8 @@ router = APIRouter(prefix="/api/sessions", tags=["export"]) @router.post("/{session_id}/export") -def start_export(session_id: int, background: BackgroundTasks, kind: str = "full", - db: OrmSession = Depends(db_dep)) -> dict: +def start_export(session_id: int, background: BackgroundTasks, request: Request, + kind: str = "full", db: OrmSession = Depends(db_dep)) -> dict: """kind='summary' → compact Finance pack (fast); kind='full' → complete audit workbook.""" if kind not in ("full", "summary"): raise HTTPException(400, "kind must be 'full' or 'summary'.") @@ -46,6 +47,7 @@ def start_export(session_id: int, background: BackgroundTasks, kind: str = "full s.progress_rows_total = 0 s.eta_seconds = 0 s.error = "" + audit(db, request, "export_generate", session=s, detail=f"kind={kind}") db.commit() background.add_task(run_summary_export if kind == "summary" else run_export, session_id) return {"started": True, "kind": kind} @@ -63,7 +65,8 @@ def list_exports(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict @router.get("/{session_id}/export/download") -def download_export(session_id: int, kind: str = "full", db: OrmSession = Depends(db_dep)): +def download_export(session_id: int, request: Request, kind: str = "full", + db: OrmSession = Depends(db_dep)): s = get_session_or_404(session_id, db) # A workbook generated before a control started failing must not keep circulating. ensure_not_blocked(s) @@ -73,6 +76,9 @@ def download_export(session_id: int, kind: str = "full", db: OrmSession = Depend r = q.order_by(models.ExportRecord.generated_at.desc()).first() if not r or not r.path or not os.path.exists(r.path): raise HTTPException(404, "No export available; generate it first.") + audit(db, request, "export_download", + session=s, detail=f"kind={kind} '{os.path.basename(r.path)}'") + db.commit() return FileResponse( r.path, filename=os.path.basename(r.path), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", diff --git a/ar-aging-app/backend/app/api/routes/files.py b/ar-aging-app/backend/app/api/routes/files.py index b2e0f20..6c14bee 100644 --- a/ar-aging-app/backend/app/api/routes/files.py +++ b/ar-aging-app/backend/app/api/routes/files.py @@ -4,13 +4,14 @@ from __future__ import annotations import hashlib import os -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from sqlalchemy.orm import Session as OrmSession from ...config import ALLOWED_EXTENSIONS, MAX_UPLOAD_BYTES, UPLOAD_DIR from ...core.readers import make_reader from ...core.xlsx_reader import ParseError from ...db import models +from ...services.audit import record as audit from ..deps import db_dep, ensure_editable, file_dict, get_session_or_404, sanitize_filename router = APIRouter(prefix="/api/sessions", tags=["files"]) @@ -44,7 +45,7 @@ def _validate(rec: models.SessionFile, path: str) -> None: @router.post("/{session_id}/files") -async def upload_files(session_id: int, files: list[UploadFile] = File(...), +async def upload_files(session_id: int, request: Request, files: list[UploadFile] = File(...), db: OrmSession = Depends(db_dep)) -> dict: """ Upload one or more source files into a closing. @@ -115,6 +116,9 @@ async def upload_files(session_id: int, files: list[UploadFile] = File(...), os.replace(tmp, path) changed = True + audit(db, request, "file_upload", session=session, + detail=f"'{safe}' ({size:,} bytes)" + + (" — replaced the existing file" if same_name is not None else "")) if same_name is not None: # Replace in place: update the existing row rather than adding a second one. rec = same_name @@ -151,7 +155,8 @@ async def upload_files(session_id: int, files: list[UploadFile] = File(...), @router.delete("/{session_id}/files/{file_id}") -def delete_file(session_id: int, file_id: int, db: OrmSession = Depends(db_dep)) -> dict: +def delete_file(session_id: int, file_id: int, request: Request, + db: OrmSession = Depends(db_dep)) -> dict: s = get_session_or_404(session_id, db) ensure_editable(s) f = db.get(models.SessionFile, file_id) @@ -162,6 +167,7 @@ def delete_file(session_id: int, file_id: int, db: OrmSession = Depends(db_dep)) os.remove(f.stored_path) except OSError: pass + audit(db, request, "file_delete", session=s, detail=f"'{f.filename}'") db.delete(f) if s.status in ("processed", "blocked"): s.needs_reprocess = True diff --git a/ar-aging-app/backend/app/api/routes/processing.py b/ar-aging-app/backend/app/api/routes/processing.py index 87b6bd2..05f3e7e 100644 --- a/ar-aging-app/backend/app/api/routes/processing.py +++ b/ar-aging-app/backend/app/api/routes/processing.py @@ -1,10 +1,11 @@ """Start processing (background) and poll status/progress.""" from __future__ import annotations -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from sqlalchemy.orm import Session as OrmSession from ...db import models +from ...services.audit import record as audit from ...services.jobs import run_processing from ..deps import db_dep, ensure_editable, get_session_or_404, session_dict @@ -12,7 +13,7 @@ router = APIRouter(prefix="/api/sessions", tags=["processing"]) @router.post("/{session_id}/process") -def start_processing(session_id: int, background: BackgroundTasks, +def start_processing(session_id: int, background: BackgroundTasks, request: Request, db: OrmSession = Depends(db_dep)) -> dict: s = get_session_or_404(session_id, db) ensure_editable(s) @@ -30,6 +31,8 @@ def start_processing(session_id: int, background: BackgroundTasks, s.progress_stage = "Queued" s.progress_pct = 0.0 s.error = "" + audit(db, request, "process_run", session=s, + detail=f"{len(valid_files)} file(s)") db.commit() background.add_task(run_processing, session_id) return {"started": True, "session_id": session_id} diff --git a/ar-aging-app/backend/app/api/routes/sessions.py b/ar-aging-app/backend/app/api/routes/sessions.py index 6dd16d5..cc761b2 100644 --- a/ar-aging-app/backend/app/api/routes/sessions.py +++ b/ar-aging-app/backend/app/api/routes/sessions.py @@ -4,12 +4,13 @@ from __future__ import annotations import logging from datetime import date -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from sqlalchemy.orm import Session as OrmSession from ...config import DEFAULT_CLEARING_LAG_DAYS, DEFAULT_TOLERANCE from ...db import models +from ...services.audit import record as audit from ..deps import db_dep, get_session_or_404, session_dict router = APIRouter(prefix="/api/sessions", tags=["sessions"]) @@ -77,7 +78,8 @@ def list_sessions(db: OrmSession = Depends(db_dep)) -> list[dict]: @router.post("") -def create_session(body: SessionCreate, db: OrmSession = Depends(db_dep)) -> dict: +def create_session(body: SessionCreate, request: Request, + db: OrmSession = Depends(db_dep)) -> dict: me = body.month_end_date month = me.strftime("%Y-%m") if me else None if month and not body.allow_duplicate: @@ -104,6 +106,9 @@ def create_session(body: SessionCreate, db: OrmSession = Depends(db_dep)) -> dic status="draft", ) db.add(s) + db.flush() # assign s.id so the audit row can reference it + audit(db, request, "session_create", session=s, + detail=f"month {month or '(none)'}") db.commit() from .ar import seed_opening_from_prior seed_opening_from_prior(db, s) @@ -132,13 +137,15 @@ def update_session(session_id: int, body: SessionUpdate, @router.post("/{session_id}/reopen") -def reopen_session(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: +def reopen_session(session_id: int, request: Request, + db: OrmSession = Depends(db_dep)) -> dict: """Unlock a completed closing for corrections. Deliberate and logged — the opposite of silently editing published history.""" s = get_session_or_404(session_id, db) if s.status != "completed": raise HTTPException(409, "Only a completed closing can be reopened.") s.status = "blocked" if s.blocked_reason else "processed" + audit(db, request, "session_reopen", session=s) db.commit() logger.warning("closing %s (%s, %s) reopened for corrections", s.id, s.name, s.reporting_month or "no month") @@ -146,10 +153,16 @@ def reopen_session(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: @router.delete("/{session_id}") -def delete_session(session_id: int, db: OrmSession = Depends(db_dep)) -> dict: +def delete_session(session_id: int, request: Request, + db: OrmSession = Depends(db_dep)) -> dict: """Delete a closing and every row/file that belongs to it.""" s = get_session_or_404(session_id, db) if s.status in ("processing", "exporting"): raise HTTPException(409, "This closing is still processing — wait for it to finish.") + # Recorded up front (audit rows carry no FK, so they survive the purge); committed + # here so the entry exists even though purge_session manages its own transaction. + audit(db, request, "session_delete", session=s, + detail=f"month {s.reporting_month or '(none)'}, status {s.status}") + db.commit() from ...services.store import purge_session return purge_session(db, session_id) diff --git a/ar-aging-app/backend/app/db/database.py b/ar-aging-app/backend/app/db/database.py index d76e611..fdeacc8 100644 --- a/ar-aging-app/backend/app/db/database.py +++ b/ar-aging-app/backend/app/db/database.py @@ -127,6 +127,7 @@ def _migrate() -> None: ("reset_code_hash", "VARCHAR(255) DEFAULT ''"), ("reset_code_expires", "DATETIME"), ("reset_code_attempts", "INTEGER DEFAULT 0"), + ("is_admin", "BOOLEAN DEFAULT 0"), ], "sessions": [ ("progress_rows_done", "INTEGER DEFAULT 0"), diff --git a/ar-aging-app/backend/app/db/models.py b/ar-aging-app/backend/app/db/models.py index e1367af..0d093bd 100644 --- a/ar-aging-app/backend/app/db/models.py +++ b/ar-aging-app/backend/app/db/models.py @@ -25,6 +25,8 @@ class User(Base): display_name = Column(String(255), nullable=False) password_hash = Column(String(512), nullable=False) is_active = Column(Boolean, default=True) + # Admins can read the audit log (/api/audit). Granted via `manage.py set-admin`. + is_admin = Column(Boolean, default=False) created_at = Column(DateTime, default=_now) # Emailed password code (usernames are email addresses). Stored as an HMAC, never the # code itself; single-use, expires, and locks after too many wrong attempts. @@ -33,6 +35,25 @@ class User(Base): reset_code_attempts = Column(Integer, default=0) +class AuditLog(Base): + """Append-only record of who did what: uploads, deletions, processing runs, exports, + closing lifecycle, logins. session_id is a plain integer (no FK) so history survives + the closing being deleted. Written via services/audit.py; read via /api/audit (admins).""" + __tablename__ = "audit_log" + id = Column(Integer, primary_key=True) + at = Column(DateTime, default=_now, nullable=False) + username = Column(String(64), default="") # "" = auth off (dev) or unknown + display_name = Column(String(255), default="") + action = Column(String(64), nullable=False) + session_id = Column(Integer) + session_name = Column(String(255), default="") + detail = Column(Text, default="") + + +Index("ix_audit_at", AuditLog.at) +Index("ix_audit_session", AuditLog.session_id) + + class Session(Base): __tablename__ = "sessions" id = Column(Integer, primary_key=True) diff --git a/ar-aging-app/backend/app/services/audit.py b/ar-aging-app/backend/app/services/audit.py new file mode 100644 index 0000000..0403f2b --- /dev/null +++ b/ar-aging-app/backend/app/services/audit.py @@ -0,0 +1,34 @@ +""" +Audit trail: one append-only row per business action, attributed to the signed-in user. + +`record()` only ADDS the row to the caller's ORM session — the caller's own `db.commit()` +persists it atomically with the action itself, so a failed action never leaves a phantom +audit entry (and a recorded action is never lost to a second commit failing). +""" +from __future__ import annotations + +from fastapi import Request +from sqlalchemy.orm import Session as OrmSession + +from ..db import models + + +def record(db: OrmSession, request: Request | None, action: str, detail: str = "", + session: models.Session | None = None) -> models.AuditLog: + """Attach an audit row to the caller's transaction. + + Identity comes from the verified bearer token; with auth off (dev / tests before the + first user) the row is still written with an empty username, so the trail's shape is + the same everywhere.""" + from ..api.auth import current_user # late import: auth imports models too + user = current_user(request) if request is not None else None + row = models.AuditLog( + username=user.username if user else "", + display_name=user.display_name if user else "", + action=action, + session_id=session.id if session is not None else None, + session_name=session.name if session is not None else "", + detail=(detail or "")[:2000], + ) + db.add(row) + return row diff --git a/ar-aging-app/backend/manage.py b/ar-aging-app/backend/manage.py index db5b4e9..860452c 100644 --- a/ar-aging-app/backend/manage.py +++ b/ar-aging-app/backend/manage.py @@ -5,6 +5,7 @@ Admin commands (run on the server, next to the app): python manage.py set-password # prompts for password python manage.py list-users python manage.py deactivate-user + python manage.py set-admin [--revoke] # audit-log access python manage.py dedupe-files [--apply] # fix double-counted uploads There is deliberately no self-signup: the 5-or-so finance users are created here. @@ -80,7 +81,25 @@ def cmd_list_users(_args) -> int: return 0 for u in rows: flag = "" if u.is_active else " [DEACTIVATED]" - print(f" {u.username:<20} {u.display_name}{flag}") + admin = " [ADMIN]" if u.is_admin else "" + print(f" {u.username:<20} {u.display_name}{admin}{flag}") + return 0 + finally: + db.close() + + +def cmd_set_admin(args) -> int: + db = SessionLocal() + try: + user = db.query(models.User).filter( + models.User.username == args.username.strip().lower()).first() + if user is None: + print(f"No user '{args.username}'.") + return 1 + user.is_admin = not args.revoke + db.commit() + state = "revoked from" if args.revoke else "granted to" + print(f"Admin (audit-log access) {state} '{user.username}'.") return 0 finally: db.close() @@ -171,6 +190,11 @@ def main(argv: list[str]) -> int: p.add_argument("username") p.set_defaults(fn=cmd_deactivate_user) + p = sub.add_parser("set-admin", help="grant (or --revoke) audit-log access") + p.add_argument("username") + p.add_argument("--revoke", action="store_true", help="remove the admin flag") + p.set_defaults(fn=cmd_set_admin) + p = sub.add_parser("dedupe-files", help="fix double-counted duplicate upload rows") p.add_argument("--apply", action="store_true", help="actually delete (default: dry run)") p.set_defaults(fn=cmd_dedupe_files) diff --git a/ar-aging-app/backend/tests/test_audit.py b/ar-aging-app/backend/tests/test_audit.py new file mode 100644 index 0000000..6d27fe1 --- /dev/null +++ b/ar-aging-app/backend/tests/test_audit.py @@ -0,0 +1,114 @@ +"""Audit trail: business actions land in audit_log attributed to the verified user, the +history survives a closing's deletion, and /api/audit is readable by admins only.""" +from __future__ import annotations + +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + +from app.api import auth as auth_mod +from app.api.main import app +from app.db import models +from app.db.database import SessionLocal, init_db +from tests.test_excel_export import make_amazon_xlsx + +_TMP = tempfile.mkdtemp(prefix="ar_audit_test_") + + +def _entries(session_id: int) -> list[models.AuditLog]: + db = SessionLocal() + try: + return (db.query(models.AuditLog) + .filter(models.AuditLog.session_id == session_id) + .order_by(models.AuditLog.id).all()) + finally: + db.close() + + +@pytest.fixture() +def clean_users(): + """Users flip AR_AUTH=auto to 'required' for the WHOLE shared test DB — always remove + them again so the rest of the suite keeps running unauthenticated.""" + init_db() + yield + db = SessionLocal() + try: + db.query(models.User).delete() + db.commit() + finally: + db.close() + auth_mod.invalidate_users_cache() + + +def _add_user(username: str, display: str, password: str, admin: bool = False) -> None: + db = SessionLocal() + try: + db.add(models.User(username=username, display_name=display, + password_hash=auth_mod.hash_password(password), + is_active=True, is_admin=admin)) + db.commit() + finally: + db.close() + auth_mod.invalidate_users_cache() + + +def test_actions_recorded_and_history_survives_delete(): + init_db() + path = os.path.join(_TMP, "USA audit.xlsx") + make_amazon_xlsx(path, order_rows=4) + with TestClient(app) as c: + sid = c.post("/api/sessions", json={ + "name": "audit-me", "month_end_date": "2027-03-31", + "allow_duplicate": True}).json()["id"] + with open(path, "rb") as fh: + r = c.post(f"/api/sessions/{sid}/files", + files={"files": ("USA audit.xlsx", fh)}) + assert r.status_code == 200 + + rows = _entries(sid) + assert [e.action for e in rows][:2] == ["session_create", "file_upload"] + assert "USA audit.xlsx" in rows[1].detail + # With auth off (no users) the row is still written, just unattributed. + assert rows[1].username == "" + + # Deleting the closing records the deletion and KEEPS the history (no FK). + assert c.delete(f"/api/sessions/{sid}").status_code == 200 + actions = [e.action for e in _entries(sid)] + assert "session_delete" in actions and "session_create" in actions + + +def test_audit_endpoint_admin_only_and_logins_attributed(clean_users): + _add_user("admin@x.com", "Admin A", "pw-longenough", admin=True) + _add_user("user@x.com", "User U", "pw-longenough2") + with TestClient(app) as c: + tok_admin = c.post("/api/auth/login", json={ + "username": "admin@x.com", "password": "pw-longenough"}).json()["token"] + tok_user = c.post("/api/auth/login", json={ + "username": "user@x.com", "password": "pw-longenough2"}).json()["token"] + + assert c.get("/api/audit").status_code == 401 # not signed in + r = c.get("/api/audit", headers={"Authorization": f"Bearer {tok_user}"}) + assert r.status_code == 403 # not an admin + r = c.get("/api/audit", headers={"Authorization": f"Bearer {tok_admin}"}) + assert r.status_code == 200 + + logins = [e for e in r.json()["entries"] if e["action"] == "login"] + assert {e["username"] for e in logins} >= {"admin@x.com", "user@x.com"} + + +def test_login_and_me_carry_admin_flag(clean_users): + _add_user("admin2@x.com", "Admin B", "pw-longenough", admin=True) + _add_user("user2@x.com", "User V", "pw-longenough2") + with TestClient(app) as c: + res = c.post("/api/auth/login", json={ + "username": "admin2@x.com", "password": "pw-longenough"}).json() + assert res["user"]["is_admin"] is True + me = c.get("/api/auth/me", + headers={"Authorization": f"Bearer {res['token']}"}).json() + assert me["is_admin"] is True + + res = c.post("/api/auth/login", json={ + "username": "user2@x.com", "password": "pw-longenough2"}).json() + assert res["user"]["is_admin"] is False diff --git a/ar-aging-app/frontend/src/App.tsx b/ar-aging-app/frontend/src/App.tsx index 04f1da3..9505b13 100644 --- a/ar-aging-app/frontend/src/App.tsx +++ b/ar-aging-app/frontend/src/App.tsx @@ -1,10 +1,11 @@ import { NavLink, Route, Routes } from "react-router-dom"; -import { LayoutDashboard, FilePlus2, LogOut, Settings as SettingsIcon, Landmark, Table2, UserCircle2 } from "lucide-react"; +import { LayoutDashboard, FilePlus2, LogOut, ScrollText, Settings as SettingsIcon, Landmark, Table2, UserCircle2 } from "lucide-react"; import Dashboard from "./pages/Dashboard"; import AccountsSummary from "./pages/AccountsSummary"; import NewClosing from "./pages/NewClosing"; import Closing from "./pages/Closing"; import Settings from "./pages/Settings"; +import AuditLog from "./pages/AuditLog"; import Login from "./pages/Login"; import { Spinner } from "./components/ui"; import { useAuth } from "./auth"; @@ -59,6 +60,7 @@ export default function App() { Accounts Summary New Closing Settings + {user?.is_admin && Audit Log} {user && (
@@ -87,6 +89,7 @@ export default function App() { } /> } /> } /> + } />
diff --git a/ar-aging-app/frontend/src/api/client.ts b/ar-aging-app/frontend/src/api/client.ts index d4d0435..8bc64e6 100644 --- a/ar-aging-app/frontend/src/api/client.ts +++ b/ar-aging-app/frontend/src/api/client.ts @@ -75,6 +75,24 @@ export interface UploadResultT { export interface AuthUserT { username: string; display_name: string; + /** Admin = can read the audit log (granted via manage.py set-admin). */ + is_admin?: boolean; +} + +export interface AuditEntryT { + id: number; + at: string | null; + username: string; + display_name: string; + action: string; + session_id: number | null; + session_name: string; + detail: string; +} + +export interface AuditLogT { + total: number; + entries: AuditEntryT[]; } export interface PayoutT { @@ -536,7 +554,12 @@ export const api = { req<{ token: string; user: AuthUserT }>("/auth/login", { method: "POST", body: JSON.stringify({ username, password }), }), - me: () => req<{ authenticated: boolean; username?: string; display_name?: string }>("/auth/me"), + me: () => req<{ authenticated: boolean; username?: string; display_name?: string; + is_admin?: boolean }>("/auth/me"), + auditLog: (opts: { limit?: number; offset?: number; action?: string } = {}) => + req(`/audit?${q({ + limit: opts.limit?.toString(), offset: opts.offset?.toString(), action: opts.action, + })}`), changePassword: (current_password: string, new_password: string) => req<{ changed: boolean }>("/auth/change-password", { method: "POST", body: JSON.stringify({ current_password, new_password }), diff --git a/ar-aging-app/frontend/src/auth.tsx b/ar-aging-app/frontend/src/auth.tsx index c19db22..09dcb8f 100644 --- a/ar-aging-app/frontend/src/auth.tsx +++ b/ar-aging-app/frontend/src/auth.tsx @@ -50,7 +50,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { const me = await api.me(); if (me.authenticated && me.username) { - setUser({ username: me.username, display_name: me.display_name ?? me.username }); + setUser({ username: me.username, display_name: me.display_name ?? me.username, + is_admin: me.is_admin ?? false }); } } catch { clearToken(); diff --git a/ar-aging-app/frontend/src/pages/AuditLog.tsx b/ar-aging-app/frontend/src/pages/AuditLog.tsx new file mode 100644 index 0000000..0cb7341 --- /dev/null +++ b/ar-aging-app/frontend/src/pages/AuditLog.tsx @@ -0,0 +1,156 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react"; +import { api } from "../api/client"; +import { EmptyState, Section, Spinner } from "../components/ui"; +import { useAuth } from "../auth"; + +const PAGE = 50; + +/** Human labels for audit actions; unknown actions fall back to the raw key. */ +const ACTION_LABELS: Record = { + login: "Signed in", + session_create: "Created closing", + session_delete: "Deleted closing", + session_reopen: "Reopened closing", + file_upload: "Uploaded file", + file_delete: "Deleted file", + process_run: "Ran processing", + export_generate: "Generated export", + export_download: "Downloaded export", +}; + +/** Backend timestamps are naive UTC — pin them to UTC before rendering local time. */ +function fmtWhen(at: string | null): string { + if (!at) return "—"; + const d = new Date(/[Z+]/.test(at.slice(-6)) ? at : at + "Z"); + return d.toLocaleString(undefined, { + year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", + }); +} + +export default function AuditLog() { + const { user } = useAuth(); + const [offset, setOffset] = useState(0); + const [action, setAction] = useState(""); + + const { data, isLoading, error } = useQuery({ + queryKey: ["audit", offset, action], + queryFn: () => api.auditLog({ limit: PAGE, offset, action: action || undefined }), + enabled: !!user?.is_admin, + }); + + if (user && !user.is_admin) + return ( +
+ +
+ ); + + const total = data?.total ?? 0; + const entries = data?.entries ?? []; + + return ( +
+
+ + + +
+

Audit Log

+

+ Who signed in, uploaded, processed, exported, and deleted — newest first. +

+
+
+ +
{ setAction(e.target.value); setOffset(0); }} + > + + {Object.entries(ACTION_LABELS).map(([k, v]) => ( + + ))} + + } + > + {isLoading && ( +
+ Loading… +
+ )} + {error instanceof Error && ( +
{error.message}
+ )} + {!isLoading && !error && entries.length === 0 && ( + + )} + {entries.length > 0 && ( +
+ + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + ))} + +
WhenWhoActionClosingDetail
{fmtWhen(e.at)} +
{e.display_name || "(no login)"}
+ {e.username &&
{e.username}
} +
+ {ACTION_LABELS[e.action] ?? e.action} + + {e.session_name || (e.session_id ? `#${e.session_id}` : "—")} + {e.detail || "—"}
+
+ )} + {total > PAGE && ( +
+ + {offset + 1}–{Math.min(offset + PAGE, total)} of {total.toLocaleString()} + +
+ + +
+
+ )} +
+
+ ); +}