1
0
forked from baron/baron-sso

adminfront 개요 통계 추가

This commit is contained in:
2026-05-06 16:14:52 +09:00
parent 6cdd0fd81e
commit 13dee9ae9b
24 changed files with 2082 additions and 297 deletions

View File

@@ -1,109 +1,25 @@
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react"; import { KeyRound } from "lucide-react";
import PermissionChecker from "./components/PermissionChecker";
const flows = [
{
title: "Admin login",
description:
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
pill: "15m TTL",
},
{
title: "Tenant pick",
description:
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
pill: "Header-ready",
},
{
title: "Device approval",
description:
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
pill: "App session",
},
];
function AuthPage() { function AuthPage() {
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]"> <div className="flex flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="space-y-1">
<div> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]"> Admin auth
Admin auth </p>
</p> <h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2> <KeyRound size={22} className="text-primary" />
<p className="text-sm text-[var(--color-muted)]">
Build the admin-only login flow first, keeping app login separate. </h2>
Respect the fallback only when user chooses rule for SMS/email <p className="text-sm text-muted-foreground">
vs app approval. ReBAC .
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
IDP session placeholder
</span>
<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"
>
<Sparkles size={14} />
Connect auth layer
</button>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
{flows.map((flow) => (
<div
key={flow.title}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
>
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
<span>{flow.pill}</span>
<Fingerprint size={14} />
</div>
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
<p className="text-sm text-[var(--color-muted)]">
{flow.description}
</p>
</div>
))}
</section>
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Smartphone size={16} />
<span className="text-xs uppercase tracking-[0.18em]">
App-based approvals
</span>
</div>
<h3 className="mt-2 text-xl font-semibold">
App session as MFA replacement
</h3>
<p className="text-sm text-[var(--color-muted)]">
If the admin keeps the mobile app signed in and opts in, use
push/deeplink approval instead of OTP. Otherwise fall back to
SMS/email based on user choice.
</p> </p>
</div> </div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6"> </div>
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<ArrowRight size={16} /> <PermissionChecker />
<span className="text-xs uppercase tracking-[0.18em]">
TTL discipline
</span>
</div>
<h3 className="mt-2 text-xl font-semibold">
Keep admin sessions short
</h3>
<p className="text-sm text-[var(--color-muted)]">
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
with step-up MFA when critical actions (rotate secret, export logs)
happen.
</p>
</div>
</section>
</div> </div>
); );
} }

View File

