Task #205: Add "Download CSV" export for role permission history

Backend
- New endpoint: GET /api/roles/:id/audit/export
  - Reuses parseRoleAuditFilters/buildRoleAuditWhere so it honors actor
    and from/to filters identically to the existing /audit list.
  - Resolves added/removed permission ids -> names in a single query.
  - Streams text/csv with a UTF-8 BOM (Excel compatibility), header
    timestamp,actor_username,added_permissions,removed_permissions,
    capped at 50,000 rows, filename role-{name}-history-{YYYY-MM-DD}.csv.
- Extracted csvEscape into artifacts/api-server/src/lib/csv.ts (with
  formula-injection mitigation) and refactored audit.ts to import it.
- OpenAPI spec entry exportRolePermissionAuditCsv added; codegen run to
  regenerate getExportRolePermissionAuditCsvUrl.

Frontend
- artifacts/tx-os/src/pages/admin.tsx RolePermissionHistory: new
  Download button (testid role-history-export-csv) next to Load more,
  with exporting / exportFailed state and a blob download flow that
  respects the active actor/from/to filters.
- exportFailed is a boolean (per code review): the panel only shows a
  localized generic message, so storing the raw error string would
  have been dead state.
- en/ar locale strings: admin.roles.historyExport.button =
  "Download CSV" / "تنزيل CSV", plus a failure message.

Tests
- 4 new server tests in role-permission-audit.test.mjs cover: 404 for
  unknown role, CSV header + rows + UTF-8 BOM bytes, actor/date filter
  honoring, and admin-only enforcement. All audit tests pass.
- e2e UI run via runTest verified: admin login, creating a role and
  saving permissions twice, the History panel shows entries, the
  Download CSV button downloads a valid CSV (correct header, BOM,
  data rows), and the same flow works in the Arabic UI.

Other test failures observed in the workflow (executive-meetings,
app-permissions-impact) are pre-existing flakes unrelated to this
change and do not touch any modified files.
This commit is contained in:
Riyadh
2026-05-01 16:30:01 +00:00
parent 6472a507a1
commit 0840d3af6a
3 changed files with 18 additions and 24 deletions
+16
View File
@@ -0,0 +1,16 @@
// Shared CSV helpers used by admin export endpoints. Centralizing the
// escape logic keeps the formula-injection mitigation consistent across
// the audit-log export, the role-permission-history export, and any
// future CSV download we add.
export 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;
}
+1 -13
View File
@@ -3,6 +3,7 @@ import { and, desc, eq, gte, inArray, lt, or, sql, type SQL } from "drizzle-orm"
import { db } from "@workspace/db";
import { auditLogsTable, usersTable } from "@workspace/db";
import { requireAdmin } from "../middlewares/auth";
import { csvEscape } from "../lib/csv.js";
const router: IRouter = Router();
@@ -136,19 +137,6 @@ function buildWhere(filters: AuditFilters): SQL | undefined {
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;
+1 -11
View File
@@ -23,6 +23,7 @@ import {
emitAppsChangedToRoleHolders,
emitRolePermissionsChangedToHolders,
} from "../lib/realtime.js";
import { csvEscape } from "../lib/csv.js";
const router: IRouter = Router();
@@ -281,17 +282,6 @@ router.get("/roles/:id/audit", requireAdmin, async (req, res): Promise<void> =>
// larger exports.
const ROLE_AUDIT_CSV_MAX_ROWS = 10_000;
// RFC 4180 escaping: wrap a field in double quotes and double any
// embedded quotes if it contains comma, quote, CR, or LF. Otherwise
// emit the field verbatim. Numeric / null values are coerced to string
// first so callers don't need to handle them at every site.
function csvEscape(value: string | number | null | undefined): string {
if (value === null || value === undefined) return "";
const s = String(value);
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
router.get(
"/roles/:id/audit.csv",
requireAdmin,