diff --git a/artifacts/api-server/src/routes/roles.ts b/artifacts/api-server/src/routes/roles.ts index da1a354f..3e4c8316 100644 --- a/artifacts/api-server/src/routes/roles.ts +++ b/artifacts/api-server/src/routes/roles.ts @@ -273,6 +273,154 @@ router.get("/roles/:id/audit", requireAdmin, async (req, res): Promise => }); }); +// Hard cap on the CSV export size. Each audit row is small (a handful of +// short fields), so 10k rows is comfortably under a few MB even with +// long permission lists. Caps the working-set memory and prevents an +// admin from accidentally tying up the connection on a giant role with +// years of churn. Date-range filters are the recommended way to scope +// 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, + async (req, res): Promise => { + // Mirrors GET /roles/:id/audit's contract (admin-only, same actor + + // date filters, same 400/404 semantics) but returns a downloadable + // CSV containing all matching rows up to ROLE_AUDIT_CSV_MAX_ROWS, + // with permission IDs resolved to human-readable names so the file + // is useful in a spreadsheet without a separate join. Used by the + // "Download CSV" button in the admin role-history panel (#205). + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + res.status(400).json({ error: "Invalid id" }); + return; + } + + const [role] = await db + .select({ id: rolesTable.id, name: rolesTable.name }) + .from(rolesTable) + .where(eq(rolesTable.id, id)); + if (!role) { + res.status(404).json({ error: "Role not found" }); + return; + } + + // Parse filters AFTER the role-existence check so 404 takes + // precedence over 400 — same ordering as the JSON endpoint. + const filters = parseRoleAuditFilters(req, res); + if (!filters) return; + + const whereClause = buildRoleAuditWhere(id, filters); + + const rows = await db + .select({ + id: rolePermissionAuditTable.id, + previousPermissionIds: rolePermissionAuditTable.previousPermissionIds, + newPermissionIds: rolePermissionAuditTable.newPermissionIds, + createdAt: rolePermissionAuditTable.createdAt, + actorUsername: usersTable.username, + }) + .from(rolePermissionAuditTable) + .leftJoin( + usersTable, + eq(usersTable.id, rolePermissionAuditTable.actorUserId), + ) + .where(whereClause) + .orderBy( + desc(rolePermissionAuditTable.createdAt), + desc(rolePermissionAuditTable.id), + ) + .limit(ROLE_AUDIT_CSV_MAX_ROWS); + + // Collect every permission id referenced anywhere in the page so we + // can resolve names in a single SELECT instead of N per row. Unknown + // ids (e.g. a permission that was deleted after the audit row was + // written) degrade to "#" — same fallback the UI uses. + const permissionIdSet = new Set(); + for (const r of rows) { + for (const p of (r.previousPermissionIds ?? []) as number[]) + permissionIdSet.add(p); + for (const p of (r.newPermissionIds ?? []) as number[]) + permissionIdSet.add(p); + } + const permissionNameById = new Map(); + if (permissionIdSet.size > 0) { + const permRows = await db + .select({ id: permissionsTable.id, name: permissionsTable.name }) + .from(permissionsTable) + .where(inArray(permissionsTable.id, Array.from(permissionIdSet))); + for (const p of permRows) permissionNameById.set(p.id, p.name); + } + const labelFor = (pid: number): string => + permissionNameById.get(pid) ?? `#${pid}`; + + // Build the file in memory — capped at 10k rows above and each row + // is short, so the worst-case payload is a few MB. Streaming is not + // worth the complexity at this size. + const lines: string[] = []; + lines.push( + [ + "timestamp", + "actor_username", + "added_permissions", + "removed_permissions", + ] + .map(csvEscape) + .join(","), + ); + for (const r of rows) { + const prev = (r.previousPermissionIds ?? []) as number[]; + const next = (r.newPermissionIds ?? []) as number[]; + const prevSet = new Set(prev); + const nextSet = new Set(next); + const added = next.filter((p) => !prevSet.has(p)).map(labelFor); + const removed = prev.filter((p) => !nextSet.has(p)).map(labelFor); + const ts = + r.createdAt instanceof Date + ? r.createdAt.toISOString() + : String(r.createdAt ?? ""); + lines.push( + [ts, r.actorUsername ?? "", added.join("; "), removed.join("; ")] + .map(csvEscape) + .join(","), + ); + } + // Trailing newline so naive `tail -1` / `wc -l` workflows don't + // miscount, and so concatenating two CSVs doesn't smash rows. + const body = `\uFEFF${lines.join("\r\n")}\r\n`; + + const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + // The role name is allowed in the suggested filename for context + // but stripped of anything other than [A-Za-z0-9_-] so it can't + // smuggle a quote into the Content-Disposition header. Falls back + // to the numeric id when the sanitized name would be empty. + const safeName = (role.name ?? "").replace(/[^A-Za-z0-9_-]/g, ""); + const filename = `role-${safeName || role.id}-history-${stamp}.csv`; + + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${filename}"`, + ); + // The export is a snapshot in time — discourage proxy / browser + // caching so a re-run after a permission change reflects reality. + res.setHeader("Cache-Control", "no-store"); + res.status(200).send(body); + }, +); + router.get("/roles/:id/usage", requireAdmin, async (req, res): Promise => { const id = Number(req.params.id); if (!Number.isInteger(id)) { diff --git a/artifacts/api-server/tests/role-permission-audit.test.mjs b/artifacts/api-server/tests/role-permission-audit.test.mjs index bc709239..083ee851 100644 --- a/artifacts/api-server/tests/role-permission-audit.test.mjs +++ b/artifacts/api-server/tests/role-permission-audit.test.mjs @@ -466,3 +466,208 @@ test("GET /api/roles/:id/audit filters by date range", async () => { assert.equal(r.status, 400, `expected 400 for from=${bad}`); } }); + +// --- GET /api/roles/:id/audit.csv ------------------------------------------- +// CSV export endpoint added for #205. The download is admin-only, returns +// `text/csv` with an attachment Content-Disposition, prepends a UTF-8 BOM +// for spreadsheet apps, and resolves permission IDs to human-readable +// names so the file is useful in Excel without a separate join. + +// Parse a single CSV line into fields, honouring RFC 4180 double-quote +// escaping. Tests build comma-separated rows by hand on the server side +// so we need a small parser that knows about quoted fields. Returns null +// inputs as undefined. +function parseCsvLine(line) { + const fields = []; + let cur = ""; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"') { + if (line[i + 1] === '"') { + cur += '"'; + i++; + } else { + inQuotes = false; + } + } else { + cur += ch; + } + } else if (ch === ",") { + fields.push(cur); + cur = ""; + } else if (ch === '"') { + inQuotes = true; + } else { + cur += ch; + } + } + fields.push(cur); + return fields; +} + +test("GET /api/roles/:id/audit.csv exports CSV with headers and resolved permission names", async () => { + const role = await createRole(); + const [p1, p2] = await getTwoPermissionIds(); + + // Look up the permission names so the assertions below don't depend + // on whatever the seeded data calls them. + const permRows = await pool.query( + `SELECT id, name FROM permissions WHERE id = ANY($1::int[])`, + [[p1, p2]], + ); + const nameById = new Map(permRows.rows.map((r) => [r.id, r.name])); + + // Two writes: first adds p1, second swaps p1 → p2 (so the second + // row should have one added and one removed permission). + for (const set of [[p1], [p2]]) { + const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: set }), + }); + assert.equal(r.status, 200); + await new Promise((r) => setTimeout(r, 5)); + } + + const res = await fetch(`${API_BASE}/api/roles/${role.id}/audit.csv`, { + headers: { Cookie: adminCookie }, + }); + assert.equal(res.status, 200); + const ctype = res.headers.get("content-type") ?? ""; + assert.match(ctype, /^text\/csv/i, `unexpected Content-Type: ${ctype}`); + const dispo = res.headers.get("content-disposition") ?? ""; + assert.match(dispo, /attachment;\s*filename="role-/i); + assert.match(dispo, /\.csv"?$/i); + + // Read raw bytes so we can verify the UTF-8 BOM is present — the + // standard `Response.text()` decoder strips a leading BOM, so we'd + // otherwise miss a regression where the server forgot to emit it. + // The BOM is required for Excel to render Arabic permission names + // correctly when an admin opens the file. + const buf = new Uint8Array(await res.arrayBuffer()); + assert.deepEqual( + [buf[0], buf[1], buf[2]], + [0xef, 0xbb, 0xbf], + "missing UTF-8 BOM", + ); + const body = new TextDecoder("utf-8").decode(buf.subarray(3)); + + const lines = body.split(/\r\n|\n/).filter((l) => l.length > 0); + assert.deepEqual(parseCsvLine(lines[0]), [ + "timestamp", + "actor_username", + "added_permissions", + "removed_permissions", + ]); + // Two writes → two data rows. + assert.equal(lines.length - 1, 2, `expected 2 data rows, got ${lines.length - 1}`); + + // Newest first: row index 1 is the p1→p2 swap, row index 2 is the + // initial add of p1. + const swap = parseCsvLine(lines[1]); + const initial = parseCsvLine(lines[2]); + assert.equal(swap[1], adminUsername); + assert.equal(swap[2], nameById.get(p2)); + assert.equal(swap[3], nameById.get(p1)); + assert.equal(initial[1], adminUsername); + assert.equal(initial[2], nameById.get(p1)); + assert.equal(initial[3], ""); +}); + +test("GET /api/roles/:id/audit.csv honours actorUserId and date filters", async () => { + const role = await createRole(); + const [p1, p2] = await getTwoPermissionIds(); + + // Spin up a second admin actor so we can prove the actor filter + // narrows the export to one row out of three. + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + const otherUsername = `role_audit_csv_other_${stamp}`; + const otherRow = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, 'Role Audit CSV Other', 'en', true) RETURNING id`, + [otherUsername, `${otherUsername}@example.com`, TEST_PASSWORD_HASH], + ); + const otherId = otherRow.rows[0].id; + createdUserIds.push(otherId); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, + [otherId], + ); + const otherCookie = await loginAndGetCookie(otherUsername, TEST_PASSWORD); + + for (const [cookie, set] of [ + [adminCookie, [p1]], + [otherCookie, [p1, p2]], + [adminCookie, [p2]], + ]) { + const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, { + method: "PUT", + headers: { "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify({ permissionIds: set }), + }); + assert.equal(r.status, 200); + await new Promise((r) => setTimeout(r, 5)); + } + + // actorUserId narrows export to one row. + const filteredRes = await fetch( + `${API_BASE}/api/roles/${role.id}/audit.csv?actorUserId=${otherId}`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(filteredRes.status, 200); + const filtered = (await filteredRes.text()).slice(1); // strip BOM + const filteredLines = filtered.split(/\r\n|\n/).filter((l) => l.length > 0); + assert.equal(filteredLines.length, 2, "header + 1 data row expected"); + assert.equal(parseCsvLine(filteredLines[1])[1], otherUsername); + + // Yesterday-only window → no data rows but still returns a header. + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const pastRes = await fetch( + `${API_BASE}/api/roles/${role.id}/audit.csv?from=${yesterday}&to=${yesterday}`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(pastRes.status, 200); + const past = (await pastRes.text()).slice(1); + const pastLines = past.split(/\r\n|\n/).filter((l) => l.length > 0); + assert.equal(pastLines.length, 1, "header only when no rows match"); + + // Bad filter → 400, same as the JSON endpoint. + const badRes = await fetch( + `${API_BASE}/api/roles/${role.id}/audit.csv?from=not-a-date`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(badRes.status, 400); +}); + +test("GET /api/roles/:id/audit.csv returns 404 for unknown role", async () => { + const res = await fetch(`${API_BASE}/api/roles/999999999/audit.csv`, { + headers: { Cookie: adminCookie }, + }); + assert.equal(res.status, 404); +}); + +test("GET /api/roles/:id/audit.csv requires admin", async () => { + const role = await createRole(); + + // Brand-new user with no admin role assignment. They should be + // blocked at the requireAdmin middleware just like the JSON endpoint. + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + const username = `role_audit_csv_nonadmin_${stamp}`; + const row = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, 'CSV Non-Admin', 'en', true) RETURNING id`, + [username, `${username}@example.com`, TEST_PASSWORD_HASH], + ); + createdUserIds.push(row.rows[0].id); + const cookie = await loginAndGetCookie(username, TEST_PASSWORD); + + const res = await fetch(`${API_BASE}/api/roles/${role.id}/audit.csv`, { + headers: { Cookie: cookie }, + }); + // requireAdmin returns 403 for authenticated-but-not-admin users. + assert.equal(res.status, 403); +}); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 74c204d0..22e8c0e8 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -516,6 +516,8 @@ "historyTotal_other": "{{count}} صلاحية بعد التغيير", "historyShowing": "يظهر {{shown}} من أصل {{total}}", "historyLoadMore": "عرض المزيد", + "historyDownloadCsv": "تنزيل CSV", + "historyDownloadCsvError": "تعذّر تنزيل ملف CSV. حاول مرة أخرى.", "historyFilters": { "actor": "غُيّرت بواسطة", "allActors": "أي شخص", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index f93ad1ff..c9247add 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -489,6 +489,8 @@ "historyTotal_other": "{{count}} permissions after change", "historyShowing": "Showing {{shown}} of {{total}}", "historyLoadMore": "Load more", + "historyDownloadCsv": "Download CSV", + "historyDownloadCsvError": "Could not download CSV. Please try again.", "historyFilters": { "actor": "Changed by", "allActors": "Anyone", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 841804f8..e4f58e09 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -93,6 +93,7 @@ import { useListAuditLogs, getListAuditLogsQueryKey, getExportAuditLogsCsvUrl, + getExportRolePermissionAuditCsvUrl, ApiError, } from "@workspace/api-client-react"; import type { @@ -4827,6 +4828,14 @@ function RolePermissionHistory({ const [isLoadingMore, setIsLoadingMore] = useState(false); const [loadMoreError, setLoadMoreError] = useState(null); + // CSV export state for the "Download CSV" button (#205). The download + // is independent of the paginated list shown above — it always asks + // the server for ALL rows matching the current filters (subject to + // a server-side cap), so the spreadsheet doesn't only contain the + // pages the admin already scrolled through. + const [isDownloadingCsv, setIsDownloadingCsv] = useState(false); + const [downloadCsvError, setDownloadCsvError] = useState(null); + // Reset filters and pagination whenever the dialog switches to a // different role so an admin doesn't see stale filters carried over. useEffect(() => { @@ -4840,6 +4849,8 @@ function RolePermissionHistory({ setHasExtras(false); setLoadMoreError(null); setIsLoadingMore(false); + setDownloadCsvError(null); + setIsDownloadingCsv(false); }, [roleId]); const fromValid = !appliedFrom || ROLE_HISTORY_DATE_RE.test(appliedFrom); @@ -4960,6 +4971,59 @@ function RolePermissionHistory({ } }; + // CSV export handler (#205). Goes via plain `fetch` (with credentials) + // rather than the generated client because we want a Blob download + // triggered through a synthetic element — the generated + // wrapper unwraps the body which makes filename / Content-Type harder + // to use. We reuse the generated URL builder so the route + query + // serialization stays in lock-step with the OpenAPI spec. `limit` / + // `offset` are intentionally NOT forwarded — the backend exports all + // matching rows up to its own cap. + const downloadCsv = async () => { + if (!filtersValid || isDownloadingCsv) return; + setIsDownloadingCsv(true); + setDownloadCsvError(null); + try { + const url = getExportRolePermissionAuditCsvUrl(roleId, { + ...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}), + ...(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(); + // Honor the server-provided filename when present so admins + // get the role name in the file (server sanitizes it). Fall + // back to a timestamped default for any unexpected response. + const disposition = res.headers.get("Content-Disposition") ?? ""; + const match = /filename="?([^";]+)"?/i.exec(disposition); + const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const filename = match?.[1] ?? `role-${roleId}-history-${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 (e) { + setDownloadCsvError( + (e as { message?: string }).message ?? + t("admin.roles.historyDownloadCsvError"), + ); + } finally { + setIsDownloadingCsv(false); + } + }; + const filtersActive = actorUserId !== "" || appliedFrom !== "" || appliedTo !== ""; @@ -5166,22 +5230,42 @@ function RolePermissionHistory({ }) )} - {filtersValid && hasMore && ( -
- + {filtersValid && (hasMore || entries.length > 0) && ( +
+ {hasMore && ( + + )} + {entries.length > 0 && ( + + )}
)} {loadMoreError && ( @@ -5192,6 +5276,14 @@ function RolePermissionHistory({ {loadMoreError}

)} + {downloadCsvError && ( +

+ {t("admin.roles.historyDownloadCsvError")} +

+ )}
); } diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index d13ff98e..8e614531 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -1254,6 +1254,24 @@ export type GetRolePermissionAuditParams = { to?: string; }; +export type ExportRolePermissionAuditCsvParams = { + /** + * Restrict results to entries authored by the given user. Use +`0` or omit to return entries from any actor. + + * @minimum 0 + */ + actorUserId?: number; + /** + * Start date (inclusive, YYYY-MM-DD UTC). + */ + from?: string; + /** + * End date (inclusive, YYYY-MM-DD UTC). + */ + to?: string; +}; + export type ListGroupsParams = { q?: string; }; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 71f3cdc5..5c065d7b 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -49,6 +49,7 @@ import type { DeleteUserParams, ErrorResponse, ExportAuditLogsCsvParams, + ExportRolePermissionAuditCsvParams, ForgotPasswordBody, ForgotPasswordResponse, GetAdminAppDependentGroupsParams, @@ -5742,6 +5743,128 @@ export function useGetRolePermissionAudit< return { ...query, queryKey: queryOptions.queryKey }; } +/** + * Returns the same audit rows as `GET /roles/{id}/audit` but as a +downloadable CSV file with permission IDs resolved to names. +Honors the optional `actorUserId`, `from`, and `to` filters +identically. Pagination parameters are NOT accepted: the file +contains all matching rows up to a server-side cap (currently +10,000 — narrow the date range for a longer history). + + * @summary Download permission-change audit entries for a role as CSV (admin) + */ +export const getExportRolePermissionAuditCsvUrl = ( + id: number, + params?: ExportRolePermissionAuditCsvParams, +) => { + 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/roles/${id}/audit.csv?${stringifiedParams}` + : `/api/roles/${id}/audit.csv`; +}; + +export const exportRolePermissionAuditCsv = async ( + id: number, + params?: ExportRolePermissionAuditCsvParams, + options?: RequestInit, +): Promise => { + return customFetch(getExportRolePermissionAuditCsvUrl(id, params), { + ...options, + method: "GET", + }); +}; + +export const getExportRolePermissionAuditCsvQueryKey = ( + id: number, + params?: ExportRolePermissionAuditCsvParams, +) => { + return [`/api/roles/${id}/audit.csv`, ...(params ? [params] : [])] as const; +}; + +export const getExportRolePermissionAuditCsvQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: ExportRolePermissionAuditCsvParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getExportRolePermissionAuditCsvQueryKey(id, params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + exportRolePermissionAuditCsv(id, params, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ExportRolePermissionAuditCsvQueryResult = NonNullable< + Awaited> +>; +export type ExportRolePermissionAuditCsvQueryError = ErrorType; + +/** + * @summary Download permission-change audit entries for a role as CSV (admin) + */ + +export function useExportRolePermissionAuditCsv< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + params?: ExportRolePermissionAuditCsvParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getExportRolePermissionAuditCsvQueryOptions( + id, + params, + options, + ); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + /** * Given a candidate set of permission IDs for the role, returns the list of permissions that would be removed and, for each one, how many users would diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 68106997..50f0646d 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -1666,6 +1666,70 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /roles/{id}/audit.csv: + get: + operationId: exportRolePermissionAuditCsv + tags: [roles] + summary: Download permission-change audit entries for a role as CSV (admin) + description: | + Returns the same audit rows as `GET /roles/{id}/audit` but as a + downloadable CSV file with permission IDs resolved to names. + Honors the optional `actorUserId`, `from`, and `to` filters + identically. Pagination parameters are NOT accepted: the file + contains all matching rows up to a server-side cap (currently + 10,000 — narrow the date range for a longer history). + parameters: + - name: id + in: path + required: true + schema: + type: integer + - in: query + name: actorUserId + required: false + schema: + type: integer + minimum: 0 + description: | + Restrict results to entries authored by the given user. Use + `0` or omit to return entries from any actor. + - 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: | + UTF-8 CSV (with BOM) containing columns: timestamp, + actor_username, added_permissions, removed_permissions. + content: + text/csv: + schema: + type: string + format: binary + "400": + description: Invalid filter parameters + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Role not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /roles/{id}/permissions/impact-preview: post: operationId: previewRolePermissionsImpact diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 42c4a200..214847e0 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -2114,6 +2114,37 @@ export const GetRolePermissionAuditResponse = zod.object({ nextOffset: zod.number().nullable(), }); +/** + * Returns the same audit rows as `GET /roles/{id}/audit` but as a +downloadable CSV file with permission IDs resolved to names. +Honors the optional `actorUserId`, `from`, and `to` filters +identically. Pagination parameters are NOT accepted: the file +contains all matching rows up to a server-side cap (currently +10,000 — narrow the date range for a longer history). + + * @summary Download permission-change audit entries for a role as CSV (admin) + */ +export const ExportRolePermissionAuditCsvParams = zod.object({ + id: zod.coerce.number(), +}); + +export const exportRolePermissionAuditCsvQueryActorUserIdMin = 0; + +export const ExportRolePermissionAuditCsvQueryParams = zod.object({ + actorUserId: zod.coerce + .number() + .min(exportRolePermissionAuditCsvQueryActorUserIdMin) + .optional() + .describe( + "Restrict results to entries authored by the given user. Use\n`0` or omit to return entries from any actor.\n", + ), + from: zod + .date() + .optional() + .describe("Start date (inclusive, YYYY-MM-DD UTC)."), + to: zod.date().optional().describe("End date (inclusive, YYYY-MM-DD UTC)."), +}); + /** * Given a candidate set of permission IDs for the role, returns the list of permissions that would be removed and, for each one, how many users would