diff --git a/e2e/tests/web.spec.ts b/e2e/tests/web.spec.ts index 287b468..f0ba0e5 100644 --- a/e2e/tests/web.spec.ts +++ b/e2e/tests/web.spec.ts @@ -271,6 +271,7 @@ test.describe('Web SPA', () => { await expect(tabs.getByTestId('admin-tab-user-groups')).toBeVisible() await expect(tabs.getByTestId('admin-tab-jobs')).toBeVisible() await expect(tabs.getByTestId('admin-tab-app-info')).toBeVisible() + await expect(tabs.getByTestId('admin-tab-audit-log')).toBeVisible() }) test('admin users page lists demo users (M18f)', async ({ page }) => { @@ -424,4 +425,68 @@ test.describe('Web SPA', () => { // Submit is disabled until a file is chosen. await expect(dialog.getByTestId('import-stig-submit')).toBeDisabled() }) + + test('admin audit log page renders with table + filters (M19)', async ({ + page, + }) => { + await page.goto(`${urls.web}/admin/audit-log`) + await expect(page.getByTestId('admin-audit-log-page')).toBeVisible() + await expect(page.getByTestId('audit-filter-form')).toBeVisible() + await expect(page.getByTestId('audit-table')).toBeVisible({ + timeout: 10_000, + }) + await expect(page.getByTestId('audit-method-select')).toBeVisible() + await expect(page.getByTestId('audit-path-input')).toBeVisible() + await expect(page.getByTestId('audit-since-input')).toBeVisible() + await expect(page.getByTestId('audit-until-input')).toBeVisible() + await expect(page.getByTestId('audit-limit-input')).toBeVisible() + await expect(page.getByTestId('audit-apply')).toBeVisible() + }) + + test('admin audit log records a mutation and the row expands (M19)', async ({ + page, + }) => { + // Generate a fresh mutation so we know at least one row exists + // (the audit middleware records POSTs synchronously after the + // handler runs, then writes asynchronously with a 5s timeout). + await page.goto(`${urls.web}/collections`) + const collectionName = `e2e-audit-${Date.now()}` + await page.getByTestId('new-collection-button').click() + const cdialog = page.getByTestId('new-collection-dialog') + await cdialog.getByTestId('new-collection-name-input').fill(collectionName) + await cdialog.getByTestId('new-collection-submit').click() + await expect(page).toHaveURL(/\/collections\/\d+$/, { timeout: 10_000 }) + + await page.goto(`${urls.web}/admin/audit-log`) + await expect(page.getByTestId('admin-audit-log-page')).toBeVisible() + + // Filter to POST /api/collections to narrow the result set, then + // expand the first row and assert the payload viewer is rendered. + await page.getByTestId('audit-method-select').selectOption('POST') + await page.getByTestId('audit-path-input').fill('/api/collections') + await page.getByTestId('audit-apply').click() + + const table = page.getByTestId('audit-table') + // Give the audit row a moment to land (async writer). + await expect(async () => { + await page.getByTestId('audit-refresh').click() + const rows = await table.locator('tbody tr').count() + // Each audit row renders one base ; expanded rows add a 2nd. + // We expect at least one populated data row (not the empty-state + // placeholder, which spans 7 cols). + expect(rows).toBeGreaterThanOrEqual(1) + const empty = await table + .getByText('No rows match the current filters.') + .count() + expect(empty).toBe(0) + }).toPass({ timeout: 15_000 }) + + const firstToggle = page + .locator('[data-testid^="audit-row-toggle-"]') + .first() + await firstToggle.click() + await expect( + page.locator('[data-testid^="audit-row-payload-"]').first(), + ).toBeVisible({ timeout: 5_000 }) + }) }) diff --git a/web/src/app/layout/nav-items.ts b/web/src/app/layout/nav-items.ts index 36ee1ba..4aa13aa 100644 --- a/web/src/app/layout/nav-items.ts +++ b/web/src/app/layout/nav-items.ts @@ -8,6 +8,7 @@ import { Briefcase, ClipboardList, Layers, + ScrollText, ShieldCheck, Users, UsersRound, @@ -64,4 +65,10 @@ export const ADMIN_NAV: readonly NavItem[] = [ icon: ClipboardList, scope: 'stig-manager:op:read', }, + { + to: '/admin/audit-log', + label: 'Audit log', + icon: ScrollText, + scope: 'stig-manager:op:read', + }, ] as const diff --git a/web/src/app/pages/admin/admin-layout.tsx b/web/src/app/pages/admin/admin-layout.tsx index 7387fab..f597b34 100644 --- a/web/src/app/pages/admin/admin-layout.tsx +++ b/web/src/app/pages/admin/admin-layout.tsx @@ -2,7 +2,7 @@ // per-page content. Visibility of each link respects the same scope // rules as the sidebar so we don't show links the user can't follow. -import { Briefcase, ClipboardList, Users, UsersRound } from 'lucide-react' +import { Briefcase, ClipboardList, ScrollText, Users, UsersRound } from 'lucide-react' import { NavLink, Outlet } from 'react-router-dom' import { useAuth } from '@/lib/auth/auth-context' @@ -41,6 +41,12 @@ const ADMIN_TABS: readonly AdminNavItem[] = [ icon: ClipboardList, scope: 'stig-manager:op:read', }, + { + to: '/admin/audit-log', + label: 'Audit log', + icon: ScrollText, + scope: 'stig-manager:op:read', + }, ] export function AdminLayout() { diff --git a/web/src/app/pages/admin/audit-log-page.tsx b/web/src/app/pages/admin/audit-log-page.tsx new file mode 100644 index 0000000..5e04227 --- /dev/null +++ b/web/src/app/pages/admin/audit-log-page.tsx @@ -0,0 +1,438 @@ +// /admin/audit-log — surface the audit_log mutations captured by the +// audit middleware. Lists rows in reverse-chronological order with +// filters (method, path substring, userId, since/until) and a +// per-row expand to inspect the JSON payload + metadata. + +import { Loader2, RefreshCw, Search } from 'lucide-react' +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + useAuditLog, + type AuditLogEntry, + type AuditLogFilter, +} from '@/lib/api/hooks' + +const HTTP_METHODS = ['', 'POST', 'PUT', 'PATCH', 'DELETE'] as const + +function formatTs(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.valueOf())) return iso + return d.toLocaleString() +} + +function statusClasses(status: number): string { + if (status >= 500) return 'bg-red-500/20 text-red-700 dark:text-red-300' + if (status >= 400) return 'bg-amber-500/20 text-amber-700 dark:text-amber-300' + if (status >= 300) return 'bg-blue-500/20 text-blue-700 dark:text-blue-300' + return 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-300' +} + +function methodClasses(method: string): string { + switch (method) { + case 'POST': + return 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-300' + case 'PUT': + return 'bg-blue-500/20 text-blue-700 dark:text-blue-300' + case 'PATCH': + return 'bg-purple-500/20 text-purple-700 dark:text-purple-300' + case 'DELETE': + return 'bg-red-500/20 text-red-700 dark:text-red-300' + default: + return 'bg-[var(--color-accent)]/40 text-[var(--color-accent-foreground)]' + } +} + +function toRfc3339(local: string): string | undefined { + // returns "YYYY-MM-DDTHH:mm"; we parse + // it as local time and emit a UTC RFC3339 string for the API. + if (!local) return undefined + const d = new Date(local) + if (Number.isNaN(d.valueOf())) return undefined + return d.toISOString() +} + +function ExpandedRow({ row }: { row: AuditLogEntry }) { + return ( + + +
+
+

+ Payload +

+ {row.payload !== undefined && row.payload !== null ? ( +
+                {JSON.stringify(row.payload, null, 2)}
+              
+ ) : ( +

+ No payload recorded. +

+ )} +
+
+

+ Metadata +

+ {row.metadata !== undefined && row.metadata !== null ? ( +
+                {JSON.stringify(row.metadata, null, 2)}
+              
+ ) : ( +

+ No metadata recorded. +

+ )} +
+
+
+ {row.route && ( + <> +
Route
+
{row.route}
+ + )} + {row.requestId && ( + <> +
Request ID
+
{row.requestId}
+ + )} + {row.subject && ( + <> +
OIDC subject
+
{row.subject}
+ + )} + {row.ip && ( + <> +
IP
+
{row.ip}
+ + )} +
+ + + ) +} + +export function AuditLogPage() { + const [method, setMethod] = React.useState<(typeof HTTP_METHODS)[number]>('') + const [path, setPath] = React.useState('') + const [userIdInput, setUserIdInput] = React.useState('') + const [since, setSince] = React.useState('') + const [until, setUntil] = React.useState('') + const [limit, setLimit] = React.useState(100) + + // Stable filter object the page renders + the hook depends on. We + // bind it on Apply rather than on every keystroke so the table + // doesn't churn while the user types. + const [filter, setFilter] = React.useState({ limit: 100 }) + + const log = useAuditLog(filter) + + const [expanded, setExpanded] = React.useState>(new Set()) + function toggleExpand(auditId: number) { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(auditId)) next.delete(auditId) + else next.add(auditId) + return next + }) + } + + function onApply(e: React.FormEvent) { + e.preventDefault() + const next: AuditLogFilter = { limit } + if (method) next.method = method + if (path.trim()) next.path = path.trim() + if (userIdInput.trim()) { + const n = Number.parseInt(userIdInput.trim(), 10) + if (!Number.isNaN(n)) next.userId = n + } + const sinceIso = toRfc3339(since) + if (sinceIso) next.since = sinceIso + const untilIso = toRfc3339(until) + if (untilIso) next.until = untilIso + setFilter(next) + setExpanded(new Set()) + } + + function onReset() { + setMethod('') + setPath('') + setUserIdInput('') + setSince('') + setUntil('') + setLimit(100) + setFilter({ limit: 100 }) + setExpanded(new Set()) + } + + return ( +
+ + +
+
+ Audit log + + Mutations captured by the audit middleware. Newest first. + +
+ +
+
+ +
+
+ + +
+
+ +
+ + setPath(e.target.value)} + placeholder="/api/collections" + data-testid="audit-path-input" + /> +
+
+
+ + setUserIdInput(e.target.value)} + placeholder="numeric" + inputMode="numeric" + data-testid="audit-userid-input" + /> +
+
+ + setSince(e.target.value)} + className="block w-full rounded-md border border-[var(--color-border)] bg-[var(--color-background)] px-2 py-1 text-sm" + data-testid="audit-since-input" + /> +
+
+ + setUntil(e.target.value)} + className="block w-full rounded-md border border-[var(--color-border)] bg-[var(--color-background)] px-2 py-1 text-sm" + data-testid="audit-until-input" + /> +
+
+ + + setLimit( + Math.min( + 1000, + Math.max(1, Number.parseInt(e.target.value, 10) || 1), + ), + ) + } + data-testid="audit-limit-input" + /> +
+
+ + + {log.isFetching && ( + + )} + {log.data && ( + + {log.data.length} row{log.data.length === 1 ? '' : 's'} + + )} +
+
+ +
+ + + + + + + + + + + + + + {log.isLoading && ( + + + + )} + {log.isError && ( + + + + )} + {log.data && log.data.length === 0 && ( + + + + )} + {log.data?.map((row) => { + const open = expanded.has(row.auditId) + return ( + + + + + + + + + + + {open && } + + ) + })} + +
TimestampMethodPathStatusDurationUserActions
+ Loading audit log… +
+ {log.error instanceof Error + ? log.error.message + : 'Failed to load audit log.'} +
+ No rows match the current filters. +
+ {formatTs(row.ts)} + + + {row.method} + + {row.path} + + {row.status} + + + {row.durationMs} ms + + {row.username ? ( + + {row.username} + {row.userId ? ( + + #{row.userId} + + ) : null} + + ) : ( + + anonymous + + )} + + +
+
+
+
+
+ ) +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index ac1d7a6..e15060a 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -5,6 +5,7 @@ import { RequireScope } from './auth/require-scope' import { AppLayout } from './layout/app-layout' import { AdminLayout } from './pages/admin/admin-layout' import { AppInfoPage } from './pages/admin/app-info-page' +import { AuditLogPage } from './pages/admin/audit-log-page' import { JobsPage } from './pages/admin/jobs-page' import { UserGroupsPage } from './pages/admin/user-groups-page' import { UsersPage } from './pages/admin/users-page' @@ -121,6 +122,14 @@ export const router = createBrowserRouter([ ), }, + { + path: 'audit-log', + element: ( + + + + ), + }, ], }, { path: '*', element: }, diff --git a/web/src/lib/api/hooks.ts b/web/src/lib/api/hooks.ts index 889f74c..cf6be07 100644 --- a/web/src/lib/api/hooks.ts +++ b/web/src/lib/api/hooks.ts @@ -27,6 +27,7 @@ import { fetchAppDataTables, fetchAppInfo, fetchAppInfoDetail, + fetchAuditLog, fetchAsset, fetchAssets, fetchAssetStigs, @@ -68,6 +69,8 @@ import { type AppDataTable, type AppInfo, type AppInfoDetail, + type AuditLogEntry, + type AuditLogFilter, type Asset, type AssetForm, type AssetStig, @@ -119,6 +122,8 @@ import { export type { AppDataTable, AppInfoDetail, + AuditLogEntry, + AuditLogFilter, Asset, AssetForm, AssetStig, @@ -207,6 +212,7 @@ export const QUERY_KEYS = { stig: (benchmarkId: string) => ['stig', benchmarkId] as const, rule: (ruleId: string) => ['rule', 'lookup', ruleId] as const, cci: (cci: string) => ['cci', cci] as const, + auditLog: (filter?: AuditLogFilter) => ['op', 'audit-log', filter ?? {}] as const, } as const export function useAppInfo(): UseQueryResult { @@ -924,3 +930,16 @@ export function useDeleteSTIG(): UseMutationResult { }, }) } + +// ---- Audit Log (M19) -------------------------------------------------------- + +export function useAuditLog( + filter?: AuditLogFilter, + options?: { refetchIntervalMs?: number }, +): UseQueryResult { + return useQuery({ + queryKey: QUERY_KEYS.auditLog(filter), + queryFn: () => fetchAuditLog(filter), + refetchInterval: options?.refetchIntervalMs ?? false, + }) +} diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index 6fcbe25..9ae79e3 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -1593,3 +1593,73 @@ export async function deleteSTIG(benchmarkId: string): Promise { throw new Error(`delete stig: HTTP ${result.response.status}`) } } + +// ---- Audit Log (M19) -------------------------------------------------------- +// +// /api/op/audit-log is wired directly on the root chi router (it's not +// part of the upstream OpenAPI surface), so this fetches with a plain +// fetch() call instead of the typed openapi-fetch client. + +export type AuditLogEntry = { + auditId: number + ts: string + method: string + path: string + route?: string + status: number + durationMs: number + userId?: number | null + username?: string + subject?: string + ip?: string + requestId?: string + payload?: unknown + metadata?: unknown +} + +export type AuditLogFilter = { + limit?: number + method?: string + path?: string + userId?: number + since?: string + until?: string +} + +export async function fetchAuditLog( + filter?: AuditLogFilter, +): Promise { + const url = new URL(`${API_BASE}/op/audit-log`, window.location.origin) + if (filter?.limit !== undefined) { + url.searchParams.set('limit', String(filter.limit)) + } + if (filter?.method) url.searchParams.set('method', filter.method) + if (filter?.path) url.searchParams.set('path', filter.path) + if (filter?.userId !== undefined) { + url.searchParams.set('userId', String(filter.userId)) + } + if (filter?.since) url.searchParams.set('since', filter.since) + if (filter?.until) url.searchParams.set('until', filter.until) + + const headers: Record = {} + const token = getAccessTokenForClient() + if (token) headers['Authorization'] = `Bearer ${token}` + + const resp = await fetch(url.toString(), { + method: 'GET', + headers, + credentials: 'include', + }) + if (!resp.ok) { + let detail = '' + try { + detail = (await resp.text()).slice(0, 256) + } catch { + // ignore body-read failure; we still have status + } + throw new Error( + `audit log: HTTP ${resp.status}${detail ? ` — ${detail}` : ''}`, + ) + } + return (await resp.json()) as AuditLogEntry[] +}