forked from baron/baron-sso
adminfront 개요 통계 추가
This commit is contained in:
@@ -1,109 +1,25 @@
|
||||
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
|
||||
|
||||
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",
|
||||
},
|
||||
];
|
||||
import { KeyRound } from "lucide-react";
|
||||
import PermissionChecker from "./components/PermissionChecker";
|
||||
|
||||
function AuthPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Admin auth
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Build the admin-only login flow first, keeping app login separate.
|
||||
Respect the “fallback only when user chooses” rule for SMS/email
|
||||
vs app approval.
|
||||
</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.
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Admin auth
|
||||
</p>
|
||||
<h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<KeyRound size={22} className="text-primary" />
|
||||
인증가드
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<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)]">
|
||||
<ArrowRight size={16} />
|
||||
<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>
|
||||
|
||||
<PermissionChecker />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function PermissionChecker() {
|
||||
const result = checkMutation.data;
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--color-panel)] border-primary/20">
|
||||
<Card className="border-primary/20 bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldAlert size={20} className="text-primary" />
|
||||
@@ -100,7 +100,7 @@ function PermissionChecker() {
|
||||
<Button
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={!object || !subject || checkMutation.isPending}
|
||||
className="w-full md:w-auto px-12"
|
||||
className="w-full px-12 md:w-auto"
|
||||
>
|
||||
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
|
||||
</Button>
|
||||
@@ -108,17 +108,17 @@ function PermissionChecker() {
|
||||
|
||||
{checkMutation.isSuccess && result && (
|
||||
<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
|
||||
? "bg-green-500/10 border-green-500/50 text-green-600"
|
||||
: "bg-destructive/10 border-destructive/50 text-destructive"
|
||||
? "border-green-500/50 bg-green-500/10 text-green-600"
|
||||
: "border-destructive/50 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{result.allowed ? (
|
||||
<>
|
||||
<CheckCircle2 size={48} />
|
||||
<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>
|
||||
@@ -127,7 +127,7 @@ function PermissionChecker() {
|
||||
<>
|
||||
<XCircle size={48} />
|
||||
<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>
|
||||
</>
|
||||
186
adminfront/src/features/overview/GlobalOverviewPage.test.tsx
Normal file
186
adminfront/src/features/overview/GlobalOverviewPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,433 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
BarChart3,
|
||||
Database,
|
||||
Key,
|
||||
PlusCircle,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
type TenantSummary,
|
||||
fetchAdminOverviewStats,
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchTenants,
|
||||
} from "../../lib/adminApi";
|
||||
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() {
|
||||
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 (
|
||||
<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="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")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.overview.description",
|
||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||
@@ -36,166 +436,61 @@ function GlobalOverviewPage() {
|
||||
</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"]}>
|
||||
<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.total_tenants", "총 테넌트")}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-primary/10 p-2 text-primary">
|
||||
<Users size={16} />
|
||||
</div>
|
||||
</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.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>
|
||||
<OverviewMetric
|
||||
icon={<Users size={14} />}
|
||||
label={t(
|
||||
"ui.admin.overview.summary.total_tenants",
|
||||
"전체 테넌트 수",
|
||||
)}
|
||||
value={metric(stats?.totalTenants)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<ShieldCheck size={14} />}
|
||||
label={t(
|
||||
"ui.admin.overview.summary.oidc_clients",
|
||||
"OIDC 클라이언트",
|
||||
)}
|
||||
value={metric(stats?.oidcClients)}
|
||||
/>
|
||||
</RoleGuard>
|
||||
|
||||
<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.audit_events_24h",
|
||||
"최근 감사 로그 (24h)",
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="rounded-full bg-orange-500/10 p-2 text-orange-500">
|
||||
<Activity size={16} />
|
||||
</div>
|
||||
</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>
|
||||
<OverviewMetric
|
||||
icon={<Activity size={14} />}
|
||||
label={t(
|
||||
"ui.admin.overview.summary.audit_events_24h",
|
||||
"24시간 이벤트",
|
||||
)}
|
||||
value={metric(stats?.auditEvents24h)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={<Database size={14} />}
|
||||
label={t("ui.admin.overview.summary.policy_gate", "정책 상태")}
|
||||
value="Active"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold tracking-tight">
|
||||
{t("ui.admin.overview.quick_links.title", "빠른 작업")}
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Link
|
||||
to="/tenants/new"
|
||||
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="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">
|
||||
<PlusCircle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold transition-colors group-hover:text-primary">
|
||||
테넌트 추가
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
새로운 조직이나 그룹을 생성합니다.
|
||||
</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>
|
||||
{usageQuery.isError ? (
|
||||
<section className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold">
|
||||
회사별 앱별 로그인요청/기타 요청 현황
|
||||
</h3>
|
||||
{chartFilters}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작
|
||||
이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위
|
||||
그래프가 표시됩니다.
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<RPUsageMixedChart
|
||||
rows={usageRows}
|
||||
filters={chartFilters}
|
||||
period={period}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,6 +101,33 @@ export type RoleListResponse = {
|
||||
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) {
|
||||
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||
params: { limit, cursor },
|
||||
@@ -108,6 +135,29 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||
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) {
|
||||
const { data } = await apiClient.get<TenantListResponse>(
|
||||
"/v1/admin/tenants",
|
||||
|
||||
@@ -183,11 +183,15 @@ func main() {
|
||||
chDB := getEnv("CLICKHOUSE_DB", "baron_sso")
|
||||
|
||||
var auditRepo domain.AuditRepository
|
||||
var rpUsageProjectionRepo domain.RPUsageProjectionRepository
|
||||
var rpUsageQueryRepo domain.RPUsageQueryRepository
|
||||
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)
|
||||
auditRepo = nil // Explicitly set to nil interface
|
||||
} else {
|
||||
auditRepo = repo
|
||||
rpUsageProjectionRepo = repo
|
||||
rpUsageQueryRepo = repo
|
||||
slog.Info("✅ Connected to ClickHouse")
|
||||
}
|
||||
|
||||
@@ -297,6 +301,7 @@ func main() {
|
||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
||||
rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db)
|
||||
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
|
||||
sharedLinkRepo := repository.NewSharedLinkRepository(db)
|
||||
kratosAdminService := service.NewKratosAdminService()
|
||||
@@ -323,6 +328,14 @@ func main() {
|
||||
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient)
|
||||
go worksmobileRelayWorker.Start(context.Background())
|
||||
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)
|
||||
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||
@@ -342,7 +355,12 @@ func main() {
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||
authHandler.HeadlessJWKS = headlessJWKSCache
|
||||
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||
authHandler.RPUsageSink = rpUsageEmitter
|
||||
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.HeadlessJWKS = headlessJWKSCache
|
||||
devHandler.AuditRepo = auditRepo
|
||||
@@ -674,6 +692,7 @@ func main() {
|
||||
|
||||
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
||||
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
||||
admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily)
|
||||
|
||||
// Tenant Management (Mixed roles, handler filters results)
|
||||
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
|
||||
|
||||
@@ -45,6 +45,7 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.ClientSecret{},
|
||||
&domain.ClientConsent{},
|
||||
&domain.KetoOutbox{},
|
||||
&domain.RPUsageEvent{},
|
||||
&domain.WorksmobileOutbox{},
|
||||
&domain.WorksmobileResourceMapping{},
|
||||
&domain.SharedLink{},
|
||||
|
||||
@@ -26,6 +26,7 @@ type AuditRepository interface {
|
||||
Create(log *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)
|
||||
CountEventsSince(ctx context.Context, since time.Time) (int64, error)
|
||||
CountFailuresSince(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
|
||||
|
||||
101
backend/internal/domain/rp_usage_event.go
Normal file
101
backend/internal/domain/rp_usage_event.go
Normal 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)
|
||||
}
|
||||
@@ -1,17 +1,29 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type adminHydraClientLister interface {
|
||||
ListClients(ctx context.Context, limit, offset int) ([]domain.HydraClient, error)
|
||||
}
|
||||
|
||||
type AdminHandler struct {
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPUsageQueries domain.RPUsageQueryRepository
|
||||
TenantRepo repository.TenantRepository
|
||||
Hydra adminHydraClientLister
|
||||
AuditRepo domain.AuditRepository
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
ctx := c.Context()
|
||||
|
||||
stats := fiber.Map{
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"cpus": runtime.NumCPU(),
|
||||
"totalTenants": h.countTenants(ctx),
|
||||
"oidcClients": h.countOIDCClients(ctx),
|
||||
"auditEvents24h": h.countAuditEventsSince(ctx, time.Now().UTC().Add(-24*time.Hour)),
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"cpus": runtime.NumCPU(),
|
||||
"memory": fiber.Map{
|
||||
"alloc": m.Alloc,
|
||||
"totalAlign": m.TotalAlloc,
|
||||
@@ -44,3 +130,59 @@ func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
156
backend/internal/handler/admin_handler_test.go
Normal file
156
backend/internal/handler/admin_handler_test.go
Normal 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())
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -101,6 +102,7 @@ type AuthHandler struct {
|
||||
UserRepo repository.UserRepository
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
RPUserMetadataRepo repository.RPUserMetadataRepository
|
||||
RPUsageSink domain.RPUsageEventSink
|
||||
}
|
||||
|
||||
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 ---
|
||||
|
||||
// CheckEmail - 이메일 사용 가능 여부를 확인합니다.
|
||||
@@ -5323,6 +5411,12 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
|
||||
if err != nil || subject == "" {
|
||||
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)
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
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)
|
||||
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -38,12 +39,14 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
rpUsageSink := &mockRPUsageEventSink{}
|
||||
h := &AuthHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: client,
|
||||
},
|
||||
AuditRepo: auditRepo,
|
||||
AuditRepo: auditRepo,
|
||||
RPUsageSink: rpUsageSink,
|
||||
}
|
||||
app := fiber.New()
|
||||
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)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
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) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -305,6 +306,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
||||
defer func() { http.DefaultClient = origDefault }()
|
||||
|
||||
consentRepo := &mockConsentRepo{}
|
||||
rpUsageSink := &mockRPUsageEventSink{}
|
||||
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
||||
|
||||
h := &AuthHandler{
|
||||
@@ -314,6 +316,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
||||
},
|
||||
KratosAdmin: mockKratosAdmin,
|
||||
ConsentRepo: consentRepo,
|
||||
RPUsageSink: rpUsageSink,
|
||||
}
|
||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
@@ -332,6 +335,11 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
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) {
|
||||
@@ -370,6 +378,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
|
||||
|
||||
auditRepo := &mockAuditRepo{}
|
||||
consentRepo := &mockConsentRepo{}
|
||||
rpUsageSink := &mockRPUsageEventSink{}
|
||||
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
||||
|
||||
h := &AuthHandler{
|
||||
@@ -380,6 +389,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
|
||||
KratosAdmin: mockKratosAdmin,
|
||||
AuditRepo: auditRepo,
|
||||
ConsentRepo: consentRepo,
|
||||
RPUsageSink: rpUsageSink,
|
||||
}
|
||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||
ID: "user-123",
|
||||
@@ -402,6 +412,21 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
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) {
|
||||
|
||||
@@ -109,12 +109,29 @@ func (m *mockAuditRepo) CountFailuresSince(ctx context.Context, since time.Time,
|
||||
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) {
|
||||
return 0, 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 {
|
||||
logs []domain.OathkeeperAccessLog
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ func (m *MockAuditRepository) CountFailuresSince(ctx context.Context, since time
|
||||
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) {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -73,6 +77,10 @@ func (r *recordingAuditRepository) CountFailuresSince(ctx context.Context, since
|
||||
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) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
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) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
@@ -228,6 +412,21 @@ func (r *ClickHouseRepository) CountFailuresSince(ctx context.Context, since tim
|
||||
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) {
|
||||
// We use uniqExact(session_id) to count unique sessions that had success events recently.
|
||||
query := `
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
log.Fatalf("failed to migrate database: %s", err)
|
||||
}
|
||||
|
||||
91
backend/internal/repository/rp_usage_outbox_repository.go
Normal file
91
backend/internal/repository/rp_usage_outbox_repository.go
Normal 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
|
||||
}
|
||||
67
backend/internal/service/rp_usage_event_emitter.go
Normal file
67
backend/internal/service/rp_usage_event_emitter.go
Normal 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[:])
|
||||
}
|
||||
132
backend/internal/service/rp_usage_event_emitter_test.go
Normal file
132
backend/internal/service/rp_usage_event_emitter_test.go
Normal 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)
|
||||
}
|
||||
82
backend/internal/service/rp_usage_projector_worker.go
Normal file
82
backend/internal/service/rp_usage_projector_worker.go
Normal 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
|
||||
}
|
||||
@@ -8,55 +8,114 @@
|
||||
inputs = ["oathkeeper_file"]
|
||||
source = '''
|
||||
raw = to_string(.message) ?? ""
|
||||
parsed = parse_json(raw) ?? {}
|
||||
parsed = object!(parse_json(raw) ?? {})
|
||||
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"]) ?? "") ?? ""
|
||||
if request_path == "" { request_path = to_string(get(parsed, ["http_request", "path"]) ?? "") ?? "" }
|
||||
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"]) ?? "") ?? ""
|
||||
if request_host == "" { request_host = to_string(get(parsed, ["http_request", "host"]) ?? "") ?? "" }
|
||||
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"]) ?? "") ?? ""
|
||||
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"]) ?? "") ?? ""
|
||||
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"]) ?? "") ?? ""
|
||||
if user_agent == "" { user_agent = to_string(get(headers, ["user-agent"]) ?? "") ?? "" }
|
||||
referer = to_string(get(headers, ["Referer"]) ?? "") ?? ""
|
||||
if referer == "" { referer = to_string(get(headers, ["referer"]) ?? "") ?? "" }
|
||||
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"]) ?? "") ?? ""
|
||||
if upstream_url == "" { upstream_url = to_string(get(parsed, ["http_url"]) ?? "") ?? "" }
|
||||
client_id = to_string(get(parsed, ["client", "id"]) ?? "") ?? ""
|
||||
parent_session_id = to_string(get(parsed, ["extra", "parent_session_id"]) ?? "") ?? ""
|
||||
parsed_url = parse_url(request_url) ?? {}
|
||||
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) ?? ""
|
||||
if event_path == "" { event_path = request_path }
|
||||
if event_path == "" { event_path = url_path }
|
||||
if event_path == "" { event_path = request_url }
|
||||
event_client_id = to_string(parsed.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, ["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) ?? "",
|
||||
"method": to_string(parsed.method) ?? to_string(parsed.http_method) ?? request_method,
|
||||
"method": event_method,
|
||||
"path": event_path,
|
||||
"status": to_int(parsed.status) ?? to_int(parsed.status_code) ?? to_int(response_status) ?? 0,
|
||||
"latency_ms": to_int(parsed.latency_ms) ?? to_int(parsed.duration_ms) ?? to_int(parsed.took) ?? 0,
|
||||
"status": event_status,
|
||||
"latency_ms": event_latency_ms,
|
||||
"client_id": event_client_id,
|
||||
"rp": to_string(parsed.rp) ?? "",
|
||||
"action": to_string(parsed.action) ?? "",
|
||||
"target": to_string(parsed.target) ?? "",
|
||||
"rule_id": to_string(parsed.rule_id) ?? rule_id,
|
||||
"host": to_string(parsed.host) ?? request_host,
|
||||
"scheme": to_string(parsed.scheme) ?? request_scheme,
|
||||
"query": to_string(parsed.query) ?? request_query,
|
||||
"host": event_host,
|
||||
"scheme": event_scheme,
|
||||
"query": event_query,
|
||||
"upstream_url": to_string(parsed.upstream_url) ?? upstream_url,
|
||||
"subject": to_string(parsed.subject) ?? identity_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) ?? "",
|
||||
"user_agent": to_string(parsed.user_agent) ?? user_agent,
|
||||
"client_ip": event_client_ip,
|
||||
"user_agent": event_user_agent,
|
||||
"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_out": to_int(parsed.bytes_out) ?? to_int(parsed.response_bytes) ?? 0,
|
||||
"bytes_out": event_bytes_out,
|
||||
"trace_id": to_string(parsed.trace_id) ?? "",
|
||||
"span_id": to_string(parsed.span_id) ?? "",
|
||||
"raw": raw
|
||||
@@ -73,3 +132,52 @@
|
||||
auth.strategy = "basic"
|
||||
auth.user = "${ORY_CLICKHOUSE_USER}"
|
||||
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")
|
||||
'''
|
||||
|
||||
@@ -58,3 +58,66 @@ if (( after_rows <= before_rows )); then
|
||||
docker logs --tail 100 ory_vector >&2 || true
|
||||
exit 1
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user