@@ -44,7 +44,7 @@ function PermissionChecker() {
const result = checkMutation.data; const result = checkMutation.data;
return ( return (
<Card className="bg-[var(--color-panel)] border-primary/20"> <Card className="border-primary/20 bg-[var(--color-panel)]">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<ShieldAlert size={20} className="text-primary" /> <ShieldAlert size={20} className="text-primary" />
@@ -100,7 +100,7 @@ function PermissionChecker() {
<Button <Button
onClick={() => checkMutation.mutate()} onClick={() => checkMutation.mutate()}
disabled={!object || !subject || checkMutation.isPending} disabled={!object || !subject || checkMutation.isPending}
className="w-full md:w-auto px-12" className="w-full px-12 md:w-auto"
> >
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"} {checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
</Button> </Button>
@@ -108,17 +108,17 @@ function PermissionChecker() {
{checkMutation.isSuccess && result && ( {checkMutation.isSuccess && result && (
<div <div
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${ className={`flex flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 animate-in zoom-in duration-300 ${
result.allowed result.allowed
? "bg-green-500/10 border-green-500/50 text-green-600" ? "border-green-500/50 bg-green-500/10 text-green-600"
: "bg-destructive/10 border-destructive/50 text-destructive" : "border-destructive/50 bg-destructive/10 text-destructive"
}`} }`}
> >
{result.allowed ? ( {result.allowed ? (
<> <>
<CheckCircle2 size={48} /> <CheckCircle2 size={48} />
<div className="text-xl font-bold">Access ALLOWED</div> <div className="text-xl font-bold">Access ALLOWED</div>
<p className="text-sm opacity-80 text-center"> <p className="text-center text-sm opacity-80">
. ( . (
) )
</p> </p>
@@ -127,7 +127,7 @@ function PermissionChecker() {
<> <>
<XCircle size={48} /> <XCircle size={48} />
<div className="text-xl font-bold">Access DENIED</div> <div className="text-xl font-bold">Access DENIED</div>
<p className="text-sm opacity-80 text-center"> <p className="text-center text-sm opacity-80">
. .
</p> </p>
</> </>

View File

@@ -0,0 +1,186 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchAdminRPUsageDaily } from "../../lib/adminApi";
import AuthPage from "../auth/AuthPage";
import GlobalOverviewPage from "./GlobalOverviewPage";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
fetchAdminOverviewStats: vi.fn(async () => ({
totalTenants: 10,
oidcClients: 3,
auditEvents24h: 18,
})),
fetchTenants: vi.fn(async () => ({
items: [
{
id: "company-1",
type: "COMPANY",
name: "한맥",
slug: "hanmac",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "org-1",
type: "ORGANIZATION",
name: "개발팀",
slug: "dev-team",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "personal-1",
type: "PERSONAL",
name: "개인",
slug: "personal",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
],
limit: 1000,
offset: 0,
total: 3,
})),
fetchAdminRPUsageDaily: vi.fn(async () => ({
days: 14,
period: "day",
items: [
{
date: "2026-05-05",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "orgfront",
clientName: "OrgFront",
loginRequests: 12,
otherRequests: 4,
uniqueSubjects: 8,
},
{
date: "2026-05-06",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "adminfront",
clientName: "AdminFront",
loginRequests: 7,
otherRequests: 3,
uniqueSubjects: 5,
},
{
date: "2026-09-28",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "devfront",
clientName: "DevFront",
loginRequests: 2,
otherRequests: 1,
uniqueSubjects: 2,
},
],
})),
}));
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin overview and auth guard pages", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders usage trend chart without quick navigation or permission checker", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"),
).toBeInTheDocument();
expect(
await screen.findByLabelText("일 단위 RP 요청 현황"),
).toBeInTheDocument();
expect(await screen.findByText("05.05")).toBeInTheDocument();
expect(await screen.findByText("05.06")).toBeInTheDocument();
expect(screen.queryByText("빠른 작업")).not.toBeInTheDocument();
expect(screen.queryByText("빠른 이동")).not.toBeInTheDocument();
expect(screen.queryByText("테넌트 추가")).not.toBeInTheDocument();
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
});
it("renders overview summary metrics from the admin stats API", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
(await screen.findByText("전체 테넌트 수")).parentElement,
).toHaveTextContent("10");
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
"3",
);
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
"18",
);
});
it("changes the RP usage perspective and targets a permitted organization", async () => {
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
fireEvent.click(screen.getByRole("button", { name: "주" }));
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "월" }));
fireEvent.change(screen.getByLabelText("조직 검색"), {
target: { value: "개발" },
});
fireEvent.change(screen.getByLabelText("대상 조직"), {
target: { value: "org-1" },
});
await waitFor(() => {
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
days: 90,
period: "month",
tenantId: "org-1",
});
});
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
});
it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
renderWithProviders(<AuthPage />);
expect(screen.getByText("인증가드")).toBeInTheDocument();
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
expect(
screen.queryByText("IDP session placeholder"),
).not.toBeInTheDocument();
expect(screen.queryByText("Admin login")).not.toBeInTheDocument();
});
});

View File

@@ -1,33 +1,433 @@
import { useQuery } from "@tanstack/react-query";
import { import {
Activity, Activity,
ArrowUpRight, BarChart3,
Database, Database,
Key,
PlusCircle,
ShieldCheck, ShieldCheck,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { Link } from "react-router-dom"; import { type ReactNode, useMemo, useState } from "react";
import { RoleGuard } from "../../components/auth/RoleGuard"; import { RoleGuard } from "../../components/auth/RoleGuard";
import { import {
Card, type RPUsageDailyMetric,
CardContent, type RPUsagePeriod,
CardDescription, type TenantSummary,
CardHeader, fetchAdminOverviewStats,
CardTitle, fetchAdminRPUsageDaily,
} from "../../components/ui/card"; fetchTenants,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
type DailyPoint = {
date: string;
loginRequests: number;
otherRequests: number;
};
type SeriesSummary = {
key: string;
tenantLabel: string;
clientLabel: string;
loginRequests: number;
otherRequests: number;
uniqueSubjects: number;
};
function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
const byDate = new Map<string, DailyPoint>();
for (const row of rows) {
const current =
byDate.get(row.date) ??
({
date: row.date,
loginRequests: 0,
otherRequests: 0,
} satisfies DailyPoint);
current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
byDate.set(row.date, current);
}
return Array.from(byDate.values()).sort((a, b) =>
a.date.localeCompare(b.date),
);
}
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
const bySeries = new Map<string, SeriesSummary>();
for (const row of rows) {
const key = `${row.tenantId}:${row.clientId}`;
const current =
bySeries.get(key) ??
({
key,
tenantLabel: row.tenantName || row.tenantId || "-",
clientLabel: row.clientName || row.clientId,
loginRequests: 0,
otherRequests: 0,
uniqueSubjects: 0,
} satisfies SeriesSummary);
current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
current.uniqueSubjects = Math.max(
current.uniqueSubjects,
row.uniqueSubjects,
);
bySeries.set(key, current);
}
return Array.from(bySeries.values())
.sort(
(a, b) =>
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
)
.slice(0, 5);
}
function parseDateParts(date: string) {
const parts = date.split("-");
if (parts.length === 3) {
return {
year: Number(parts[0]),
month: Number(parts[1]),
day: Number(parts[2]),
monthText: parts[1],
dayText: parts[2],
};
}
return null;
}
function getISOWeekNumber(year: number, month: number, day: number) {
const date = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
function getISOWeekThursday(year: number, month: number, day: number) {
const date = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
return date;
}
function formatPeriodLabel(date: string, period: RPUsagePeriod) {
const parts = parseDateParts(date);
if (!parts) {
return date;
}
if (period === "month") {
return `${parts.monthText}`;
}
if (period === "week") {
const weekNumber = String(
getISOWeekNumber(parts.year, parts.month, parts.day),
).padStart(2, "0");
const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day);
const weekMonth = weekThursday.getUTCMonth() + 1;
const weekDay = weekThursday.getUTCDate();
const weekMonthText = String(weekMonth).padStart(2, "0");
const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7)));
return `${weekNumber}(${weekMonthText}${weekOfMonth}주)`;
}
return `${parts.monthText}.${parts.dayText}`;
}
function OverviewMetric({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
<span className="text-muted-foreground">{icon}</span>
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums">{value}</span>
</span>
);
}
function RPUsageMixedChart({
rows,
filters,
period,
}: {
rows: RPUsageDailyMetric[];
filters: ReactNode;
period: RPUsagePeriod;
}) {
const daily = summarizeDaily(rows);
const series = summarizeSeries(rows);
const chartWidth = 720;
const chartHeight = 230;
const padX = 48;
const padTop = 32;
const padBottom = 34;
const innerWidth = chartWidth - padX * 2;
const innerHeight = chartHeight - padTop - padBottom;
const maxValue = Math.max(
1,
...daily.map((point) => point.loginRequests + point.otherRequests),
...daily.map((point) => point.loginRequests),
);
const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth;
const barWidth = Math.min(28, Math.max(10, slot * 0.42));
const y = (value: number) =>
padTop + innerHeight - (value / maxValue) * innerHeight;
const x = (index: number) => padX + slot * index + slot / 2;
const linePoints = daily
.map((point, index) => `${x(index)},${y(point.loginRequests)}`)
.join(" ");
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 size={18} className="text-primary" />
<h3 className="text-base font-semibold">
/
</h3>
</div>
{filters}
</div>
{daily.length === 0 ? (
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
RP .
</div>
) : (
<div className="overflow-x-auto">
<svg
role="img"
aria-label="일 단위 RP 요청 현황"
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
className="h-[235px] min-w-[720px] w-full"
>
<title> RP </title>
<g transform="translate(510 10)">
<rect
x="0"
y="3"
width="10"
height="10"
rx="2"
className="fill-sky-500/70"
/>
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
</text>
<line
x1="78"
x2="98"
y1="8"
y2="8"
className="stroke-emerald-500"
strokeWidth="3"
strokeLinecap="round"
/>
<text
x="104"
y="12"
className="fill-muted-foreground text-[11px]"
>
</text>
</g>
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const gridY = padTop + innerHeight * ratio;
const label = Math.round(maxValue * (1 - ratio));
return (
<g key={ratio}>
<line
x1={padX}
x2={chartWidth - padX}
y1={gridY}
y2={gridY}
stroke="currentColor"
className="text-border"
strokeWidth="1"
/>
<text
x={padX - 12}
y={gridY + 4}
textAnchor="end"
className="fill-muted-foreground text-[11px]"
>
{label}
</text>
</g>
);
})}
{daily.map((point, index) => {
const center = x(index);
const otherHeight =
(point.otherRequests / maxValue) * innerHeight;
return (
<g key={point.date}>
<rect
x={center - barWidth / 2}
y={padTop + innerHeight - otherHeight}
width={barWidth}
height={otherHeight}
rx="3"
className="fill-sky-500/70"
/>
<text
x={center}
y={chartHeight - 12}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
{formatPeriodLabel(point.date, period)}
</text>
</g>
);
})}
<polyline
points={linePoints}
fill="none"
className="stroke-emerald-500"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{daily.map((point, index) => (
<circle
key={`${point.date}-login`}
cx={x(index)}
cy={y(point.loginRequests)}
r="4"
className="fill-emerald-500 stroke-background"
strokeWidth="2"
/>
))}
</svg>
</div>
)}
{series.length > 0 && (
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
{series.map((item) => (
<div key={item.key} className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{item.clientLabel}</span>
<span className="truncate text-muted-foreground">
{item.tenantLabel}
</span>
<span className="ml-auto whitespace-nowrap tabular-nums">
{item.loginRequests.toLocaleString()} / {" "}
{item.otherRequests.toLocaleString()} / {" "}
{item.uniqueSubjects.toLocaleString()}
</span>
</div>
))}
</div>
)}
</section>
);
}
function GlobalOverviewPage() { function GlobalOverviewPage() {
const [period, setPeriod] = useState<RPUsagePeriod>("day");
const [tenantSearch, setTenantSearch] = useState("");
const [selectedTenantId, setSelectedTenantId] = useState("");
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
const statsQuery = useQuery({
queryKey: ["admin-overview-stats"],
queryFn: fetchAdminOverviewStats,
retry: false,
});
const tenantsQuery = useQuery({
queryKey: ["admin-overview-tenant-options"],
queryFn: () => fetchTenants(1000, 0),
retry: false,
});
const tenantOptions = useMemo(() => {
const term = tenantSearch.trim().toLowerCase();
return (tenantsQuery.data?.items ?? [])
.filter(
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
)
.filter((tenant) => {
if (!term) return true;
return (
tenant.name.toLowerCase().includes(term) ||
tenant.slug.toLowerCase().includes(term) ||
tenant.id.toLowerCase().includes(term)
);
});
}, [tenantSearch, tenantsQuery.data?.items]);
const usageQuery = useQuery({
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId],
queryFn: () =>
fetchAdminRPUsageDaily({
days: usageDays,
period,
tenantId: selectedTenantId || undefined,
}),
retry: false,
});
const stats = statsQuery.data;
const usageRows = usageQuery.data?.items ?? [];
const metric = (value: number | undefined) =>
value === undefined ? "-" : value.toLocaleString();
const chartFilters = (
<div className="flex flex-wrap items-center gap-2">
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
{[
["day", "일"],
["week", "주"],
["month", "월"],
].map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={period === value}
onClick={() => setPeriod(value as RPUsagePeriod)}
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
period === value
? "bg-primary text-primary-foreground"
: "bg-muted/60 hover:bg-muted"
}`}
>
{label}
</button>
))}
</div>
<input
aria-label="조직 검색"
value={tenantSearch}
onChange={(event) => setTenantSearch(event.target.value)}
placeholder="조직 검색"
className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44"
/>
<select
aria-label="대상 조직"
value={selectedTenantId}
onChange={(event) => setSelectedTenantId(event.target.value)}
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-52"
>
<option value=""> </option>
{tenantOptions.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name} ({tenant.slug})
</option>
))}
</select>
</div>
);
return ( return (
<div className="space-y-8 animate-in fade-in duration-500"> <div className="space-y-4 animate-in fade-in duration-500">
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight"> <h2 className="text-2xl font-semibold tracking-tight">
{t("ui.admin.overview.title", "Dashboard")} {t("ui.admin.overview.title", "Dashboard")}
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t( {t(
"msg.admin.overview.description", "msg.admin.overview.description",
"시스템 전반의 주요 현황을 확인하고 관리합니다.", "시스템 전반의 주요 현황을 확인하고 관리합니다.",
@@ -36,166 +436,61 @@ function GlobalOverviewPage() {
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
<RoleGuard roles={["super_admin"]}> <RoleGuard roles={["super_admin"]}>
<Card className="transition-all hover:shadow-md"> <OverviewMetric
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> icon={<Users size={14} />}
<CardTitle className="text-sm font-medium"> label={t(
{t("ui.admin.overview.summary.total_tenants", "총 테넌트")} "ui.admin.overview.summary.total_tenants",
</CardTitle> "전체 테넌트 수",
<div className="rounded-full bg-primary/10 p-2 text-primary"> )}
<Users size={16} /> value={metric(stats?.totalTenants)}
</div> />
</CardHeader> <OverviewMetric
<CardContent> icon={<ShieldCheck size={14} />}
<div className="text-2xl font-bold">-</div> label={t(
<p className="mt-1 text-xs text-muted-foreground"> "ui.admin.overview.summary.oidc_clients",
"OIDC 클라이언트",
</p> )}
</CardContent> value={metric(stats?.oidcClients)}
</Card> />
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")}
</CardTitle>
<div className="rounded-full bg-blue-500/10 p-2 text-blue-500">
<ShieldCheck size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
OIDC
</p>
</CardContent>
</Card>
</RoleGuard> </RoleGuard>
<OverviewMetric
<Card className="transition-all hover:shadow-md"> icon={<Activity size={14} />}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> label={t(
<CardTitle className="text-sm font-medium"> "ui.admin.overview.summary.audit_events_24h",
{t( "24시간 이벤트",
"ui.admin.overview.summary.audit_events_24h", )}
"최근 감사 로그 (24h)", value={metric(stats?.auditEvents24h)}
)} />
</CardTitle> <OverviewMetric
<div className="rounded-full bg-orange-500/10 p-2 text-orange-500"> icon={<Database size={14} />}
<Activity size={16} /> label={t("ui.admin.overview.summary.policy_gate", "정책 상태")}
</div> value="Active"
</CardHeader> />
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.policy_gate", "정책 상태")}
</CardTitle>
<div className="rounded-full bg-green-500/10 p-2 text-green-500">
<Database size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600 dark:text-green-500">
Active
</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
</div> </div>
<div className="space-y-4"> {usageQuery.isError ? (
<h3 className="text-lg font-semibold tracking-tight"> <section className="space-y-2">
{t("ui.admin.overview.quick_links.title", "빠른 작업")} <div className="flex flex-wrap items-center justify-between gap-3">
</h3> <h3 className="text-base font-semibold">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> /
<RoleGuard roles={["super_admin"]}> </h3>
<Link {chartFilters}
to="/tenants/new" </div>
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/50 hover:shadow-md" <div className="text-sm text-muted-foreground">
> RP Query API . backend
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground"> `rp_usage_daily_aggregate`
<PlusCircle size={20} /> .
</div> </div>
<div> </section>
<h4 className="font-semibold transition-colors group-hover:text-primary"> ) : (
<RPUsageMixedChart
</h4> rows={usageRows}
<p className="mt-1 text-xs text-muted-foreground"> filters={chartFilters}
. period={period}
</p> />
</div> )}
</Link>
</RoleGuard>
<Link
to="/users"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-blue-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500 transition-colors group-hover:bg-blue-500 group-hover:text-white">
<Users size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-blue-500">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
<RoleGuard roles={["super_admin"]}>
<Link
to="/api-keys"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-purple-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 text-purple-500 transition-colors group-hover:bg-purple-500 group-hover:text-white">
<Key size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-purple-500">
API
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</RoleGuard>
<Link
to="/audit-logs"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-orange-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-orange-500/10 text-orange-500 transition-colors group-hover:bg-orange-500 group-hover:text-white">
<Activity size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-orange-500">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</div>
</div>
<RoleGuard roles={["super_admin"]}>
<div className="pt-4">
<PermissionChecker />
</div>
</RoleGuard>
</div> </div>
); );
} }

View File

@@ -101,6 +101,33 @@ export type RoleListResponse = {
total: number; total: number;
}; };
export type RPUsageDailyMetric = {
date: string;
tenantId: string;
tenantType: string;
tenantName?: string;
clientId: string;
clientName: string;
loginRequests: number;
otherRequests: number;
uniqueSubjects: number;
};
export type RPUsagePeriod = "day" | "week" | "month";
export type RPUsageDailyResponse = {
items: RPUsageDailyMetric[];
days: number;
period: RPUsagePeriod;
tenantId?: string;
};
export type AdminOverviewStats = {
totalTenants: number;
oidcClients: number;
auditEvents24h: number;
};
export async function fetchAuditLogs(limit = 50, cursor?: string) { 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, cursor }, params: { limit, cursor },
@@ -108,6 +135,29 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
return data; return data;
} }
export async function fetchAdminOverviewStats() {
const { data } = await apiClient.get<AdminOverviewStats>("/v1/admin/stats");
return data;
}
export async function fetchAdminRPUsageDaily({
days = 14,
period = "day",
tenantId,
}: {
days?: number;
period?: RPUsagePeriod;
tenantId?: string;
} = {}) {
const { data } = await apiClient.get<RPUsageDailyResponse>(
"/v1/admin/rp-usage/daily",
{
params: { days, period, tenantId: tenantId || undefined },
},
);
return data;
}
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) { export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
const { data } = await apiClient.get<TenantListResponse>( const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants", "/v1/admin/tenants",

View File

@@ -183,11 +183,15 @@ func main() {
chDB := getEnv("CLICKHOUSE_DB", "baron_sso") chDB := getEnv("CLICKHOUSE_DB", "baron_sso")
var auditRepo domain.AuditRepository var auditRepo domain.AuditRepository
var rpUsageProjectionRepo domain.RPUsageProjectionRepository
var rpUsageQueryRepo domain.RPUsageQueryRepository
if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil { if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil {
slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err) slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err)
auditRepo = nil // Explicitly set to nil interface auditRepo = nil // Explicitly set to nil interface
} else { } else {
auditRepo = repo auditRepo = repo
rpUsageProjectionRepo = repo
rpUsageQueryRepo = repo
slog.Info("✅ Connected to ClickHouse") slog.Info("✅ Connected to ClickHouse")
} }
@@ -297,6 +301,7 @@ func main() {
userGroupRepo := repository.NewUserGroupRepository(db) userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db) userRepo := repository.NewUserRepository(db)
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db)
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db) worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
sharedLinkRepo := repository.NewSharedLinkRepository(db) sharedLinkRepo := repository.NewSharedLinkRepository(db)
kratosAdminService := service.NewKratosAdminService() kratosAdminService := service.NewKratosAdminService()
@@ -323,6 +328,14 @@ func main() {
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient) worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient)
go worksmobileRelayWorker.Start(context.Background()) go worksmobileRelayWorker.Start(context.Background())
slog.Info("✅ Worksmobile Relay Worker started") slog.Info("✅ Worksmobile Relay Worker started")
rpUsageEmitter := service.NewRPUsageEventEmitter(rpUsageOutboxRepo)
if rpUsageProjectionRepo != nil {
rpUsageProjectorWorker := service.NewRPUsageProjectorWorker(rpUsageOutboxRepo, rpUsageProjectionRepo)
go rpUsageProjectorWorker.Start(context.Background())
slog.Info("✅ RP Usage Projector Worker started")
} else {
slog.Warn("RP Usage Projector Worker skipped because ClickHouse is unavailable")
}
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo) sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService) userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
tenantService.SetKetoService(ketoService) // Keto 주입 tenantService.SetKetoService(ketoService) // Keto 주입
@@ -342,7 +355,12 @@ func main() {
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
authHandler.HeadlessJWKS = headlessJWKSCache authHandler.HeadlessJWKS = headlessJWKSCache
authHandler.RPUserMetadataRepo = rpUserMetadataRepo authHandler.RPUserMetadataRepo = rpUserMetadataRepo
authHandler.RPUsageSink = rpUsageEmitter
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo) adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
adminHandler.RPUsageQueries = rpUsageQueryRepo
adminHandler.TenantRepo = tenantRepo
adminHandler.Hydra = hydraService
adminHandler.AuditRepo = auditRepo
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler) devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
devHandler.HeadlessJWKS = headlessJWKSCache devHandler.HeadlessJWKS = headlessJWKSCache
devHandler.AuditRepo = auditRepo devHandler.AuditRepo = auditRepo
@@ -674,6 +692,7 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily)
// Tenant Management (Mixed roles, handler filters results) // Tenant Management (Mixed roles, handler filters results)
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants) admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)

