Skip to content

Commit 53f1b51

Browse files
authored
feat(audit): expose GET /api/v1/audit + dashboard page (closes #246) (#248)
* feat(audit): expose GET /api/v1/audit for the authenticated tenant Adds an admin-only audit query endpoint that returns events recorded for the caller's tenant. The tenant is derived from AuthContext so operators cannot inspect events from another tenant. Query supports event_type, time range, limit (default 100, capped at 1000), and offset. Registered in the router and OpenAPI spec. * feat(dashboard): add /audit page wired to GET /api/v1/audit Adds an Audit nav entry (between Security and Costs) that surfaces the tenant's audit log: timestamp, event type, actor, resource, and a collapsible JSON payload. Includes a free-text event-type filter, prev/next pagination at the default 100 page size, and loading / empty / error states. Mirrors the locked Rust response shape (flat JSON array of AuditEvent) — see crates/llmtrace-proxy/src/audit_api.rs.
1 parent 34893fa commit 53f1b51

8 files changed

Lines changed: 784 additions & 0 deletions

File tree

crates/llmtrace-proxy/src/audit_api.rs

Lines changed: 451 additions & 0 deletions
Large diffs are not rendered by default.

crates/llmtrace-proxy/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod action_router;
77
pub mod alerts;
88
pub mod anomaly;
99
pub mod api;
10+
pub mod audit_api;
1011
pub mod auth;
1112
pub mod boundary;
1213
pub mod circuit_breaker;

crates/llmtrace-proxy/src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,11 @@ fn build_router(state: Arc<AppState>) -> Router {
10021002
"/api/v1/auth/keys/:id",
10031003
delete(llmtrace_proxy::auth::revoke_api_key),
10041004
)
1005+
// Audit log API
1006+
.route(
1007+
"/api/v1/audit",
1008+
get(llmtrace_proxy::audit_api::list_audit_events),
1009+
)
10051010
// REST Query API
10061011
.route(
10071012
"/api/v1/config/live",

crates/llmtrace-proxy/src/openapi.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ impl Modify for SecurityAddon {
4040
crate::api::get_current_costs,
4141
crate::api::report_action,
4242
crate::api::actions_summary,
43+
crate::audit_api::list_audit_events,
4344
crate::auth::create_api_key,
4445
crate::auth::list_api_keys,
4546
crate::auth::revoke_api_key,

dashboard/src/app/audit/page.tsx

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
"use client";
2+
3+
import { useEffect, useMemo, useState } from "react";
4+
import { ScrollText, RefreshCw, AlertTriangle, ChevronDown, ChevronRight } from "lucide-react";
5+
import { Button } from "@/components/ui/button";
6+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7+
import { Badge } from "@/components/ui/badge";
8+
import { DataTable } from "@/components/data-table";
9+
import {
10+
findActiveTenant,
11+
listAuditEvents,
12+
type AuditEvent,
13+
type ListAuditEventsParams,
14+
} from "@/lib/api";
15+
16+
const PAGE_SIZE = 100;
17+
18+
/** Collapsible JSON viewer for the per-row `data` payload. */
19+
function DataCell({ value }: { value: unknown }) {
20+
const [open, setOpen] = useState(false);
21+
const json = useMemo(() => {
22+
try {
23+
return JSON.stringify(value, null, 2);
24+
} catch {
25+
return String(value);
26+
}
27+
}, [value]);
28+
29+
if (value === null || value === undefined || json === "{}" || json === "null") {
30+
return <span className="text-xs text-muted-foreground"></span>;
31+
}
32+
33+
return (
34+
<div className="max-w-xl">
35+
<button
36+
type="button"
37+
onClick={() => setOpen((o) => !o)}
38+
className="flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
39+
data-testid="audit-data-toggle"
40+
>
41+
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
42+
{open ? "Hide" : "Show"} payload
43+
</button>
44+
{open && (
45+
<pre
46+
data-testid="audit-data-payload"
47+
className="mt-2 max-h-60 overflow-auto rounded-md border bg-muted/30 p-2 text-[11px] leading-relaxed"
48+
>
49+
{json}
50+
</pre>
51+
)}
52+
</div>
53+
);
54+
}
55+
56+
export default function AuditPage() {
57+
const [events, setEvents] = useState<AuditEvent[] | null>(null);
58+
const [loading, setLoading] = useState(true);
59+
const [error, setError] = useState<string | null>(null);
60+
const [tenantId, setTenantId] = useState<string | undefined>(undefined);
61+
const [filter, setFilter] = useState("");
62+
const [offset, setOffset] = useState(0);
63+
64+
async function load(currentTenant: string | undefined, params: ListAuditEventsParams) {
65+
setLoading(true);
66+
setError(null);
67+
try {
68+
const data = await listAuditEvents(params, currentTenant);
69+
setEvents(data);
70+
} catch (e) {
71+
setEvents(null);
72+
setError(e instanceof Error ? e.message : "Failed to load audit events");
73+
} finally {
74+
setLoading(false);
75+
}
76+
}
77+
78+
useEffect(() => {
79+
let cancelled = false;
80+
(async () => {
81+
const t = await findActiveTenant();
82+
if (cancelled) return;
83+
setTenantId(t);
84+
await load(t, { limit: PAGE_SIZE, offset: 0 });
85+
})();
86+
return () => {
87+
cancelled = true;
88+
};
89+
}, []);
90+
91+
const handleFilterApply = () => {
92+
setOffset(0);
93+
load(tenantId, {
94+
limit: PAGE_SIZE,
95+
offset: 0,
96+
event_type: filter.trim() || undefined,
97+
});
98+
};
99+
100+
const handlePage = (direction: "prev" | "next") => {
101+
const nextOffset =
102+
direction === "prev" ? Math.max(0, offset - PAGE_SIZE) : offset + PAGE_SIZE;
103+
setOffset(nextOffset);
104+
load(tenantId, {
105+
limit: PAGE_SIZE,
106+
offset: nextOffset,
107+
event_type: filter.trim() || undefined,
108+
});
109+
};
110+
111+
const columns = [
112+
{
113+
header: "Timestamp",
114+
accessor: (e: AuditEvent) => (
115+
<span className="font-mono text-xs">
116+
{new Date(e.timestamp).toLocaleString()}
117+
</span>
118+
),
119+
},
120+
{
121+
header: "Event type",
122+
accessor: (e: AuditEvent) => (
123+
<Badge variant="secondary" className="font-mono text-[11px]">
124+
{e.event_type}
125+
</Badge>
126+
),
127+
},
128+
{
129+
header: "Actor",
130+
accessor: (e: AuditEvent) => (
131+
<span className="text-xs text-muted-foreground">{e.actor || "—"}</span>
132+
),
133+
},
134+
{
135+
header: "Resource",
136+
accessor: (e: AuditEvent) => (
137+
<span className="font-mono text-[11px] break-all">{e.resource || "—"}</span>
138+
),
139+
className: "max-w-xs",
140+
},
141+
{
142+
header: "Data",
143+
accessor: (e: AuditEvent) => <DataCell value={e.data} />,
144+
},
145+
];
146+
147+
const pageNumber = Math.floor(offset / PAGE_SIZE) + 1;
148+
const hasNext = (events?.length ?? 0) === PAGE_SIZE;
149+
const hasPrev = offset > 0;
150+
151+
return (
152+
<div className="space-y-6 max-w-6xl mx-auto pb-12">
153+
<div className="flex items-center justify-between">
154+
<div className="space-y-1">
155+
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
156+
<ScrollText className="h-7 w-7 text-primary" /> Audit
157+
</h1>
158+
<p className="text-sm text-muted-foreground">
159+
Tenant-scoped audit events: tenant CRUD, API key mint/revoke, config changes, etc.
160+
</p>
161+
</div>
162+
<Button
163+
variant="outline"
164+
onClick={() => load(tenantId, { limit: PAGE_SIZE, offset })}
165+
disabled={loading}
166+
className="shadow-sm"
167+
>
168+
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} /> Refresh
169+
</Button>
170+
</div>
171+
172+
<Card>
173+
<CardHeader className="pb-3">
174+
<CardTitle className="text-base">Filter</CardTitle>
175+
<CardDescription className="text-xs">
176+
Filter by exact event type (e.g. <code className="font-mono">tenant_created</code>,{" "}
177+
<code className="font-mono">api_key_created</code>).
178+
</CardDescription>
179+
</CardHeader>
180+
<CardContent className="flex flex-wrap items-end gap-3">
181+
<div className="space-y-1">
182+
<label className="text-[10px] font-bold uppercase text-muted-foreground">
183+
Event type
184+
</label>
185+
<input
186+
type="text"
187+
value={filter}
188+
data-testid="audit-event-type-filter"
189+
onChange={(e) => setFilter(e.target.value)}
190+
onKeyDown={(e) => {
191+
if (e.key === "Enter") handleFilterApply();
192+
}}
193+
placeholder="tenant_created"
194+
className="h-9 w-56 rounded-md border border-input bg-background px-3 py-2 text-sm focus:ring-2 focus:ring-primary"
195+
/>
196+
</div>
197+
<Button onClick={handleFilterApply} disabled={loading} size="sm" className="h-9">
198+
Apply
199+
</Button>
200+
{filter && (
201+
<Button
202+
onClick={() => {
203+
setFilter("");
204+
setOffset(0);
205+
load(tenantId, { limit: PAGE_SIZE, offset: 0 });
206+
}}
207+
variant="ghost"
208+
size="sm"
209+
className="h-9"
210+
>
211+
Clear
212+
</Button>
213+
)}
214+
</CardContent>
215+
</Card>
216+
217+
<Card>
218+
<CardHeader className="pb-3 flex flex-row items-center justify-between">
219+
<div>
220+
<CardTitle className="text-base">Audit Events</CardTitle>
221+
<CardDescription className="text-xs">
222+
Page {pageNumber} · {events?.length ?? 0} event(s)
223+
</CardDescription>
224+
</div>
225+
<div className="flex gap-2">
226+
<Button
227+
variant="outline"
228+
size="sm"
229+
disabled={!hasPrev || loading}
230+
onClick={() => handlePage("prev")}
231+
data-testid="audit-prev-page"
232+
>
233+
Prev
234+
</Button>
235+
<Button
236+
variant="outline"
237+
size="sm"
238+
disabled={!hasNext || loading}
239+
onClick={() => handlePage("next")}
240+
data-testid="audit-next-page"
241+
>
242+
Next
243+
</Button>
244+
</div>
245+
</CardHeader>
246+
<CardContent>
247+
{error && (
248+
<div className="mb-4 flex items-center gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
249+
<AlertTriangle className="h-4 w-4" />
250+
<span data-testid="audit-error">{error}</span>
251+
</div>
252+
)}
253+
{loading ? (
254+
<div className="py-12 text-center">
255+
<RefreshCw className="mx-auto h-8 w-8 animate-spin text-muted-foreground/40" />
256+
<p className="mt-4 text-sm text-muted-foreground animate-pulse">
257+
Loading audit events…
258+
</p>
259+
</div>
260+
) : (
261+
<DataTable
262+
columns={columns}
263+
data={events ?? []}
264+
emptyMessage="No audit events recorded yet"
265+
/>
266+
)}
267+
</CardContent>
268+
</Card>
269+
</div>
270+
);
271+
}

dashboard/src/components/sidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
FileCheck,
1515
BookOpen,
1616
Gauge,
17+
ScrollText,
1718
} from "lucide-react";
1819
import { cn } from "@/lib/utils";
1920
import { listTenants, setStoredTenant, type Tenant, DEFAULT_TENANT_ID } from "@/lib/api";
@@ -22,6 +23,7 @@ const navItems = [
2223
{ href: "/", label: "Overview", icon: LayoutDashboard },
2324
{ href: "/traces", label: "Traces", icon: FileSearch },
2425
{ href: "/security", label: "Security", icon: Shield },
26+
{ href: "/audit", label: "Audit", icon: ScrollText },
2527
{ href: "/costs", label: "Costs", icon: DollarSign },
2628
{ href: "/tenants", label: "Tenants", icon: Users },
2729
{ href: "/compliance", label: "Compliance", icon: FileCheck },

dashboard/src/lib/api.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
// LLMTrace REST API Client — typed fetch wrapper
33
// ---------------------------------------------------------------------------
44

5+
import type { AuditEvent } from "./types";
6+
7+
export type { AuditEvent };
8+
59
const API_BASE = "";
610

711
/** Default tenant ID used as a fallback for the "default" tenant. */
@@ -533,3 +537,33 @@ export async function listReports(
533537
export async function getReport(id: string, tenantId?: string): Promise<ComplianceReport> {
534538
return apiFetch(`/api/v1/reports/${id}`, undefined, tenantId);
535539
}
540+
541+
// -- Audit log -------------------------------------------------------------
542+
543+
/** Query parameters for `GET /api/v1/audit`. */
544+
export interface ListAuditEventsParams {
545+
event_type?: string;
546+
start_time?: string;
547+
end_time?: string;
548+
limit?: number;
549+
offset?: number;
550+
}
551+
552+
/**
553+
* List audit events for the authenticated tenant.
554+
*
555+
* The proxy returns a flat JSON array (newest-first). The handler is
556+
* admin-only and derives the tenant from the auth context — passing
557+
* `tenantId` here only sets the `X-LLMTrace-Tenant-ID` header used by the
558+
* bootstrap admin key, never to broaden the result set.
559+
*/
560+
export async function listAuditEvents(
561+
params: ListAuditEventsParams = {},
562+
tenantId?: string,
563+
): Promise<AuditEvent[]> {
564+
return apiFetch(
565+
`/api/v1/audit${qs(params as Record<string, string | number | undefined>)}`,
566+
undefined,
567+
tenantId,
568+
);
569+
}

dashboard/src/lib/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// ---------------------------------------------------------------------------
2+
// Shared TypeScript types that mirror Rust API payloads.
3+
// ---------------------------------------------------------------------------
4+
5+
/**
6+
* An audit log entry recorded by the proxy for tenant-scoped actions
7+
* (tenant CRUD, API key mint/revoke, config changes, etc.).
8+
*
9+
* Mirrors `llmtrace_core::AuditEvent`.
10+
*/
11+
export interface AuditEvent {
12+
id: string;
13+
tenant_id: string;
14+
event_type: string;
15+
actor: string;
16+
resource: string;
17+
data: unknown;
18+
timestamp: string; // ISO 8601
19+
}

0 commit comments

Comments
 (0)