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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user