View File

@@ -45,6 +45,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.ClientSecret{}, &domain.ClientSecret{},
&domain.ClientConsent{}, &domain.ClientConsent{},
&domain.KetoOutbox{}, &domain.KetoOutbox{},
&domain.RPUsageEvent{},
&domain.WorksmobileOutbox{}, &domain.WorksmobileOutbox{},
&domain.WorksmobileResourceMapping{}, &domain.WorksmobileResourceMapping{},
&domain.SharedLink{}, &domain.SharedLink{},

View File

@@ -26,6 +26,7 @@ type AuditRepository interface {
Create(log *AuditLog) error Create(log *AuditLog) error
FindPage(ctx context.Context, limit int, cursor *AuditCursor, tenantID string) ([]AuditLog, error) FindPage(ctx context.Context, limit int, cursor *AuditCursor, tenantID string) ([]AuditLog, error)
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error) FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
CountEventsSince(ctx context.Context, since time.Time) (int64, error)
CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error) CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
Ping(ctx context.Context) error Ping(ctx context.Context) error

View File

@@ -0,0 +1,101 @@
package domain
import (
"context"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"gorm.io/gorm"
)
const (
RPUsageOutboxStatusPending = "pending"
RPUsageOutboxStatusProcessing = "processing"
RPUsageOutboxStatusProcessed = "processed"
RPUsageOutboxStatusFailed = "failed"
)
const (
RPUsageEventTypeAuthorizationGranted = "rp_usage.authorization_granted"
RPUsageEventTypeAuthorizationRevoked = "rp_usage.authorization_revoked"
)
const (
RPUsageTenantTypeCompany = TenantTypeCompany
RPUsageTenantTypeOrganization = TenantTypeOrganization
)
type RPUsageEvent struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
EventType string `gorm:"not null;index:idx_rp_usage_outbox_event" json:"eventType"`
Subject string `gorm:"not null;index:idx_rp_usage_outbox_subject" json:"subject"`
TenantID string `gorm:"index:idx_rp_usage_outbox_tenant" json:"tenantId,omitempty"`
TenantType string `gorm:"index:idx_rp_usage_outbox_tenant" json:"tenantType,omitempty"`
ClientID string `gorm:"not null;index:idx_rp_usage_outbox_client" json:"clientId"`
ClientName string `json:"clientName,omitempty"`
SessionID string `gorm:"index" json:"sessionId,omitempty"`
Scopes pq.StringArray `gorm:"type:text[]" json:"scopes,omitempty"`
Source string `gorm:"not null;index" json:"source"`
CorrelationID string `gorm:"index" json:"correlationId,omitempty"`
Payload JSONMap `gorm:"type:jsonb" json:"payload,omitempty"`
DedupeKey string `gorm:"uniqueIndex" json:"dedupeKey"`
Status string `gorm:"default:'pending';index" json:"status"`
RetryCount int `gorm:"default:0" json:"retryCount"`
LastError string `json:"lastError,omitempty"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
OccurredAt time.Time `gorm:"not null;index" json:"occurredAt"`
ProcessedAt *time.Time `json:"processedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (e *RPUsageEvent) TableName() string {
return "rp_usage_outbox"
}
func (e *RPUsageEvent) BeforeCreate(tx *gorm.DB) error {
if e.ID == "" {
e.ID = uuid.NewString()
}
if e.Status == "" {
e.Status = RPUsageOutboxStatusPending
}
if e.OccurredAt.IsZero() {
e.OccurredAt = time.Now()
}
if e.Payload == nil {
e.Payload = JSONMap{}
}
return nil
}
type RPUsageEventSink interface {
EmitRPUsageEvent(ctx context.Context, event RPUsageEvent) error
}
type RPUsageProjectionRepository interface {
CreateRPUsageEvent(ctx context.Context, event RPUsageEvent) error
}
type RPUsageDailyMetric struct {
Date string `json:"date"`
TenantID string `json:"tenantId"`
TenantType string `json:"tenantType"`
TenantName string `json:"tenantName,omitempty"`
ClientID string `json:"clientId"`
ClientName string `json:"clientName"`
LoginRequests uint64 `json:"loginRequests"`
OtherRequests uint64 `json:"otherRequests"`
UniqueSubjects uint64 `json:"uniqueSubjects"`
}
type RPUsageQuery struct {
Days int
Period string
TenantID string
}
type RPUsageQueryRepository interface {
FindRPUsage(ctx context.Context, query RPUsageQuery) ([]RPUsageDailyMetric, error)
}

View File

