forked from baron/baron-sso
adminFront에 Audit Log 기능 추가
This commit is contained in:
@@ -1,21 +1,138 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import { Filter, ListChecks, Search, Terminal } from "lucide-react";
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Copy,
|
||||||
|
ListChecks,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Terminal,
|
||||||
|
} from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
import type { AuditLog } from "../../lib/adminApi";
|
||||||
import { fetchAuditLogs } from "../../lib/adminApi";
|
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||||
|
|
||||||
const auditFilters = [
|
const defaultAuditFilters = [
|
||||||
"Actor role = admin",
|
"method:POST path:/api/v1/*",
|
||||||
"Action = client.rotate_secret",
|
"status:failure",
|
||||||
"Tenant = selected header",
|
"latency_ms:>1000",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type AuditDetails = {
|
||||||
|
request_id?: string;
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
status?: number;
|
||||||
|
latency_ms?: number;
|
||||||
|
error?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
actor_id?: string;
|
||||||
|
action?: string;
|
||||||
|
target?: string;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseDetails(details?: string): AuditDetails {
|
||||||
|
if (!details) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(details);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return parsed as AuditDetails;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCellValue(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIsoDateTime(value: string) {
|
||||||
|
if (!value) {
|
||||||
|
return { date: "-", time: "-" };
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return { date: value, time: "-" };
|
||||||
|
}
|
||||||
|
const date = parsed.toISOString().slice(0, 10);
|
||||||
|
const time = parsed.toLocaleTimeString("ko-KR", { hour12: false });
|
||||||
|
return { date, time };
|
||||||
|
}
|
||||||
|
|
||||||
function AuditLogsPage() {
|
function AuditLogsPage() {
|
||||||
const { data, isLoading, error } = useQuery({
|
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
||||||
|
const [filterDraft, setFilterDraft] = React.useState("");
|
||||||
|
const [expandedRows, setExpandedRows] = React.useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const handleCopy = (value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useInfiniteQuery({
|
||||||
queryKey: ["audit-logs"],
|
queryKey: ["audit-logs"],
|
||||||
queryFn: () => fetchAuditLogs(),
|
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const logs = data?.items || [];
|
const logs =
|
||||||
|
data?.pages?.flatMap((page) =>
|
||||||
|
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const handleAddFilter = () => {
|
||||||
|
const trimmed = filterDraft.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed]));
|
||||||
|
setFilterDraft("");
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="p-8 text-center">Loading audit logs...</div>;
|
return <div className="p-8 text-center">Loading audit logs...</div>;
|
||||||
@@ -34,121 +151,307 @@ function AuditLogsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
Audit stream
|
<span>Audit</span>
|
||||||
</p>
|
<span>/</span>
|
||||||
<h2 className="text-2xl font-semibold">
|
<span className="text-foreground">Logs</span>
|
||||||
Observe admin actions per tenant
|
</div>
|
||||||
</h2>
|
<h2 className="text-3xl font-semibold">감사 로그</h2>
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
ClickHouse-backed feed. Filter by tenant, actor, action, and
|
Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는
|
||||||
rate-limit status. Enforce admin-only access under /admin.
|
추후 세션 연동 시 자동 채워집니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
type="button"
|
<RefreshCw size={16} />
|
||||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]"
|
새로고침
|
||||||
>
|
</Button>
|
||||||
<Filter size={14} />
|
<Button>
|
||||||
Saved filters
|
<ListChecks size={16} />
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
|
||||||
>
|
|
||||||
<ListChecks size={14} />
|
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-[1.1fr,0.9fr]">
|
<div className="space-y-4">
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
<Card className="bg-[var(--color-panel)]">
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-[var(--color-muted)]">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<Search size={14} />
|
<div>
|
||||||
<span className="text-sm">
|
<CardTitle>Audit registry</CardTitle>
|
||||||
Try: tenant:TENANT-12 action:client.*
|
<CardDescription>로드된 로그 {logs.length}건</CardDescription>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<Badge variant="muted">Command only</Badge>
|
||||||
<div className="mt-4 space-y-3">
|
</CardHeader>
|
||||||
{auditFilters.map((filter) => (
|
<CardContent>
|
||||||
<span
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
key={filter}
|
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
||||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
<Search size={14} />
|
||||||
>
|
<input
|
||||||
<Terminal size={12} />
|
value={filterDraft}
|
||||||
{filter}
|
onChange={(event) => setFilterDraft(event.target.value)}
|
||||||
</span>
|
onKeyDown={(event) => {
|
||||||
))}
|
if (event.key === "Enter") {
|
||||||
</div>
|
handleAddFilter();
|
||||||
<div className="mt-5 divide-y divide-[var(--color-border)]">
|
}
|
||||||
{logs.length === 0 ? (
|
}}
|
||||||
<div className="py-8 text-center text-sm text-[var(--color-muted)]">
|
placeholder="필터 추가 (예: status:failure)"
|
||||||
No audit logs found.
|
className="w-full bg-transparent text-sm text-foreground outline-none"
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleAddFilter}>
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{filters.length === 0 ? (
|
||||||
logs.map((row) => (
|
<span className="text-xs text-[var(--color-muted)]">
|
||||||
<div
|
필터 없음
|
||||||
key={`${row.event_type}-${row.user_id}-${row.timestamp}`}
|
</span>
|
||||||
className="grid grid-cols-[1.2fr,1fr,1fr,1fr] items-center gap-2 py-3 text-sm"
|
) : (
|
||||||
>
|
filters.map((filter) => (
|
||||||
<div className="font-semibold">{row.event_type}</div>
|
<span
|
||||||
<div className="text-[var(--color-muted)]">
|
key={filter}
|
||||||
{/* Tenant info not yet in basic schema, show generic or details snippet */}
|
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||||
Tenant-?
|
>
|
||||||
</div>
|
<Terminal size={12} />
|
||||||
<div className="text-[var(--color-muted)] overflow-hidden text-ellipsis whitespace-nowrap">
|
{filter}
|
||||||
{row.user_id}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div className="inline-flex items-center gap-2">
|
onClick={() =>
|
||||||
<span
|
setFilters((prev) =>
|
||||||
className={`rounded-full px-2 py-1 text-xs ${
|
prev.filter((item) => item !== filter),
|
||||||
row.status === "success" || row.status === "ok"
|
)
|
||||||
? "bg-[rgba(54,211,153,0.16)] text-[var(--color-accent)]"
|
}
|
||||||
: "bg-[rgba(249,168,38,0.16)] text-[var(--color-accent-strong)]"
|
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
|
||||||
}`}
|
aria-label={`${filter} 필터 제거`}
|
||||||
>
|
>
|
||||||
{row.status}
|
×
|
||||||
</span>
|
</button>
|
||||||
<span className="text-[var(--color-muted)] text-xs">
|
</span>
|
||||||
{new Date(row.timestamp).toLocaleString()}
|
))
|
||||||
</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Table className="table-fixed">
|
||||||
))
|
<TableHeader>
|
||||||
)}
|
<TableRow>
|
||||||
</div>
|
<TableHead className="w-[140px]">TIME</TableHead>
|
||||||
</div>
|
<TableHead className="w-[160px]">ACTOR (ID)</TableHead>
|
||||||
|
<TableHead>REQUEST</TableHead>
|
||||||
|
<TableHead>PATH</TableHead>
|
||||||
|
<TableHead className="w-[120px]">STATUS</TableHead>
|
||||||
|
<TableHead>Action / Target</TableHead>
|
||||||
|
<TableHead className="w-[80px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7}>로딩 중...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!isLoading && logs.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7}>
|
||||||
|
아직 수집된 감사 로그가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{logs.map((row, index) => {
|
||||||
|
const details = parseDetails(row.details);
|
||||||
|
const actionLabel =
|
||||||
|
details.action ||
|
||||||
|
(details.method && details.path
|
||||||
|
? `${details.method} ${details.path}`
|
||||||
|
: row.event_type);
|
||||||
|
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||||
|
const isExpanded = Boolean(expandedRows[rowKey]);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={rowKey}>
|
||||||
|
<TableRow className="bg-card/40">
|
||||||
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
|
{(() => {
|
||||||
|
const { date, time } = formatIsoDateTime(
|
||||||
|
row.timestamp,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>{date}</div>
|
||||||
|
<div>{time}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||||
|
{row.user_id || details.actor_id || "-"}
|
||||||
|
</code>
|
||||||
|
{(row.user_id || details.actor_id) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label="Copy actor id"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopy(row.user_id || details.actor_id || "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="break-all">
|
||||||
|
{formatCellValue(details.request_id)}
|
||||||
|
</span>
|
||||||
|
{details.request_id && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label="Copy request id"
|
||||||
|
onClick={() => handleCopy(details.request_id || "")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
|
<div className="font-semibold text-foreground">
|
||||||
|
{formatCellValue(details.method)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
{formatCellValue(details.path)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
row.status === "success" || row.status === "ok"
|
||||||
|
? "success"
|
||||||
|
: "warning"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
|
<div className="font-semibold text-foreground">
|
||||||
|
{actionLabel}
|
||||||
|
</div>
|
||||||
|
{details.target && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="break-all">
|
||||||
|
Target · {details.target}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label="Copy target"
|
||||||
|
onClick={() => handleCopy(details.target || "")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedRows((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rowKey]: !isExpanded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isExpanded && (
|
||||||
|
<TableRow className="bg-card/20">
|
||||||
|
<TableCell colSpan={7} className="text-xs">
|
||||||
|
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="uppercase tracking-[0.16em]">
|
||||||
|
Request
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Request ID · {formatCellValue(details.request_id)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Event ID · {formatCellValue(row.event_id)}
|
||||||
|
</div>
|
||||||
|
<div>IP · {formatCellValue(row.ip_address)}</div>
|
||||||
|
<div>
|
||||||
|
Latency ·{" "}
|
||||||
|
{details.latency_ms !== undefined
|
||||||
|
? `${details.latency_ms}ms`
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="uppercase tracking-[0.16em]">
|
||||||
|
Actor
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Actor ID · {row.user_id || details.actor_id || "-"}
|
||||||
|
</div>
|
||||||
|
<div>Tenant · {formatCellValue(details.tenant_id)}</div>
|
||||||
|
<div>Device · {formatCellValue(row.device_id)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="uppercase tracking-[0.16em]">
|
||||||
|
Result
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Error · {formatCellValue(details.error)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Before · {formatCellValue(details.before)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
After · {formatCellValue(details.after)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<div className="pt-4 text-center">
|
||||||
|
{hasNextPage ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? "Loading..." : "Load more"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--color-muted)]">
|
||||||
|
End of audit feed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
|
||||||
Guard rails
|
|
||||||
</p>
|
|
||||||
<h3 className="mt-1 text-lg font-semibold">Tenant admin only</h3>
|
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
|
||||||
Enforce Tenant Admin middleware and admin session TTL before
|
|
||||||
surfacing any audit feed. Super Admin role can bypass tenant
|
|
||||||
filter when needed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
|
||||||
Export rules
|
|
||||||
</p>
|
|
||||||
<h3 className="mt-1 text-lg font-semibold">
|
|
||||||
Rate-limit sensitive exports
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
|
||||||
Keep export endpoints behind admin-only routes with ClickHouse
|
|
||||||
query limits. Log download attempts with IP, role, and tenant
|
|
||||||
scope.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import apiClient from "./apiClient";
|
import apiClient from "./apiClient";
|
||||||
|
|
||||||
export type AuditLog = {
|
export type AuditLog = {
|
||||||
|
event_id: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
event_type: string;
|
event_type: string;
|
||||||
@@ -14,7 +15,8 @@ export type AuditLog = {
|
|||||||
export type AuditLogListResponse = {
|
export type AuditLogListResponse = {
|
||||||
items: AuditLog[];
|
items: AuditLog[];
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
cursor?: string;
|
||||||
|
next_cursor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TenantSummary = {
|
export type TenantSummary = {
|
||||||
@@ -48,9 +50,9 @@ export type TenantUpdateRequest = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchAuditLogs(limit = 50, offset = 0) {
|
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||||
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||||
params: { limit, offset },
|
params: { limit, cursor },
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"baron-sso-backend/internal/handler"
|
"baron-sso-backend/internal/handler"
|
||||||
"baron-sso-backend/internal/idp"
|
"baron-sso-backend/internal/idp"
|
||||||
"baron-sso-backend/internal/logger"
|
"baron-sso-backend/internal/logger"
|
||||||
|
"baron-sso-backend/internal/middleware"
|
||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/validator"
|
"baron-sso-backend/internal/validator"
|
||||||
@@ -335,6 +336,13 @@ func main() {
|
|||||||
|
|
||||||
// API Group
|
// API Group
|
||||||
api := app.Group("/api/v1")
|
api := app.Group("/api/v1")
|
||||||
|
api.Use(middleware.RequireAudit(middleware.AuditRequiredConfig{
|
||||||
|
Repo: auditRepo,
|
||||||
|
ExcludePaths: map[string]struct{}{
|
||||||
|
"/api/v1/audit": {},
|
||||||
|
"/api/v1/client-log": {},
|
||||||
|
},
|
||||||
|
}))
|
||||||
api.Post("/audit", auditHandler.CreateLog)
|
api.Post("/audit", auditHandler.CreateLog)
|
||||||
api.Get("/audit", auditHandler.ListLogs)
|
api.Get("/audit", auditHandler.ListLogs)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
// AuditLog represents a single audit event
|
// AuditLog represents a single audit event
|
||||||
type AuditLog struct {
|
type AuditLog struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
EventType string `json:"event_type"` // e.g., "login_success", "login_failed", "otp_sent"
|
EventType string `json:"event_type"` // e.g., "login_success", "login_failed", "otp_sent"
|
||||||
@@ -20,6 +21,11 @@ type AuditLog struct {
|
|||||||
// AuditRepository defines interface for storing logs
|
// AuditRepository defines interface for storing logs
|
||||||
type AuditRepository interface {
|
type AuditRepository interface {
|
||||||
Create(log *AuditLog) error
|
Create(log *AuditLog) error
|
||||||
FindAll(ctx context.Context, limit, offset int) ([]AuditLog, error)
|
FindPage(ctx context.Context, limit int, cursor *AuditCursor) ([]AuditLog, error)
|
||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuditCursor struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
EventID string
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuditHandler struct {
|
type AuditHandler struct {
|
||||||
@@ -34,6 +38,9 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
|
|||||||
if req.Timestamp.IsZero() {
|
if req.Timestamp.IsZero() {
|
||||||
req.Timestamp = time.Now()
|
req.Timestamp = time.Now()
|
||||||
}
|
}
|
||||||
|
if req.EventID == "" {
|
||||||
|
req.EventID = ensureRequestID(c)
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.repo.Create(&req); err != nil {
|
if err := h.repo.Create(&req); err != nil {
|
||||||
// Log internal error but don't expose details
|
// Log internal error but don't expose details
|
||||||
@@ -50,18 +57,68 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
|
|||||||
// ListLogs handles GET /api/v1/audit
|
// ListLogs handles GET /api/v1/audit
|
||||||
func (h *AuditHandler) ListLogs(c *fiber.Ctx) error {
|
func (h *AuditHandler) ListLogs(c *fiber.Ctx) error {
|
||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
cursorRaw := c.Query("cursor")
|
||||||
|
cursor, err := parseAuditCursor(cursorRaw)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"error": "Invalid cursor",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
logs, err := h.repo.FindAll(c.Context(), limit, offset)
|
logs, err := h.repo.FindPage(c.Context(), limit+1, cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
"error": "Failed to retrieve audit logs",
|
"error": "Failed to retrieve audit logs",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextCursor := ""
|
||||||
|
if len(logs) > limit {
|
||||||
|
last := logs[limit-1]
|
||||||
|
nextCursor = encodeAuditCursor(last)
|
||||||
|
logs = logs[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"items": logs,
|
"items": logs,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"cursor": cursorRaw,
|
||||||
|
"next_cursor": nextCursor,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureRequestID(c *fiber.Ctx) string {
|
||||||
|
reqID := c.Get("X-Request-Id")
|
||||||
|
if reqID == "" {
|
||||||
|
reqID = uuid.New().String()
|
||||||
|
c.Set("X-Request-Id", reqID)
|
||||||
|
}
|
||||||
|
return reqID
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAuditCursor(raw string) (*domain.AuditCursor, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
decoded, err := base64.RawURLEncoding.DecodeString(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(string(decoded), "|", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, errors.New("invalid cursor")
|
||||||
|
}
|
||||||
|
ts, err := time.Parse(time.RFC3339Nano, parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &domain.AuditCursor{
|
||||||
|
Timestamp: ts,
|
||||||
|
EventID: parts[1],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeAuditCursor(log domain.AuditLog) string {
|
||||||
|
payload := log.Timestamp.UTC().Format(time.RFC3339Nano) + "|" + log.EventID
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||||
|
}
|
||||||
|
|||||||
106
backend/internal/middleware/audit_required.go
Normal file
106
backend/internal/middleware/audit_required.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditRequiredConfig struct {
|
||||||
|
Repo domain.AuditRepository
|
||||||
|
ExcludePaths map[string]struct{}
|
||||||
|
CommandMethods map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireAudit(config AuditRequiredConfig) fiber.Handler {
|
||||||
|
commandMethods := config.CommandMethods
|
||||||
|
if len(commandMethods) == 0 {
|
||||||
|
commandMethods = map[string]struct{}{
|
||||||
|
fiber.MethodPost: {},
|
||||||
|
fiber.MethodPut: {},
|
||||||
|
fiber.MethodPatch: {},
|
||||||
|
fiber.MethodDelete: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
excludePaths := config.ExcludePaths
|
||||||
|
if excludePaths == nil {
|
||||||
|
excludePaths = map[string]struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
if _, ok := commandMethods[c.Method()]; !ok {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
if _, excluded := excludePaths[c.Path()]; excluded {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
if config.Repo == nil {
|
||||||
|
return fiber.NewError(fiber.StatusServiceUnavailable, "audit repository unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
reqID := c.Get("X-Request-Id")
|
||||||
|
if reqID == "" {
|
||||||
|
reqID = uuid.New().String()
|
||||||
|
c.Set("X-Request-Id", reqID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.Next()
|
||||||
|
latency := time.Since(start)
|
||||||
|
|
||||||
|
status := c.Response().StatusCode()
|
||||||
|
if err != nil {
|
||||||
|
if fiberErr, ok := err.(*fiber.Error); ok {
|
||||||
|
status = fiberErr.Code
|
||||||
|
} else {
|
||||||
|
status = fiber.StatusInternalServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText := "success"
|
||||||
|
if status >= fiber.StatusBadRequest {
|
||||||
|
statusText = "failure"
|
||||||
|
}
|
||||||
|
|
||||||
|
details := map[string]any{
|
||||||
|
"request_id": reqID,
|
||||||
|
"method": c.Method(),
|
||||||
|
"path": c.Path(),
|
||||||
|
"status": status,
|
||||||
|
"latency_ms": latency.Milliseconds(),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
details["error"] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
detailsJSON, jsonErr := json.Marshal(details)
|
||||||
|
if jsonErr != nil {
|
||||||
|
slog.Warn("failed to marshal audit details", "error", jsonErr, "req_id", reqID)
|
||||||
|
}
|
||||||
|
|
||||||
|
auditLog := &domain.AuditLog{
|
||||||
|
EventID: reqID,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
UserID: "",
|
||||||
|
EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()),
|
||||||
|
Status: statusText,
|
||||||
|
IPAddress: c.IP(),
|
||||||
|
UserAgent: c.Get("User-Agent"),
|
||||||
|
DeviceID: "",
|
||||||
|
Details: string(detailsJSON),
|
||||||
|
}
|
||||||
|
|
||||||
|
if createErr := config.Repo.Create(auditLog); createErr != nil {
|
||||||
|
slog.Error("audit log write failed", "error", createErr, "req_id", reqID, "path", c.Path())
|
||||||
|
return fiber.NewError(fiber.StatusServiceUnavailable, "audit logging unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ func NewClickHouseRepository(host string, port int, user, password, db string) (
|
|||||||
// Note: In production, use migrations.
|
// Note: In production, use migrations.
|
||||||
query := `
|
query := `
|
||||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
event_id String,
|
||||||
timestamp DateTime DEFAULT now(),
|
timestamp DateTime DEFAULT now(),
|
||||||
user_id String,
|
user_id String,
|
||||||
event_type String,
|
event_type String,
|
||||||
@@ -51,6 +52,14 @@ func NewClickHouseRepository(host string, port int, user, password, db string) (
|
|||||||
return nil, fmt.Errorf("failed to create table: %w", err)
|
return nil, fmt.Errorf("failed to create table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
alterQuery := `
|
||||||
|
ALTER TABLE audit_logs
|
||||||
|
ADD COLUMN IF NOT EXISTS event_id String
|
||||||
|
`
|
||||||
|
if err := conn.Exec(context.Background(), alterQuery); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to alter table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &ClickHouseRepository{conn: conn}, nil
|
return &ClickHouseRepository{conn: conn}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +72,11 @@ func (r *ClickHouseRepository) Create(log *domain.AuditLog) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO audit_logs (timestamp, user_id, event_type, status, ip_address, user_agent, device_id, details)
|
INSERT INTO audit_logs (event_id, timestamp, user_id, event_type, status, ip_address, user_agent, device_id, details)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
return r.conn.Exec(ctx, query,
|
return r.conn.Exec(ctx, query,
|
||||||
|
log.EventID,
|
||||||
log.Timestamp,
|
log.Timestamp,
|
||||||
log.UserID,
|
log.UserID,
|
||||||
log.EventType,
|
log.EventType,
|
||||||
@@ -78,21 +88,28 @@ func (r *ClickHouseRepository) Create(log *domain.AuditLog) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ClickHouseRepository) FindAll(ctx context.Context, limit, offset int) ([]domain.AuditLog, error) {
|
func (r *ClickHouseRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor) ([]domain.AuditLog, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT timestamp, user_id, event_type, status, ip_address, user_agent, device_id, details
|
SELECT event_id, timestamp, user_id, event_type, status, ip_address, user_agent, device_id, details
|
||||||
FROM audit_logs
|
FROM audit_logs
|
||||||
ORDER BY timestamp DESC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`
|
`
|
||||||
rows, err := r.conn.Query(ctx, query, limit, offset)
|
args := make([]any, 0, 4)
|
||||||
|
if cursor != nil {
|
||||||
|
query += `
|
||||||
|
WHERE (timestamp < ?) OR (timestamp = ? AND event_id < ?)
|
||||||
|
`
|
||||||
|
args = append(args, cursor.Timestamp, cursor.Timestamp, cursor.EventID)
|
||||||
|
}
|
||||||
|
query += `
|
||||||
|
ORDER BY timestamp DESC, event_id DESC
|
||||||
|
LIMIT ?
|
||||||
|
`
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := r.conn.Query(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query audit logs: %w", err)
|
return nil, fmt.Errorf("failed to query audit logs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -102,6 +119,7 @@ func (r *ClickHouseRepository) FindAll(ctx context.Context, limit, offset int) (
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var log domain.AuditLog
|
var log domain.AuditLog
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
|
&log.EventID,
|
||||||
&log.Timestamp,
|
&log.Timestamp,
|
||||||
&log.UserID,
|
&log.UserID,
|
||||||
&log.EventType,
|
&log.EventType,
|
||||||
|
|||||||
Reference in New Issue
Block a user