Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
451 changes: 451 additions & 0 deletions crates/llmtrace-proxy/src/audit_api.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/llmtrace-proxy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod action_router;
pub mod alerts;
pub mod anomaly;
pub mod api;
pub mod audit_api;
pub mod auth;
pub mod boundary;
pub mod circuit_breaker;
Expand Down
5 changes: 5 additions & 0 deletions crates/llmtrace-proxy/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,11 @@ fn build_router(state: Arc<AppState>) -> Router {
"/api/v1/auth/keys/:id",
delete(llmtrace_proxy::auth::revoke_api_key),
)
// Audit log API
.route(
"/api/v1/audit",
get(llmtrace_proxy::audit_api::list_audit_events),
)
// REST Query API
.route(
"/api/v1/config/live",
Expand Down
1 change: 1 addition & 0 deletions crates/llmtrace-proxy/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ impl Modify for SecurityAddon {
crate::api::get_current_costs,
crate::api::report_action,
crate::api::actions_summary,
crate::audit_api::list_audit_events,
crate::auth::create_api_key,
crate::auth::list_api_keys,
crate::auth::revoke_api_key,
Expand Down
271 changes: 271 additions & 0 deletions dashboard/src/app/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import { ScrollText, RefreshCw, AlertTriangle, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { DataTable } from "@/components/data-table";
import {
findActiveTenant,
listAuditEvents,
type AuditEvent,
type ListAuditEventsParams,
} from "@/lib/api";

const PAGE_SIZE = 100;

/** Collapsible JSON viewer for the per-row `data` payload. */
function DataCell({ value }: { value: unknown }) {
const [open, setOpen] = useState(false);
const json = useMemo(() => {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}, [value]);

if (value === null || value === undefined || json === "{}" || json === "null") {
return <span className="text-xs text-muted-foreground">—</span>;
}

return (
<div className="max-w-xl">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
data-testid="audit-data-toggle"
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{open ? "Hide" : "Show"} payload
</button>
{open && (
<pre
data-testid="audit-data-payload"
className="mt-2 max-h-60 overflow-auto rounded-md border bg-muted/30 p-2 text-[11px] leading-relaxed"
>
{json}
</pre>
)}
</div>
);
}

export default function AuditPage() {
const [events, setEvents] = useState<AuditEvent[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tenantId, setTenantId] = useState<string | undefined>(undefined);
const [filter, setFilter] = useState("");
const [offset, setOffset] = useState(0);

async function load(currentTenant: string | undefined, params: ListAuditEventsParams) {
setLoading(true);
setError(null);
try {
const data = await listAuditEvents(params, currentTenant);
setEvents(data);
} catch (e) {
setEvents(null);
setError(e instanceof Error ? e.message : "Failed to load audit events");
} finally {
setLoading(false);
}
}

useEffect(() => {
let cancelled = false;
(async () => {
const t = await findActiveTenant();
if (cancelled) return;
setTenantId(t);
await load(t, { limit: PAGE_SIZE, offset: 0 });
})();
return () => {
cancelled = true;
};
}, []);

const handleFilterApply = () => {
setOffset(0);
load(tenantId, {
limit: PAGE_SIZE,
offset: 0,
event_type: filter.trim() || undefined,
});
};

const handlePage = (direction: "prev" | "next") => {
const nextOffset =
direction === "prev" ? Math.max(0, offset - PAGE_SIZE) : offset + PAGE_SIZE;
setOffset(nextOffset);
load(tenantId, {
limit: PAGE_SIZE,
offset: nextOffset,
event_type: filter.trim() || undefined,
});
};

const columns = [
{
header: "Timestamp",
accessor: (e: AuditEvent) => (
<span className="font-mono text-xs">
{new Date(e.timestamp).toLocaleString()}
</span>
),
},
{
header: "Event type",
accessor: (e: AuditEvent) => (
<Badge variant="secondary" className="font-mono text-[11px]">
{e.event_type}
</Badge>
),
},
{
header: "Actor",
accessor: (e: AuditEvent) => (
<span className="text-xs text-muted-foreground">{e.actor || "—"}</span>
),
},
{
header: "Resource",
accessor: (e: AuditEvent) => (
<span className="font-mono text-[11px] break-all">{e.resource || "—"}</span>
),
className: "max-w-xs",
},
{
header: "Data",
accessor: (e: AuditEvent) => <DataCell value={e.data} />,
},
];

const pageNumber = Math.floor(offset / PAGE_SIZE) + 1;
const hasNext = (events?.length ?? 0) === PAGE_SIZE;
const hasPrev = offset > 0;

return (
<div className="space-y-6 max-w-6xl mx-auto pb-12">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<ScrollText className="h-7 w-7 text-primary" /> Audit
</h1>
<p className="text-sm text-muted-foreground">
Tenant-scoped audit events: tenant CRUD, API key mint/revoke, config changes, etc.
</p>
</div>
<Button
variant="outline"
onClick={() => load(tenantId, { limit: PAGE_SIZE, offset })}
disabled={loading}
className="shadow-sm"
>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} /> Refresh
</Button>
</div>

<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Filter</CardTitle>
<CardDescription className="text-xs">
Filter by exact event type (e.g. <code className="font-mono">tenant_created</code>,{" "}
<code className="font-mono">api_key_created</code>).
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase text-muted-foreground">
Event type
</label>
<input
type="text"
value={filter}
data-testid="audit-event-type-filter"
onChange={(e) => setFilter(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleFilterApply();
}}
placeholder="tenant_created"
className="h-9 w-56 rounded-md border border-input bg-background px-3 py-2 text-sm focus:ring-2 focus:ring-primary"
/>
</div>
<Button onClick={handleFilterApply} disabled={loading} size="sm" className="h-9">
Apply
</Button>
{filter && (
<Button
onClick={() => {
setFilter("");
setOffset(0);
load(tenantId, { limit: PAGE_SIZE, offset: 0 });
}}
variant="ghost"
size="sm"
className="h-9"
>
Clear
</Button>
)}
</CardContent>
</Card>

<Card>
<CardHeader className="pb-3 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-base">Audit Events</CardTitle>
<CardDescription className="text-xs">
Page {pageNumber} · {events?.length ?? 0} event(s)
</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={!hasPrev || loading}
onClick={() => handlePage("prev")}
data-testid="audit-prev-page"
>
Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={!hasNext || loading}
onClick={() => handlePage("next")}
data-testid="audit-next-page"
>
Next
</Button>
</div>
</CardHeader>
<CardContent>
{error && (
<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">
<AlertTriangle className="h-4 w-4" />
<span data-testid="audit-error">{error}</span>
</div>
)}
{loading ? (
<div className="py-12 text-center">
<RefreshCw className="mx-auto h-8 w-8 animate-spin text-muted-foreground/40" />
<p className="mt-4 text-sm text-muted-foreground animate-pulse">
Loading audit events…
</p>
</div>
) : (
<DataTable
columns={columns}
data={events ?? []}
emptyMessage="No audit events recorded yet"
/>
)}
</CardContent>
</Card>
</div>
);
}
2 changes: 2 additions & 0 deletions dashboard/src/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
FileCheck,
BookOpen,
Gauge,
ScrollText,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { listTenants, setStoredTenant, type Tenant, DEFAULT_TENANT_ID } from "@/lib/api";
Expand All @@ -22,6 +23,7 @@ const navItems = [
{ href: "/", label: "Overview", icon: LayoutDashboard },
{ href: "/traces", label: "Traces", icon: FileSearch },
{ href: "/security", label: "Security", icon: Shield },
{ href: "/audit", label: "Audit", icon: ScrollText },
{ href: "/costs", label: "Costs", icon: DollarSign },
{ href: "/tenants", label: "Tenants", icon: Users },
{ href: "/compliance", label: "Compliance", icon: FileCheck },
Expand Down
34 changes: 34 additions & 0 deletions dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
// LLMTrace REST API Client — typed fetch wrapper
// ---------------------------------------------------------------------------

import type { AuditEvent } from "./types";

export type { AuditEvent };

const API_BASE = "";

/** Default tenant ID used as a fallback for the "default" tenant. */
Expand Down Expand Up @@ -533,3 +537,33 @@ export async function listReports(
export async function getReport(id: string, tenantId?: string): Promise<ComplianceReport> {
return apiFetch(`/api/v1/reports/${id}`, undefined, tenantId);
}

// -- Audit log -------------------------------------------------------------

/** Query parameters for `GET /api/v1/audit`. */
export interface ListAuditEventsParams {
event_type?: string;
start_time?: string;
end_time?: string;
limit?: number;
offset?: number;
}

/**
* List audit events for the authenticated tenant.
*
* The proxy returns a flat JSON array (newest-first). The handler is
* admin-only and derives the tenant from the auth context — passing
* `tenantId` here only sets the `X-LLMTrace-Tenant-ID` header used by the
* bootstrap admin key, never to broaden the result set.
*/
export async function listAuditEvents(
params: ListAuditEventsParams = {},
tenantId?: string,
): Promise<AuditEvent[]> {
return apiFetch(
`/api/v1/audit${qs(params as Record<string, string | number | undefined>)}`,
undefined,
tenantId,
);
}
19 changes: 19 additions & 0 deletions dashboard/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// ---------------------------------------------------------------------------
// Shared TypeScript types that mirror Rust API payloads.
// ---------------------------------------------------------------------------

/**
* An audit log entry recorded by the proxy for tenant-scoped actions
* (tenant CRUD, API key mint/revoke, config changes, etc.).
*
* Mirrors `llmtrace_core::AuditEvent`.
*/
export interface AuditEvent {
id: string;
tenant_id: string;
event_type: string;
actor: string;
resource: string;
data: unknown;
timestamp: string; // ISO 8601
}
Loading