@@ -1,17 +1,29 @@
package handler package handler
import ( import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository" "baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"context"
"runtime" "runtime"
"strconv"
"strings"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
type adminHydraClientLister interface {
ListClients(ctx context.Context, limit, offset int) ([]domain.HydraClient, error)
}
type AdminHandler struct { type AdminHandler struct {
Keto service.KetoService Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository KetoOutbox repository.KetoOutboxRepository
RPUsageQueries domain.RPUsageQueryRepository
TenantRepo repository.TenantRepository
Hydra adminHydraClientLister
AuditRepo domain.AuditRepository
} }
func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler { func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler {
@@ -21,6 +33,76 @@ func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxR
} }
} }
func (h *AdminHandler) GetRPUsageDaily(c *fiber.Ctx) error {
if h == nil || h.RPUsageQueries == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "rp usage query service unavailable",
})
}
days := 14
if raw := c.Query("days"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
days = parsed
}
}
period := normalizeRPUsagePeriod(c.Query("period"))
tenantID, allowed := h.authorizedRPUsageTenantID(c, strings.TrimSpace(c.Query("tenantId")))
if !allowed {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "forbidden: tenant rp usage stats permission denied",
})
}
items, err := h.RPUsageQueries.FindRPUsage(c.Context(), domain.RPUsageQuery{
Days: days,
Period: period,
TenantID: tenantID,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
return c.JSON(fiber.Map{
"items": items,
"days": days,
"period": period,
"tenantId": tenantID,
})
}
func normalizeRPUsagePeriod(period string) string {
switch strings.ToLower(strings.TrimSpace(period)) {
case "week":
return "week"
case "month":
return "month"
default:
return "day"
}
}
func (h *AdminHandler) authorizedRPUsageTenantID(c *fiber.Ctx, requestedTenantID string) (string, bool) {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin {
return requestedTenantID, true
}
tenantID := requestedTenantID
if tenantID == "" && profile != nil && profile.TenantID != nil {
tenantID = strings.TrimSpace(*profile.TenantID)
}
if tenantID == "" {
return "", false
}
if h == nil || h.Keto == nil || profile == nil || strings.TrimSpace(profile.ID) == "" {
return "", false
}
allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+profile.ID, "Tenant", tenantID, "view_rp_usage_stats")
if err != nil || !allowed {
return "", false
}
return tenantID, true
}
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
} }
@@ -29,10 +111,14 @@ func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats var m runtime.MemStats
runtime.ReadMemStats(&m) runtime.ReadMemStats(&m)
ctx := c.Context()
stats := fiber.Map{ stats := fiber.Map{
"goroutines": runtime.NumGoroutine(), "totalTenants": h.countTenants(ctx),
"cpus": runtime.NumCPU(), "oidcClients": h.countOIDCClients(ctx),
"auditEvents24h": h.countAuditEventsSince(ctx, time.Now().UTC().Add(-24*time.Hour)),
"goroutines": runtime.NumGoroutine(),
"cpus": runtime.NumCPU(),
"memory": fiber.Map{ "memory": fiber.Map{
"alloc": m.Alloc, "alloc": m.Alloc,
"totalAlign": m.TotalAlloc, "totalAlign": m.TotalAlloc,
@@ -44,3 +130,59 @@ func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(stats) return c.Status(fiber.StatusOK).JSON(stats)
} }
func (h *AdminHandler) countTenants(ctx context.Context) int64 {
if h == nil || h.TenantRepo == nil {
return 0
}
_, total, err := h.TenantRepo.List(ctx, 1, 0, "")
if err != nil {
return 0
}
return total
}
func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 {
if h == nil || h.Hydra == nil {
return 0
}
const pageSize = 500
var total int64
for offset := 0; ; offset += pageSize {
clients, err := h.Hydra.ListClients(ctx, pageSize, offset)
if err != nil {
return total
}
for _, client := range clients {
if isHiddenSystemClient(client) {
continue
}
total++
}
if len(clients) < pageSize {
break
}
}
return total
}
func (h *AdminHandler) countAuditEventsSince(ctx context.Context, since time.Time) int64 {
if h == nil || h.AuditRepo == nil {
return 0
}
count, err := h.AuditRepo.CountEventsSince(ctx, since)
if err == nil && count > 0 {
return count
}
logs, pageErr := h.AuditRepo.FindPage(ctx, 10000, nil, "")
if pageErr != nil {
return count
}
var fallbackCount int64
for _, log := range logs {
if !log.Timestamp.Before(since) {
fallbackCount++
}
}
return fallbackCount
}

View File

@@ -0,0 +1,156 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/require"
)
type fakeRPUsageQueryRepo struct {
query domain.RPUsageQuery
items []domain.RPUsageDailyMetric
}
func (f *fakeRPUsageQueryRepo) FindRPUsage(ctx context.Context, query domain.RPUsageQuery) ([]domain.RPUsageDailyMetric, error) {
f.query = query
return f.items, nil
}
type fakeAdminKeto struct {
allowed bool
subject string
object string
relation string
}
func (f *fakeAdminKeto) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
f.subject = subject
f.object = object
f.relation = relation
return f.allowed, nil
}
func (f *fakeAdminKeto) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
return nil
}
func (f *fakeAdminKeto) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return nil
}
func (f *fakeAdminKeto) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
return nil, nil
}
func (f *fakeAdminKeto) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
return nil, nil
}
type fakeOverviewAuditRepo struct {
mockAuditRepo
since time.Time
count int64
}
func (f *fakeOverviewAuditRepo) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
f.since = since
return f.count, nil
}
func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
repo := &fakeRPUsageQueryRepo{
items: []domain.RPUsageDailyMetric{
{
Date: "2026-05-06",
TenantID: "tenant-1",
TenantType: domain.TenantTypeCompany,
ClientID: "orgfront",
ClientName: "OrgFront",
LoginRequests: 12,
OtherRequests: 4,
UniqueSubjects: 8,
},
},
}
h := &AdminHandler{RPUsageQueries: repo}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/admin/rp-usage/daily", h.GetRPUsageDaily)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/rp-usage/daily?days=7&period=week&tenantId=tenant-1", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 7, repo.query.Days)
require.Equal(t, "week", repo.query.Period)
require.Equal(t, "tenant-1", repo.query.TenantID)
var body struct {
Items []domain.RPUsageDailyMetric `json:"items"`
Days int `json:"days"`
Period string `json:"period"`
TenantID string `json:"tenantId"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, 7, body.Days)
require.Equal(t, "week", body.Period)
require.Equal(t, "tenant-1", body.TenantID)
require.Len(t, body.Items, 1)
require.Equal(t, "orgfront", body.Items[0].ClientID)
require.Equal(t, uint64(12), body.Items[0].LoginRequests)
}
func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) {
repo := &fakeRPUsageQueryRepo{}
keto := &fakeAdminKeto{allowed: true}
h := &AdminHandler{RPUsageQueries: repo, Keto: keto}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
})
return c.Next()
})
app.Get("/api/v1/admin/rp-usage/daily", h.GetRPUsageDaily)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/rp-usage/daily?tenantId=tenant-allowed", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "User:user-1", keto.subject)
require.Equal(t, "tenant-allowed", keto.object)
require.Equal(t, "view_rp_usage_stats", keto.relation)
require.Equal(t, "tenant-allowed", repo.query.TenantID)
}
func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
auditRepo := &fakeOverviewAuditRepo{count: 22}
h := &AdminHandler{AuditRepo: auditRepo}
app := fiber.New()
app.Get("/api/v1/admin/stats", h.GetSystemStats)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/stats", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Contains(t, body, "totalTenants")
require.Contains(t, body, "oidcClients")
require.Contains(t, body, "auditEvents24h")
require.Equal(t, float64(22), body["auditEvents24h"])
require.Equal(t, time.UTC, auditRepo.since.Location())
}

View File

@@ -29,6 +29,7 @@ import (
"github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4"
josejwt "github.com/go-jose/go-jose/v4/jwt" josejwt "github.com/go-jose/go-jose/v4/jwt"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/lib/pq"
) )
const ( const (
@@ -101,6 +102,7 @@ type AuthHandler struct {
UserRepo repository.UserRepository UserRepo repository.UserRepository
ConsentRepo repository.ClientConsentRepository ConsentRepo repository.ClientConsentRepository
RPUserMetadataRepo repository.RPUserMetadataRepository RPUserMetadataRepo repository.RPUserMetadataRepository
RPUsageSink domain.RPUsageEventSink
} }
type signupState struct { type signupState struct {
@@ -245,6 +247,92 @@ func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.Iden
} }
} }
func (h *AuthHandler) emitRPUsageAuthorizationGranted(c *fiber.Ctx, consentRequest *domain.HydraConsentRequest, profile *domain.UserProfileResponse, sessionID string, autoAccepted bool, correlationID string) error {
if consentRequest == nil {
return nil
}
return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationGranted, consentRequest.Subject, consentRequest.Client, consentRequest.RequestedScope, profile, sessionID, "hydra_consent", correlationID, domain.JSONMap{
"auto_accepted": autoAccepted,
"scopes": consentRequest.RequestedScope,
})
}
func (h *AuthHandler) emitRPUsageAuthorizationRevoked(c *fiber.Ctx, subject string, clientID string, profile *domain.UserProfileResponse, sessionID string) error {
return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationRevoked, subject, domain.HydraClient{ClientID: clientID}, nil, profile, sessionID, "hydra_consent", clientID, domain.JSONMap{})
}
func (h *AuthHandler) emitRPUsageEvent(c *fiber.Ctx, eventType string, subject string, client domain.HydraClient, scopes []string, profile *domain.UserProfileResponse, sessionID string, source string, correlationID string, payload domain.JSONMap) error {
if h.RPUsageSink == nil {
return nil
}
clientID := strings.TrimSpace(client.ClientID)
if clientID == "" || strings.TrimSpace(subject) == "" {
return nil
}
tenantID, tenantType := rpUsageTenantFromProfile(profile)
event := domain.RPUsageEvent{
EventType: eventType,
Subject: strings.TrimSpace(subject),
TenantID: tenantID,
TenantType: tenantType,
ClientID: clientID,
ClientName: strings.TrimSpace(client.ClientName),
SessionID: strings.TrimSpace(sessionID),
Scopes: pq.StringArray(scopes),
Source: source,
CorrelationID: strings.TrimSpace(correlationID),
Payload: payload,
OccurredAt: time.Now(),
}
if event.Payload == nil {
event.Payload = domain.JSONMap{}
}
if event.ClientName != "" {
event.Payload["client_name"] = event.ClientName
}
if tenantID != "" {
event.Payload["tenant_id"] = tenantID
}
if tenantType != "" {
event.Payload["tenant_type"] = tenantType
}
if c != nil {
event.Payload["ip_address"] = c.IP()
event.Payload["user_agent"] = string(c.Request().Header.UserAgent())
}
ctx := context.Background()
if c != nil && c.UserContext() != nil {
ctx = c.UserContext()
}
return h.RPUsageSink.EmitRPUsageEvent(ctx, event)
}
func rpUsageTenantFromProfile(profile *domain.UserProfileResponse) (string, string) {
if profile == nil {
return "", ""
}
tenantID := ""
if profile.SessionTenantID != nil {
tenantID = strings.TrimSpace(*profile.SessionTenantID)
}
if tenantID == "" && profile.TenantID != nil {
tenantID = strings.TrimSpace(*profile.TenantID)
}
tenantType := ""
if profile.Tenant != nil {
switch strings.ToUpper(strings.TrimSpace(profile.Tenant.Type)) {
case domain.TenantTypeCompany, domain.TenantTypeOrganization:
tenantType = strings.ToUpper(strings.TrimSpace(profile.Tenant.Type))
if tenantID == "" {
tenantID = strings.TrimSpace(profile.Tenant.ID)
}
case domain.TenantTypeUserGroup, domain.TenantTypePersonal:
return "", ""
}
}
return tenantID, tenantType
}
// --- Signup Flow Handlers --- // --- Signup Flow Handlers ---
// CheckEmail - 이메일 사용 가능 여부를 확인합니다. // CheckEmail - 이메일 사용 가능 여부를 확인합니다.
@@ -5323,6 +5411,12 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
if err != nil || subject == "" { if err != nil || subject == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required") return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
} }
profile, profileErr := h.resolveCurrentProfile(c)
if (profileErr != nil || profile == nil) && subject != "" {
if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), subject); fallbackErr == nil {
profile = fallbackProfile
}
}
slog.Info("RevokeLinkedRp called", "subject", subject, "client_id", clientID) slog.Info("RevokeLinkedRp called", "subject", subject, "client_id", clientID)
@@ -5354,6 +5448,11 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
}) })
} }
if err := h.emitRPUsageAuthorizationRevoked(c, subject, clientID, profile, h.resolveCurrentSessionID(c)); err != nil {
slog.Error("failed to emit rp usage event for revoked consent", "error", err, "client_id", clientID, "subject", subject)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
}
h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "") h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "")
return c.Status(fiber.StatusOK).JSON(fiber.Map{ return c.Status(fiber.StatusOK).JSON(fiber.Map{
@@ -5434,6 +5533,10 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
if err == nil { if err == nil {
if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil {
slog.Error("failed to emit rp usage event for local consent auto-accept", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
}
return c.JSON(acceptResp) return c.JSON(acceptResp)
} }
slog.Error("failed to force auto-accept based on local DB", "error", err) slog.Error("failed to force auto-accept based on local DB", "error", err)
@@ -5516,6 +5619,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
}) })
} }
if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil {
slog.Error("failed to emit rp usage event for skip consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
}
slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID, "session_id", currentSessionID) slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID, "session_id", currentSessionID)
return c.JSON(acceptResp) return c.JSON(acceptResp)
} }
@@ -5705,6 +5813,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
}) })
} }
if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, false, req.ConsentChallenge); err != nil {
slog.Error("failed to emit rp usage event for accepted consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
}
return c.JSON(acceptResp) return c.JSON(acceptResp)
} }

View File

@@ -3,6 +3,7 @@ package handler
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
@@ -38,12 +39,14 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
defer func() { http.DefaultClient = origDefault }() defer func() { http.DefaultClient = origDefault }()
auditRepo := &mockAuditRepo{} auditRepo := &mockAuditRepo{}
rpUsageSink := &mockRPUsageEventSink{}
h := &AuthHandler{ h := &AuthHandler{
Hydra: &service.HydraAdminService{ Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test", AdminURL: "http://hydra.test",
HTTPClient: client, HTTPClient: client,
}, },
AuditRepo: auditRepo, AuditRepo: auditRepo,
RPUsageSink: rpUsageSink,
} }
app := fiber.New() app := fiber.New()
app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp) app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp)
@@ -54,6 +57,16 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, 1, len(auditRepo.logs)) assert.Equal(t, 1, len(auditRepo.logs))
assert.Equal(t, "consent.revoked", auditRepo.logs[0].EventType)
assert.Equal(t, "user-123", auditRepo.logs[0].UserID)
assert.Equal(t, "success", auditRepo.logs[0].Status)
auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details)
assert.NoError(t, err)
assert.Equal(t, "app-1", auditDetails["client_id"])
assert.Equal(t, 1, len(rpUsageSink.events))
assert.Equal(t, domain.RPUsageEventTypeAuthorizationRevoked, rpUsageSink.events[0].EventType)
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
assert.Equal(t, "app-1", rpUsageSink.events[0].ClientID)
} }
func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) { func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) {

View File

@@ -3,6 +3,7 @@ package handler
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
@@ -305,6 +306,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
defer func() { http.DefaultClient = origDefault }() defer func() { http.DefaultClient = origDefault }()
consentRepo := &mockConsentRepo{} consentRepo := &mockConsentRepo{}
rpUsageSink := &mockRPUsageEventSink{}
mockKratosAdmin := &MockKratosAdminServiceForConsent{} mockKratosAdmin := &MockKratosAdminServiceForConsent{}
h := &AuthHandler{ h := &AuthHandler{
@@ -314,6 +316,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
}, },
KratosAdmin: mockKratosAdmin, KratosAdmin: mockKratosAdmin,
ConsentRepo: consentRepo, ConsentRepo: consentRepo,
RPUsageSink: rpUsageSink,
} }
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123", ID: "user-123",
@@ -332,6 +335,11 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
var body map[string]interface{} var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body) json.NewDecoder(resp.Body).Decode(&body)
assert.Equal(t, "http://rp/cb", body["redirectTo"]) assert.Equal(t, "http://rp/cb", body["redirectTo"])
assert.Equal(t, 1, len(rpUsageSink.events))
assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType)
assert.Equal(t, "client-app", rpUsageSink.events[0].ClientID)
assert.Equal(t, "challenge-skip", rpUsageSink.events[0].CorrelationID)
assert.Equal(t, true, rpUsageSink.events[0].Payload["auto_accepted"])
} }
func TestAcceptConsentRequest_Normal(t *testing.T) { func TestAcceptConsentRequest_Normal(t *testing.T) {
@@ -370,6 +378,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
auditRepo := &mockAuditRepo{} auditRepo := &mockAuditRepo{}
consentRepo := &mockConsentRepo{} consentRepo := &mockConsentRepo{}
rpUsageSink := &mockRPUsageEventSink{}
mockKratosAdmin := &MockKratosAdminServiceForConsent{} mockKratosAdmin := &MockKratosAdminServiceForConsent{}
h := &AuthHandler{ h := &AuthHandler{
@@ -380,6 +389,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
KratosAdmin: mockKratosAdmin, KratosAdmin: mockKratosAdmin,
AuditRepo: auditRepo, AuditRepo: auditRepo,
ConsentRepo: consentRepo, ConsentRepo: consentRepo,
RPUsageSink: rpUsageSink,
} }
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123", ID: "user-123",
@@ -402,6 +412,21 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, 1, len(auditRepo.logs)) assert.Equal(t, 1, len(auditRepo.logs))
assert.Equal(t, "consent.granted", auditRepo.logs[0].EventType)
assert.Equal(t, "user-123", auditRepo.logs[0].UserID)
assert.Equal(t, "success", auditRepo.logs[0].Status)
auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details)
assert.NoError(t, err)
assert.Equal(t, "client-app", auditDetails["client_id"])
assert.Equal(t, "Test App", auditDetails["client_name"])
assert.Equal(t, []interface{}{"openid"}, auditDetails["scopes"])
assert.Equal(t, 1, len(rpUsageSink.events))
assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType)
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
assert.Equal(t, "client-app", rpUsageSink.events[0].ClientID)
assert.Equal(t, "Test App", rpUsageSink.events[0].ClientName)
assert.Equal(t, []string{"openid"}, []string(rpUsageSink.events[0].Scopes))
assert.Equal(t, "hydra_consent", rpUsageSink.events[0].Source)
} }
func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) { func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {

View File

@@ -109,12 +109,29 @@ func (m *mockAuditRepo) CountFailuresSince(ctx context.Context, since time.Time,
return 0, nil return 0, nil
} }
func (m *mockAuditRepo) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
return 0, nil
}
func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
return 0, nil return 0, nil
} }
func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil } func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil }
type mockRPUsageEventSink struct {
events []domain.RPUsageEvent
err error
}
func (m *mockRPUsageEventSink) EmitRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
if m.err != nil {
return m.err
}
m.events = append(m.events, event)
return nil
}
type mockOathkeeperRepo struct { type mockOathkeeperRepo struct {
logs []domain.OathkeeperAccessLog logs []domain.OathkeeperAccessLog
} }

View File

@@ -40,6 +40,10 @@ func (m *MockAuditRepository) CountFailuresSince(ctx context.Context, since time
return 0, nil return 0, nil
} }
func (m *MockAuditRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
return 0, nil
}
func (m *MockAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { func (m *MockAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
return 0, nil return 0, nil
} }
@@ -73,6 +77,10 @@ func (r *recordingAuditRepository) CountFailuresSince(ctx context.Context, since
return 0, nil return 0, nil
} }
func (r *recordingAuditRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
return 0, nil
}
func (r *recordingAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { func (r *recordingAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
return 0, nil return 0, nil
} }

View File

@@ -3,6 +3,7 @@ package repository
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"context" "context"
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -77,9 +78,73 @@ func NewClickHouseRepository(host string, port int, user, password, db string) (
return nil, fmt.Errorf("failed to alter table: %w", err) return nil, fmt.Errorf("failed to alter table: %w", err)
} }
if err := ensureRPUsageTables(context.Background(), conn); err != nil {
return nil, fmt.Errorf("failed to create rp usage tables: %w", err)
}
return &ClickHouseRepository{conn: conn}, nil return &ClickHouseRepository{conn: conn}, nil
} }
func ensureRPUsageTables(ctx context.Context, conn driver.Conn) error {
factQuery := `
CREATE TABLE IF NOT EXISTS rp_usage_events (
event_id String,
occurred_at DateTime64(3) DEFAULT now64(3),
event_type String,
subject String,
tenant_id String,
tenant_type String,
client_id String,
client_name String,
session_id String,
scopes Array(String),
source String,
correlation_id String,
payload String
) ENGINE = MergeTree()
ORDER BY (occurred_at, event_id)
`
if err := conn.Exec(ctx, factQuery); err != nil {
return err
}
aggregateQuery := `
CREATE TABLE IF NOT EXISTS rp_usage_daily_aggregate (
event_date Date,
tenant_id String,
tenant_type String,
client_id String,
client_name String,
event_type String,
events_count AggregateFunction(count),
unique_subjects AggregateFunction(uniqExact, String)
) ENGINE = AggregatingMergeTree()
ORDER BY (event_date, tenant_id, client_id, event_type)
`
if err := conn.Exec(ctx, aggregateQuery); err != nil {
return err
}
viewQuery := `
CREATE MATERIALIZED VIEW IF NOT EXISTS rp_usage_daily_aggregate_mv
TO rp_usage_daily_aggregate
AS
SELECT
toDate(occurred_at) AS event_date,
tenant_id,
tenant_type,
client_id,
any(client_name) AS client_name,
event_type,
countState() AS events_count,
uniqExactState(subject) AS unique_subjects
FROM rp_usage_events
WHERE tenant_type IN ('COMPANY', 'ORGANIZATION')
GROUP BY event_date, tenant_id, tenant_type, client_id, event_type
`
return conn.Exec(ctx, viewQuery)
}
func (r *ClickHouseRepository) Create(log *domain.AuditLog) error { func (r *ClickHouseRepository) Create(log *domain.AuditLog) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -106,6 +171,125 @@ func (r *ClickHouseRepository) Create(log *domain.AuditLog) error {
) )
} }
func (r *ClickHouseRepository) CreateRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
if r == nil || r.conn == nil {
return fmt.Errorf("clickhouse connection is nil")
}
if event.OccurredAt.IsZero() {
event.OccurredAt = time.Now()
}
payloadBytes, err := json.Marshal(event.Payload)
if err != nil {
return fmt.Errorf("failed to marshal rp usage payload: %w", err)
}
query := `
INSERT INTO rp_usage_events (
event_id, occurred_at, event_type, subject, tenant_id, tenant_type,
client_id, client_name, session_id, scopes, source, correlation_id, payload
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
return r.conn.Exec(ctx, query,
event.ID,
event.OccurredAt,
event.EventType,
event.Subject,
event.TenantID,
event.TenantType,
event.ClientID,
event.ClientName,
event.SessionID,
[]string(event.Scopes),
event.Source,
event.CorrelationID,
string(payloadBytes),
)
}
func (r *ClickHouseRepository) FindRPUsage(ctx context.Context, rpQuery domain.RPUsageQuery) ([]domain.RPUsageDailyMetric, error) {
if r == nil || r.conn == nil {
return nil, fmt.Errorf("clickhouse connection is nil")
}
days := rpQuery.Days
if days <= 0 || days > 90 {
days = 14
}
periodExpr := "event_date"
switch rpQuery.Period {
case "week":
periodExpr = "toMonday(event_date)"
case "month":
periodExpr = "toStartOfMonth(event_date)"
case "day", "":
periodExpr = "event_date"
default:
periodExpr = "event_date"
}
query := fmt.Sprintf(`
SELECT
date,
tenant_id,
tenant_type,
client_id,
any(client_name) AS client_name,
sumIf(events, event_type = ?) AS login_requests,
sumIf(events, event_type != ?) AS other_requests,
max(unique_subjects) AS unique_subjects
FROM (
SELECT
toString(%s) AS date,
tenant_id,
tenant_type,
client_id,
any(client_name) AS client_name,
event_type,
countMerge(events_count) AS events,
uniqExactMerge(unique_subjects) AS unique_subjects
FROM rp_usage_daily_aggregate
WHERE event_date >= today() - ?
AND tenant_type IN ('COMPANY', 'ORGANIZATION')
`, periodExpr)
args := []any{domain.RPUsageEventTypeAuthorizationGranted, domain.RPUsageEventTypeAuthorizationGranted, days - 1}
if rpQuery.TenantID != "" {
query += " AND tenant_id = ?\n"
args = append(args, rpQuery.TenantID)
}
query += fmt.Sprintf(`
GROUP BY %s, tenant_id, tenant_type, client_id, event_type
)
GROUP BY date, tenant_id, tenant_type, client_id
ORDER BY date ASC, tenant_id ASC, client_id ASC
`, periodExpr)
rows, err := r.conn.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query rp usage daily aggregate: %w", err)
}
defer rows.Close()
metrics := make([]domain.RPUsageDailyMetric, 0)
for rows.Next() {
var metric domain.RPUsageDailyMetric
if err := rows.Scan(
&metric.Date,
&metric.TenantID,
&metric.TenantType,
&metric.ClientID,
&metric.ClientName,
&metric.LoginRequests,
&metric.OtherRequests,
&metric.UniqueSubjects,
); err != nil {
return nil, fmt.Errorf("failed to scan rp usage daily aggregate: %w", err)
}
if metric.ClientName == "" {
metric.ClientName = metric.ClientID
}
metrics = append(metrics, metric)
}
return metrics, nil
}
func (r *ClickHouseRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor, tenantID string) ([]domain.AuditLog, error) { func (r *ClickHouseRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor, tenantID string) ([]domain.AuditLog, error) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
@@ -228,6 +412,21 @@ func (r *ClickHouseRepository) CountFailuresSince(ctx context.Context, since tim
return count, nil return count, nil
} }
func (r *ClickHouseRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
sinceUTC := since.UTC().Format("2006-01-02 15:04:05")
query := fmt.Sprintf(`
SELECT count()
FROM audit_logs
WHERE timestamp >= toDateTime('%s')
`, sinceUTC)
var count int64
err := r.conn.QueryRow(ctx, query).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to count audit events: %w", err)
}
return count, nil
}
func (r *ClickHouseRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { func (r *ClickHouseRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
// We use uniqExact(session_id) to count unique sessions that had success events recently. // We use uniqExact(session_id) to count unique sessions that had success events recently.
query := ` query := `

View File

@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
} }
// Auto-migrate // Auto-migrate
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}) err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{})
if err != nil { if err != nil {
log.Fatalf("failed to migrate database: %s", err) log.Fatalf("failed to migrate database: %s", err)
} }

