forked from baron/baron-sso
adminfront 개요 통계 추가
This commit is contained in:
@@ -1,109 +1,25 @@
|
|||||||
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
|
import { KeyRound } from "lucide-react";
|
||||||
|
import PermissionChecker from "./components/PermissionChecker";
|
||||||
const flows = [
|
|
||||||
{
|
|
||||||
title: "Admin login",
|
|
||||||
description:
|
|
||||||
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
|
|
||||||
pill: "15m TTL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Tenant pick",
|
|
||||||
description:
|
|
||||||
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
|
|
||||||
pill: "Header-ready",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Device approval",
|
|
||||||
description:
|
|
||||||
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
|
|
||||||
pill: "App session",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function AuthPage() {
|
function AuthPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="space-y-1">
|
||||||
<div>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
Admin auth
|
||||||
Admin auth
|
</p>
|
||||||
</p>
|
<h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||||
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
|
<KeyRound size={22} className="text-primary" />
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
인증가드
|
||||||
Build the admin-only login flow first, keeping app login separate.
|
</h2>
|
||||||
Respect the “fallback only when user chooses” rule for SMS/email
|
<p className="text-sm text-muted-foreground">
|
||||||
vs app approval.
|
관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다.
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
|
|
||||||
IDP session placeholder
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
|
||||||
>
|
|
||||||
<Sparkles size={14} />
|
|
||||||
Connect auth layer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-3">
|
|
||||||
{flows.map((flow) => (
|
|
||||||
<div
|
|
||||||
key={flow.title}
|
|
||||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
|
|
||||||
<span>{flow.pill}</span>
|
|
||||||
<Fingerprint size={14} />
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
|
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
|
||||||
{flow.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
|
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
|
||||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
|
||||||
<Smartphone size={16} />
|
|
||||||
<span className="text-xs uppercase tracking-[0.18em]">
|
|
||||||
App-based approvals
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-2 text-xl font-semibold">
|
|
||||||
App session as MFA replacement
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
|
||||||
If the admin keeps the mobile app signed in and opts in, use
|
|
||||||
push/deeplink approval instead of OTP. Otherwise fall back to
|
|
||||||
SMS/email based on user choice.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
|
||||||
<ArrowRight size={16} />
|
<PermissionChecker />
|
||||||
<span className="text-xs uppercase tracking-[0.18em]">
|
|
||||||
TTL discipline
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-2 text-xl font-semibold">
|
|
||||||
Keep admin sessions short
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
|
||||||
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
|
|
||||||
with step-up MFA when critical actions (rotate secret, export logs)
|
|
||||||
happen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function PermissionChecker() {
|
|||||||
const result = checkMutation.data;
|
const result = checkMutation.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-[var(--color-panel)] border-primary/20">
|
<Card className="border-primary/20 bg-[var(--color-panel)]">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<ShieldAlert size={20} className="text-primary" />
|
<ShieldAlert size={20} className="text-primary" />
|
||||||
@@ -100,7 +100,7 @@ function PermissionChecker() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => checkMutation.mutate()}
|
onClick={() => checkMutation.mutate()}
|
||||||
disabled={!object || !subject || checkMutation.isPending}
|
disabled={!object || !subject || checkMutation.isPending}
|
||||||
className="w-full md:w-auto px-12"
|
className="w-full px-12 md:w-auto"
|
||||||
>
|
>
|
||||||
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
|
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -108,17 +108,17 @@ function PermissionChecker() {
|
|||||||
|
|
||||||
{checkMutation.isSuccess && result && (
|
{checkMutation.isSuccess && result && (
|
||||||
<div
|
<div
|
||||||
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
|
className={`flex flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 animate-in zoom-in duration-300 ${
|
||||||
result.allowed
|
result.allowed
|
||||||
? "bg-green-500/10 border-green-500/50 text-green-600"
|
? "border-green-500/50 bg-green-500/10 text-green-600"
|
||||||
: "bg-destructive/10 border-destructive/50 text-destructive"
|
: "border-destructive/50 bg-destructive/10 text-destructive"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{result.allowed ? (
|
{result.allowed ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle2 size={48} />
|
<CheckCircle2 size={48} />
|
||||||
<div className="text-xl font-bold">Access ALLOWED</div>
|
<div className="text-xl font-bold">Access ALLOWED</div>
|
||||||
<p className="text-sm opacity-80 text-center">
|
<p className="text-center text-sm opacity-80">
|
||||||
해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속
|
해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속
|
||||||
포함)
|
포함)
|
||||||
</p>
|
</p>
|
||||||
@@ -127,7 +127,7 @@ function PermissionChecker() {
|
|||||||
<>
|
<>
|
||||||
<XCircle size={48} />
|
<XCircle size={48} />
|
||||||
<div className="text-xl font-bold">Access DENIED</div>
|
<div className="text-xl font-bold">Access DENIED</div>
|
||||||
<p className="text-sm opacity-80 text-center">
|
<p className="text-center text-sm opacity-80">
|
||||||
해당 사용자는 요청한 리소스에 대해 권한이 없습니다.
|
해당 사용자는 요청한 리소스에 대해 권한이 없습니다.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
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 {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowUpRight,
|
BarChart3,
|
||||||
Database,
|
Database,
|
||||||
Key,
|
|
||||||
PlusCircle,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { type ReactNode, useMemo, useState } from "react";
|
||||||
import { RoleGuard } from "../../components/auth/RoleGuard";
|
import { RoleGuard } from "../../components/auth/RoleGuard";
|
||||||
import {
|
import {
|
||||||
Card,
|
type RPUsageDailyMetric,
|
||||||
CardContent,
|
type RPUsagePeriod,
|
||||||
CardDescription,
|
type TenantSummary,
|
||||||
CardHeader,
|
fetchAdminOverviewStats,
|
||||||
CardTitle,
|
fetchAdminRPUsageDaily,
|
||||||
} from "../../components/ui/card";
|
fetchTenants,
|
||||||
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import PermissionChecker from "./components/PermissionChecker";
|
|
||||||
|
type DailyPoint = {
|
||||||
|
date: string;
|
||||||
|
loginRequests: number;
|
||||||
|
otherRequests: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeriesSummary = {
|
||||||
|
key: string;
|
||||||
|
tenantLabel: string;
|
||||||
|
clientLabel: string;
|
||||||
|
loginRequests: number;
|
||||||
|
otherRequests: number;
|
||||||
|
uniqueSubjects: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
|
||||||
|
const byDate = new Map<string, DailyPoint>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const current =
|
||||||
|
byDate.get(row.date) ??
|
||||||
|
({
|
||||||
|
date: row.date,
|
||||||
|
loginRequests: 0,
|
||||||
|
otherRequests: 0,
|
||||||
|
} satisfies DailyPoint);
|
||||||
|
current.loginRequests += row.loginRequests;
|
||||||
|
current.otherRequests += row.otherRequests;
|
||||||
|
byDate.set(row.date, current);
|
||||||
|
}
|
||||||
|
return Array.from(byDate.values()).sort((a, b) =>
|
||||||
|
a.date.localeCompare(b.date),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
|
||||||
|
const bySeries = new Map<string, SeriesSummary>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = `${row.tenantId}:${row.clientId}`;
|
||||||
|
const current =
|
||||||
|
bySeries.get(key) ??
|
||||||
|
({
|
||||||
|
key,
|
||||||
|
tenantLabel: row.tenantName || row.tenantId || "-",
|
||||||
|
clientLabel: row.clientName || row.clientId,
|
||||||
|
loginRequests: 0,
|
||||||
|
otherRequests: 0,
|
||||||
|
uniqueSubjects: 0,
|
||||||
|
} satisfies SeriesSummary);
|
||||||
|
current.loginRequests += row.loginRequests;
|
||||||
|
current.otherRequests += row.otherRequests;
|
||||||
|
current.uniqueSubjects = Math.max(
|
||||||
|
current.uniqueSubjects,
|
||||||
|
row.uniqueSubjects,
|
||||||
|
);
|
||||||
|
bySeries.set(key, current);
|
||||||
|
}
|
||||||
|
return Array.from(bySeries.values())
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
|
||||||
|
)
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateParts(date: string) {
|
||||||
|
const parts = date.split("-");
|
||||||
|
if (parts.length === 3) {
|
||||||
|
return {
|
||||||
|
year: Number(parts[0]),
|
||||||
|
month: Number(parts[1]),
|
||||||
|
day: Number(parts[2]),
|
||||||
|
monthText: parts[1],
|
||||||
|
dayText: parts[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getISOWeekNumber(year: number, month: number, day: number) {
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
const dayOfWeek = date.getUTCDay() || 7;
|
||||||
|
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
|
||||||
|
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getISOWeekThursday(year: number, month: number, day: number) {
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
const dayOfWeek = date.getUTCDay() || 7;
|
||||||
|
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPeriodLabel(date: string, period: RPUsagePeriod) {
|
||||||
|
const parts = parseDateParts(date);
|
||||||
|
if (!parts) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
if (period === "month") {
|
||||||
|
return `${parts.monthText}월`;
|
||||||
|
}
|
||||||
|
if (period === "week") {
|
||||||
|
const weekNumber = String(
|
||||||
|
getISOWeekNumber(parts.year, parts.month, parts.day),
|
||||||
|
).padStart(2, "0");
|
||||||
|
const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day);
|
||||||
|
const weekMonth = weekThursday.getUTCMonth() + 1;
|
||||||
|
const weekDay = weekThursday.getUTCDate();
|
||||||
|
const weekMonthText = String(weekMonth).padStart(2, "0");
|
||||||
|
const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7)));
|
||||||
|
return `${weekNumber}(${weekMonthText}월${weekOfMonth}주)`;
|
||||||
|
}
|
||||||
|
return `${parts.monthText}.${parts.dayText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewMetric({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
|
||||||
|
<span className="text-muted-foreground">{icon}</span>
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-semibold tabular-nums">{value}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RPUsageMixedChart({
|
||||||
|
rows,
|
||||||
|
filters,
|
||||||
|
period,
|
||||||
|
}: {
|
||||||
|
rows: RPUsageDailyMetric[];
|
||||||
|
filters: ReactNode;
|
||||||
|
period: RPUsagePeriod;
|
||||||
|
}) {
|
||||||
|
const daily = summarizeDaily(rows);
|
||||||
|
const series = summarizeSeries(rows);
|
||||||
|
const chartWidth = 720;
|
||||||
|
const chartHeight = 230;
|
||||||
|
const padX = 48;
|
||||||
|
const padTop = 32;
|
||||||
|
const padBottom = 34;
|
||||||
|
const innerWidth = chartWidth - padX * 2;
|
||||||
|
const innerHeight = chartHeight - padTop - padBottom;
|
||||||
|
const maxValue = Math.max(
|
||||||
|
1,
|
||||||
|
...daily.map((point) => point.loginRequests + point.otherRequests),
|
||||||
|
...daily.map((point) => point.loginRequests),
|
||||||
|
);
|
||||||
|
const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth;
|
||||||
|
const barWidth = Math.min(28, Math.max(10, slot * 0.42));
|
||||||
|
const y = (value: number) =>
|
||||||
|
padTop + innerHeight - (value / maxValue) * innerHeight;
|
||||||
|
const x = (index: number) => padX + slot * index + slot / 2;
|
||||||
|
const linePoints = daily
|
||||||
|
.map((point, index) => `${x(index)},${y(point.loginRequests)}`)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 size={18} className="text-primary" />
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
회사별 앱별 로그인요청/기타 요청 현황
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{filters}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{daily.length === 0 ? (
|
||||||
|
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
|
||||||
|
표시할 RP 이용 집계가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
aria-label="일 단위 RP 요청 현황"
|
||||||
|
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||||
|
className="h-[235px] min-w-[720px] w-full"
|
||||||
|
>
|
||||||
|
<title>일 단위 RP 요청 현황</title>
|
||||||
|
<g transform="translate(510 10)">
|
||||||
|
<rect
|
||||||
|
x="0"
|
||||||
|
y="3"
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
rx="2"
|
||||||
|
className="fill-sky-500/70"
|
||||||
|
/>
|
||||||
|
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
|
||||||
|
기타 요청
|
||||||
|
</text>
|
||||||
|
<line
|
||||||
|
x1="78"
|
||||||
|
x2="98"
|
||||||
|
y1="8"
|
||||||
|
y2="8"
|
||||||
|
className="stroke-emerald-500"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="104"
|
||||||
|
y="12"
|
||||||
|
className="fill-muted-foreground text-[11px]"
|
||||||
|
>
|
||||||
|
로그인 요청
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||||
|
const gridY = padTop + innerHeight * ratio;
|
||||||
|
const label = Math.round(maxValue * (1 - ratio));
|
||||||
|
return (
|
||||||
|
<g key={ratio}>
|
||||||
|
<line
|
||||||
|
x1={padX}
|
||||||
|
x2={chartWidth - padX}
|
||||||
|
y1={gridY}
|
||||||
|
y2={gridY}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="text-border"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={padX - 12}
|
||||||
|
y={gridY + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
className="fill-muted-foreground text-[11px]"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{daily.map((point, index) => {
|
||||||
|
const center = x(index);
|
||||||
|
const otherHeight =
|
||||||
|
(point.otherRequests / maxValue) * innerHeight;
|
||||||
|
return (
|
||||||
|
<g key={point.date}>
|
||||||
|
<rect
|
||||||
|
x={center - barWidth / 2}
|
||||||
|
y={padTop + innerHeight - otherHeight}
|
||||||
|
width={barWidth}
|
||||||
|
height={otherHeight}
|
||||||
|
rx="3"
|
||||||
|
className="fill-sky-500/70"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={center}
|
||||||
|
y={chartHeight - 12}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-muted-foreground text-[11px]"
|
||||||
|
>
|
||||||
|
{formatPeriodLabel(point.date, period)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<polyline
|
||||||
|
points={linePoints}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-emerald-500"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{daily.map((point, index) => (
|
||||||
|
<circle
|
||||||
|
key={`${point.date}-login`}
|
||||||
|
cx={x(index)}
|
||||||
|
cy={y(point.loginRequests)}
|
||||||
|
r="4"
|
||||||
|
className="fill-emerald-500 stroke-background"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{series.length > 0 && (
|
||||||
|
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{series.map((item) => (
|
||||||
|
<div key={item.key} className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate font-medium">{item.clientLabel}</span>
|
||||||
|
<span className="truncate text-muted-foreground">
|
||||||
|
{item.tenantLabel}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto whitespace-nowrap tabular-nums">
|
||||||
|
로그인 {item.loginRequests.toLocaleString()} / 기타{" "}
|
||||||
|
{item.otherRequests.toLocaleString()} / 사용자{" "}
|
||||||
|
{item.uniqueSubjects.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GlobalOverviewPage() {
|
function GlobalOverviewPage() {
|
||||||
|
const [period, setPeriod] = useState<RPUsagePeriod>("day");
|
||||||
|
const [tenantSearch, setTenantSearch] = useState("");
|
||||||
|
const [selectedTenantId, setSelectedTenantId] = useState("");
|
||||||
|
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
|
||||||
|
const statsQuery = useQuery({
|
||||||
|
queryKey: ["admin-overview-stats"],
|
||||||
|
queryFn: fetchAdminOverviewStats,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const tenantsQuery = useQuery({
|
||||||
|
queryKey: ["admin-overview-tenant-options"],
|
||||||
|
queryFn: () => fetchTenants(1000, 0),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const tenantOptions = useMemo(() => {
|
||||||
|
const term = tenantSearch.trim().toLowerCase();
|
||||||
|
return (tenantsQuery.data?.items ?? [])
|
||||||
|
.filter(
|
||||||
|
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
|
||||||
|
)
|
||||||
|
.filter((tenant) => {
|
||||||
|
if (!term) return true;
|
||||||
|
return (
|
||||||
|
tenant.name.toLowerCase().includes(term) ||
|
||||||
|
tenant.slug.toLowerCase().includes(term) ||
|
||||||
|
tenant.id.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [tenantSearch, tenantsQuery.data?.items]);
|
||||||
|
const usageQuery = useQuery({
|
||||||
|
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchAdminRPUsageDaily({
|
||||||
|
days: usageDays,
|
||||||
|
period,
|
||||||
|
tenantId: selectedTenantId || undefined,
|
||||||
|
}),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const stats = statsQuery.data;
|
||||||
|
const usageRows = usageQuery.data?.items ?? [];
|
||||||
|
const metric = (value: number | undefined) =>
|
||||||
|
value === undefined ? "-" : value.toLocaleString();
|
||||||
|
const chartFilters = (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
|
||||||
|
{[
|
||||||
|
["day", "일"],
|
||||||
|
["week", "주"],
|
||||||
|
["month", "월"],
|
||||||
|
].map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={period === value}
|
||||||
|
onClick={() => setPeriod(value as RPUsagePeriod)}
|
||||||
|
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
|
||||||
|
period === value
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted/60 hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
aria-label="조직 검색"
|
||||||
|
value={tenantSearch}
|
||||||
|
onChange={(event) => setTenantSearch(event.target.value)}
|
||||||
|
placeholder="조직 검색"
|
||||||
|
className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
aria-label="대상 조직"
|
||||||
|
value={selectedTenantId}
|
||||||
|
onChange={(event) => setSelectedTenantId(event.target.value)}
|
||||||
|
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-52"
|
||||||
|
>
|
||||||
|
<option value="">전체 조직</option>
|
||||||
|
{tenantOptions.map((tenant) => (
|
||||||
|
<option key={tenant.id} value={tenant.id}>
|
||||||
|
{tenant.name} ({tenant.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500">
|
<div className="space-y-4 animate-in fade-in duration-500">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
{t("ui.admin.overview.title", "Dashboard")}
|
{t("ui.admin.overview.title", "Dashboard")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.overview.description",
|
"msg.admin.overview.description",
|
||||||
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
|
||||||
@@ -36,166 +436,61 @@ function GlobalOverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
|
||||||
<RoleGuard roles={["super_admin"]}>
|
<RoleGuard roles={["super_admin"]}>
|
||||||
<Card className="transition-all hover:shadow-md">
|
<OverviewMetric
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
icon={<Users size={14} />}
|
||||||
<CardTitle className="text-sm font-medium">
|
label={t(
|
||||||
{t("ui.admin.overview.summary.total_tenants", "총 테넌트")}
|
"ui.admin.overview.summary.total_tenants",
|
||||||
</CardTitle>
|
"전체 테넌트 수",
|
||||||
<div className="rounded-full bg-primary/10 p-2 text-primary">
|
)}
|
||||||
<Users size={16} />
|
value={metric(stats?.totalTenants)}
|
||||||
</div>
|
/>
|
||||||
</CardHeader>
|
<OverviewMetric
|
||||||
<CardContent>
|
icon={<ShieldCheck size={14} />}
|
||||||
<div className="text-2xl font-bold">-</div>
|
label={t(
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
"ui.admin.overview.summary.oidc_clients",
|
||||||
활성화된 테넌트 수
|
"OIDC 클라이언트",
|
||||||
</p>
|
)}
|
||||||
</CardContent>
|
value={metric(stats?.oidcClients)}
|
||||||
</Card>
|
/>
|
||||||
<Card className="transition-all hover:shadow-md">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
{t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="rounded-full bg-blue-500/10 p-2 text-blue-500">
|
|
||||||
<ShieldCheck size={16} />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">-</div>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
등록된 OIDC 앱
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</RoleGuard>
|
</RoleGuard>
|
||||||
|
<OverviewMetric
|
||||||
<Card className="transition-all hover:shadow-md">
|
icon={<Activity size={14} />}
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
label={t(
|
||||||
<CardTitle className="text-sm font-medium">
|
"ui.admin.overview.summary.audit_events_24h",
|
||||||
{t(
|
"24시간 이벤트",
|
||||||
"ui.admin.overview.summary.audit_events_24h",
|
)}
|
||||||
"최근 감사 로그 (24h)",
|
value={metric(stats?.auditEvents24h)}
|
||||||
)}
|
/>
|
||||||
</CardTitle>
|
<OverviewMetric
|
||||||
<div className="rounded-full bg-orange-500/10 p-2 text-orange-500">
|
icon={<Database size={14} />}
|
||||||
<Activity size={16} />
|
label={t("ui.admin.overview.summary.policy_gate", "정책 상태")}
|
||||||
</div>
|
value="Active"
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">-</div>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
발생한 이벤트 수
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="transition-all hover:shadow-md">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
{t("ui.admin.overview.summary.policy_gate", "정책 상태")}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="rounded-full bg-green-500/10 p-2 text-green-500">
|
|
||||||
<Database size={16} />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold text-green-600 dark:text-green-500">
|
|
||||||
Active
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
접근 제어 정상 동작
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{usageQuery.isError ? (
|
||||||
<h3 className="text-lg font-semibold tracking-tight">
|
<section className="space-y-2">
|
||||||
{t("ui.admin.overview.quick_links.title", "빠른 작업")}
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
</h3>
|
<h3 className="text-base font-semibold">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
회사별 앱별 로그인요청/기타 요청 현황
|
||||||
<RoleGuard roles={["super_admin"]}>
|
</h3>
|
||||||
<Link
|
{chartFilters}
|
||||||
to="/tenants/new"
|
</div>
|
||||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/50 hover:shadow-md"
|
<div className="text-sm text-muted-foreground">
|
||||||
>
|
RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작
|
||||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위
|
||||||
<PlusCircle size={20} />
|
그래프가 표시됩니다.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</section>
|
||||||
<h4 className="font-semibold transition-colors group-hover:text-primary">
|
) : (
|
||||||
테넌트 추가
|
<RPUsageMixedChart
|
||||||
</h4>
|
rows={usageRows}
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
filters={chartFilters}
|
||||||
새로운 조직이나 그룹을 생성합니다.
|
period={period}
|
||||||
</p>
|
/>
|
||||||
</div>
|
)}
|
||||||
</Link>
|
|
||||||
</RoleGuard>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
to="/users"
|
|
||||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-blue-500/50 hover:shadow-md"
|
|
||||||
>
|
|
||||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500 transition-colors group-hover:bg-blue-500 group-hover:text-white">
|
|
||||||
<Users size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold transition-colors group-hover:text-blue-500">
|
|
||||||
사용자 관리
|
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
전체 사용자를 조회하고 관리합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<RoleGuard roles={["super_admin"]}>
|
|
||||||
<Link
|
|
||||||
to="/api-keys"
|
|
||||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-purple-500/50 hover:shadow-md"
|
|
||||||
>
|
|
||||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 text-purple-500 transition-colors group-hover:bg-purple-500 group-hover:text-white">
|
|
||||||
<Key size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold transition-colors group-hover:text-purple-500">
|
|
||||||
API 키 관리
|
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
시스템 연동을 위한 키를 발급합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</RoleGuard>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
to="/audit-logs"
|
|
||||||
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-orange-500/50 hover:shadow-md"
|
|
||||||
>
|
|
||||||
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-orange-500/10 text-orange-500 transition-colors group-hover:bg-orange-500 group-hover:text-white">
|
|
||||||
<Activity size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold transition-colors group-hover:text-orange-500">
|
|
||||||
감사 로그
|
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
보안 이벤트를 모니터링합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RoleGuard roles={["super_admin"]}>
|
|
||||||
<div className="pt-4">
|
|
||||||
<PermissionChecker />
|
|
||||||
</div>
|
|
||||||
</RoleGuard>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,33 @@ export type RoleListResponse = {
|
|||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RPUsageDailyMetric = {
|
||||||
|
date: string;
|
||||||
|
tenantId: string;
|
||||||
|
tenantType: string;
|
||||||
|
tenantName?: string;
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
loginRequests: number;
|
||||||
|
otherRequests: number;
|
||||||
|
uniqueSubjects: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RPUsagePeriod = "day" | "week" | "month";
|
||||||
|
|
||||||
|
export type RPUsageDailyResponse = {
|
||||||
|
items: RPUsageDailyMetric[];
|
||||||
|
days: number;
|
||||||
|
period: RPUsagePeriod;
|
||||||
|
tenantId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminOverviewStats = {
|
||||||
|
totalTenants: number;
|
||||||
|
oidcClients: number;
|
||||||
|
auditEvents24h: number;
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
||||||
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
|
||||||
params: { limit, cursor },
|
params: { limit, cursor },
|
||||||
@@ -108,6 +135,29 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminOverviewStats() {
|
||||||
|
const { data } = await apiClient.get<AdminOverviewStats>("/v1/admin/stats");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminRPUsageDaily({
|
||||||
|
days = 14,
|
||||||
|
period = "day",
|
||||||
|
tenantId,
|
||||||
|
}: {
|
||||||
|
days?: number;
|
||||||
|
period?: RPUsagePeriod;
|
||||||
|
tenantId?: string;
|
||||||
|
} = {}) {
|
||||||
|
const { data } = await apiClient.get<RPUsageDailyResponse>(
|
||||||
|
"/v1/admin/rp-usage/daily",
|
||||||
|
{
|
||||||
|
params: { days, period, tenantId: tenantId || undefined },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
|
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
|
||||||
const { data } = await apiClient.get<TenantListResponse>(
|
const { data } = await apiClient.get<TenantListResponse>(
|
||||||
"/v1/admin/tenants",
|
"/v1/admin/tenants",
|
||||||
|
|||||||
@@ -183,11 +183,15 @@ func main() {
|
|||||||
chDB := getEnv("CLICKHOUSE_DB", "baron_sso")
|
chDB := getEnv("CLICKHOUSE_DB", "baron_sso")
|
||||||
|
|
||||||
var auditRepo domain.AuditRepository
|
var auditRepo domain.AuditRepository
|
||||||
|
var rpUsageProjectionRepo domain.RPUsageProjectionRepository
|
||||||
|
var rpUsageQueryRepo domain.RPUsageQueryRepository
|
||||||
if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil {
|
if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil {
|
||||||
slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err)
|
slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err)
|
||||||
auditRepo = nil // Explicitly set to nil interface
|
auditRepo = nil // Explicitly set to nil interface
|
||||||
} else {
|
} else {
|
||||||
auditRepo = repo
|
auditRepo = repo
|
||||||
|
rpUsageProjectionRepo = repo
|
||||||
|
rpUsageQueryRepo = repo
|
||||||
slog.Info("✅ Connected to ClickHouse")
|
slog.Info("✅ Connected to ClickHouse")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +301,7 @@ func main() {
|
|||||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
|
||||||
|
rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db)
|
||||||
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
|
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
|
||||||
sharedLinkRepo := repository.NewSharedLinkRepository(db)
|
sharedLinkRepo := repository.NewSharedLinkRepository(db)
|
||||||
kratosAdminService := service.NewKratosAdminService()
|
kratosAdminService := service.NewKratosAdminService()
|
||||||
@@ -323,6 +328,14 @@ func main() {
|
|||||||
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient)
|
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient)
|
||||||
go worksmobileRelayWorker.Start(context.Background())
|
go worksmobileRelayWorker.Start(context.Background())
|
||||||
slog.Info("✅ Worksmobile Relay Worker started")
|
slog.Info("✅ Worksmobile Relay Worker started")
|
||||||
|
rpUsageEmitter := service.NewRPUsageEventEmitter(rpUsageOutboxRepo)
|
||||||
|
if rpUsageProjectionRepo != nil {
|
||||||
|
rpUsageProjectorWorker := service.NewRPUsageProjectorWorker(rpUsageOutboxRepo, rpUsageProjectionRepo)
|
||||||
|
go rpUsageProjectorWorker.Start(context.Background())
|
||||||
|
slog.Info("✅ RP Usage Projector Worker started")
|
||||||
|
} else {
|
||||||
|
slog.Warn("RP Usage Projector Worker skipped because ClickHouse is unavailable")
|
||||||
|
}
|
||||||
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
|
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
|
||||||
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||||
@@ -342,7 +355,12 @@ func main() {
|
|||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||||
authHandler.HeadlessJWKS = headlessJWKSCache
|
authHandler.HeadlessJWKS = headlessJWKSCache
|
||||||
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||||
|
authHandler.RPUsageSink = rpUsageEmitter
|
||||||
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
|
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
|
||||||
|
adminHandler.RPUsageQueries = rpUsageQueryRepo
|
||||||
|
adminHandler.TenantRepo = tenantRepo
|
||||||
|
adminHandler.Hydra = hydraService
|
||||||
|
adminHandler.AuditRepo = auditRepo
|
||||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
|
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
|
||||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||||
devHandler.AuditRepo = auditRepo
|
devHandler.AuditRepo = auditRepo
|
||||||
@@ -674,6 +692,7 @@ func main() {
|
|||||||
|
|
||||||
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
||||||
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
||||||
|
admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily)
|
||||||
|
|
||||||
// Tenant Management (Mixed roles, handler filters results)
|
// Tenant Management (Mixed roles, handler filters results)
|
||||||
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
|
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func migrateSchemas(db *gorm.DB) error {
|
|||||||
&domain.ClientSecret{},
|
&domain.ClientSecret{},
|
||||||
&domain.ClientConsent{},
|
&domain.ClientConsent{},
|
||||||
&domain.KetoOutbox{},
|
&domain.KetoOutbox{},
|
||||||
|
&domain.RPUsageEvent{},
|
||||||
&domain.WorksmobileOutbox{},
|
&domain.WorksmobileOutbox{},
|
||||||
&domain.WorksmobileResourceMapping{},
|
&domain.WorksmobileResourceMapping{},
|
||||||
&domain.SharedLink{},
|
&domain.SharedLink{},
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type AuditRepository interface {
|
|||||||
Create(log *AuditLog) error
|
Create(log *AuditLog) error
|
||||||
FindPage(ctx context.Context, limit int, cursor *AuditCursor, tenantID string) ([]AuditLog, error)
|
FindPage(ctx context.Context, limit int, cursor *AuditCursor, tenantID string) ([]AuditLog, error)
|
||||||
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
|
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
|
||||||
|
CountEventsSince(ctx context.Context, since time.Time) (int64, error)
|
||||||
CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
|
CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
|
||||||
CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
|
CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
|
||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
|
|||||||
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
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
|
"context"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type adminHydraClientLister interface {
|
||||||
|
ListClients(ctx context.Context, limit, offset int) ([]domain.HydraClient, error)
|
||||||
|
}
|
||||||
|
|
||||||
type AdminHandler struct {
|
type AdminHandler struct {
|
||||||
Keto service.KetoService
|
Keto service.KetoService
|
||||||
KetoOutbox repository.KetoOutboxRepository
|
KetoOutbox repository.KetoOutboxRepository
|
||||||
|
RPUsageQueries domain.RPUsageQueryRepository
|
||||||
|
TenantRepo repository.TenantRepository
|
||||||
|
Hydra adminHydraClientLister
|
||||||
|
AuditRepo domain.AuditRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler {
|
func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler {
|
||||||
@@ -21,6 +33,76 @@ func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) GetRPUsageDaily(c *fiber.Ctx) error {
|
||||||
|
if h == nil || h.RPUsageQueries == nil {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
||||||
|
"error": "rp usage query service unavailable",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
days := 14
|
||||||
|
if raw := c.Query("days"); raw != "" {
|
||||||
|
if parsed, err := strconv.Atoi(raw); err == nil {
|
||||||
|
days = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
period := normalizeRPUsagePeriod(c.Query("period"))
|
||||||
|
tenantID, allowed := h.authorizedRPUsageTenantID(c, strings.TrimSpace(c.Query("tenantId")))
|
||||||
|
if !allowed {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"error": "forbidden: tenant rp usage stats permission denied",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
items, err := h.RPUsageQueries.FindRPUsage(c.Context(), domain.RPUsageQuery{
|
||||||
|
Days: days,
|
||||||
|
Period: period,
|
||||||
|
TenantID: tenantID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"items": items,
|
||||||
|
"days": days,
|
||||||
|
"period": period,
|
||||||
|
"tenantId": tenantID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRPUsagePeriod(period string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(period)) {
|
||||||
|
case "week":
|
||||||
|
return "week"
|
||||||
|
case "month":
|
||||||
|
return "month"
|
||||||
|
default:
|
||||||
|
return "day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) authorizedRPUsageTenantID(c *fiber.Ctx, requestedTenantID string) (string, bool) {
|
||||||
|
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin {
|
||||||
|
return requestedTenantID, true
|
||||||
|
}
|
||||||
|
tenantID := requestedTenantID
|
||||||
|
if tenantID == "" && profile != nil && profile.TenantID != nil {
|
||||||
|
tenantID = strings.TrimSpace(*profile.TenantID)
|
||||||
|
}
|
||||||
|
if tenantID == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if h == nil || h.Keto == nil || profile == nil || strings.TrimSpace(profile.ID) == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+profile.ID, "Tenant", tenantID, "view_rp_usage_stats")
|
||||||
|
if err != nil || !allowed {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return tenantID, true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
||||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
||||||
}
|
}
|
||||||
@@ -29,10 +111,14 @@ func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
|||||||
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
||||||
var m runtime.MemStats
|
var m runtime.MemStats
|
||||||
runtime.ReadMemStats(&m)
|
runtime.ReadMemStats(&m)
|
||||||
|
ctx := c.Context()
|
||||||
|
|
||||||
stats := fiber.Map{
|
stats := fiber.Map{
|
||||||
"goroutines": runtime.NumGoroutine(),
|
"totalTenants": h.countTenants(ctx),
|
||||||
"cpus": runtime.NumCPU(),
|
"oidcClients": h.countOIDCClients(ctx),
|
||||||
|
"auditEvents24h": h.countAuditEventsSince(ctx, time.Now().UTC().Add(-24*time.Hour)),
|
||||||
|
"goroutines": runtime.NumGoroutine(),
|
||||||
|
"cpus": runtime.NumCPU(),
|
||||||
"memory": fiber.Map{
|
"memory": fiber.Map{
|
||||||
"alloc": m.Alloc,
|
"alloc": m.Alloc,
|
||||||
"totalAlign": m.TotalAlloc,
|
"totalAlign": m.TotalAlloc,
|
||||||
@@ -44,3 +130,59 @@ func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.Status(fiber.StatusOK).JSON(stats)
|
return c.Status(fiber.StatusOK).JSON(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) countTenants(ctx context.Context) int64 {
|
||||||
|
if h == nil || h.TenantRepo == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
_, total, err := h.TenantRepo.List(ctx, 1, 0, "")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 {
|
||||||
|
if h == nil || h.Hydra == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const pageSize = 500
|
||||||
|
var total int64
|
||||||
|
for offset := 0; ; offset += pageSize {
|
||||||
|
clients, err := h.Hydra.ListClients(ctx, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
for _, client := range clients {
|
||||||
|
if isHiddenSystemClient(client) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
if len(clients) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) countAuditEventsSince(ctx context.Context, since time.Time) int64 {
|
||||||
|
if h == nil || h.AuditRepo == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
count, err := h.AuditRepo.CountEventsSince(ctx, since)
|
||||||
|
if err == nil && count > 0 {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
logs, pageErr := h.AuditRepo.FindPage(ctx, 10000, nil, "")
|
||||||
|
if pageErr != nil {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
var fallbackCount int64
|
||||||
|
for _, log := range logs {
|
||||||
|
if !log.Timestamp.Before(since) {
|
||||||
|
fallbackCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackCount
|
||||||
|
}
|
||||||
|
|||||||
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"
|
"github.com/go-jose/go-jose/v4"
|
||||||
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
josejwt "github.com/go-jose/go-jose/v4/jwt"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -101,6 +102,7 @@ type AuthHandler struct {
|
|||||||
UserRepo repository.UserRepository
|
UserRepo repository.UserRepository
|
||||||
ConsentRepo repository.ClientConsentRepository
|
ConsentRepo repository.ClientConsentRepository
|
||||||
RPUserMetadataRepo repository.RPUserMetadataRepository
|
RPUserMetadataRepo repository.RPUserMetadataRepository
|
||||||
|
RPUsageSink domain.RPUsageEventSink
|
||||||
}
|
}
|
||||||
|
|
||||||
type signupState struct {
|
type signupState struct {
|
||||||
@@ -245,6 +247,92 @@ func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.Iden
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) emitRPUsageAuthorizationGranted(c *fiber.Ctx, consentRequest *domain.HydraConsentRequest, profile *domain.UserProfileResponse, sessionID string, autoAccepted bool, correlationID string) error {
|
||||||
|
if consentRequest == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationGranted, consentRequest.Subject, consentRequest.Client, consentRequest.RequestedScope, profile, sessionID, "hydra_consent", correlationID, domain.JSONMap{
|
||||||
|
"auto_accepted": autoAccepted,
|
||||||
|
"scopes": consentRequest.RequestedScope,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) emitRPUsageAuthorizationRevoked(c *fiber.Ctx, subject string, clientID string, profile *domain.UserProfileResponse, sessionID string) error {
|
||||||
|
return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationRevoked, subject, domain.HydraClient{ClientID: clientID}, nil, profile, sessionID, "hydra_consent", clientID, domain.JSONMap{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) emitRPUsageEvent(c *fiber.Ctx, eventType string, subject string, client domain.HydraClient, scopes []string, profile *domain.UserProfileResponse, sessionID string, source string, correlationID string, payload domain.JSONMap) error {
|
||||||
|
if h.RPUsageSink == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clientID := strings.TrimSpace(client.ClientID)
|
||||||
|
if clientID == "" || strings.TrimSpace(subject) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tenantID, tenantType := rpUsageTenantFromProfile(profile)
|
||||||
|
event := domain.RPUsageEvent{
|
||||||
|
EventType: eventType,
|
||||||
|
Subject: strings.TrimSpace(subject),
|
||||||
|
TenantID: tenantID,
|
||||||
|
TenantType: tenantType,
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientName: strings.TrimSpace(client.ClientName),
|
||||||
|
SessionID: strings.TrimSpace(sessionID),
|
||||||
|
Scopes: pq.StringArray(scopes),
|
||||||
|
Source: source,
|
||||||
|
CorrelationID: strings.TrimSpace(correlationID),
|
||||||
|
Payload: payload,
|
||||||
|
OccurredAt: time.Now(),
|
||||||
|
}
|
||||||
|
if event.Payload == nil {
|
||||||
|
event.Payload = domain.JSONMap{}
|
||||||
|
}
|
||||||
|
if event.ClientName != "" {
|
||||||
|
event.Payload["client_name"] = event.ClientName
|
||||||
|
}
|
||||||
|
if tenantID != "" {
|
||||||
|
event.Payload["tenant_id"] = tenantID
|
||||||
|
}
|
||||||
|
if tenantType != "" {
|
||||||
|
event.Payload["tenant_type"] = tenantType
|
||||||
|
}
|
||||||
|
if c != nil {
|
||||||
|
event.Payload["ip_address"] = c.IP()
|
||||||
|
event.Payload["user_agent"] = string(c.Request().Header.UserAgent())
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if c != nil && c.UserContext() != nil {
|
||||||
|
ctx = c.UserContext()
|
||||||
|
}
|
||||||
|
return h.RPUsageSink.EmitRPUsageEvent(ctx, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpUsageTenantFromProfile(profile *domain.UserProfileResponse) (string, string) {
|
||||||
|
if profile == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
tenantID := ""
|
||||||
|
if profile.SessionTenantID != nil {
|
||||||
|
tenantID = strings.TrimSpace(*profile.SessionTenantID)
|
||||||
|
}
|
||||||
|
if tenantID == "" && profile.TenantID != nil {
|
||||||
|
tenantID = strings.TrimSpace(*profile.TenantID)
|
||||||
|
}
|
||||||
|
tenantType := ""
|
||||||
|
if profile.Tenant != nil {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(profile.Tenant.Type)) {
|
||||||
|
case domain.TenantTypeCompany, domain.TenantTypeOrganization:
|
||||||
|
tenantType = strings.ToUpper(strings.TrimSpace(profile.Tenant.Type))
|
||||||
|
if tenantID == "" {
|
||||||
|
tenantID = strings.TrimSpace(profile.Tenant.ID)
|
||||||
|
}
|
||||||
|
case domain.TenantTypeUserGroup, domain.TenantTypePersonal:
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tenantID, tenantType
|
||||||
|
}
|
||||||
|
|
||||||
// --- Signup Flow Handlers ---
|
// --- Signup Flow Handlers ---
|
||||||
|
|
||||||
// CheckEmail - 이메일 사용 가능 여부를 확인합니다.
|
// CheckEmail - 이메일 사용 가능 여부를 확인합니다.
|
||||||
@@ -5323,6 +5411,12 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
|
|||||||
if err != nil || subject == "" {
|
if err != nil || subject == "" {
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
|
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
|
||||||
}
|
}
|
||||||
|
profile, profileErr := h.resolveCurrentProfile(c)
|
||||||
|
if (profileErr != nil || profile == nil) && subject != "" {
|
||||||
|
if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), subject); fallbackErr == nil {
|
||||||
|
profile = fallbackProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("RevokeLinkedRp called", "subject", subject, "client_id", clientID)
|
slog.Info("RevokeLinkedRp called", "subject", subject, "client_id", clientID)
|
||||||
|
|
||||||
@@ -5354,6 +5448,11 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := h.emitRPUsageAuthorizationRevoked(c, subject, clientID, profile, h.resolveCurrentSessionID(c)); err != nil {
|
||||||
|
slog.Error("failed to emit rp usage event for revoked consent", "error", err, "client_id", clientID, "subject", subject)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
|
||||||
|
}
|
||||||
|
|
||||||
h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "")
|
h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "")
|
||||||
|
|
||||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||||
@@ -5434,6 +5533,10 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|||||||
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||||
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
|
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil {
|
||||||
|
slog.Error("failed to emit rp usage event for local consent auto-accept", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
|
||||||
|
}
|
||||||
return c.JSON(acceptResp)
|
return c.JSON(acceptResp)
|
||||||
}
|
}
|
||||||
slog.Error("failed to force auto-accept based on local DB", "error", err)
|
slog.Error("failed to force auto-accept based on local DB", "error", err)
|
||||||
@@ -5516,6 +5619,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil {
|
||||||
|
slog.Error("failed to emit rp usage event for skip consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID, "session_id", currentSessionID)
|
slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID, "session_id", currentSessionID)
|
||||||
return c.JSON(acceptResp)
|
return c.JSON(acceptResp)
|
||||||
}
|
}
|
||||||
@@ -5705,6 +5813,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, false, req.ConsentChallenge); err != nil {
|
||||||
|
slog.Error("failed to emit rp usage event for accepted consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
|
||||||
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(acceptResp)
|
return c.JSON(acceptResp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
|
"baron-sso-backend/internal/utils"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -38,12 +39,14 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
|
|||||||
defer func() { http.DefaultClient = origDefault }()
|
defer func() { http.DefaultClient = origDefault }()
|
||||||
|
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
|
rpUsageSink := &mockRPUsageEventSink{}
|
||||||
h := &AuthHandler{
|
h := &AuthHandler{
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
HTTPClient: client,
|
HTTPClient: client,
|
||||||
},
|
},
|
||||||
AuditRepo: auditRepo,
|
AuditRepo: auditRepo,
|
||||||
|
RPUsageSink: rpUsageSink,
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp)
|
app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp)
|
||||||
@@ -54,6 +57,16 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
|
|||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Equal(t, 1, len(auditRepo.logs))
|
assert.Equal(t, 1, len(auditRepo.logs))
|
||||||
|
assert.Equal(t, "consent.revoked", auditRepo.logs[0].EventType)
|
||||||
|
assert.Equal(t, "user-123", auditRepo.logs[0].UserID)
|
||||||
|
assert.Equal(t, "success", auditRepo.logs[0].Status)
|
||||||
|
auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "app-1", auditDetails["client_id"])
|
||||||
|
assert.Equal(t, 1, len(rpUsageSink.events))
|
||||||
|
assert.Equal(t, domain.RPUsageEventTypeAuthorizationRevoked, rpUsageSink.events[0].EventType)
|
||||||
|
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
|
||||||
|
assert.Equal(t, "app-1", rpUsageSink.events[0].ClientID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) {
|
func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
|
"baron-sso-backend/internal/utils"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -305,6 +306,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
|||||||
defer func() { http.DefaultClient = origDefault }()
|
defer func() { http.DefaultClient = origDefault }()
|
||||||
|
|
||||||
consentRepo := &mockConsentRepo{}
|
consentRepo := &mockConsentRepo{}
|
||||||
|
rpUsageSink := &mockRPUsageEventSink{}
|
||||||
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
||||||
|
|
||||||
h := &AuthHandler{
|
h := &AuthHandler{
|
||||||
@@ -314,6 +316,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
|||||||
},
|
},
|
||||||
KratosAdmin: mockKratosAdmin,
|
KratosAdmin: mockKratosAdmin,
|
||||||
ConsentRepo: consentRepo,
|
ConsentRepo: consentRepo,
|
||||||
|
RPUsageSink: rpUsageSink,
|
||||||
}
|
}
|
||||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||||
ID: "user-123",
|
ID: "user-123",
|
||||||
@@ -332,6 +335,11 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
|
|||||||
var body map[string]interface{}
|
var body map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&body)
|
json.NewDecoder(resp.Body).Decode(&body)
|
||||||
assert.Equal(t, "http://rp/cb", body["redirectTo"])
|
assert.Equal(t, "http://rp/cb", body["redirectTo"])
|
||||||
|
assert.Equal(t, 1, len(rpUsageSink.events))
|
||||||
|
assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType)
|
||||||
|
assert.Equal(t, "client-app", rpUsageSink.events[0].ClientID)
|
||||||
|
assert.Equal(t, "challenge-skip", rpUsageSink.events[0].CorrelationID)
|
||||||
|
assert.Equal(t, true, rpUsageSink.events[0].Payload["auto_accepted"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptConsentRequest_Normal(t *testing.T) {
|
func TestAcceptConsentRequest_Normal(t *testing.T) {
|
||||||
@@ -370,6 +378,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
|
|||||||
|
|
||||||
auditRepo := &mockAuditRepo{}
|
auditRepo := &mockAuditRepo{}
|
||||||
consentRepo := &mockConsentRepo{}
|
consentRepo := &mockConsentRepo{}
|
||||||
|
rpUsageSink := &mockRPUsageEventSink{}
|
||||||
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
|
||||||
|
|
||||||
h := &AuthHandler{
|
h := &AuthHandler{
|
||||||
@@ -380,6 +389,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
|
|||||||
KratosAdmin: mockKratosAdmin,
|
KratosAdmin: mockKratosAdmin,
|
||||||
AuditRepo: auditRepo,
|
AuditRepo: auditRepo,
|
||||||
ConsentRepo: consentRepo,
|
ConsentRepo: consentRepo,
|
||||||
|
RPUsageSink: rpUsageSink,
|
||||||
}
|
}
|
||||||
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
|
||||||
ID: "user-123",
|
ID: "user-123",
|
||||||
@@ -402,6 +412,21 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
assert.Equal(t, 1, len(auditRepo.logs))
|
assert.Equal(t, 1, len(auditRepo.logs))
|
||||||
|
assert.Equal(t, "consent.granted", auditRepo.logs[0].EventType)
|
||||||
|
assert.Equal(t, "user-123", auditRepo.logs[0].UserID)
|
||||||
|
assert.Equal(t, "success", auditRepo.logs[0].Status)
|
||||||
|
auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "client-app", auditDetails["client_id"])
|
||||||
|
assert.Equal(t, "Test App", auditDetails["client_name"])
|
||||||
|
assert.Equal(t, []interface{}{"openid"}, auditDetails["scopes"])
|
||||||
|
assert.Equal(t, 1, len(rpUsageSink.events))
|
||||||
|
assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType)
|
||||||
|
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
|
||||||
|
assert.Equal(t, "client-app", rpUsageSink.events[0].ClientID)
|
||||||
|
assert.Equal(t, "Test App", rpUsageSink.events[0].ClientName)
|
||||||
|
assert.Equal(t, []string{"openid"}, []string(rpUsageSink.events[0].Scopes))
|
||||||
|
assert.Equal(t, "hydra_consent", rpUsageSink.events[0].Source)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
|
func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
|
||||||
|
|||||||
@@ -109,12 +109,29 @@ func (m *mockAuditRepo) CountFailuresSince(ctx context.Context, since time.Time,
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockAuditRepo) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil }
|
func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
type mockRPUsageEventSink struct {
|
||||||
|
events []domain.RPUsageEvent
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRPUsageEventSink) EmitRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
|
||||||
|
if m.err != nil {
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
m.events = append(m.events, event)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type mockOathkeeperRepo struct {
|
type mockOathkeeperRepo struct {
|
||||||
logs []domain.OathkeeperAccessLog
|
logs []domain.OathkeeperAccessLog
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ func (m *MockAuditRepository) CountFailuresSince(ctx context.Context, since time
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockAuditRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
func (m *MockAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,10 @@ func (r *recordingAuditRepository) CountFailuresSince(ctx context.Context, since
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *recordingAuditRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *recordingAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
func (r *recordingAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -77,9 +78,73 @@ func NewClickHouseRepository(host string, port int, user, password, db string) (
|
|||||||
return nil, fmt.Errorf("failed to alter table: %w", err)
|
return nil, fmt.Errorf("failed to alter table: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensureRPUsageTables(context.Background(), conn); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create rp usage tables: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &ClickHouseRepository{conn: conn}, nil
|
return &ClickHouseRepository{conn: conn}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureRPUsageTables(ctx context.Context, conn driver.Conn) error {
|
||||||
|
factQuery := `
|
||||||
|
CREATE TABLE IF NOT EXISTS rp_usage_events (
|
||||||
|
event_id String,
|
||||||
|
occurred_at DateTime64(3) DEFAULT now64(3),
|
||||||
|
event_type String,
|
||||||
|
subject String,
|
||||||
|
tenant_id String,
|
||||||
|
tenant_type String,
|
||||||
|
client_id String,
|
||||||
|
client_name String,
|
||||||
|
session_id String,
|
||||||
|
scopes Array(String),
|
||||||
|
source String,
|
||||||
|
correlation_id String,
|
||||||
|
payload String
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
ORDER BY (occurred_at, event_id)
|
||||||
|
`
|
||||||
|
if err := conn.Exec(ctx, factQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregateQuery := `
|
||||||
|
CREATE TABLE IF NOT EXISTS rp_usage_daily_aggregate (
|
||||||
|
event_date Date,
|
||||||
|
tenant_id String,
|
||||||
|
tenant_type String,
|
||||||
|
client_id String,
|
||||||
|
client_name String,
|
||||||
|
event_type String,
|
||||||
|
events_count AggregateFunction(count),
|
||||||
|
unique_subjects AggregateFunction(uniqExact, String)
|
||||||
|
) ENGINE = AggregatingMergeTree()
|
||||||
|
ORDER BY (event_date, tenant_id, client_id, event_type)
|
||||||
|
`
|
||||||
|
if err := conn.Exec(ctx, aggregateQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
viewQuery := `
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS rp_usage_daily_aggregate_mv
|
||||||
|
TO rp_usage_daily_aggregate
|
||||||
|
AS
|
||||||
|
SELECT
|
||||||
|
toDate(occurred_at) AS event_date,
|
||||||
|
tenant_id,
|
||||||
|
tenant_type,
|
||||||
|
client_id,
|
||||||
|
any(client_name) AS client_name,
|
||||||
|
event_type,
|
||||||
|
countState() AS events_count,
|
||||||
|
uniqExactState(subject) AS unique_subjects
|
||||||
|
FROM rp_usage_events
|
||||||
|
WHERE tenant_type IN ('COMPANY', 'ORGANIZATION')
|
||||||
|
GROUP BY event_date, tenant_id, tenant_type, client_id, event_type
|
||||||
|
`
|
||||||
|
return conn.Exec(ctx, viewQuery)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ClickHouseRepository) Create(log *domain.AuditLog) error {
|
func (r *ClickHouseRepository) Create(log *domain.AuditLog) error {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -106,6 +171,125 @@ func (r *ClickHouseRepository) Create(log *domain.AuditLog) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ClickHouseRepository) CreateRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
|
||||||
|
if r == nil || r.conn == nil {
|
||||||
|
return fmt.Errorf("clickhouse connection is nil")
|
||||||
|
}
|
||||||
|
if event.OccurredAt.IsZero() {
|
||||||
|
event.OccurredAt = time.Now()
|
||||||
|
}
|
||||||
|
payloadBytes, err := json.Marshal(event.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal rp usage payload: %w", err)
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
INSERT INTO rp_usage_events (
|
||||||
|
event_id, occurred_at, event_type, subject, tenant_id, tenant_type,
|
||||||
|
client_id, client_name, session_id, scopes, source, correlation_id, payload
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
return r.conn.Exec(ctx, query,
|
||||||
|
event.ID,
|
||||||
|
event.OccurredAt,
|
||||||
|
event.EventType,
|
||||||
|
event.Subject,
|
||||||
|
event.TenantID,
|
||||||
|
event.TenantType,
|
||||||
|
event.ClientID,
|
||||||
|
event.ClientName,
|
||||||
|
event.SessionID,
|
||||||
|
[]string(event.Scopes),
|
||||||
|
event.Source,
|
||||||
|
event.CorrelationID,
|
||||||
|
string(payloadBytes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClickHouseRepository) FindRPUsage(ctx context.Context, rpQuery domain.RPUsageQuery) ([]domain.RPUsageDailyMetric, error) {
|
||||||
|
if r == nil || r.conn == nil {
|
||||||
|
return nil, fmt.Errorf("clickhouse connection is nil")
|
||||||
|
}
|
||||||
|
days := rpQuery.Days
|
||||||
|
if days <= 0 || days > 90 {
|
||||||
|
days = 14
|
||||||
|
}
|
||||||
|
periodExpr := "event_date"
|
||||||
|
switch rpQuery.Period {
|
||||||
|
case "week":
|
||||||
|
periodExpr = "toMonday(event_date)"
|
||||||
|
case "month":
|
||||||
|
periodExpr = "toStartOfMonth(event_date)"
|
||||||
|
case "day", "":
|
||||||
|
periodExpr = "event_date"
|
||||||
|
default:
|
||||||
|
periodExpr = "event_date"
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
date,
|
||||||
|
tenant_id,
|
||||||
|
tenant_type,
|
||||||
|
client_id,
|
||||||
|
any(client_name) AS client_name,
|
||||||
|
sumIf(events, event_type = ?) AS login_requests,
|
||||||
|
sumIf(events, event_type != ?) AS other_requests,
|
||||||
|
max(unique_subjects) AS unique_subjects
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
toString(%s) AS date,
|
||||||
|
tenant_id,
|
||||||
|
tenant_type,
|
||||||
|
client_id,
|
||||||
|
any(client_name) AS client_name,
|
||||||
|
event_type,
|
||||||
|
countMerge(events_count) AS events,
|
||||||
|
uniqExactMerge(unique_subjects) AS unique_subjects
|
||||||
|
FROM rp_usage_daily_aggregate
|
||||||
|
WHERE event_date >= today() - ?
|
||||||
|
AND tenant_type IN ('COMPANY', 'ORGANIZATION')
|
||||||
|
`, periodExpr)
|
||||||
|
args := []any{domain.RPUsageEventTypeAuthorizationGranted, domain.RPUsageEventTypeAuthorizationGranted, days - 1}
|
||||||
|
if rpQuery.TenantID != "" {
|
||||||
|
query += " AND tenant_id = ?\n"
|
||||||
|
args = append(args, rpQuery.TenantID)
|
||||||
|
}
|
||||||
|
query += fmt.Sprintf(`
|
||||||
|
GROUP BY %s, tenant_id, tenant_type, client_id, event_type
|
||||||
|
)
|
||||||
|
GROUP BY date, tenant_id, tenant_type, client_id
|
||||||
|
ORDER BY date ASC, tenant_id ASC, client_id ASC
|
||||||
|
`, periodExpr)
|
||||||
|
rows, err := r.conn.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query rp usage daily aggregate: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
metrics := make([]domain.RPUsageDailyMetric, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var metric domain.RPUsageDailyMetric
|
||||||
|
if err := rows.Scan(
|
||||||
|
&metric.Date,
|
||||||
|
&metric.TenantID,
|
||||||
|
&metric.TenantType,
|
||||||
|
&metric.ClientID,
|
||||||
|
&metric.ClientName,
|
||||||
|
&metric.LoginRequests,
|
||||||
|
&metric.OtherRequests,
|
||||||
|
&metric.UniqueSubjects,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan rp usage daily aggregate: %w", err)
|
||||||
|
}
|
||||||
|
if metric.ClientName == "" {
|
||||||
|
metric.ClientName = metric.ClientID
|
||||||
|
}
|
||||||
|
metrics = append(metrics, metric)
|
||||||
|
}
|
||||||
|
return metrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ClickHouseRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor, tenantID string) ([]domain.AuditLog, error) {
|
func (r *ClickHouseRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor, tenantID string) ([]domain.AuditLog, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
@@ -228,6 +412,21 @@ func (r *ClickHouseRepository) CountFailuresSince(ctx context.Context, since tim
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ClickHouseRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
|
||||||
|
sinceUTC := since.UTC().Format("2006-01-02 15:04:05")
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT count()
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE timestamp >= toDateTime('%s')
|
||||||
|
`, sinceUTC)
|
||||||
|
var count int64
|
||||||
|
err := r.conn.QueryRow(ctx, query).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count audit events: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ClickHouseRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
func (r *ClickHouseRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
|
||||||
// We use uniqExact(session_id) to count unique sessions that had success events recently.
|
// We use uniqExact(session_id) to count unique sessions that had success events recently.
|
||||||
query := `
|
query := `
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-migrate
|
// Auto-migrate
|
||||||
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{})
|
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to migrate database: %s", err)
|
log.Fatalf("failed to migrate database: %s", err)
|
||||||
}
|
}
|
||||||
|
|||||||
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"]
|
inputs = ["oathkeeper_file"]
|
||||||
source = '''
|
source = '''
|
||||||
raw = to_string(.message) ?? ""
|
raw = to_string(.message) ?? ""
|
||||||
parsed = parse_json(raw) ?? {}
|
parsed = object!(parse_json(raw) ?? {})
|
||||||
request_method = to_string(get(parsed, ["request", "method"]) ?? "") ?? ""
|
request_method = to_string(get(parsed, ["request", "method"]) ?? "") ?? ""
|
||||||
|
if request_method == "" { request_method = to_string(get(parsed, ["http_request", "method"]) ?? "") ?? "" }
|
||||||
request_path = to_string(get(parsed, ["request", "path"]) ?? "") ?? ""
|
request_path = to_string(get(parsed, ["request", "path"]) ?? "") ?? ""
|
||||||
|
if request_path == "" { request_path = to_string(get(parsed, ["http_request", "path"]) ?? "") ?? "" }
|
||||||
request_url = to_string(get(parsed, ["request", "url"]) ?? "") ?? ""
|
request_url = to_string(get(parsed, ["request", "url"]) ?? "") ?? ""
|
||||||
|
if request_url == "" { request_url = to_string(get(parsed, ["http_url"]) ?? "") ?? "" }
|
||||||
request_host = to_string(get(parsed, ["request", "host"]) ?? "") ?? ""
|
request_host = to_string(get(parsed, ["request", "host"]) ?? "") ?? ""
|
||||||
|
if request_host == "" { request_host = to_string(get(parsed, ["http_request", "host"]) ?? "") ?? "" }
|
||||||
request_scheme = to_string(get(parsed, ["request", "scheme"]) ?? "") ?? ""
|
request_scheme = to_string(get(parsed, ["request", "scheme"]) ?? "") ?? ""
|
||||||
|
if request_scheme == "" { request_scheme = to_string(get(parsed, ["http_request", "scheme"]) ?? "") ?? "" }
|
||||||
request_query = to_string(get(parsed, ["request", "query"]) ?? "") ?? ""
|
request_query = to_string(get(parsed, ["request", "query"]) ?? "") ?? ""
|
||||||
response_status = get(parsed, ["response", "status"]) ?? 0
|
if request_query == "" { request_query = to_string(get(parsed, ["http_request", "query"]) ?? "") ?? "" }
|
||||||
|
response_status = to_int(get(parsed, ["response", "status"]) ?? 0) ?? 0
|
||||||
|
if response_status == 0 { response_status = to_int(get(parsed, ["http_response", "status"]) ?? 0) ?? 0 }
|
||||||
|
response_size = to_int(get(parsed, ["response", "size"]) ?? 0) ?? 0
|
||||||
|
if response_size == 0 { response_size = to_int(get(parsed, ["http_response", "size"]) ?? 0) ?? 0 }
|
||||||
|
response_took = to_int(get(parsed, ["response", "took"]) ?? 0) ?? 0
|
||||||
|
if response_took == 0 { response_took = to_int(get(parsed, ["http_response", "took"]) ?? 0) ?? 0 }
|
||||||
identity_id = to_string(get(parsed, ["identity", "id"]) ?? "") ?? ""
|
identity_id = to_string(get(parsed, ["identity", "id"]) ?? "") ?? ""
|
||||||
headers = get(parsed, ["headers"]) ?? {}
|
if identity_id == "" { identity_id = to_string(get(parsed, ["subject"]) ?? "") ?? "" }
|
||||||
|
headers = object(get(parsed, ["headers"]) ?? {}) ?? {}
|
||||||
|
if length(headers) == 0 { headers = object(get(parsed, ["http_request", "headers"]) ?? {}) ?? {} }
|
||||||
user_agent = to_string(get(headers, ["User-Agent"]) ?? "") ?? ""
|
user_agent = to_string(get(headers, ["User-Agent"]) ?? "") ?? ""
|
||||||
|
if user_agent == "" { user_agent = to_string(get(headers, ["user-agent"]) ?? "") ?? "" }
|
||||||
referer = to_string(get(headers, ["Referer"]) ?? "") ?? ""
|
referer = to_string(get(headers, ["Referer"]) ?? "") ?? ""
|
||||||
|
if referer == "" { referer = to_string(get(headers, ["referer"]) ?? "") ?? "" }
|
||||||
rule_id = to_string(get(parsed, ["rule", "id"]) ?? "") ?? ""
|
rule_id = to_string(get(parsed, ["rule", "id"]) ?? "") ?? ""
|
||||||
|
if rule_id == "" { rule_id = to_string(get(parsed, ["rule_id"]) ?? "") ?? "" }
|
||||||
upstream_url = to_string(get(parsed, ["upstream", "url"]) ?? "") ?? ""
|
upstream_url = to_string(get(parsed, ["upstream", "url"]) ?? "") ?? ""
|
||||||
|
if upstream_url == "" { upstream_url = to_string(get(parsed, ["http_url"]) ?? "") ?? "" }
|
||||||
client_id = to_string(get(parsed, ["client", "id"]) ?? "") ?? ""
|
client_id = to_string(get(parsed, ["client", "id"]) ?? "") ?? ""
|
||||||
parent_session_id = to_string(get(parsed, ["extra", "parent_session_id"]) ?? "") ?? ""
|
parent_session_id = to_string(get(parsed, ["extra", "parent_session_id"]) ?? "") ?? ""
|
||||||
parsed_url = parse_url(request_url) ?? {}
|
parsed_url = parse_url(request_url) ?? {}
|
||||||
query_params = get(parsed_url, ["query"]) ?? {}
|
query_params = get(parsed_url, ["query"]) ?? {}
|
||||||
|
url_path = to_string(get(parsed_url, ["path"]) ?? "") ?? ""
|
||||||
|
parsed_request_query = parse_url("http://localhost/?" + request_query) ?? {}
|
||||||
|
request_query_params = get(parsed_request_query, ["query"]) ?? {}
|
||||||
event_path = to_string(parsed.path) ?? to_string(parsed.http_path) ?? ""
|
event_path = to_string(parsed.path) ?? to_string(parsed.http_path) ?? ""
|
||||||
if event_path == "" { event_path = request_path }
|
if event_path == "" { event_path = request_path }
|
||||||
|
if event_path == "" { event_path = url_path }
|
||||||
if event_path == "" { event_path = request_url }
|
if event_path == "" { event_path = request_url }
|
||||||
event_client_id = to_string(parsed.client_id) ?? ""
|
event_client_id = to_string(parsed.client_id) ?? ""
|
||||||
if event_client_id == "" { event_client_id = client_id }
|
if event_client_id == "" { event_client_id = client_id }
|
||||||
if event_client_id == "" { event_client_id = to_string(get(query_params, ["client_id"]) ?? "") ?? "" }
|
if event_client_id == "" { event_client_id = to_string(get(query_params, ["client_id"]) ?? "") ?? "" }
|
||||||
if event_client_id == "" { event_client_id = to_string(get(query_params, ["clientId"]) ?? "") ?? "" }
|
if event_client_id == "" { event_client_id = to_string(get(query_params, ["clientId"]) ?? "") ?? "" }
|
||||||
|
if event_client_id == "" { event_client_id = to_string(get(request_query_params, ["client_id"]) ?? "") ?? "" }
|
||||||
|
if event_client_id == "" { event_client_id = to_string(get(request_query_params, ["clientId"]) ?? "") ?? "" }
|
||||||
|
event_latency_ms = to_int(parsed.latency_ms) ?? to_int(parsed.duration_ms) ?? 0
|
||||||
|
if event_latency_ms == 0 && response_took != 0 {
|
||||||
|
event_latency_ms = to_int(to_float(response_took) / 1000000.0)
|
||||||
|
}
|
||||||
|
event_client_ip = to_string(parsed.client_ip) ?? to_string(parsed.remote_ip) ?? to_string(parsed.ip) ?? ""
|
||||||
|
if event_client_ip == "" { event_client_ip = to_string(get(headers, ["X-Real-Ip"]) ?? "") ?? "" }
|
||||||
|
if event_client_ip == "" { event_client_ip = to_string(get(headers, ["x-real-ip"]) ?? "") ?? "" }
|
||||||
|
if event_client_ip == "" { event_client_ip = to_string(get(headers, ["X-Forwarded-For"]) ?? "") ?? "" }
|
||||||
|
if event_client_ip == "" { event_client_ip = to_string(get(headers, ["x-forwarded-for"]) ?? "") ?? "" }
|
||||||
|
event_decision = to_string(parsed.decision) ?? to_string(parsed.result) ?? ""
|
||||||
|
if event_decision == "" && exists(parsed.granted) {
|
||||||
|
if parsed.granted == true {
|
||||||
|
event_decision = "granted"
|
||||||
|
} else {
|
||||||
|
event_decision = "denied"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event_status = to_int(get(parsed, ["status"]) ?? 0) ?? 0
|
||||||
|
if event_status == 0 { event_status = to_int(get(parsed, ["status_code"]) ?? 0) ?? 0 }
|
||||||
|
if event_status == 0 { event_status = response_status }
|
||||||
|
event_bytes_out = to_int(get(parsed, ["bytes_out"]) ?? 0) ?? 0
|
||||||
|
if event_bytes_out == 0 { event_bytes_out = to_int(get(parsed, ["response_bytes"]) ?? 0) ?? 0 }
|
||||||
|
if event_bytes_out == 0 { event_bytes_out = response_size }
|
||||||
|
event_method = to_string(get(parsed, ["method"]) ?? "") ?? ""
|
||||||
|
if event_method == "" { event_method = to_string(get(parsed, ["http_method"]) ?? "") ?? "" }
|
||||||
|
if event_method == "" { event_method = request_method }
|
||||||
|
event_host = to_string(get(parsed, ["host"]) ?? "") ?? ""
|
||||||
|
if event_host == "" { event_host = to_string(get(parsed, ["http_host"]) ?? "") ?? "" }
|
||||||
|
if event_host == "" { event_host = request_host }
|
||||||
|
event_scheme = to_string(get(parsed, ["scheme"]) ?? "") ?? ""
|
||||||
|
if event_scheme == "" { event_scheme = request_scheme }
|
||||||
|
event_query = to_string(get(parsed, ["query"]) ?? "") ?? ""
|
||||||
|
if event_query == "" { event_query = request_query }
|
||||||
|
event_user_agent = to_string(get(parsed, ["user_agent"]) ?? "") ?? ""
|
||||||
|
if event_user_agent == "" { event_user_agent = to_string(get(parsed, ["http_user_agent"]) ?? "") ?? "" }
|
||||||
|
if event_user_agent == "" { event_user_agent = user_agent }
|
||||||
|
|
||||||
. = {
|
. = {
|
||||||
"request_id": to_string(parsed.request_id) ?? to_string(parsed.req_id) ?? "",
|
"request_id": to_string(parsed.request_id) ?? to_string(parsed.req_id) ?? "",
|
||||||
"method": to_string(parsed.method) ?? to_string(parsed.http_method) ?? request_method,
|
"method": event_method,
|
||||||
"path": event_path,
|
"path": event_path,
|
||||||
"status": to_int(parsed.status) ?? to_int(parsed.status_code) ?? to_int(response_status) ?? 0,
|
"status": event_status,
|
||||||
"latency_ms": to_int(parsed.latency_ms) ?? to_int(parsed.duration_ms) ?? to_int(parsed.took) ?? 0,
|
"latency_ms": event_latency_ms,
|
||||||
"client_id": event_client_id,
|
"client_id": event_client_id,
|
||||||
"rp": to_string(parsed.rp) ?? "",
|
"rp": to_string(parsed.rp) ?? "",
|
||||||
"action": to_string(parsed.action) ?? "",
|
"action": to_string(parsed.action) ?? "",
|
||||||
"target": to_string(parsed.target) ?? "",
|
"target": to_string(parsed.target) ?? "",
|
||||||
"rule_id": to_string(parsed.rule_id) ?? rule_id,
|
"rule_id": to_string(parsed.rule_id) ?? rule_id,
|
||||||
"host": to_string(parsed.host) ?? request_host,
|
"host": event_host,
|
||||||
"scheme": to_string(parsed.scheme) ?? request_scheme,
|
"scheme": event_scheme,
|
||||||
"query": to_string(parsed.query) ?? request_query,
|
"query": event_query,
|
||||||
"upstream_url": to_string(parsed.upstream_url) ?? upstream_url,
|
"upstream_url": to_string(parsed.upstream_url) ?? upstream_url,
|
||||||
"subject": to_string(parsed.subject) ?? identity_id,
|
"subject": to_string(parsed.subject) ?? identity_id,
|
||||||
"parent_session_id": to_string(parsed.parent_session_id) ?? parent_session_id,
|
"parent_session_id": to_string(parsed.parent_session_id) ?? parent_session_id,
|
||||||
"client_ip": to_string(parsed.client_ip) ?? to_string(parsed.remote_ip) ?? to_string(parsed.ip) ?? "",
|
"client_ip": event_client_ip,
|
||||||
"user_agent": to_string(parsed.user_agent) ?? user_agent,
|
"user_agent": event_user_agent,
|
||||||
"referer": referer,
|
"referer": referer,
|
||||||
"decision": to_string(parsed.decision) ?? to_string(parsed.result) ?? "",
|
"decision": event_decision,
|
||||||
"bytes_in": to_int(parsed.bytes_in) ?? to_int(parsed.request_bytes) ?? 0,
|
"bytes_in": to_int(parsed.bytes_in) ?? to_int(parsed.request_bytes) ?? 0,
|
||||||
"bytes_out": to_int(parsed.bytes_out) ?? to_int(parsed.response_bytes) ?? 0,
|
"bytes_out": event_bytes_out,
|
||||||
"trace_id": to_string(parsed.trace_id) ?? "",
|
"trace_id": to_string(parsed.trace_id) ?? "",
|
||||||
"span_id": to_string(parsed.span_id) ?? "",
|
"span_id": to_string(parsed.span_id) ?? "",
|
||||||
"raw": raw
|
"raw": raw
|
||||||
@@ -73,3 +132,52 @@
|
|||||||
auth.strategy = "basic"
|
auth.strategy = "basic"
|
||||||
auth.user = "${ORY_CLICKHOUSE_USER}"
|
auth.user = "${ORY_CLICKHOUSE_USER}"
|
||||||
auth.password = "${ORY_CLICKHOUSE_PASSWORD}"
|
auth.password = "${ORY_CLICKHOUSE_PASSWORD}"
|
||||||
|
|
||||||
|
[[tests]]
|
||||||
|
name = "parses_oathkeeper_v26_completed_request"
|
||||||
|
|
||||||
|
[[tests.inputs]]
|
||||||
|
insert_at = "oathkeeper_parse"
|
||||||
|
type = "log"
|
||||||
|
|
||||||
|
[tests.inputs.log_fields]
|
||||||
|
message = '{"http_request":{"headers":{"user-agent":"Mozilla/5.0","referer":"http://localhost:5173/","x-real-ip":"172.19.0.1"},"host":"localhost","method":"GET","path":"/oauth2/auth","query":"client_id=orgfront&response_type=code","remote":"172.23.0.2:56744","scheme":"http"},"http_response":{"status":302,"size":1339,"took":4854092},"http_url":"http://hydra:4444/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback","level":"info","msg":"completed handling request","subject":"","time":"2026-05-06T01:40:51.46074548Z"}'
|
||||||
|
|
||||||
|
[[tests.outputs]]
|
||||||
|
extract_from = "oathkeeper_parse"
|
||||||
|
|
||||||
|
[[tests.outputs.conditions]]
|
||||||
|
type = "vrl"
|
||||||
|
source = '''
|
||||||
|
assert_eq!(.method, "GET")
|
||||||
|
assert_eq!(.path, "/oauth2/auth")
|
||||||
|
assert_eq!(.status, 302)
|
||||||
|
assert_eq!(.client_id, "orgfront")
|
||||||
|
assert_eq!(.host, "localhost")
|
||||||
|
assert_eq!(.scheme, "http")
|
||||||
|
assert_eq!(.user_agent, "Mozilla/5.0")
|
||||||
|
assert_eq!(.referer, "http://localhost:5173/")
|
||||||
|
'''
|
||||||
|
|
||||||
|
[[tests]]
|
||||||
|
name = "parses_oathkeeper_v26_granted_request"
|
||||||
|
|
||||||
|
[[tests.inputs]]
|
||||||
|
insert_at = "oathkeeper_parse"
|
||||||
|
type = "log"
|
||||||
|
|
||||||
|
[tests.inputs.log_fields]
|
||||||
|
message = '{"audience":"application","granted":true,"http_host":"hydra:4444","http_method":"GET","http_url":"http://hydra:4444/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback&response_type=code","http_user_agent":"curl/8.10.1","level":"info","msg":"Access request granted","service_name":"ORY Oathkeeper","service_version":"v26.2.0","subject":"","time":"2026-05-06T01:52:25.431Z"}'
|
||||||
|
|
||||||
|
[[tests.outputs]]
|
||||||
|
extract_from = "oathkeeper_parse"
|
||||||
|
|
||||||
|
[[tests.outputs.conditions]]
|
||||||
|
type = "vrl"
|
||||||
|
source = '''
|
||||||
|
assert_eq!(.method, "GET")
|
||||||
|
assert_eq!(.path, "/oauth2/auth")
|
||||||
|
assert_eq!(.status, 0)
|
||||||
|
assert_eq!(.client_id, "orgfront")
|
||||||
|
assert_eq!(.decision, "granted")
|
||||||
|
'''
|
||||||
|
|||||||
@@ -58,3 +58,66 @@ if (( after_rows <= before_rows )); then
|
|||||||
docker logs --tail 100 ory_vector >&2 || true
|
docker logs --tail 100 ory_vector >&2 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
before_auth_ts="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "SELECT now64(3)")"
|
||||||
|
auth_status="$(docker run --rm --network public_net curlimages/curl:8.10.1 \
|
||||||
|
-sS -o /dev/null -w '%{http_code}' \
|
||||||
|
'http://ory_oathkeeper:4455/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback&response_type=code&scope=openid&state=access-log-e2e&code_challenge=accessloge2e&code_challenge_method=S256')"
|
||||||
|
|
||||||
|
if [[ "$auth_status" != "302" ]]; then
|
||||||
|
echo "ERROR: expected Oathkeeper OIDC auth request to return 302, got: $auth_status" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
deadline=$((SECONDS + 30))
|
||||||
|
completed_rows=0
|
||||||
|
granted_rows=0
|
||||||
|
while (( SECONDS < deadline )); do
|
||||||
|
completed_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
|
||||||
|
SELECT count()
|
||||||
|
FROM ory.oathkeeper_access_logs
|
||||||
|
WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
|
||||||
|
AND method = 'GET'
|
||||||
|
AND path = '/oauth2/auth'
|
||||||
|
AND status = 302
|
||||||
|
")"
|
||||||
|
granted_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
|
||||||
|
SELECT count()
|
||||||
|
FROM ory.oathkeeper_access_logs
|
||||||
|
WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
|
||||||
|
AND method = 'GET'
|
||||||
|
AND path = '/oauth2/auth'
|
||||||
|
AND client_id = 'orgfront'
|
||||||
|
AND decision = 'granted'
|
||||||
|
")"
|
||||||
|
if (( completed_rows > 0 && granted_rows > 0 )); then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( completed_rows <= 0 )); then
|
||||||
|
echo "ERROR: Oathkeeper completed request log did not preserve method/path/status." >&2
|
||||||
|
docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
|
||||||
|
SELECT timestamp, method, path, status, client_id, decision
|
||||||
|
FROM ory.oathkeeper_access_logs
|
||||||
|
WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 20
|
||||||
|
FORMAT Vertical
|
||||||
|
" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( granted_rows <= 0 )); then
|
||||||
|
echo "ERROR: Oathkeeper granted request log did not preserve client_id." >&2
|
||||||
|
docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
|
||||||
|
SELECT timestamp, method, path, status, client_id, decision
|
||||||
|
FROM ory.oathkeeper_access_logs
|
||||||
|
WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 20
|
||||||
|
FORMAT Vertical
|
||||||
|
" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user