Skip to content

Commit 4fc2ed9

Browse files
Add RequestAudit component and related API endpoints: Implement audit logging features in the frontend and backend, including new routes for fetching recent audit entries and aggregated audit stats, enhancing user navigation with a sidebar entry for request audits.
1 parent a3c77d5 commit 4fc2ed9

6 files changed

Lines changed: 277 additions & 2 deletions

File tree

backend/src/server.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ app.use(cors({ origin: process.env.CORS_ORIGIN || "http://129.212.238.68:5173" }
88
app.use(express.json({ limit: "512kb" }));
99

1010
const LIFECYCLE_SERVICE_URL = process.env.LIFECYCLE_SERVICE_URL || "http://129.212.238.68:3003";
11+
const GATEWAY_URL = process.env.GATEWAY_URL || "http://129.212.238.68:3000";
1112

1213
app.get("/health", (_req: Request, res: Response) => {
1314
res.json({ service: "security-admin-dashboard-backend", status: "active" });
@@ -48,6 +49,45 @@ app.get("/api/sla/metrics", async (_req: Request, res: Response) => {
4849
}
4950
});
5051

52+
/** Proxy to Gateway request audit log (who requested what, granted/denied) */
53+
app.get("/api/audit/recent", async (req: Request, res: Response) => {
54+
try {
55+
const params = new URLSearchParams();
56+
if (req.query.limit) params.set("limit", String(req.query.limit));
57+
if (req.query.page) params.set("page", String(req.query.page));
58+
const qs = params.toString() ? `?${params.toString()}` : "";
59+
const r = await fetch(`${GATEWAY_URL}/api/audit/recent${qs}`, {
60+
headers: { Accept: "application/json" },
61+
});
62+
const text = await r.text();
63+
if (!r.ok) {
64+
try {
65+
return res.status(r.status).json(JSON.parse(text));
66+
} catch {
67+
return res.status(r.status).json({ error: "Gateway error", body: text.slice(0, 200) });
68+
}
69+
}
70+
try {
71+
const data = JSON.parse(text);
72+
res.json(data);
73+
} catch {
74+
console.error("Audit proxy: Gateway returned non-JSON (check GATEWAY_URL)");
75+
res.status(503).json({
76+
error: "Gateway returned invalid response; ensure GATEWAY_URL points to the Gateway API",
77+
entries: [],
78+
timestamp: new Date().toISOString(),
79+
});
80+
}
81+
} catch (e) {
82+
console.error("Audit proxy error:", e);
83+
res.status(503).json({
84+
error: "Gateway unavailable",
85+
entries: [],
86+
timestamp: new Date().toISOString(),
87+
});
88+
}
89+
});
90+
5191
app.get("/api/did/ids", async (req: Request, res: Response) => {
5292
try {
5393
const type = (req.query.type as string) || "client";

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
IssueVC,
77
RevokeVC,
88
AvailabilityMetrics,
9+
RequestAudit,
910
} from "./components";
1011
import "./styles/layout.css";
1112

@@ -19,6 +20,7 @@ export default function App() {
1920
{activeNav === "issue-vc" && <IssueVC />}
2021
{activeNav === "revoke-vc" && <RevokeVC />}
2122
{activeNav === "availability" && <AvailabilityMetrics />}
23+
{activeNav === "request-audit" && <RequestAudit />}
2224
</MainLayout>
2325
);
2426
}

frontend/src/api/dashboard.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,42 @@ export async function fetchSlaMetrics(): Promise<SlaMetricsResponse | null> {
3030
}
3131
}
3232

33+
/** Request audit entry (Gateway: who requested what, granted/denied) */
34+
export interface AuditEntry {
35+
did: string | null;
36+
method: string;
37+
path: string;
38+
granted: boolean;
39+
reason?: string;
40+
timestamp: string;
41+
}
42+
43+
export interface AuditRecentResponse {
44+
entries: AuditEntry[];
45+
count: number;
46+
total: number;
47+
page: number;
48+
limit: number;
49+
totalPages: number;
50+
timestamp: string;
51+
hint?: string;
52+
}
53+
54+
export async function fetchAuditRecent(
55+
limit = 10,
56+
page = 1
57+
): Promise<AuditRecentResponse | null> {
58+
try {
59+
const params = new URLSearchParams({ limit: String(limit), page: String(page) });
60+
const res = await fetch(`${BASE}/api/audit/recent?${params.toString()}`);
61+
const data = (await res.json()) as AuditRecentResponse & { error?: string };
62+
if (!res.ok) return null;
63+
return data;
64+
} catch {
65+
return null;
66+
}
67+
}
68+
3369
/** Fetch IDs from did-documents repo for the given type (clients/, servers/, or core). */
3470
export async function fetchDidIds(type: DidDocumentType): Promise<string[]> {
3571
try {
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { useEffect, useState, useCallback } from "react";
2+
import { RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
3+
import { fetchAuditRecent, type AuditEntry } from "../api/dashboard";
4+
import "../styles/layout.css";
5+
6+
const PAGE_SIZES = [10, 25, 50, 100];
7+
8+
// Row padding by page size: 10 = bigger rows, 25 = medium, 50/100 = compact
9+
function getRowPadding(pageSize: number): string {
10+
if (pageSize <= 10) return "0.85rem 1rem";
11+
if (pageSize <= 25) return "0.65rem 0.85rem";
12+
return "0.5rem 0.75rem";
13+
}
14+
15+
export function RequestAudit() {
16+
const [entries, setEntries] = useState<AuditEntry[]>([]);
17+
const [loading, setLoading] = useState(true);
18+
const [error, setError] = useState<string | null>(null);
19+
const [timestamp, setTimestamp] = useState<string | null>(null);
20+
const [hint, setHint] = useState<string | null>(null);
21+
const [page, setPage] = useState(1);
22+
const [pageSize, setPageSize] = useState(10);
23+
const [total, setTotal] = useState(0);
24+
const [totalPages, setTotalPages] = useState(0);
25+
26+
const load = useCallback(async (pageNum: number, size: number) => {
27+
setLoading(true);
28+
setError(null);
29+
setHint(null);
30+
const result = await fetchAuditRecent(size, pageNum);
31+
if (result) {
32+
setEntries(result.entries);
33+
setTimestamp(result.timestamp);
34+
const t = result.total ?? 0;
35+
setTotal(t);
36+
setTotalPages(result.totalPages ?? Math.max(1, Math.ceil(t / size)));
37+
if (result.hint) setHint(result.hint);
38+
} else {
39+
setError("Could not load audit log. Is the Gateway running and REDIS_URL set?");
40+
setEntries([]);
41+
setTotal(0);
42+
setTotalPages(0);
43+
}
44+
setLoading(false);
45+
}, []);
46+
47+
useEffect(() => {
48+
load(page, pageSize);
49+
}, [page, pageSize, load]);
50+
51+
useEffect(() => {
52+
const interval = setInterval(() => load(page, pageSize), 15_000);
53+
return () => clearInterval(interval);
54+
}, [page, pageSize, load]);
55+
56+
return (
57+
<div>
58+
<h1 style={{ marginBottom: "0.5rem", fontSize: "1.5rem" }}>Request Audit</h1>
59+
<p style={{ color: "var(--text-muted)", marginBottom: "1.5rem" }}>
60+
Recent requests through the Gateway: who (DID) requested what (method + path) and whether access was granted or denied. Data is stored in Gateway Redis and does not affect the request path.
61+
</p>
62+
63+
{loading && entries.length === 0 && (
64+
<div className="card">
65+
<p style={{ color: "var(--text-muted)" }}>Loading audit log…</p>
66+
</div>
67+
)}
68+
69+
{error && entries.length === 0 && (
70+
<div className="card" style={{ borderColor: "var(--danger)" }}>
71+
<p style={{ color: "var(--danger)" }}>{error}</p>
72+
<button type="button" className="btn btn-secondary" onClick={() => load(page, pageSize)} style={{ marginTop: "0.75rem" }}>
73+
<RefreshCw size={16} /> Retry
74+
</button>
75+
</div>
76+
)}
77+
78+
{(entries.length > 0 || (!loading && timestamp)) && (
79+
<div className="card">
80+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "1rem", flexWrap: "wrap", gap: "0.75rem" }}>
81+
<h2 className="card-title" style={{ margin: 0 }}>Recent requests</h2>
82+
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
83+
<label style={{ fontSize: "0.85rem", color: "var(--text-muted)", display: "flex", alignItems: "center", gap: "0.35rem" }}>
84+
Per page
85+
<select
86+
value={pageSize}
87+
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
88+
style={{ padding: "0.35rem 0.5rem", borderRadius: "6px", border: "1px solid var(--border)", background: "var(--input-bg)", color: "var(--text-primary)", fontSize: "0.85rem" }}
89+
>
90+
{PAGE_SIZES.map((n) => (
91+
<option key={n} value={n}>{n}</option>
92+
))}
93+
</select>
94+
</label>
95+
<button type="button" className="btn btn-secondary" onClick={() => load(page, pageSize)} disabled={loading}>
96+
<RefreshCw size={16} /> Refresh
97+
</button>
98+
</div>
99+
</div>
100+
{timestamp && (
101+
<p style={{ fontSize: "0.85rem", color: "var(--text-muted)", marginBottom: "1rem" }}>
102+
Last updated: {timestamp}
103+
</p>
104+
)}
105+
{total > 0 && (
106+
<p style={{ fontSize: "0.85rem", color: "var(--text-muted)", marginBottom: "0.75rem" }}>
107+
Showing {((page - 1) * pageSize) + 1}{Math.min(page * pageSize, total)} of {total}
108+
</p>
109+
)}
110+
111+
<div style={{ overflowX: "auto" }}>
112+
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: pageSize <= 10 ? "0.95rem" : pageSize <= 25 ? "0.9rem" : "0.875rem" }}>
113+
<thead>
114+
<tr style={{ borderBottom: "1px solid var(--border)", textAlign: "left" }}>
115+
<th style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontWeight: 600 }}>Time</th>
116+
<th style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontWeight: 600 }}>From (DID)</th>
117+
<th style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontWeight: 600 }}>Method</th>
118+
<th style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontWeight: 600 }}>Path</th>
119+
<th style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontWeight: 600 }}>Result</th>
120+
<th style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontWeight: 600 }}>Reason</th>
121+
</tr>
122+
</thead>
123+
<tbody>
124+
{entries.map((e, i) => (
125+
<tr key={`${e.timestamp}-${i}`} style={{ borderBottom: "1px solid var(--border)" }}>
126+
<td style={{ padding: getRowPadding(pageSize), color: "var(--text-secondary)", whiteSpace: "nowrap" }}>
127+
{new Date(e.timestamp).toLocaleString()}
128+
</td>
129+
<td style={{ padding: getRowPadding(pageSize), color: "var(--text-primary)", wordBreak: "break-all", maxWidth: "200px" }}>
130+
{e.did ?? "—"}
131+
</td>
132+
<td style={{ padding: getRowPadding(pageSize) }}>
133+
<code style={{ fontSize: "0.8em", background: "var(--input-bg)", padding: "0.2rem 0.4rem", borderRadius: "4px" }}>
134+
{e.method}
135+
</code>
136+
</td>
137+
<td style={{ padding: getRowPadding(pageSize), color: "var(--text-secondary)", wordBreak: "break-all", maxWidth: "280px" }}>
138+
{e.path}
139+
</td>
140+
<td style={{ padding: getRowPadding(pageSize) }}>
141+
<span className={`pill ${e.granted ? "pill-success" : "pill-error"}`}>
142+
{e.granted ? "Granted" : "Denied"}
143+
</span>
144+
</td>
145+
<td style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontSize: "0.8rem", maxWidth: "180px" }}>
146+
{e.reason ?? "—"}
147+
</td>
148+
</tr>
149+
))}
150+
</tbody>
151+
</table>
152+
</div>
153+
{entries.length === 0 && !loading && (
154+
<div style={{ marginTop: "1rem" }}>
155+
<p style={{ color: "var(--text-muted)" }}>No audit entries yet.</p>
156+
{hint && (
157+
<p style={{ color: "var(--text-muted)", fontSize: "0.85rem", marginTop: "0.5rem" }}>{hint}</p>
158+
)}
159+
<p style={{ color: "var(--text-muted)", fontSize: "0.85rem", marginTop: "0.5rem" }}>
160+
Only requests to protected routes are logged: <code style={{ fontSize: "0.8em" }}>/api/farmer/*</code>, <code style={{ fontSize: "0.8em" }}>/api/agents/*</code>, <code style={{ fontSize: "0.8em" }}>/api/data</code>. Check Gateway logs for &quot;Audit:&quot; if Redis write fails.
161+
</p>
162+
</div>
163+
)}
164+
165+
{/* Bottom pagination: Back / Page N of M / Next */}
166+
{(entries.length > 0 || total > 0) && (
167+
<nav style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "1rem", paddingTop: "1rem", borderTop: "1px solid var(--border)", flexWrap: "wrap", gap: "0.5rem" }} aria-label="Audit pagination">
168+
<button
169+
type="button"
170+
className="btn btn-secondary"
171+
onClick={() => setPage((p) => Math.max(1, p - 1))}
172+
disabled={loading || page <= 1}
173+
aria-label="Previous page"
174+
>
175+
<ChevronLeft size={16} /> Back
176+
</button>
177+
<span style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}>
178+
Page {page} of {Math.max(1, totalPages)}
179+
</span>
180+
<button
181+
type="button"
182+
className="btn btn-secondary"
183+
onClick={() => setPage((p) => Math.min(Math.max(1, totalPages), p + 1))}
184+
disabled={loading || page >= Math.max(1, totalPages)}
185+
aria-label="Next page"
186+
>
187+
Next <ChevronRight size={16} />
188+
</button>
189+
</nav>
190+
)}
191+
</div>
192+
)}
193+
</div>
194+
);
195+
}