View File

@@ -0,0 +1,91 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type RPUsageOutboxRepository interface {
Create(ctx context.Context, event *domain.RPUsageEvent) error
ListReady(ctx context.Context, limit int) ([]domain.RPUsageEvent, error)
MarkProcessing(ctx context.Context, id string) error
MarkProcessed(ctx context.Context, id string) error
MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error
}
type rpUsageOutboxRepository struct {
db *gorm.DB
}
func NewRPUsageOutboxRepository(db *gorm.DB) RPUsageOutboxRepository {
return &rpUsageOutboxRepository{db: db}
}
func (r *rpUsageOutboxRepository) Create(ctx context.Context, event *domain.RPUsageEvent) error {
if event.Payload == nil {
event.Payload = domain.JSONMap{}
}
if event.Status == "" {
event.Status = domain.RPUsageOutboxStatusPending
}
if event.OccurredAt.IsZero() {
event.OccurredAt = time.Now()
}
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "dedupe_key"}},
DoNothing: true,
}).Create(event).Error
}
func (r *rpUsageOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.RPUsageEvent, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
var rows []domain.RPUsageEvent
err := r.db.WithContext(ctx).
Where("status = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)", domain.RPUsageOutboxStatusPending, time.Now()).
Order("occurred_at asc, created_at asc").
Limit(limit).
Find(&rows).Error
return rows, err
}
func (r *rpUsageOutboxRepository) MarkProcessing(ctx context.Context, id string) error {
return r.db.WithContext(ctx).
Model(&domain.RPUsageEvent{}).
Where("id = ? AND status = ?", id, domain.RPUsageOutboxStatusPending).
Updates(map[string]any{
"status": domain.RPUsageOutboxStatusProcessing,
"updated_at": time.Now(),
}).Error
}
func (r *rpUsageOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
now := time.Now()
return r.db.WithContext(ctx).
Model(&domain.RPUsageEvent{}).
Where("id = ?", id).
Updates(map[string]any{
"status": domain.RPUsageOutboxStatusProcessed,
"last_error": "",
"processed_at": &now,
"updated_at": now,
}).Error
}
func (r *rpUsageOutboxRepository) MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error {
return r.db.WithContext(ctx).
Model(&domain.RPUsageEvent{}).
Where("id = ?", id).
Updates(map[string]any{
"status": domain.RPUsageOutboxStatusFailed,
"retry_count": gorm.Expr("retry_count + 1"),
"last_error": message,
"next_attempt_at": &nextAttemptAt,
"updated_at": time.Now(),
}).Error
}

