Audit trail: record who uploads, processes, exports, deletes; admin-only log view
Deploy to S3 / deploy (push) Successful in 23s
Details
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
parent
5d1ccd774c
commit
367a64b59d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 list-users
|
||||
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
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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() {
|
|||
<SideLink to="/accounts" icon={Table2}>Accounts Summary</SideLink>
|
||||
<SideLink to="/new" icon={FilePlus2}>New Closing</SideLink>
|
||||
<SideLink to="/settings" icon={SettingsIcon}>Settings</SideLink>
|
||||
{user?.is_admin && <SideLink to="/audit" icon={ScrollText}>Audit Log</SideLink>}
|
||||
</nav>
|
||||
{user && (
|
||||
<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="/closing/:id/*" element={<Closing />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/audit" element={<AuditLog />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<AuditLogT>(`/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 }),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue