Backend - A run's outcome write sits outside the job's own try: a database blip after a run that succeeded no longer records it as failed (the row waits for the next start's sweep). - An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does). - The Scheduler's ad-hoc row says running while any backfill runs, not only the newest. - recent_runs reads each job's last runs off its index instead of ranking the whole table. - An interval change is stored with its audit row in one transaction, then rescheduled. - One API instance per database is the stated contract (.env.example, jobruns). - Turning full history on for a channel already complete records no run. - Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are test-held to the enums; the account filter is one helper for the four activity reads. - An audit row says how many of its request's log lines are still kept. Frontend - An "All" period reaches every row still kept (runs and admin actions outlive 90 days). - Back/Forward is followed in place by the server log: a closing dialog no longer turns Live off; a pivot still opens a fresh log. - Runs moved over from the audit log read their one-liner in the reader's language. - Filter choices or accounts that failed to load say so beside the filters, with a retry. - An old server-log link keeps its account, text and range; a remembered retired page opens its tab. - A request whose lines are gone says so instead of linking to an empty list. - The log lines and the history rows share one row frame, and the server log one list frame with the other tabs; an empty list is announced; each day's quota is readable.
155 lines
5.7 KiB
Python
155 lines
5.7 KiB
Python
"""Admin audit log API: read the append-only who/what/when trail, and clear it.
|
|
|
|
Admin-only. The activity page reads it a page at a time, newest first, filtered on the server like
|
|
the server log (`_keyset` paging), so the whole trail is reachable however long it grows."""
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import Text, cast, delete, func, or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import audit
|
|
from app.audit import AuditAction
|
|
from app.auth import admin_user
|
|
from app.db import get_db
|
|
from app.models import AuditLog, LogEvent, User
|
|
from app.routes import _account as account
|
|
from app.routes._keyset import PAGE_MAX, accounts, older_than, page, utc
|
|
from app.utils import iso, like_escape
|
|
|
|
router = APIRouter(prefix="/api/admin/audit", tags=["admin"])
|
|
|
|
|
|
def _serialize(row: AuditLog, lines: dict[str, int]) -> dict:
|
|
return {
|
|
"id": row.id,
|
|
"created_at": iso(row.created_at),
|
|
# NULL = the background did it (the client shows "System"); the address is in `accounts`.
|
|
"actor_id": row.actor_id,
|
|
"action": row.action,
|
|
"target_type": row.target_type,
|
|
"target_id": row.target_id,
|
|
"summary": row.summary,
|
|
"before": row.before,
|
|
"after": row.after,
|
|
"request_id": row.request_id,
|
|
# How many of that request's log lines are still kept: they go after days, the row after
|
|
# months, and a link to none would open an empty list.
|
|
"log_lines": lines.get(row.request_id, 0) if row.request_id else 0,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AuditFilters:
|
|
action: str | None
|
|
actor: str | None
|
|
q: str | None
|
|
since: datetime | None
|
|
until: datetime | None
|
|
|
|
def conditions(self, skip: Literal["action"] | None = None) -> list:
|
|
"""The WHERE conditions, ANDed. `skip="action"` leaves the action filter out, so the action
|
|
menu counts every action under the other filters (the way back from a pick)."""
|
|
out = []
|
|
if self.action is not None and skip != "action":
|
|
out.append(AuditLog.action == self.action)
|
|
if self.actor is not None:
|
|
out.append(account.condition(AuditLog.actor_id, self.actor))
|
|
if self.since is not None:
|
|
out.append(AuditLog.created_at >= utc(self.since))
|
|
if self.until is not None:
|
|
out.append(AuditLog.created_at < utc(self.until))
|
|
if self.q and self.q.strip():
|
|
like = f"%{like_escape(self.q.strip())}%"
|
|
out.append(
|
|
or_(
|
|
AuditLog.summary.ilike(like),
|
|
AuditLog.target_id.ilike(like),
|
|
AuditLog.action.ilike(like),
|
|
AuditLog.request_id.ilike(like),
|
|
cast(AuditLog.before, Text).ilike(like),
|
|
cast(AuditLog.after, Text).ilike(like),
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
def audit_filters(
|
|
action: str | None = Query(default=None, max_length=48, pattern=r"^[a-z][a-z0-9_]*$"),
|
|
user: str | None = Query(default=None, pattern=account.PATTERN),
|
|
q: str | None = Query(default=None, max_length=200),
|
|
since: datetime | None = None,
|
|
until: datetime | None = None,
|
|
) -> AuditFilters:
|
|
return AuditFilters(action, user, q, since, until)
|
|
|
|
|
|
@router.get("")
|
|
def list_audit(
|
|
filters: AuditFilters = Depends(audit_filters),
|
|
before: str | None = Query(default=None, max_length=200),
|
|
limit: int = Query(default=100, ge=1, le=PAGE_MAX),
|
|
_: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""One page of rows, newest first, matching every filter given; `before` continues from a
|
|
previous page's `next_cursor`."""
|
|
query = select(AuditLog).where(*filters.conditions())
|
|
if before is not None:
|
|
query = query.where(older_than(AuditLog.created_at, AuditLog.id, before))
|
|
order = (AuditLog.created_at.desc(), AuditLog.id.desc())
|
|
rows, cursor = page(
|
|
list(db.execute(query.order_by(*order).limit(limit + 1)).scalars().all()),
|
|
limit,
|
|
lambda r: r.created_at,
|
|
)
|
|
requests = {r.request_id for r in rows if r.request_id}
|
|
lines: dict[str, int] = {}
|
|
if requests:
|
|
counted = db.execute(
|
|
select(LogEvent.request_id, func.count())
|
|
.where(LogEvent.request_id.in_(requests))
|
|
.group_by(LogEvent.request_id)
|
|
)
|
|
lines = {rid: n for rid, n in counted.tuples() if rid is not None}
|
|
return {
|
|
"items": [_serialize(r, lines) for r in rows],
|
|
"accounts": accounts(db, {r.actor_id for r in rows if r.actor_id is not None}),
|
|
"next_cursor": cursor,
|
|
}
|
|
|
|
|
|
@router.get("/facets")
|
|
def audit_facets(
|
|
filters: AuditFilters = Depends(audit_filters),
|
|
_: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""The actions present under the other filters, with counts — what the action menu offers."""
|
|
n = func.count().label("n")
|
|
stmt = (
|
|
select(AuditLog.action, n)
|
|
.where(*filters.conditions("action"))
|
|
.group_by(AuditLog.action)
|
|
.order_by(n.desc(), AuditLog.action)
|
|
)
|
|
return {"actions": [{"value": a, "count": c} for a, c in db.execute(stmt).all()]}
|
|
|
|
|
|
@router.delete("")
|
|
def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
|
"""Clear the audit log. Leaves ONE row recording who cleared it + how many (tamper-evidence)."""
|
|
n = db.execute(delete(AuditLog)).rowcount or 0
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.AUDIT_CLEAR,
|
|
summary=f"Cleared {n} audit entries",
|
|
after={"cleared": n}, # the count as data: the log names it in the reader's language
|
|
)
|
|
db.commit()
|
|
return {"ok": True, "cleared": n}
|