Skip to content

Commit 40387a2

Browse files
feat(audit): CSV/JSON export of the filtered audit trail (Phase 3b) (#623)
api-audit-events-query v1.3.0 (C-08/AC-13) + frontend-settings v1.13.0 (AC-32). Phase 3b of the activity readability initiative — the AU-7 (audit reduction + report generation) compliance piece. Backend: GET /api/v1/audit/events/export streams the filtered audit trail as a downloadable CSV (default) or JSON attachment. audit:read gated, same filters as the list, whole filtered set newest-first capped at 10000 rows (not one page). CSV columns: occurred_at, action, message, severity, actor_type, actor_label, actor_id, resource_type, resource_id, correlation_id; the message reuses activity.FormatAudit (readable sentence, not the raw code). Mirrors the OSCAL-export download pattern. Frontend: Export CSV / Export JSON buttons on the settings Audit log fetch the endpoint with the currently-applied filters as a blob (the SPA uses a bearer token, so a plain link would 401) and trigger a browser download; buttons disable while in flight and surface an inline export error. Verified live: Export CSV -> GET /audit/events/export?format=csv -> 200, download fired. Full frontend suite (323) + backend audit suite + specter (111, structural 100%) green.
1 parent 02007b5 commit 40387a2

9 files changed

Lines changed: 1033 additions & 478 deletions

File tree

api/openapi.yaml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,6 +1130,64 @@ paths:
11301130
'400':
11311131
$ref: '#/components/responses/BadRequest'
11321132

1133+
/api/v1/audit/events/export:
1134+
get:
1135+
operationId: getAuditEventsExport
1136+
summary: Export the filtered audit events as a downloadable CSV or JSON file
1137+
description: >
1138+
Downloads the audit trail matching the same filters as the list
1139+
endpoint, as a CSV or JSON attachment (NIST 800-53 AU-7 audit
1140+
reduction + report generation). audit:read gated. Unlike the
1141+
paginated list it returns the whole filtered set in one file, capped
1142+
at 10000 rows newest-first (a larger window is paginated via the list
1143+
endpoint). CSV columns: occurred_at, action, message, severity,
1144+
actor_type, actor_label, actor_id, resource_type, resource_id,
1145+
correlation_id.
1146+
x-required-permission: audit:read
1147+
parameters:
1148+
- name: format
1149+
in: query
1150+
description: Output format. Defaults to csv.
1151+
schema: {type: string, enum: [csv, json], default: csv}
1152+
- name: action
1153+
in: query
1154+
schema: {type: string}
1155+
- name: actor_type
1156+
in: query
1157+
schema: {type: string}
1158+
- name: resource_type
1159+
in: query
1160+
schema: {type: string}
1161+
- name: resource_id
1162+
in: query
1163+
schema: {type: string}
1164+
- name: since
1165+
in: query
1166+
schema: {type: string, format: date-time}
1167+
- name: until
1168+
in: query
1169+
schema: {type: string, format: date-time}
1170+
responses:
1171+
'200':
1172+
description: Audit export file (CSV or JSON attachment)
1173+
content:
1174+
text/csv:
1175+
schema: {type: string}
1176+
application/json:
1177+
schema:
1178+
type: array
1179+
items: {$ref: '#/components/schemas/AuditEvent'}
1180+
'401':
1181+
description: Caller is not authenticated
1182+
content:
1183+
application/json:
1184+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
1185+
'403':
1186+
description: Caller lacks audit:read
1187+
content:
1188+
application/json:
1189+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
1190+
11331191
# Spec: app/specs/api/fleet-observability.spec.yaml (v1.1.0)
11341192
/api/v1/fleet/score:
11351193
get:

frontend/src/api/schema.d.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,26 @@ export interface paths {
748748
patch?: never;
749749
trace?: never;
750750
};
751+
"/api/v1/audit/events/export": {
752+
parameters: {
753+
query?: never;
754+
header?: never;
755+
path?: never;
756+
cookie?: never;
757+
};
758+
/**
759+
* Export the filtered audit events as a downloadable CSV or JSON file
760+
* @description Downloads the audit trail matching the same filters as the list endpoint, as a CSV or JSON attachment (NIST 800-53 AU-7 audit reduction + report generation). audit:read gated. Unlike the paginated list it returns the whole filtered set in one file, capped at 10000 rows newest-first (a larger window is paginated via the list endpoint). CSV columns: occurred_at, action, message, severity, actor_type, actor_label, actor_id, resource_type, resource_id, correlation_id.
761+
*/
762+
get: operations["getAuditEventsExport"];
763+
put?: never;
764+
post?: never;
765+
delete?: never;
766+
options?: never;
767+
head?: never;
768+
patch?: never;
769+
trace?: never;
770+
};
751771
"/api/v1/fleet/score": {
752772
parameters: {
753773
query?: never;
@@ -5650,6 +5670,54 @@ export interface operations {
56505670
400: components["responses"]["BadRequest"];
56515671
};
56525672
};
5673+
getAuditEventsExport: {
5674+
parameters: {
5675+
query?: {
5676+
/** @description Output format. Defaults to csv. */
5677+
format?: "csv" | "json";
5678+
action?: string;
5679+
actor_type?: string;
5680+
resource_type?: string;
5681+
resource_id?: string;
5682+
since?: string;
5683+
until?: string;
5684+
};
5685+
header?: never;
5686+
path?: never;
5687+
cookie?: never;
5688+
};
5689+
requestBody?: never;
5690+
responses: {
5691+
/** @description Audit export file (CSV or JSON attachment) */
5692+
200: {
5693+
headers: {
5694+
[name: string]: unknown;
5695+
};
5696+
content: {
5697+
"text/csv": string;
5698+
"application/json": components["schemas"]["AuditEvent"][];
5699+
};
5700+
};
5701+
/** @description Caller is not authenticated */
5702+
401: {
5703+
headers: {
5704+
[name: string]: unknown;
5705+
};
5706+
content: {
5707+
"application/json": components["schemas"]["ErrorEnvelope"];
5708+
};
5709+
};
5710+
/** @description Caller lacks audit:read */
5711+
403: {
5712+
headers: {
5713+
[name: string]: unknown;
5714+
};
5715+
content: {
5716+
"application/json": components["schemas"]["ErrorEnvelope"];
5717+
};
5718+
};
5719+
};
5720+
};
56535721
getFleetScore: {
56545722
parameters: {
56555723
query?: {

frontend/src/pages/settings/AuditPage.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ export function AuditPage() {
6565
since: string;
6666
until: string;
6767
}>({ action: '', actorType: '', since: '', until: '' });
68+
const [exporting, setExporting] = useState<'csv' | 'json' | null>(null);
69+
const [exportError, setExportError] = useState<string | null>(null);
6870

6971
const sinceIso = useMemo(() => localDateToRFC3339(applied.since, false), [applied.since]);
7072
const untilIso = useMemo(() => localDateToRFC3339(applied.until, true), [applied.until]);
@@ -119,6 +121,44 @@ export function AuditPage() {
119121
setApplied({ action: '', actorType: '', since: '', until: '' });
120122
}
121123

124+
// Export the CURRENT filter as a downloadable CSV/JSON (AU-7). The SPA
125+
// authenticates with a bearer token, so a plain link would 401. Fetch the
126+
// file as a blob through the auth'd client, then trigger a download.
127+
async function exportAudit(format: 'csv' | 'json') {
128+
setExporting(format);
129+
setExportError(null);
130+
try {
131+
const { data, response } = await api.GET('/api/v1/audit/events/export', {
132+
params: {
133+
query: {
134+
format,
135+
...(applied.action ? { action: applied.action } : {}),
136+
...(applied.actorType ? { actor_type: applied.actorType } : {}),
137+
...(sinceIso ? { since: sinceIso } : {}),
138+
...(untilIso ? { until: untilIso } : {}),
139+
},
140+
},
141+
parseAs: 'blob',
142+
});
143+
if (!response.ok || !data) {
144+
setExportError(`Export failed (${response.status})`);
145+
return;
146+
}
147+
const objUrl = URL.createObjectURL(data as unknown as Blob);
148+
const a = document.createElement('a');
149+
a.href = objUrl;
150+
a.download = `audit-log.${format}`;
151+
document.body.appendChild(a);
152+
a.click();
153+
a.remove();
154+
URL.revokeObjectURL(objUrl);
155+
} catch (e) {
156+
setExportError(e instanceof Error ? e.message : 'Export failed');
157+
} finally {
158+
setExporting(null);
159+
}
160+
}
161+
122162
return (
123163
<SettingsLayout>
124164
<PageHead
@@ -193,6 +233,30 @@ export function AuditPage() {
193233

194234
<Section title="Events">
195235
<SettingCard>
236+
<div
237+
style={{
238+
display: 'flex',
239+
gap: 8,
240+
alignItems: 'center',
241+
padding: '12px 20px',
242+
borderBottom: '1px solid var(--ow-line)',
243+
}}
244+
>
245+
<span style={{ fontSize: 12, color: 'var(--ow-fg-3)', marginRight: 'auto' }}>
246+
Export the current filter as a downloadable file (up to 10,000 newest events).
247+
</span>
248+
<Btn type="button" onClick={() => exportAudit('csv')} disabled={exporting !== null}>
249+
{exporting === 'csv' ? 'Exporting…' : 'Export CSV'}
250+
</Btn>
251+
<Btn type="button" onClick={() => exportAudit('json')} disabled={exporting !== null}>
252+
{exporting === 'json' ? 'Exporting…' : 'Export JSON'}
253+
</Btn>
254+
</div>
255+
{exportError && (
256+
<div role="alert" style={errorStyle}>
257+
{exportError}
258+
</div>
259+
)}
196260
{query.isPending ? (
197261
<div style={emptyStyle}>Loading audit events.</div>
198262
) : query.isError ? (

frontend/tests/pages/settings.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,4 +515,25 @@ describe('frontend-settings — structural', () => {
515515
// Still read-only.
516516
expect(AUDIT_SRC).not.toMatch(/api\.(POST|PATCH|PUT|DELETE)\(/);
517517
});
518+
519+
// @ac AC-32
520+
test('frontend-settings/AC-32 — Audit log export: CSV/JSON download via the auth’d client', () => {
521+
// Both export buttons wired to exportAudit.
522+
expect(AUDIT_SRC).toMatch(/exportAudit\('csv'\)/);
523+
expect(AUDIT_SRC).toMatch(/exportAudit\('json'\)/);
524+
// Fetches the export endpoint as a blob with the format + applied filters.
525+
expect(AUDIT_SRC).toMatch(/api\.GET\(\s*['"]\/api\/v1\/audit\/events\/export['"]/);
526+
expect(AUDIT_SRC).toMatch(/parseAs:\s*['"]blob['"]/);
527+
expect(AUDIT_SRC).toMatch(/format,/);
528+
expect(AUDIT_SRC).toMatch(/applied\.action/);
529+
// Triggers a browser download (object URL + temporary anchor).
530+
expect(AUDIT_SRC).toContain('URL.createObjectURL');
531+
expect(AUDIT_SRC).toMatch(/a\.download =/);
532+
expect(AUDIT_SRC).toContain('URL.revokeObjectURL');
533+
// Disabled while exporting + inline error surface.
534+
expect(AUDIT_SRC).toMatch(/disabled=\{exporting !== null\}/);
535+
expect(AUDIT_SRC).toMatch(/setExportError/);
536+
// Reading remains GET-only.
537+
expect(AUDIT_SRC).not.toMatch(/api\.(POST|PATCH|PUT|DELETE)\(/);
538+
});
518539
});

0 commit comments

Comments
 (0)