frontend/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export { IssueVC } from "./IssueVC";
33
export { RevokeVC } from "./RevokeVC";
44
export { DashboardHome } from "./DashboardHome";
55
export { AvailabilityMetrics } from "./AvailabilityMetrics";
6+
export { RequestAudit } from "./RequestAudit";

frontend/src/layout/Sidebar.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { LayoutDashboard, KeyRound, FileBadge, FileX, Activity, Menu } from "lucide-react";
1+
import { LayoutDashboard, KeyRound, FileBadge, FileX, Activity, ClipboardList, Menu } from "lucide-react";
22

3-
export type NavId = "dashboard" | "key-did" | "issue-vc" | "revoke-vc" | "availability";
3+
export type NavId = "dashboard" | "key-did" | "issue-vc" | "revoke-vc" | "availability" | "request-audit";
44

55
interface SidebarProps {
66
active: NavId;
@@ -13,6 +13,7 @@ const navItems: { id: NavId; label: string; Icon: typeof LayoutDashboard }[] = [
1313
{ id: "issue-vc", label: "Issue VC", Icon: FileBadge },
1414
{ id: "revoke-vc", label: "Revoke VC", Icon: FileX },
1515
{ id: "availability", label: "Availability", Icon: Activity },
16+
{ id: "request-audit", label: "Request Audit", Icon: ClipboardList },
1617
];
1718

1819
export function Sidebar({ active, onNavigate }: SidebarProps) {

0 commit comments

Comments
 (0)