View File

@@ -0,0 +1,67 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
)
type RPUsageEventEmitter struct {
repo repository.RPUsageOutboxRepository
}
func NewRPUsageEventEmitter(repo repository.RPUsageOutboxRepository) *RPUsageEventEmitter {
return &RPUsageEventEmitter{repo: repo}
}
func (e *RPUsageEventEmitter) EmitRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
if e == nil || e.repo == nil {
return nil
}
event.EventType = strings.TrimSpace(event.EventType)
event.Subject = strings.TrimSpace(event.Subject)
event.ClientID = strings.TrimSpace(event.ClientID)
event.Source = strings.TrimSpace(event.Source)
event.CorrelationID = strings.TrimSpace(event.CorrelationID)
if event.EventType == "" {
return fmt.Errorf("rp usage event type is required")
}
if event.Subject == "" {
return fmt.Errorf("rp usage subject is required")
}
if event.ClientID == "" {
return fmt.Errorf("rp usage client_id is required")
}
if event.Source == "" {
event.Source = "backend"
}
if event.OccurredAt.IsZero() {
event.OccurredAt = time.Now()
}
if event.DedupeKey == "" {
event.DedupeKey = buildRPUsageDedupeKey(event)
}
if event.Payload == nil {
event.Payload = domain.JSONMap{}
}
return e.repo.Create(ctx, &event)
}
func buildRPUsageDedupeKey(event domain.RPUsageEvent) string {
raw := strings.Join([]string{
event.EventType,
event.Subject,
event.ClientID,
event.SessionID,
event.Source,
event.CorrelationID,
event.OccurredAt.UTC().Format("2006-01-02T15:04:05.000Z"),
}, "|")
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,132 @@
package service
import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type fakeRPUsageOutboxRepo struct {
created []domain.RPUsageEvent
ready []domain.RPUsageEvent
processing []string
processed []string
failed []string
createErr error
projectErr error
}
func (f *fakeRPUsageOutboxRepo) Create(ctx context.Context, event *domain.RPUsageEvent) error {
if f.createErr != nil {
return f.createErr
}
f.created = append(f.created, *event)
return nil
}
func (f *fakeRPUsageOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.RPUsageEvent, error) {
return f.ready, nil
}
func (f *fakeRPUsageOutboxRepo) MarkProcessing(ctx context.Context, id string) error {
f.processing = append(f.processing, id)
return nil
}
func (f *fakeRPUsageOutboxRepo) MarkProcessed(ctx context.Context, id string) error {
f.processed = append(f.processed, id)
return nil
}
func (f *fakeRPUsageOutboxRepo) MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error {
f.failed = append(f.failed, id)
return nil
}
type fakeRPUsageProjectionRepo struct {
created []domain.RPUsageEvent
err error
}
func (f *fakeRPUsageProjectionRepo) CreateRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
if f.err != nil {
return f.err
}
f.created = append(f.created, event)
return nil
}
func TestRPUsageEventEmitterRequiresCanonicalFields(t *testing.T) {
repo := &fakeRPUsageOutboxRepo{}
emitter := NewRPUsageEventEmitter(repo)
err := emitter.EmitRPUsageEvent(context.Background(), domain.RPUsageEvent{
EventType: domain.RPUsageEventTypeAuthorizationGranted,
ClientID: "client-app",
})
require.Error(t, err)
require.Empty(t, repo.created)
}
func TestRPUsageEventEmitterCreatesPendingOutboxEvent(t *testing.T) {
repo := &fakeRPUsageOutboxRepo{}
emitter := NewRPUsageEventEmitter(repo)
err := emitter.EmitRPUsageEvent(context.Background(), domain.RPUsageEvent{
EventType: domain.RPUsageEventTypeAuthorizationGranted,
Subject: "user-123",
ClientID: "client-app",
Source: "hydra_consent",
CorrelationID: "challenge-1",
})
require.NoError(t, err)
require.Len(t, repo.created, 1)
require.NotEmpty(t, repo.created[0].DedupeKey)
require.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, repo.created[0].EventType)
require.Equal(t, "hydra_consent", repo.created[0].Source)
}
func TestRPUsageProjectorWorkerMarksProcessedAfterProjection(t *testing.T) {
outbox := &fakeRPUsageOutboxRepo{
ready: []domain.RPUsageEvent{{
ID: "event-1",
EventType: domain.RPUsageEventTypeAuthorizationGranted,
Subject: "user-123",
ClientID: "client-app",
}},
}
projection := &fakeRPUsageProjectionRepo{}
worker := NewRPUsageProjectorWorker(outbox, projection)
worker.processOnce(context.Background())
require.Equal(t, []string{"event-1"}, outbox.processing)
require.Equal(t, []string{"event-1"}, outbox.processed)
require.Empty(t, outbox.failed)
require.Len(t, projection.created, 1)
}
func TestRPUsageProjectorWorkerMarksFailedWhenProjectionFails(t *testing.T) {
outbox := &fakeRPUsageOutboxRepo{
ready: []domain.RPUsageEvent{{
ID: "event-1",
EventType: domain.RPUsageEventTypeAuthorizationGranted,
Subject: "user-123",
ClientID: "client-app",
}},
}
projection := &fakeRPUsageProjectionRepo{err: errors.New("clickhouse unavailable")}
worker := NewRPUsageProjectorWorker(outbox, projection)
worker.processOnce(context.Background())
require.Equal(t, []string{"event-1"}, outbox.processing)
require.Empty(t, outbox.processed)
require.Equal(t, []string{"event-1"}, outbox.failed)
}

