Task #95: Let admins export the audit log as CSV

What was done
- Added a new admin-only endpoint GET /api/admin/audit-logs/export that streams
  the filtered audit log as a CSV download. Honors the same `action`, `from`,
  and `to` query parameters as the list endpoint, validated through a shared
  parseFilters/buildWhere helper extracted from the existing handler.
- Columns: action, actor_username, target_type, target_id, created_at,
  metadata (compact JSON). Rows ordered newest-first and capped at 50,000 to
  bound response size. UTF-8 BOM prepended so spreadsheet apps (incl. Excel)
  detect encoding correctly for Arabic content stored in metadata.
- Response sets Content-Type: text/csv; charset=utf-8,
  Content-Disposition: attachment; filename="audit-log-YYYY-MM-DD.csv",
  Cache-Control: no-store, and is written via res.write streaming.
- Updated lib/api-spec/openapi.yaml with operationId exportAuditLogsCsv and
  ran codegen to regenerate the React client + Zod schemas.
- Frontend (artifacts/tx-os/src/pages/admin.tsx → AuditLogPanel): added an
  "Export CSV" button next to the existing filter actions. Clicking it
  fetches the export URL with current filters using same-origin cookies,
  triggers a browser download, and surfaces a localized error if the request
  fails. The button is disabled while filters are invalid or while a
  download is in flight, and a spinner replaces the icon during export.
- Added bilingual translation keys admin.audit.export.button /
  admin.audit.export.failed in en.json and ar.json.

Security hardening (post code-review)
- csvEscape now prefixes a single quote when a cell value begins with one of
  =, +, -, @, tab, or CR, mitigating CSV/spreadsheet formula injection
  (Excel, LibreOffice, Google Sheets evaluate such cells as formulas).
  Verified end-to-end by inserting a synthetic audit row whose action started
  with "=" and confirming the export wrote it as "'=...".

Verification
- Typecheck of tx-os passes; api-server has only pre-existing executive-meetings
  errors unrelated to this change.
- Manual curl smoke test: 200 with correct headers, BOM present, action and
  date filters honored, invalid date returns 400.
- E2e UI test (Playwright via testing skill): logged in as admin, opened
  the Audit Log section, downloaded the unfiltered CSV (correct filename and
  header row), then re-exported with action=app.delete and confirmed only
  matching rows came back.
- Targeted security check confirmed CSV injection mitigation (see above).

Notes / drift
- None for the spec. The generated `exportAuditLogsCsv()` helper in
  lib/api-client-react is typed Promise<Blob> but, because customFetch
  auto-infers text/csv as text, would currently return text if anyone called
  it directly. The Audit Log UI uses a raw fetch with credentials: "include"
  to download the blob, so this does not affect the feature. Updating the
  generated client's responseType handling is project-wide config and is
  intentionally out of scope here.

Follow-ups proposed
- #123 Add automated tests for the audit log CSV export (test_gaps)
- #124 Let admins pick which columns to include when exporting the audit log
  (next_steps)

