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;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+4
View File
@@ -489,6 +489,10 @@
"showing": "عرض {{shown}} من أصل {{total}}",
"empty": "لا توجد إدخالات في سجل التدقيق تطابق الفلاتر.",
"loadMore": "عرض المزيد",
"export": {
"button": "تصدير CSV",
"failed": "تعذّر تصدير سجل التدقيق. يُرجى المحاولة مرة أخرى."
},
"errors": {
"invalidDate": "يجب أن تكون التواريخ بصيغة YYYY-MM-DD.",
"invertedDates": "تاريخ \"من\" يجب أن يكون قبل أو يساوي تاريخ \"إلى\".",
+4
View File
@@ -486,6 +486,10 @@
"showing": "Showing {{shown}} of {{total}}",
"empty": "No audit log entries match these filters.",
"loadMore": "Load more",
"export": {
"button": "Export CSV",
"failed": "Could not export the audit log. Please try again."
},
"errors": {
"invalidDate": "Dates must be in YYYY-MM-DD format.",
"invertedDates": "\"From\" date must be on or before \"To\" date.",
+65 -1
View File
@@ -48,6 +48,7 @@ import {
useReplaceRolePermissions,
useListAuditLogs,
getListAuditLogsQueryKey,
getExportAuditLogsCsvUrl,
ApiError,
} from "@workspace/api-client-react";
import type {
@@ -74,7 +75,7 @@ import { useQueryClient, useQuery } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText } from "lucide-react";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -4001,6 +4002,8 @@ function AuditLogPanel() {
const [appliedFrom, setAppliedFrom] = useState<string>("");
const [appliedTo, setAppliedTo] = useState<string>("");
const [pageLimit, setPageLimit] = useState<number>(50);
const [exporting, setExporting] = useState<boolean>(false);
const [exportError, setExportError] = useState<string | null>(null);
const fromValid = !appliedFrom || DATE_RE.test(appliedFrom);
const toValid = !appliedTo || DATE_RE.test(appliedTo);
@@ -4048,6 +4051,45 @@ function AuditLogPanel() {
const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200));
const handleExportCsv = async () => {
if (!filtersValid || exporting) return;
setExportError(null);
setExporting(true);
try {
const url = getExportAuditLogsCsvUrl({
...(actionFilter ? { action: actionFilter } : {}),
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
});
const res = await fetch(url, {
method: "GET",
credentials: "include",
headers: { Accept: "text/csv" },
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const blob = await res.blob();
const stamp = new Date().toISOString().slice(0, 10);
const filename = `audit-log-${stamp}.csv`;
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Defer revoke so the browser can finish the download.
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch (err) {
setExportError(
err instanceof Error ? err.message : t("admin.audit.export.failed"),
);
} finally {
setExporting(false);
}
};
return (
<div className="space-y-3" data-testid="audit-log-panel">
<div className="glass-panel rounded-2xl p-3 space-y-3">
@@ -4107,11 +4149,33 @@ function AuditLogPanel() {
</Button>
</div>
</div>
<div className="flex items-center justify-end">
<Button
size="sm"
variant="outline"
onClick={handleExportCsv}
disabled={!filtersValid || exporting}
className="gap-1.5"
data-testid="audit-export-csv"
>
{exporting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{t("admin.audit.export.button")}
</Button>
</div>
{inputErrors.map((msg, i) => (
<p key={i} className="text-xs text-destructive">
{msg}
</p>
))}
{exportError && (
<p className="text-xs text-destructive" data-testid="audit-export-error">
{t("admin.audit.export.failed")}
</p>
)}
<div className="text-xs text-muted-foreground">
{filtersValid
? t("admin.audit.showing", {
@@ -999,3 +999,18 @@ export type ListAuditLogsParams = {
*/
offset?: number;
};
export type ExportAuditLogsCsvParams = {
/**
* Exact action name to filter by (e.g. "group.force_delete").
*/
action?: string;
/**
* Start date (inclusive, YYYY-MM-DD UTC).
*/
from?: string;
/**
* End date (inclusive, YYYY-MM-DD UTC).
*/
to?: string;
};
+103
View File
@@ -41,6 +41,7 @@ import type {
DeleteServiceParams,
DeleteUserParams,
ErrorResponse,
ExportAuditLogsCsvParams,
ForgotPasswordBody,
ForgotPasswordResponse,
GetAdminAppOpensByAppParams,
@@ -6261,3 +6262,105 @@ export function useListAuditLogs<
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Streams the filtered audit log as a CSV download.
Honors the same `action`, `from`, and `to` filters as the list endpoint.
Columns: action, actor_username, target_type, target_id, created_at, metadata.
Capped at 50,000 rows per export to bound response size.
* @summary Export filtered audit log as CSV (admin)
*/
export const getExportAuditLogsCsvUrl = (params?: ExportAuditLogsCsvParams) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/admin/audit-logs/export?${stringifiedParams}`
: `/api/admin/audit-logs/export`;
};
export const exportAuditLogsCsv = async (
params?: ExportAuditLogsCsvParams,
options?: RequestInit,
): Promise<Blob> => {
return customFetch<Blob>(getExportAuditLogsCsvUrl(params), {
...options,
method: "GET",
});
};
export const getExportAuditLogsCsvQueryKey = (
params?: ExportAuditLogsCsvParams,
) => {
return [`/api/admin/audit-logs/export`, ...(params ? [params] : [])] as const;
};
export const getExportAuditLogsCsvQueryOptions = <
TData = Awaited<ReturnType<typeof exportAuditLogsCsv>>,
TError = ErrorType<ErrorResponse>,
>(
params?: ExportAuditLogsCsvParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof exportAuditLogsCsv>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getExportAuditLogsCsvQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof exportAuditLogsCsv>>
> = ({ signal }) => exportAuditLogsCsv(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof exportAuditLogsCsv>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ExportAuditLogsCsvQueryResult = NonNullable<
Awaited<ReturnType<typeof exportAuditLogsCsv>>
>;
export type ExportAuditLogsCsvQueryError = ErrorType<ErrorResponse>;
/**
* @summary Export filtered audit log as CSV (admin)
*/
export function useExportAuditLogsCsv<
TData = Awaited<ReturnType<typeof exportAuditLogsCsv>>,
TError = ErrorType<ErrorResponse>,
>(
params?: ExportAuditLogsCsvParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof exportAuditLogsCsv>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getExportAuditLogsCsvQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
+46
View File
@@ -1854,6 +1854,52 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/admin/audit-logs/export:
get:
operationId: exportAuditLogsCsv
tags: [audit]
summary: Export filtered audit log as CSV (admin)
description: |
Streams the filtered audit log as a CSV download.
Honors the same `action`, `from`, and `to` filters as the list endpoint.
Columns: action, actor_username, target_type, target_id, created_at, metadata.
Capped at 50,000 rows per export to bound response size.
parameters:
- in: query
name: action
required: false
schema:
type: string
description: Exact action name to filter by (e.g. "group.force_delete").
- in: query
name: from
required: false
schema:
type: string
format: date
description: Start date (inclusive, YYYY-MM-DD UTC).
- in: query
name: to
required: false
schema:
type: string
format: date
description: End date (inclusive, YYYY-MM-DD UTC).
responses:
"200":
description: CSV file download
content:
text/csv:
schema:
type: string
format: binary
"400":
description: Invalid filter parameters
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
components:
schemas:
HealthStatus:
+20
View File
@@ -2098,3 +2098,23 @@ export const ListAuditLogsResponse = zod.object({
"All distinct action names present in the audit log (for building filter UI).",
),
});
/**
* Streams the filtered audit log as a CSV download.
Honors the same `action`, `from`, and `to` filters as the list endpoint.
Columns: action, actor_username, target_type, target_id, created_at, metadata.
Capped at 50,000 rows per export to bound response size.
* @summary Export filtered audit log as CSV (admin)
*/
export const ExportAuditLogsCsvQueryParams = zod.object({
action: zod.coerce
.string()
.optional()
.describe('Exact action name to filter by (e.g. \"group.force_delete\").'),
from: zod
.date()
.optional()
.describe("Start date (inclusive, YYYY-MM-DD UTC)."),
to: zod.date().optional().describe("End date (inclusive, YYYY-MM-DD UTC)."),
});