View File

@@ -0,0 +1,82 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"log/slog"
"time"
)
type RPUsageProjectorWorker struct {
outbox repository.RPUsageOutboxRepository
projection domain.RPUsageProjectionRepository
interval time.Duration
batchSize int
}
func NewRPUsageProjectorWorker(outbox repository.RPUsageOutboxRepository, projection domain.RPUsageProjectionRepository) *RPUsageProjectorWorker {
return &RPUsageProjectorWorker{
outbox: outbox,
projection: projection,
interval: 5 * time.Second,
batchSize: 50,
}
}
func (w *RPUsageProjectorWorker) Start(ctx context.Context) {
if w == nil || w.outbox == nil || w.projection == nil {
return
}
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
default:
w.processOnce(ctx)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (w *RPUsageProjectorWorker) processOnce(ctx context.Context) {
events, err := w.outbox.ListReady(ctx, w.batchSize)
if err != nil {
slog.Warn("failed to list rp usage outbox", "error", err)
return
}
for _, event := range events {
if err := w.outbox.MarkProcessing(ctx, event.ID); err != nil {
slog.Warn("failed to mark rp usage event processing", "event_id", event.ID, "error", err)
continue
}
if err := w.projection.CreateRPUsageEvent(ctx, event); err != nil {
nextAttempt := time.Now().Add(backoffDuration(event.RetryCount))
_ = w.outbox.MarkFailed(ctx, event.ID, err.Error(), nextAttempt)
slog.Warn("failed to project rp usage event", "event_id", event.ID, "error", err)
continue
}
if err := w.outbox.MarkProcessed(ctx, event.ID); err != nil {
slog.Warn("failed to mark rp usage event processed", "event_id", event.ID, "error", err)
}
}
}
func backoffDuration(retryCount int) time.Duration {
if retryCount < 0 {
retryCount = 0
}
delay := time.Duration(retryCount+1) * time.Minute
if delay > 30*time.Minute {
return 30 * time.Minute
}
return delay
}

View File

@@ -8,55 +8,114 @@
inputs = ["oathkeeper_file"] inputs = ["oathkeeper_file"]
source = ''' source = '''
raw = to_string(.message) ?? "" raw = to_string(.message) ?? ""
parsed = parse_json(raw) ?? {} parsed = object!(parse_json(raw) ?? {})
request_method = to_string(get(parsed, ["request", "method"]) ?? "") ?? "" request_method = to_string(get(parsed, ["request", "method"]) ?? "") ?? ""
if request_method == "" { request_method = to_string(get(parsed, ["http_request", "method"]) ?? "") ?? "" }
request_path = to_string(get(parsed, ["request", "path"]) ?? "") ?? "" request_path = to_string(get(parsed, ["request", "path"]) ?? "") ?? ""
if request_path == "" { request_path = to_string(get(parsed, ["http_request", "path"]) ?? "") ?? "" }
request_url = to_string(get(parsed, ["request", "url"]) ?? "") ?? "" request_url = to_string(get(parsed, ["request", "url"]) ?? "") ?? ""
if request_url == "" { request_url = to_string(get(parsed, ["http_url"]) ?? "") ?? "" }
request_host = to_string(get(parsed, ["request", "host"]) ?? "") ?? "" request_host = to_string(get(parsed, ["request", "host"]) ?? "") ?? ""
if request_host == "" { request_host = to_string(get(parsed, ["http_request", "host"]) ?? "") ?? "" }
request_scheme = to_string(get(parsed, ["request", "scheme"]) ?? "") ?? "" request_scheme = to_string(get(parsed, ["request", "scheme"]) ?? "") ?? ""
if request_scheme == "" { request_scheme = to_string(get(parsed, ["http_request", "scheme"]) ?? "") ?? "" }
request_query = to_string(get(parsed, ["request", "query"]) ?? "") ?? "" request_query = to_string(get(parsed, ["request", "query"]) ?? "") ?? ""
response_status = get(parsed, ["response", "status"]) ?? 0 if request_query == "" { request_query = to_string(get(parsed, ["http_request", "query"]) ?? "") ?? "" }
response_status = to_int(get(parsed, ["response", "status"]) ?? 0) ?? 0
if response_status == 0 { response_status = to_int(get(parsed, ["http_response", "status"]) ?? 0) ?? 0 }
response_size = to_int(get(parsed, ["response", "size"]) ?? 0) ?? 0
if response_size == 0 { response_size = to_int(get(parsed, ["http_response", "size"]) ?? 0) ?? 0 }
response_took = to_int(get(parsed, ["response", "took"]) ?? 0) ?? 0
if response_took == 0 { response_took = to_int(get(parsed, ["http_response", "took"]) ?? 0) ?? 0 }
identity_id = to_string(get(parsed, ["identity", "id"]) ?? "") ?? "" identity_id = to_string(get(parsed, ["identity", "id"]) ?? "") ?? ""
headers = get(parsed, ["headers"]) ?? {} if identity_id == "" { identity_id = to_string(get(parsed, ["subject"]) ?? "") ?? "" }
headers = object(get(parsed, ["headers"]) ?? {}) ?? {}
if length(headers) == 0 { headers = object(get(parsed, ["http_request", "headers"]) ?? {}) ?? {} }
user_agent = to_string(get(headers, ["User-Agent"]) ?? "") ?? "" user_agent = to_string(get(headers, ["User-Agent"]) ?? "") ?? ""
if user_agent == "" { user_agent = to_string(get(headers, ["user-agent"]) ?? "") ?? "" }
referer = to_string(get(headers, ["Referer"]) ?? "") ?? "" referer = to_string(get(headers, ["Referer"]) ?? "") ?? ""
if referer == "" { referer = to_string(get(headers, ["referer"]) ?? "") ?? "" }
rule_id = to_string(get(parsed, ["rule", "id"]) ?? "") ?? "" rule_id = to_string(get(parsed, ["rule", "id"]) ?? "") ?? ""
if rule_id == "" { rule_id = to_string(get(parsed, ["rule_id"]) ?? "") ?? "" }
upstream_url = to_string(get(parsed, ["upstream", "url"]) ?? "") ?? "" upstream_url = to_string(get(parsed, ["upstream", "url"]) ?? "") ?? ""
if upstream_url == "" { upstream_url = to_string(get(parsed, ["http_url"]) ?? "") ?? "" }
client_id = to_string(get(parsed, ["client", "id"]) ?? "") ?? "" client_id = to_string(get(parsed, ["client", "id"]) ?? "") ?? ""
parent_session_id = to_string(get(parsed, ["extra", "parent_session_id"]) ?? "") ?? "" parent_session_id = to_string(get(parsed, ["extra", "parent_session_id"]) ?? "") ?? ""
parsed_url = parse_url(request_url) ?? {} parsed_url = parse_url(request_url) ?? {}
query_params = get(parsed_url, ["query"]) ?? {} query_params = get(parsed_url, ["query"]) ?? {}
url_path = to_string(get(parsed_url, ["path"]) ?? "") ?? ""
parsed_request_query = parse_url("http://localhost/?" + request_query) ?? {}
request_query_params = get(parsed_request_query, ["query"]) ?? {}
event_path = to_string(parsed.path) ?? to_string(parsed.http_path) ?? "" event_path = to_string(parsed.path) ?? to_string(parsed.http_path) ?? ""
if event_path == "" { event_path = request_path } if event_path == "" { event_path = request_path }
if event_path == "" { event_path = url_path }
if event_path == "" { event_path = request_url } if event_path == "" { event_path = request_url }
event_client_id = to_string(parsed.client_id) ?? "" event_client_id = to_string(parsed.client_id) ?? ""
if event_client_id == "" { event_client_id = client_id } if event_client_id == "" { event_client_id = client_id }
if event_client_id == "" { event_client_id = to_string(get(query_params, ["client_id"]) ?? "") ?? "" } if event_client_id == "" { event_client_id = to_string(get(query_params, ["client_id"]) ?? "") ?? "" }
if event_client_id == "" { event_client_id = to_string(get(query_params, ["clientId"]) ?? "") ?? "" } if event_client_id == "" { event_client_id = to_string(get(query_params, ["clientId"]) ?? "") ?? "" }
if event_client_id == "" { event_client_id = to_string(get(request_query_params, ["client_id"]) ?? "") ?? "" }
if event_client_id == "" { event_client_id = to_string(get(request_query_params, ["clientId"]) ?? "") ?? "" }
event_latency_ms = to_int(parsed.latency_ms) ?? to_int(parsed.duration_ms) ?? 0
if event_latency_ms == 0 && response_took != 0 {
event_latency_ms = to_int(to_float(response_took) / 1000000.0)
}
event_client_ip = to_string(parsed.client_ip) ?? to_string(parsed.remote_ip) ?? to_string(parsed.ip) ?? ""
if event_client_ip == "" { event_client_ip = to_string(get(headers, ["X-Real-Ip"]) ?? "") ?? "" }
if event_client_ip == "" { event_client_ip = to_string(get(headers, ["x-real-ip"]) ?? "") ?? "" }
if event_client_ip == "" { event_client_ip = to_string(get(headers, ["X-Forwarded-For"]) ?? "") ?? "" }
if event_client_ip == "" { event_client_ip = to_string(get(headers, ["x-forwarded-for"]) ?? "") ?? "" }
event_decision = to_string(parsed.decision) ?? to_string(parsed.result) ?? ""
if event_decision == "" && exists(parsed.granted) {
if parsed.granted == true {
event_decision = "granted"
} else {
event_decision = "denied"
}
}
event_status = to_int(get(parsed, ["status"]) ?? 0) ?? 0
if event_status == 0 { event_status = to_int(get(parsed, ["status_code"]) ?? 0) ?? 0 }
if event_status == 0 { event_status = response_status }
event_bytes_out = to_int(get(parsed, ["bytes_out"]) ?? 0) ?? 0
if event_bytes_out == 0 { event_bytes_out = to_int(get(parsed, ["response_bytes"]) ?? 0) ?? 0 }
if event_bytes_out == 0 { event_bytes_out = response_size }
event_method = to_string(get(parsed, ["method"]) ?? "") ?? ""
if event_method == "" { event_method = to_string(get(parsed, ["http_method"]) ?? "") ?? "" }
if event_method == "" { event_method = request_method }
event_host = to_string(get(parsed, ["host"]) ?? "") ?? ""
if event_host == "" { event_host = to_string(get(parsed, ["http_host"]) ?? "") ?? "" }
if event_host == "" { event_host = request_host }
event_scheme = to_string(get(parsed, ["scheme"]) ?? "") ?? ""
if event_scheme == "" { event_scheme = request_scheme }
event_query = to_string(get(parsed, ["query"]) ?? "") ?? ""
if event_query == "" { event_query = request_query }
event_user_agent = to_string(get(parsed, ["user_agent"]) ?? "") ?? ""
if event_user_agent == "" { event_user_agent = to_string(get(parsed, ["http_user_agent"]) ?? "") ?? "" }
if event_user_agent == "" { event_user_agent = user_agent }
. = { . = {
"request_id": to_string(parsed.request_id) ?? to_string(parsed.req_id) ?? "", "request_id": to_string(parsed.request_id) ?? to_string(parsed.req_id) ?? "",
"method": to_string(parsed.method) ?? to_string(parsed.http_method) ?? request_method, "method": event_method,
"path": event_path, "path": event_path,
"status": to_int(parsed.status) ?? to_int(parsed.status_code) ?? to_int(response_status) ?? 0, "status": event_status,
"latency_ms": to_int(parsed.latency_ms) ?? to_int(parsed.duration_ms) ?? to_int(parsed.took) ?? 0, "latency_ms": event_latency_ms,
"client_id": event_client_id, "client_id": event_client_id,
"rp": to_string(parsed.rp) ?? "", "rp": to_string(parsed.rp) ?? "",
"action": to_string(parsed.action) ?? "", "action": to_string(parsed.action) ?? "",
"target": to_string(parsed.target) ?? "", "target": to_string(parsed.target) ?? "",
"rule_id": to_string(parsed.rule_id) ?? rule_id, "rule_id": to_string(parsed.rule_id) ?? rule_id,
"host": to_string(parsed.host) ?? request_host, "host": event_host,
"scheme": to_string(parsed.scheme) ?? request_scheme, "scheme": event_scheme,
"query": to_string(parsed.query) ?? request_query, "query": event_query,
"upstream_url": to_string(parsed.upstream_url) ?? upstream_url, "upstream_url": to_string(parsed.upstream_url) ?? upstream_url,
"subject": to_string(parsed.subject) ?? identity_id, "subject": to_string(parsed.subject) ?? identity_id,
"parent_session_id": to_string(parsed.parent_session_id) ?? parent_session_id, "parent_session_id": to_string(parsed.parent_session_id) ?? parent_session_id,
"client_ip": to_string(parsed.client_ip) ?? to_string(parsed.remote_ip) ?? to_string(parsed.ip) ?? "", "client_ip": event_client_ip,
"user_agent": to_string(parsed.user_agent) ?? user_agent, "user_agent": event_user_agent,
"referer": referer, "referer": referer,
"decision": to_string(parsed.decision) ?? to_string(parsed.result) ?? "", "decision": event_decision,
"bytes_in": to_int(parsed.bytes_in) ?? to_int(parsed.request_bytes) ?? 0, "bytes_in": to_int(parsed.bytes_in) ?? to_int(parsed.request_bytes) ?? 0,
"bytes_out": to_int(parsed.bytes_out) ?? to_int(parsed.response_bytes) ?? 0, "bytes_out": event_bytes_out,
"trace_id": to_string(parsed.trace_id) ?? "", "trace_id": to_string(parsed.trace_id) ?? "",
"span_id": to_string(parsed.span_id) ?? "", "span_id": to_string(parsed.span_id) ?? "",
"raw": raw "raw": raw
@@ -73,3 +132,52 @@
auth.strategy = "basic" auth.strategy = "basic"
auth.user = "${ORY_CLICKHOUSE_USER}" auth.user = "${ORY_CLICKHOUSE_USER}"
auth.password = "${ORY_CLICKHOUSE_PASSWORD}" auth.password = "${ORY_CLICKHOUSE_PASSWORD}"
[[tests]]
name = "parses_oathkeeper_v26_completed_request"
[[tests.inputs]]
insert_at = "oathkeeper_parse"
type = "log"
[tests.inputs.log_fields]
message = '{"http_request":{"headers":{"user-agent":"Mozilla/5.0","referer":"http://localhost:5173/","x-real-ip":"172.19.0.1"},"host":"localhost","method":"GET","path":"/oauth2/auth","query":"client_id=orgfront&response_type=code","remote":"172.23.0.2:56744","scheme":"http"},"http_response":{"status":302,"size":1339,"took":4854092},"http_url":"http://hydra:4444/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback","level":"info","msg":"completed handling request","subject":"","time":"2026-05-06T01:40:51.46074548Z"}'
[[tests.outputs]]
extract_from = "oathkeeper_parse"
[[tests.outputs.conditions]]
type = "vrl"
source = '''
assert_eq!(.method, "GET")
assert_eq!(.path, "/oauth2/auth")
assert_eq!(.status, 302)
assert_eq!(.client_id, "orgfront")
assert_eq!(.host, "localhost")
assert_eq!(.scheme, "http")
assert_eq!(.user_agent, "Mozilla/5.0")
assert_eq!(.referer, "http://localhost:5173/")
'''
[[tests]]
name = "parses_oathkeeper_v26_granted_request"
[[tests.inputs]]
insert_at = "oathkeeper_parse"
type = "log"
[tests.inputs.log_fields]
message = '{"audience":"application","granted":true,"http_host":"hydra:4444","http_method":"GET","http_url":"http://hydra:4444/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback&response_type=code","http_user_agent":"curl/8.10.1","level":"info","msg":"Access request granted","service_name":"ORY Oathkeeper","service_version":"v26.2.0","subject":"","time":"2026-05-06T01:52:25.431Z"}'
[[tests.outputs]]
extract_from = "oathkeeper_parse"
[[tests.outputs.conditions]]
type = "vrl"
source = '''
assert_eq!(.method, "GET")
assert_eq!(.path, "/oauth2/auth")
assert_eq!(.status, 0)
assert_eq!(.client_id, "orgfront")
assert_eq!(.decision, "granted")
'''

View File

@@ -58,3 +58,66 @@ if (( after_rows <= before_rows )); then
docker logs --tail 100 ory_vector >&2 || true docker logs --tail 100 ory_vector >&2 || true
exit 1 exit 1
fi fi
before_auth_ts="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "SELECT now64(3)")"
auth_status="$(docker run --rm --network public_net curlimages/curl:8.10.1 \
-sS -o /dev/null -w '%{http_code}' \
'http://ory_oathkeeper:4455/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback&response_type=code&scope=openid&state=access-log-e2e&code_challenge=accessloge2e&code_challenge_method=S256')"
if [[ "$auth_status" != "302" ]]; then
echo "ERROR: expected Oathkeeper OIDC auth request to return 302, got: $auth_status" >&2
exit 1
fi
deadline=$((SECONDS + 30))
completed_rows=0
granted_rows=0
while (( SECONDS < deadline )); do
completed_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
SELECT count()
FROM ory.oathkeeper_access_logs
WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
AND method = 'GET'
AND path = '/oauth2/auth'
AND status = 302
")"
granted_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
SELECT count()
FROM ory.oathkeeper_access_logs
WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
AND method = 'GET'
AND path = '/oauth2/auth'
AND client_id = 'orgfront'
AND decision = 'granted'
")"
if (( completed_rows > 0 && granted_rows > 0 )); then
break
fi
sleep 2
done
if (( completed_rows <= 0 )); then
echo "ERROR: Oathkeeper completed request log did not preserve method/path/status." >&2
docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
SELECT timestamp, method, path, status, client_id, decision
FROM ory.oathkeeper_access_logs
WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
ORDER BY timestamp DESC
LIMIT 20
FORMAT Vertical
" >&2 || true
exit 1
fi
if (( granted_rows <= 0 )); then
echo "ERROR: Oathkeeper granted request log did not preserve client_id." >&2
docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
SELECT timestamp, method, path, status, client_id, decision
FROM ory.oathkeeper_access_logs
WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
ORDER BY timestamp DESC
LIMIT 20
FORMAT Vertical
" >&2 || true
exit 1
fi