Replit-Task-Id: 88a50100-caa7-4b37-b9da-cfdc42bee119
This commit is contained in:
riyadhafraa
2026-04-29 06:56:09 +00:00
parent 30ea1facd3
commit be4ecb30bb
9 changed files with 360 additions and 11 deletions
+103 -10
View File
@@ -1,4 +1,4 @@
import { Router, type IRouter } from "express";
import { Router, type IRouter, type Request, type Response } from "express";
import { and, desc, eq, gte, lt, sql, type SQL } from "drizzle-orm";
import { db } from "@workspace/db";
import { auditLogsTable, usersTable } from "@workspace/db";
@@ -7,6 +7,7 @@ import { requireAdmin } from "../middlewares/auth";
const router: IRouter = Router();
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const EXPORT_ROW_CAP = 50_000;
function parseUtcDate(s: string): Date | null {
if (!DATE_RE.test(s)) return null;
@@ -14,7 +15,13 @@ function parseUtcDate(s: string): Date | null {
return Number.isNaN(d.getTime()) ? null : d;
}
router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> => {
type AuditFilters = {
action: string;
fromDate: Date | null;
toDateExclusive: Date | null;
};
function parseFilters(req: Request, res: Response): AuditFilters | null {
const action = typeof req.query.action === "string" ? req.query.action.trim() : "";
const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
const toStr = typeof req.query.to === "string" ? req.query.to.trim() : "";
@@ -25,21 +32,49 @@ router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> =>
fromDate = parseUtcDate(fromStr);
if (!fromDate) {
res.status(400).json({ error: "Invalid 'from' date (expected YYYY-MM-DD)" });
return;
return null;
}
}
if (toStr) {
const toDate = parseUtcDate(toStr);
if (!toDate) {
res.status(400).json({ error: "Invalid 'to' date (expected YYYY-MM-DD)" });
return;
return null;
}
toDateExclusive = new Date(toDate.getTime() + 24 * 60 * 60 * 1000);
}
if (fromDate && toDateExclusive && fromDate >= toDateExclusive) {
res.status(400).json({ error: "'from' must be on or before 'to'" });
return;
return null;
}
return { action, fromDate, toDateExclusive };
}
function buildWhere(filters: AuditFilters): SQL | undefined {
const conditions: SQL[] = [];
if (filters.action) conditions.push(eq(auditLogsTable.action, filters.action));
if (filters.fromDate) conditions.push(gte(auditLogsTable.createdAt, filters.fromDate));
if (filters.toDateExclusive)
conditions.push(lt(auditLogsTable.createdAt, filters.toDateExclusive));
return conditions.length > 0 ? and(...conditions) : undefined;
}
function csvEscape(value: unknown): string {
if (value === null || value === undefined) return "";
let s = typeof value === "string" ? value : String(value);
// Mitigate CSV/spreadsheet formula injection (Excel, LibreOffice, Google
// Sheets evaluate cells starting with =, +, -, @, or a tab/CR as formulas).
// Prefix a single quote so the spreadsheet renders the value as text.
if (s.length > 0 && /^[=+\-@\t\r]/.test(s)) {
s = `'${s}`;
}
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> => {
const filters = parseFilters(req, res);
if (!filters) return;
const limitRaw = Number(req.query.limit);
const offsetRaw = Number(req.query.offset);
@@ -50,11 +85,7 @@ router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> =>
const offset =
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
const conditions: SQL[] = [];
if (action) conditions.push(eq(auditLogsTable.action, action));
if (fromDate) conditions.push(gte(auditLogsTable.createdAt, fromDate));
if (toDateExclusive) conditions.push(lt(auditLogsTable.createdAt, toDateExclusive));
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const whereClause = buildWhere(filters);
const baseQuery = db
.select({
@@ -121,4 +152,66 @@ router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> =>
});
});
router.get(
"/admin/audit-logs/export",
requireAdmin,
async (req, res): Promise<void> => {
const filters = parseFilters(req, res);
if (!filters) return;
const whereClause = buildWhere(filters);
const baseQuery = db
.select({
action: auditLogsTable.action,
targetType: auditLogsTable.targetType,
targetId: auditLogsTable.targetId,
metadata: auditLogsTable.metadata,
createdAt: auditLogsTable.createdAt,
actorUsername: usersTable.username,
})
.from(auditLogsTable)
.leftJoin(usersTable, eq(usersTable.id, auditLogsTable.actorUserId));
const rows = await (whereClause ? baseQuery.where(whereClause) : baseQuery)
.orderBy(desc(auditLogsTable.createdAt), desc(auditLogsTable.id))
.limit(EXPORT_ROW_CAP);
const stamp = new Date().toISOString().slice(0, 10);
const filename = `audit-log-${stamp}.csv`;
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="${filename}"`,
);
res.setHeader("Cache-Control", "no-store");
// UTF-8 BOM so spreadsheet apps (Excel) detect encoding correctly,
// important for Arabic content stored in metadata.
res.write("\uFEFF");
res.write("action,actor_username,target_type,target_id,created_at,metadata\r\n");
for (const r of rows) {
const createdAtIso =
r.createdAt instanceof Date
? r.createdAt.toISOString()
: new Date(r.createdAt as unknown as string).toISOString();
const metadataJson =
r.metadata == null ? "" : JSON.stringify(r.metadata);
const line = [
csvEscape(r.action),
csvEscape(r.actorUsername),
csvEscape(r.targetType),
csvEscape(r.targetId),
csvEscape(createdAtIso),
csvEscape(metadataJson),
].join(",");
res.write(line);
res.write("\r\n");
}
res.end();
},
);
export default router;