Audit trail: record who uploads, processes, exports, deletes; admin-only log view
Deploy to S3 / deploy (push) Successful in 23s Details

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 <username>`)
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 <noreply@anthropic.com>
main
Talha Ahmed 2026-09-01 21:01:58 +05:00
parent 5d1ccd774c
commit 367a64b59d
16 changed files with 489 additions and 21 deletions

View File

@ -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. # One message for both wrong-user and wrong-password: don't confirm usernames.
raise HTTPException(401, "Wrong username or password.") raise HTTPException(401, "Wrong username or password.")
logger.info("login: %s", user.username) logger.info("login: %s", user.username)
db.add(models.AuditLog(username=user.username, display_name=user.display_name,
action="login"))
db.commit()
return { return {
"token": create_token(user), "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, "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") @router.get("/me")
def me(request: Request) -> dict: def me(request: Request, db: OrmSession = Depends(db_dep)) -> dict:
user = current_user(request) user = current_user(request)
if user is None: if user is None:
if auth_required(): if auth_required():
raise HTTPException(401, "Not signed in.") raise HTTPException(401, "Not signed in.")
return {"authenticated": False, "auth_required": False} return {"authenticated": False, "auth_required": False}
return {"authenticated": True, "auth_required": True, 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 # ------------------------------------------------------------- emailed password codes

View File

@ -16,7 +16,7 @@ from ..db.database import ENGINE, init_db
from . import auth from . import auth
from .routes import ( from .routes import (
sessions, files, processing, results, settings as settings_routes, export, ar, control, 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( logging.basicConfig(
@ -108,3 +108,4 @@ app.include_router(controls.router)
app.include_router(payouts.router) app.include_router(payouts.router)
app.include_router(accounts_summary.router) app.include_router(accounts_summary.router)
app.include_router(fx.router) app.include_router(fx.router)
app.include_router(audit.router)

View File

@ -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],
}

View File

@ -3,11 +3,12 @@ from __future__ import annotations
import os import os
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.orm import Session as OrmSession from sqlalchemy.orm import Session as OrmSession
from ...db import models from ...db import models
from ...services.audit import record as audit
from ...services.jobs import run_export, run_summary_export from ...services.jobs import run_export, run_summary_export
from ..deps import db_dep, ensure_not_blocked, get_session_or_404 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") @router.post("/{session_id}/export")
def start_export(session_id: int, background: BackgroundTasks, kind: str = "full", def start_export(session_id: int, background: BackgroundTasks, request: Request,
db: OrmSession = Depends(db_dep)) -> dict: kind: str = "full", db: OrmSession = Depends(db_dep)) -> dict:
"""kind='summary' → compact Finance pack (fast); kind='full' → complete audit workbook.""" """kind='summary' → compact Finance pack (fast); kind='full' → complete audit workbook."""
if kind not in ("full", "summary"): if kind not in ("full", "summary"):
raise HTTPException(400, "kind must be 'full' or '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.progress_rows_total = 0
s.eta_seconds = 0 s.eta_seconds = 0
s.error = "" s.error = ""
audit(db, request, "export_generate", session=s, detail=f"kind={kind}")
db.commit() db.commit()
background.add_task(run_summary_export if kind == "summary" else run_export, session_id) background.add_task(run_summary_export if kind == "summary" else run_export, session_id)
return {"started": True, "kind": kind} 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") @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) s = get_session_or_404(session_id, db)
# A workbook generated before a control started failing must not keep circulating. # A workbook generated before a control started failing must not keep circulating.
ensure_not_blocked(s) 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() r = q.order_by(models.ExportRecord.generated_at.desc()).first()
if not r or not r.path or not os.path.exists(r.path): if not r or not r.path or not os.path.exists(r.path):
raise HTTPException(404, "No export available; generate it first.") 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( return FileResponse(
r.path, filename=os.path.basename(r.path), r.path, filename=os.path.basename(r.path),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",

View File

@ -4,13 +4,14 @@ from __future__ import annotations
import hashlib import hashlib
import os 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 sqlalchemy.orm import Session as OrmSession
from ...config import ALLOWED_EXTENSIONS, MAX_UPLOAD_BYTES, UPLOAD_DIR from ...config import ALLOWED_EXTENSIONS, MAX_UPLOAD_BYTES, UPLOAD_DIR
from ...core.readers import make_reader from ...core.readers import make_reader
from ...core.xlsx_reader import ParseError from ...core.xlsx_reader import ParseError
from ...db import models 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 from ..deps import db_dep, ensure_editable, file_dict, get_session_or_404, sanitize_filename
router = APIRouter(prefix="/api/sessions", tags=["files"]) router = APIRouter(prefix="/api/sessions", tags=["files"])
@ -44,7 +45,7 @@ def _validate(rec: models.SessionFile, path: str) -> None:
@router.post("/{session_id}/files") @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: db: OrmSession = Depends(db_dep)) -> dict:
""" """
Upload one or more source files into a closing. 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) os.replace(tmp, path)
changed = True 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: if same_name is not None:
# Replace in place: update the existing row rather than adding a second one. # Replace in place: update the existing row rather than adding a second one.
rec = same_name 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}") @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) s = get_session_or_404(session_id, db)
ensure_editable(s) ensure_editable(s)
f = db.get(models.SessionFile, file_id) 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) os.remove(f.stored_path)
except OSError: except OSError:
pass pass
audit(db, request, "file_delete", session=s, detail=f"'{f.filename}'")
db.delete(f) db.delete(f)
if s.status in ("processed", "blocked"): if s.status in ("processed", "blocked"):
s.needs_reprocess = True s.needs_reprocess = True

View File

@ -1,10 +1,11 @@
"""Start processing (background) and poll status/progress.""" """Start processing (background) and poll status/progress."""
from __future__ import annotations 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 sqlalchemy.orm import Session as OrmSession
from ...db import models from ...db import models
from ...services.audit import record as audit
from ...services.jobs import run_processing from ...services.jobs import run_processing
from ..deps import db_dep, ensure_editable, get_session_or_404, session_dict 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") @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: db: OrmSession = Depends(db_dep)) -> dict:
s = get_session_or_404(session_id, db) s = get_session_or_404(session_id, db)
ensure_editable(s) ensure_editable(s)
@ -30,6 +31,8 @@ def start_processing(session_id: int, background: BackgroundTasks,
s.progress_stage = "Queued" s.progress_stage = "Queued"
s.progress_pct = 0.0 s.progress_pct = 0.0
s.error = "" s.error = ""
audit(db, request, "process_run", session=s,
detail=f"{len(valid_files)} file(s)")
db.commit() db.commit()
background.add_task(run_processing, session_id) background.add_task(run_processing, session_id)
return {"started": True, "session_id": session_id} return {"started": True, "session_id": session_id}

View File

@ -4,12 +4,13 @@ from __future__ import annotations
import logging import logging
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session as OrmSession from sqlalchemy.orm import Session as OrmSession
from ...config import DEFAULT_CLEARING_LAG_DAYS, DEFAULT_TOLERANCE from ...config import DEFAULT_CLEARING_LAG_DAYS, DEFAULT_TOLERANCE
from ...db import models from ...db import models
from ...services.audit import record as audit
from ..deps import db_dep, get_session_or_404, session_dict from ..deps import db_dep, get_session_or_404, session_dict
router = APIRouter(prefix="/api/sessions", tags=["sessions"]) router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@ -77,7 +78,8 @@ def list_sessions(db: OrmSession = Depends(db_dep)) -> list[dict]:
@router.post("") @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 me = body.month_end_date
month = me.strftime("%Y-%m") if me else None month = me.strftime("%Y-%m") if me else None
if month and not body.allow_duplicate: if month and not body.allow_duplicate:
@ -104,6 +106,9 @@ def create_session(body: SessionCreate, db: OrmSession = Depends(db_dep)) -> dic
status="draft", status="draft",
) )
db.add(s) 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() db.commit()
from .ar import seed_opening_from_prior from .ar import seed_opening_from_prior
seed_opening_from_prior(db, s) seed_opening_from_prior(db, s)
@ -132,13 +137,15 @@ def update_session(session_id: int, body: SessionUpdate,
@router.post("/{session_id}/reopen") @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 """Unlock a completed closing for corrections. Deliberate and logged — the opposite of
silently editing published history.""" silently editing published history."""
s = get_session_or_404(session_id, db) s = get_session_or_404(session_id, db)
if s.status != "completed": if s.status != "completed":
raise HTTPException(409, "Only a completed closing can be reopened.") raise HTTPException(409, "Only a completed closing can be reopened.")
s.status = "blocked" if s.blocked_reason else "processed" s.status = "blocked" if s.blocked_reason else "processed"
audit(db, request, "session_reopen", session=s)
db.commit() db.commit()
logger.warning("closing %s (%s, %s) reopened for corrections", logger.warning("closing %s (%s, %s) reopened for corrections",
s.id, s.name, s.reporting_month or "no month") 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}") @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.""" """Delete a closing and every row/file that belongs to it."""
s = get_session_or_404(session_id, db) s = get_session_or_404(session_id, db)
if s.status in ("processing", "exporting"): if s.status in ("processing", "exporting"):
raise HTTPException(409, "This closing is still processing — wait for it to finish.") 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 from ...services.store import purge_session
return purge_session(db, session_id) return purge_session(db, session_id)

View File

@ -127,6 +127,7 @@ def _migrate() -> None:
("reset_code_hash", "VARCHAR(255) DEFAULT ''"), ("reset_code_hash", "VARCHAR(255) DEFAULT ''"),
("reset_code_expires", "DATETIME"), ("reset_code_expires", "DATETIME"),
("reset_code_attempts", "INTEGER DEFAULT 0"), ("reset_code_attempts", "INTEGER DEFAULT 0"),
("is_admin", "BOOLEAN DEFAULT 0"),
], ],
"sessions": [ "sessions": [
("progress_rows_done", "INTEGER DEFAULT 0"), ("progress_rows_done", "INTEGER DEFAULT 0"),

View File

@ -25,6 +25,8 @@ class User(Base):
display_name = Column(String(255), nullable=False) display_name = Column(String(255), nullable=False)
password_hash = Column(String(512), nullable=False) password_hash = Column(String(512), nullable=False)
is_active = Column(Boolean, default=True) 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) created_at = Column(DateTime, default=_now)
# Emailed password code (usernames are email addresses). Stored as an HMAC, never the # 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. # 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) 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): class Session(Base):
__tablename__ = "sessions" __tablename__ = "sessions"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)

View File

@ -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

View File

@ -5,6 +5,7 @@ Admin commands (run on the server, next to the app):
python manage.py set-password <username> # prompts for password python manage.py set-password <username> # prompts for password
python manage.py list-users python manage.py list-users
python manage.py deactivate-user <username> python manage.py deactivate-user <username>
python manage.py set-admin <username> [--revoke] # audit-log access
python manage.py dedupe-files [--apply] # fix double-counted uploads 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. 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 return 0
for u in rows: for u in rows:
flag = "" if u.is_active else " [DEACTIVATED]" 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 return 0
finally: finally:
db.close() db.close()
@ -171,6 +190,11 @@ def main(argv: list[str]) -> int:
p.add_argument("username") p.add_argument("username")
p.set_defaults(fn=cmd_deactivate_user) 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 = 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.add_argument("--apply", action="store_true", help="actually delete (default: dry run)")
p.set_defaults(fn=cmd_dedupe_files) p.set_defaults(fn=cmd_dedupe_files)

View File

@ -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

View File

@ -1,10 +1,11 @@
import { NavLink, Route, Routes } from "react-router-dom"; 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 Dashboard from "./pages/Dashboard";
import AccountsSummary from "./pages/AccountsSummary"; import AccountsSummary from "./pages/AccountsSummary";
import NewClosing from "./pages/NewClosing"; import NewClosing from "./pages/NewClosing";
import Closing from "./pages/Closing"; import Closing from "./pages/Closing";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
import AuditLog from "./pages/AuditLog";
import Login from "./pages/Login"; import Login from "./pages/Login";
import { Spinner } from "./components/ui"; import { Spinner } from "./components/ui";
import { useAuth } from "./auth"; import { useAuth } from "./auth";
@ -59,6 +60,7 @@ export default function App() {
<SideLink to="/accounts" icon={Table2}>Accounts Summary</SideLink> <SideLink to="/accounts" icon={Table2}>Accounts Summary</SideLink>
<SideLink to="/new" icon={FilePlus2}>New Closing</SideLink> <SideLink to="/new" icon={FilePlus2}>New Closing</SideLink>
<SideLink to="/settings" icon={SettingsIcon}>Settings</SideLink> <SideLink to="/settings" icon={SettingsIcon}>Settings</SideLink>
{user?.is_admin && <SideLink to="/audit" icon={ScrollText}>Audit Log</SideLink>}
</nav> </nav>
{user && ( {user && (
<div className="m-3 p-3 rounded-2xl bg-canvas/70 space-y-2.5"> <div className="m-3 p-3 rounded-2xl bg-canvas/70 space-y-2.5">
@ -87,6 +89,7 @@ export default function App() {
<Route path="/new" element={<NewClosing />} /> <Route path="/new" element={<NewClosing />} />
<Route path="/closing/:id/*" element={<Closing />} /> <Route path="/closing/:id/*" element={<Closing />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/audit" element={<AuditLog />} />
</Routes> </Routes>
</main> </main>
</div> </div>

View File

@ -75,6 +75,24 @@ export interface UploadResultT {
export interface AuthUserT { export interface AuthUserT {
username: string; username: string;
display_name: 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 { export interface PayoutT {
@ -536,7 +554,12 @@ export const api = {
req<{ token: string; user: AuthUserT }>("/auth/login", { req<{ token: string; user: AuthUserT }>("/auth/login", {
method: "POST", body: JSON.stringify({ username, password }), 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<AuditLogT>(`/audit?${q({
limit: opts.limit?.toString(), offset: opts.offset?.toString(), action: opts.action,
})}`),
changePassword: (current_password: string, new_password: string) => changePassword: (current_password: string, new_password: string) =>
req<{ changed: boolean }>("/auth/change-password", { req<{ changed: boolean }>("/auth/change-password", {
method: "POST", body: JSON.stringify({ current_password, new_password }), method: "POST", body: JSON.stringify({ current_password, new_password }),

View File

@ -50,7 +50,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try { try {
const me = await api.me(); const me = await api.me();
if (me.authenticated && me.username) { 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 { } catch {
clearToken(); clearToken();

View File

@ -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<string, string> = {
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 (
<div className="p-6">
<EmptyState title="Admin access required"
hint="The audit log is visible to administrators only." />
</div>
);
const total = data?.total ?? 0;
const entries = data?.entries ?? [];
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<header className="flex items-center gap-3">
<span className="inline-flex items-center justify-center w-9 h-9 rounded-xl bg-primary-soft text-primary">
<ScrollText size={18} />
</span>
<div>
<h1 className="text-xl font-semibold text-ink">Audit Log</h1>
<p className="text-sm text-subink">
Who signed in, uploaded, processed, exported, and deleted newest first.
</p>
</div>
</header>
<Section
title={`Activity${total ? ` · ${total.toLocaleString()} entries` : ""}`}
actions={
<select
className="text-sm rounded-xl border border-line bg-panel px-3 py-1.5 text-ink"
value={action}
onChange={(e) => { setAction(e.target.value); setOffset(0); }}
>
<option value="">All actions</option>
{Object.entries(ACTION_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
}
>
{isLoading && (
<div className="p-8 flex items-center justify-center gap-2 text-subink">
<Spinner /> Loading
</div>
)}
{error instanceof Error && (
<div className="p-6 text-sm text-bad">{error.message}</div>
)}
{!isLoading && !error && entries.length === 0 && (
<EmptyState title="No activity recorded yet"
hint="Entries appear here as people sign in, upload files, run processing, and export." />
)}
{entries.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-muted border-b border-line">
<th className="px-4 py-2.5 whitespace-nowrap">When</th>
<th className="px-4 py-2.5 whitespace-nowrap">Who</th>
<th className="px-4 py-2.5 whitespace-nowrap">Action</th>
<th className="px-4 py-2.5 whitespace-nowrap">Closing</th>
<th className="px-4 py-2.5">Detail</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{entries.map((e) => (
<tr key={e.id} className="align-top">
<td className="px-4 py-2.5 whitespace-nowrap text-subink">{fmtWhen(e.at)}</td>
<td className="px-4 py-2.5 whitespace-nowrap">
<div className="font-medium text-ink">{e.display_name || "(no login)"}</div>
{e.username && <div className="text-[11px] text-muted">{e.username}</div>}
</td>
<td className="px-4 py-2.5 whitespace-nowrap text-ink">
{ACTION_LABELS[e.action] ?? e.action}
</td>
<td className="px-4 py-2.5 whitespace-nowrap text-subink">
{e.session_name || (e.session_id ? `#${e.session_id}` : "—")}
</td>
<td className="px-4 py-2.5 text-subink break-words max-w-md">{e.detail || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{total > PAGE && (
<div className="flex items-center justify-between px-4 py-3 border-t border-line text-sm text-subink">
<span>
{offset + 1}{Math.min(offset + PAGE, total)} of {total.toLocaleString()}
</span>
<div className="flex gap-2">
<button
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-xl border border-line
bg-panel text-ink disabled:opacity-40"
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE))}
>
<ChevronLeft size={15} /> Newer
</button>
<button
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-xl border border-line
bg-panel text-ink disabled:opacity-40"
disabled={offset + PAGE >= total}
onClick={() => setOffset(offset + PAGE)}
>
Older <ChevronRight size={15} />
</button>
</div>
</div>
)}
</Section>
</div>
);
}