From 13dee9ae9b4585403c0e9691010852486210462b Mon Sep 17 00:00:00 2001 From: Lectom Date: Wed, 6 May 2026 16:14:52 +0900 Subject: [PATCH] =?UTF-8?q?adminfront=20=EA=B0=9C=EC=9A=94=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/features/auth/AuthPage.tsx | 118 +--- .../components/PermissionChecker.tsx | 14 +- .../overview/GlobalOverviewPage.test.tsx | 186 +++++ .../features/overview/GlobalOverviewPage.tsx | 635 +++++++++++++----- adminfront/src/lib/adminApi.ts | 50 ++ backend/cmd/server/main.go | 19 + backend/internal/bootstrap/bootstrap.go | 1 + backend/internal/domain/models.go | 1 + backend/internal/domain/rp_usage_event.go | 101 +++ backend/internal/handler/admin_handler.go | 150 ++++- .../internal/handler/admin_handler_test.go | 156 +++++ backend/internal/handler/auth_handler.go | 113 ++++ .../handler/auth_handler_client_test.go | 15 +- .../handler/auth_handler_consent_test.go | 25 + backend/internal/handler/common_test.go | 17 + .../middleware/audit_middleware_test.go | 8 + .../internal/repository/clickhouse_repo.go | 199 ++++++ backend/internal/repository/main_test.go | 2 +- .../repository/rp_usage_outbox_repository.go | 91 +++ .../service/rp_usage_event_emitter.go | 67 ++ .../service/rp_usage_event_emitter_test.go | 132 ++++ .../service/rp_usage_projector_worker.go | 82 +++ docker/ory/vector/vector.toml | 134 +++- test/oathkeeper_access_log_e2e_test.sh | 63 ++ 24 files changed, 2082 insertions(+), 297 deletions(-) rename adminfront/src/features/{overview => auth}/components/PermissionChecker.tsx (90%) create mode 100644 adminfront/src/features/overview/GlobalOverviewPage.test.tsx create mode 100644 backend/internal/domain/rp_usage_event.go create mode 100644 backend/internal/handler/admin_handler_test.go create mode 100644 backend/internal/repository/rp_usage_outbox_repository.go create mode 100644 backend/internal/service/rp_usage_event_emitter.go create mode 100644 backend/internal/service/rp_usage_event_emitter_test.go create mode 100644 backend/internal/service/rp_usage_projector_worker.go diff --git a/adminfront/src/features/auth/AuthPage.tsx b/adminfront/src/features/auth/AuthPage.tsx index 015bbb67..002c69e6 100644 --- a/adminfront/src/features/auth/AuthPage.tsx +++ b/adminfront/src/features/auth/AuthPage.tsx @@ -1,109 +1,25 @@ -import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react"; - -const flows = [ - { - title: "Admin login", - description: - "Enforce short TTL and step-up MFA. Keep admin session separate from app session.", - pill: "15m TTL", - }, - { - title: "Tenant pick", - description: - "Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.", - pill: "Header-ready", - }, - { - title: "Device approval", - description: - "If app session exists and user opts in, use push/deeplink approval as MFA replacement.", - pill: "App session", - }, -]; +import { KeyRound } from "lucide-react"; +import PermissionChecker from "./components/PermissionChecker"; function AuthPage() { return ( -
-
-
-
-

- Admin auth -

-

Admin auth guardrails

-

- Build the admin-only login flow first, keeping app login separate. - Respect the “fallback only when user chooses” rule for SMS/email - vs app approval. -

-
-
- - IDP session placeholder - - -
-
-
- -
- {flows.map((flow) => ( -
-
- {flow.pill} - -
-

{flow.title}

-

- {flow.description} -

-
- ))} -
- -
-
-
- - - App-based approvals - -
-

- App session as MFA replacement -

-

- 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. +

+
+
+

+ Admin auth +

+

+ + 인증가드 +

+

+ 관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다.

-
-
- - - TTL discipline - -
-

- Keep admin sessions short -

-

- Default admin TTL is 15 minutes. Show countdown and nudge re-auth - with step-up MFA when critical actions (rotate secret, export logs) - happen. -

-
-
+
+ + ); } diff --git a/adminfront/src/features/overview/components/PermissionChecker.tsx b/adminfront/src/features/auth/components/PermissionChecker.tsx similarity index 90% rename from adminfront/src/features/overview/components/PermissionChecker.tsx rename to adminfront/src/features/auth/components/PermissionChecker.tsx index 8b2e090b..d2965ffc 100644 --- a/adminfront/src/features/overview/components/PermissionChecker.tsx +++ b/adminfront/src/features/auth/components/PermissionChecker.tsx @@ -44,7 +44,7 @@ function PermissionChecker() { const result = checkMutation.data; return ( - + @@ -100,7 +100,7 @@ function PermissionChecker() { @@ -108,17 +108,17 @@ function PermissionChecker() { {checkMutation.isSuccess && result && (
{result.allowed ? ( <>
Access ALLOWED
-

+

해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 포함)

@@ -127,7 +127,7 @@ function PermissionChecker() { <>
Access DENIED
-

+

해당 사용자는 요청한 리소스에 대해 권한이 없습니다.

diff --git a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx new file mode 100644 index 00000000..fbf9f83a --- /dev/null +++ b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx @@ -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( + + {ui} + , + ); +} + +describe("admin overview and auth guard pages", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders usage trend chart without quick navigation or permission checker", async () => { + renderWithProviders(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index e3d3a14e..6f1be04f 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -1,33 +1,433 @@ +import { useQuery } from "@tanstack/react-query"; import { Activity, - ArrowUpRight, + BarChart3, Database, - Key, - PlusCircle, ShieldCheck, Users, } from "lucide-react"; -import { Link } from "react-router-dom"; +import { type ReactNode, useMemo, useState } from "react"; import { RoleGuard } from "../../components/auth/RoleGuard"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "../../components/ui/card"; + type RPUsageDailyMetric, + type RPUsagePeriod, + type TenantSummary, + fetchAdminOverviewStats, + fetchAdminRPUsageDaily, + fetchTenants, +} from "../../lib/adminApi"; import { t } from "../../lib/i18n"; -import PermissionChecker from "./components/PermissionChecker"; + +type DailyPoint = { + date: string; + loginRequests: number; + otherRequests: number; +}; + +type SeriesSummary = { + key: string; + tenantLabel: string; + clientLabel: string; + loginRequests: number; + otherRequests: number; + uniqueSubjects: number; +}; + +function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] { + const byDate = new Map(); + 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(); + 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 ( + + {icon} + {label} + {value} + + ); +} + +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 ( +
+
+
+ +

+ 회사별 앱별 로그인요청/기타 요청 현황 +

+
+ {filters} +
+ + {daily.length === 0 ? ( +
+ 표시할 RP 이용 집계가 없습니다. +
+ ) : ( +
+ + 일 단위 RP 요청 현황 + + + + 기타 요청 + + + + 로그인 요청 + + + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const gridY = padTop + innerHeight * ratio; + const label = Math.round(maxValue * (1 - ratio)); + return ( + + + + {label} + + + ); + })} + {daily.map((point, index) => { + const center = x(index); + const otherHeight = + (point.otherRequests / maxValue) * innerHeight; + return ( + + + + {formatPeriodLabel(point.date, period)} + + + ); + })} + + {daily.map((point, index) => ( + + ))} + +
+ )} + + {series.length > 0 && ( +
+ {series.map((item) => ( +
+ {item.clientLabel} + + {item.tenantLabel} + + + 로그인 {item.loginRequests.toLocaleString()} / 기타{" "} + {item.otherRequests.toLocaleString()} / 사용자{" "} + {item.uniqueSubjects.toLocaleString()} + +
+ ))} +
+ )} +
+ ); +} function GlobalOverviewPage() { + const [period, setPeriod] = useState("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 = ( +
+
+ {[ + ["day", "일"], + ["week", "주"], + ["month", "월"], + ].map(([value, label]) => ( + + ))} +
+ 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" + /> + +
+ ); + return ( -
+
-

+

{t("ui.admin.overview.title", "Dashboard")}

-

+

{t( "msg.admin.overview.description", "시스템 전반의 주요 현황을 확인하고 관리합니다.", @@ -36,166 +436,61 @@ function GlobalOverviewPage() {

-
+
- - - - {t("ui.admin.overview.summary.total_tenants", "총 테넌트")} - -
- -
-
- -
-
-

- 활성화된 테넌트 수 -

-
-
- - - - {t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")} - -
- -
-
- -
-
-

- 등록된 OIDC 앱 -

-
-
+ } + label={t( + "ui.admin.overview.summary.total_tenants", + "전체 테넌트 수", + )} + value={metric(stats?.totalTenants)} + /> + } + label={t( + "ui.admin.overview.summary.oidc_clients", + "OIDC 클라이언트", + )} + value={metric(stats?.oidcClients)} + />
- - - - - {t( - "ui.admin.overview.summary.audit_events_24h", - "최근 감사 로그 (24h)", - )} - -
- -
-
- -
-
-

- 발생한 이벤트 수 -

-
-
- - - - - {t("ui.admin.overview.summary.policy_gate", "정책 상태")} - -
- -
-
- -
- Active -
-

- 접근 제어 정상 동작 -

-
-
+ } + label={t( + "ui.admin.overview.summary.audit_events_24h", + "24시간 이벤트", + )} + value={metric(stats?.auditEvents24h)} + /> + } + label={t("ui.admin.overview.summary.policy_gate", "정책 상태")} + value="Active" + />
-
-

- {t("ui.admin.overview.quick_links.title", "빠른 작업")} -

-
- - -
- -
-
-

- 테넌트 추가 -

-

- 새로운 조직이나 그룹을 생성합니다. -

-
- -
- - -
- -
-
-

- 사용자 관리 -

-

- 전체 사용자를 조회하고 관리합니다. -

-
- - - - -
- -
-
-

- API 키 관리 -

-

- 시스템 연동을 위한 키를 발급합니다. -

-
- -
- - -
- -
-
-

- 감사 로그 -

-

- 보안 이벤트를 모니터링합니다. -

-
- -
-
- - -
- -
-
+ {usageQuery.isError ? ( +
+
+

+ 회사별 앱별 로그인요청/기타 요청 현황 +

+ {chartFilters} +
+
+ RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작 + 이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위 + 그래프가 표시됩니다. +
+
+ ) : ( + + )}
); } diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index d1e4b245..d7a8c274 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -101,6 +101,33 @@ export type RoleListResponse = { total: number; }; +export type RPUsageDailyMetric = { + date: string; + tenantId: string; + tenantType: string; + tenantName?: string; + clientId: string; + clientName: string; + loginRequests: number; + otherRequests: number; + uniqueSubjects: number; +}; + +export type RPUsagePeriod = "day" | "week" | "month"; + +export type RPUsageDailyResponse = { + items: RPUsageDailyMetric[]; + days: number; + period: RPUsagePeriod; + tenantId?: string; +}; + +export type AdminOverviewStats = { + totalTenants: number; + oidcClients: number; + auditEvents24h: number; +}; + export async function fetchAuditLogs(limit = 50, cursor?: string) { const { data } = await apiClient.get("/v1/audit", { params: { limit, cursor }, @@ -108,6 +135,29 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) { return data; } +export async function fetchAdminOverviewStats() { + const { data } = await apiClient.get("/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( + "/v1/admin/rp-usage/daily", + { + params: { days, period, tenantId: tenantId || undefined }, + }, + ); + return data; +} + export async function fetchTenants(limit = 50, offset = 0, parentId?: string) { const { data } = await apiClient.get( "/v1/admin/tenants", diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 39a99db8..3fb5f052 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -183,11 +183,15 @@ func main() { chDB := getEnv("CLICKHOUSE_DB", "baron_sso") var auditRepo domain.AuditRepository + var rpUsageProjectionRepo domain.RPUsageProjectionRepository + var rpUsageQueryRepo domain.RPUsageQueryRepository if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil { slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err) auditRepo = nil // Explicitly set to nil interface } else { auditRepo = repo + rpUsageProjectionRepo = repo + rpUsageQueryRepo = repo slog.Info("✅ Connected to ClickHouse") } @@ -297,6 +301,7 @@ func main() { userGroupRepo := repository.NewUserGroupRepository(db) userRepo := repository.NewUserRepository(db) ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init + rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db) worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db) sharedLinkRepo := repository.NewSharedLinkRepository(db) kratosAdminService := service.NewKratosAdminService() @@ -323,6 +328,14 @@ func main() { worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient) go worksmobileRelayWorker.Start(context.Background()) slog.Info("✅ Worksmobile Relay Worker started") + rpUsageEmitter := service.NewRPUsageEventEmitter(rpUsageOutboxRepo) + if rpUsageProjectionRepo != nil { + rpUsageProjectorWorker := service.NewRPUsageProjectorWorker(rpUsageOutboxRepo, rpUsageProjectionRepo) + go rpUsageProjectorWorker.Start(context.Background()) + slog.Info("✅ RP Usage Projector Worker started") + } else { + slog.Warn("RP Usage Projector Worker skipped because ClickHouse is unavailable") + } sharedLinkService := service.NewSharedLinkService(sharedLinkRepo) userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService) tenantService.SetKetoService(ketoService) // Keto 주입 @@ -342,7 +355,12 @@ func main() { authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) authHandler.HeadlessJWKS = headlessJWKSCache authHandler.RPUserMetadataRepo = rpUserMetadataRepo + authHandler.RPUsageSink = rpUsageEmitter adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo) + adminHandler.RPUsageQueries = rpUsageQueryRepo + adminHandler.TenantRepo = tenantRepo + adminHandler.Hydra = hydraService + adminHandler.AuditRepo = auditRepo devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler) devHandler.HeadlessJWKS = headlessJWKSCache devHandler.AuditRepo = auditRepo @@ -674,6 +692,7 @@ func main() { admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) + admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily) // Tenant Management (Mixed roles, handler filters results) admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index ddb4bfe5..2a38fb4c 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -45,6 +45,7 @@ func migrateSchemas(db *gorm.DB) error { &domain.ClientSecret{}, &domain.ClientConsent{}, &domain.KetoOutbox{}, + &domain.RPUsageEvent{}, &domain.WorksmobileOutbox{}, &domain.WorksmobileResourceMapping{}, &domain.SharedLink{}, diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 248618ea..1bacb35d 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -26,6 +26,7 @@ type AuditRepository interface { Create(log *AuditLog) error FindPage(ctx context.Context, limit int, cursor *AuditCursor, tenantID string) ([]AuditLog, error) FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error) + CountEventsSince(ctx context.Context, since time.Time) (int64, error) CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) Ping(ctx context.Context) error diff --git a/backend/internal/domain/rp_usage_event.go b/backend/internal/domain/rp_usage_event.go new file mode 100644 index 00000000..d551bf45 --- /dev/null +++ b/backend/internal/domain/rp_usage_event.go @@ -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) +} diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index 42ee1815..84d1ce0b 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -1,17 +1,29 @@ package handler import ( + "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" + "context" "runtime" + "strconv" + "strings" "time" "github.com/gofiber/fiber/v2" ) +type adminHydraClientLister interface { + ListClients(ctx context.Context, limit, offset int) ([]domain.HydraClient, error) +} + type AdminHandler struct { - Keto service.KetoService - KetoOutbox repository.KetoOutboxRepository + Keto service.KetoService + KetoOutbox repository.KetoOutboxRepository + RPUsageQueries domain.RPUsageQueryRepository + TenantRepo repository.TenantRepository + Hydra adminHydraClientLister + AuditRepo domain.AuditRepository } func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler { @@ -21,6 +33,76 @@ func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxR } } +func (h *AdminHandler) GetRPUsageDaily(c *fiber.Ctx) error { + if h == nil || h.RPUsageQueries == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "rp usage query service unavailable", + }) + } + days := 14 + if raw := c.Query("days"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil { + days = parsed + } + } + period := normalizeRPUsagePeriod(c.Query("period")) + tenantID, allowed := h.authorizedRPUsageTenantID(c, strings.TrimSpace(c.Query("tenantId"))) + if !allowed { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "forbidden: tenant rp usage stats permission denied", + }) + } + items, err := h.RPUsageQueries.FindRPUsage(c.Context(), domain.RPUsageQuery{ + Days: days, + Period: period, + TenantID: tenantID, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + return c.JSON(fiber.Map{ + "items": items, + "days": days, + "period": period, + "tenantId": tenantID, + }) +} + +func normalizeRPUsagePeriod(period string) string { + switch strings.ToLower(strings.TrimSpace(period)) { + case "week": + return "week" + case "month": + return "month" + default: + return "day" + } +} + +func (h *AdminHandler) authorizedRPUsageTenantID(c *fiber.Ctx, requestedTenantID string) (string, bool) { + profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) + if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin { + return requestedTenantID, true + } + tenantID := requestedTenantID + if tenantID == "" && profile != nil && profile.TenantID != nil { + tenantID = strings.TrimSpace(*profile.TenantID) + } + if tenantID == "" { + return "", false + } + if h == nil || h.Keto == nil || profile == nil || strings.TrimSpace(profile.ID) == "" { + return "", false + } + allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+profile.ID, "Tenant", tenantID, "view_rp_usage_stats") + if err != nil || !allowed { + return "", false + } + return tenantID, true +} + func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) } @@ -29,10 +111,14 @@ func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { var m runtime.MemStats runtime.ReadMemStats(&m) + ctx := c.Context() stats := fiber.Map{ - "goroutines": runtime.NumGoroutine(), - "cpus": runtime.NumCPU(), + "totalTenants": h.countTenants(ctx), + "oidcClients": h.countOIDCClients(ctx), + "auditEvents24h": h.countAuditEventsSince(ctx, time.Now().UTC().Add(-24*time.Hour)), + "goroutines": runtime.NumGoroutine(), + "cpus": runtime.NumCPU(), "memory": fiber.Map{ "alloc": m.Alloc, "totalAlign": m.TotalAlloc, @@ -44,3 +130,59 @@ func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(stats) } + +func (h *AdminHandler) countTenants(ctx context.Context) int64 { + if h == nil || h.TenantRepo == nil { + return 0 + } + _, total, err := h.TenantRepo.List(ctx, 1, 0, "") + if err != nil { + return 0 + } + return total +} + +func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 { + if h == nil || h.Hydra == nil { + return 0 + } + const pageSize = 500 + var total int64 + for offset := 0; ; offset += pageSize { + clients, err := h.Hydra.ListClients(ctx, pageSize, offset) + if err != nil { + return total + } + for _, client := range clients { + if isHiddenSystemClient(client) { + continue + } + total++ + } + if len(clients) < pageSize { + break + } + } + return total +} + +func (h *AdminHandler) countAuditEventsSince(ctx context.Context, since time.Time) int64 { + if h == nil || h.AuditRepo == nil { + return 0 + } + count, err := h.AuditRepo.CountEventsSince(ctx, since) + if err == nil && count > 0 { + return count + } + logs, pageErr := h.AuditRepo.FindPage(ctx, 10000, nil, "") + if pageErr != nil { + return count + } + var fallbackCount int64 + for _, log := range logs { + if !log.Timestamp.Before(since) { + fallbackCount++ + } + } + return fallbackCount +} diff --git a/backend/internal/handler/admin_handler_test.go b/backend/internal/handler/admin_handler_test.go new file mode 100644 index 00000000..b392787e --- /dev/null +++ b/backend/internal/handler/admin_handler_test.go @@ -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()) +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 6f1d625b..8493d2ec 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -29,6 +29,7 @@ import ( "github.com/go-jose/go-jose/v4" josejwt "github.com/go-jose/go-jose/v4/jwt" "github.com/gofiber/fiber/v2" + "github.com/lib/pq" ) const ( @@ -101,6 +102,7 @@ type AuthHandler struct { UserRepo repository.UserRepository ConsentRepo repository.ClientConsentRepository RPUserMetadataRepo repository.RPUserMetadataRepository + RPUsageSink domain.RPUsageEventSink } type signupState struct { @@ -245,6 +247,92 @@ func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.Iden } } +func (h *AuthHandler) emitRPUsageAuthorizationGranted(c *fiber.Ctx, consentRequest *domain.HydraConsentRequest, profile *domain.UserProfileResponse, sessionID string, autoAccepted bool, correlationID string) error { + if consentRequest == nil { + return nil + } + return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationGranted, consentRequest.Subject, consentRequest.Client, consentRequest.RequestedScope, profile, sessionID, "hydra_consent", correlationID, domain.JSONMap{ + "auto_accepted": autoAccepted, + "scopes": consentRequest.RequestedScope, + }) +} + +func (h *AuthHandler) emitRPUsageAuthorizationRevoked(c *fiber.Ctx, subject string, clientID string, profile *domain.UserProfileResponse, sessionID string) error { + return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationRevoked, subject, domain.HydraClient{ClientID: clientID}, nil, profile, sessionID, "hydra_consent", clientID, domain.JSONMap{}) +} + +func (h *AuthHandler) emitRPUsageEvent(c *fiber.Ctx, eventType string, subject string, client domain.HydraClient, scopes []string, profile *domain.UserProfileResponse, sessionID string, source string, correlationID string, payload domain.JSONMap) error { + if h.RPUsageSink == nil { + return nil + } + clientID := strings.TrimSpace(client.ClientID) + if clientID == "" || strings.TrimSpace(subject) == "" { + return nil + } + tenantID, tenantType := rpUsageTenantFromProfile(profile) + event := domain.RPUsageEvent{ + EventType: eventType, + Subject: strings.TrimSpace(subject), + TenantID: tenantID, + TenantType: tenantType, + ClientID: clientID, + ClientName: strings.TrimSpace(client.ClientName), + SessionID: strings.TrimSpace(sessionID), + Scopes: pq.StringArray(scopes), + Source: source, + CorrelationID: strings.TrimSpace(correlationID), + Payload: payload, + OccurredAt: time.Now(), + } + if event.Payload == nil { + event.Payload = domain.JSONMap{} + } + if event.ClientName != "" { + event.Payload["client_name"] = event.ClientName + } + if tenantID != "" { + event.Payload["tenant_id"] = tenantID + } + if tenantType != "" { + event.Payload["tenant_type"] = tenantType + } + if c != nil { + event.Payload["ip_address"] = c.IP() + event.Payload["user_agent"] = string(c.Request().Header.UserAgent()) + } + ctx := context.Background() + if c != nil && c.UserContext() != nil { + ctx = c.UserContext() + } + return h.RPUsageSink.EmitRPUsageEvent(ctx, event) +} + +func rpUsageTenantFromProfile(profile *domain.UserProfileResponse) (string, string) { + if profile == nil { + return "", "" + } + tenantID := "" + if profile.SessionTenantID != nil { + tenantID = strings.TrimSpace(*profile.SessionTenantID) + } + if tenantID == "" && profile.TenantID != nil { + tenantID = strings.TrimSpace(*profile.TenantID) + } + tenantType := "" + if profile.Tenant != nil { + switch strings.ToUpper(strings.TrimSpace(profile.Tenant.Type)) { + case domain.TenantTypeCompany, domain.TenantTypeOrganization: + tenantType = strings.ToUpper(strings.TrimSpace(profile.Tenant.Type)) + if tenantID == "" { + tenantID = strings.TrimSpace(profile.Tenant.ID) + } + case domain.TenantTypeUserGroup, domain.TenantTypePersonal: + return "", "" + } + } + return tenantID, tenantType +} + // --- Signup Flow Handlers --- // CheckEmail - 이메일 사용 가능 여부를 확인합니다. @@ -5323,6 +5411,12 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error { if err != nil || subject == "" { return fiber.NewError(fiber.StatusUnauthorized, "Authentication required") } + profile, profileErr := h.resolveCurrentProfile(c) + if (profileErr != nil || profile == nil) && subject != "" { + if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), subject); fallbackErr == nil { + profile = fallbackProfile + } + } slog.Info("RevokeLinkedRp called", "subject", subject, "client_id", clientID) @@ -5354,6 +5448,11 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error { }) } + if err := h.emitRPUsageAuthorizationRevoked(c, subject, clientID, profile, h.resolveCurrentSessionID(c)); err != nil { + slog.Error("failed to emit rp usage event for revoked consent", "error", err, "client_id", clientID, "subject", subject) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event") + } + h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "") return c.Status(fiber.StatusOK).JSON(fiber.Map{ @@ -5434,6 +5533,10 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) if err == nil { + if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil { + slog.Error("failed to emit rp usage event for local consent auto-accept", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event") + } return c.JSON(acceptResp) } slog.Error("failed to force auto-accept based on local DB", "error", err) @@ -5516,6 +5619,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { }) } + if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil { + slog.Error("failed to emit rp usage event for skip consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event") + } + slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID, "session_id", currentSessionID) return c.JSON(acceptResp) } @@ -5705,6 +5813,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { }) } + if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, false, req.ConsentChallenge); err != nil { + slog.Error("failed to emit rp usage event for accepted consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event") + } + return c.JSON(acceptResp) } diff --git a/backend/internal/handler/auth_handler_client_test.go b/backend/internal/handler/auth_handler_client_test.go index 6119340f..81b5fb89 100644 --- a/backend/internal/handler/auth_handler_client_test.go +++ b/backend/internal/handler/auth_handler_client_test.go @@ -3,6 +3,7 @@ package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" + "baron-sso-backend/internal/utils" "encoding/json" "io" "net/http" @@ -38,12 +39,14 @@ func TestRevokeLinkedRp_Success(t *testing.T) { defer func() { http.DefaultClient = origDefault }() auditRepo := &mockAuditRepo{} + rpUsageSink := &mockRPUsageEventSink{} h := &AuthHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, - AuditRepo: auditRepo, + AuditRepo: auditRepo, + RPUsageSink: rpUsageSink, } app := fiber.New() app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp) @@ -54,6 +57,16 @@ func TestRevokeLinkedRp_Success(t *testing.T) { resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, 1, len(auditRepo.logs)) + assert.Equal(t, "consent.revoked", auditRepo.logs[0].EventType) + assert.Equal(t, "user-123", auditRepo.logs[0].UserID) + assert.Equal(t, "success", auditRepo.logs[0].Status) + auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details) + assert.NoError(t, err) + assert.Equal(t, "app-1", auditDetails["client_id"]) + assert.Equal(t, 1, len(rpUsageSink.events)) + assert.Equal(t, domain.RPUsageEventTypeAuthorizationRevoked, rpUsageSink.events[0].EventType) + assert.Equal(t, "user-123", rpUsageSink.events[0].Subject) + assert.Equal(t, "app-1", rpUsageSink.events[0].ClientID) } func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) { diff --git a/backend/internal/handler/auth_handler_consent_test.go b/backend/internal/handler/auth_handler_consent_test.go index 2d701319..f064b482 100644 --- a/backend/internal/handler/auth_handler_consent_test.go +++ b/backend/internal/handler/auth_handler_consent_test.go @@ -3,6 +3,7 @@ package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" + "baron-sso-backend/internal/utils" "bytes" "context" "encoding/json" @@ -305,6 +306,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) { defer func() { http.DefaultClient = origDefault }() consentRepo := &mockConsentRepo{} + rpUsageSink := &mockRPUsageEventSink{} mockKratosAdmin := &MockKratosAdminServiceForConsent{} h := &AuthHandler{ @@ -314,6 +316,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) { }, KratosAdmin: mockKratosAdmin, ConsentRepo: consentRepo, + RPUsageSink: rpUsageSink, } mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ ID: "user-123", @@ -332,6 +335,11 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) { var body map[string]interface{} json.NewDecoder(resp.Body).Decode(&body) assert.Equal(t, "http://rp/cb", body["redirectTo"]) + assert.Equal(t, 1, len(rpUsageSink.events)) + assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType) + assert.Equal(t, "client-app", rpUsageSink.events[0].ClientID) + assert.Equal(t, "challenge-skip", rpUsageSink.events[0].CorrelationID) + assert.Equal(t, true, rpUsageSink.events[0].Payload["auto_accepted"]) } func TestAcceptConsentRequest_Normal(t *testing.T) { @@ -370,6 +378,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) { auditRepo := &mockAuditRepo{} consentRepo := &mockConsentRepo{} + rpUsageSink := &mockRPUsageEventSink{} mockKratosAdmin := &MockKratosAdminServiceForConsent{} h := &AuthHandler{ @@ -380,6 +389,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) { KratosAdmin: mockKratosAdmin, AuditRepo: auditRepo, ConsentRepo: consentRepo, + RPUsageSink: rpUsageSink, } mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ ID: "user-123", @@ -402,6 +412,21 @@ func TestAcceptConsentRequest_Normal(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, 1, len(auditRepo.logs)) + assert.Equal(t, "consent.granted", auditRepo.logs[0].EventType) + assert.Equal(t, "user-123", auditRepo.logs[0].UserID) + assert.Equal(t, "success", auditRepo.logs[0].Status) + auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details) + assert.NoError(t, err) + assert.Equal(t, "client-app", auditDetails["client_id"]) + assert.Equal(t, "Test App", auditDetails["client_name"]) + assert.Equal(t, []interface{}{"openid"}, auditDetails["scopes"]) + assert.Equal(t, 1, len(rpUsageSink.events)) + assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType) + assert.Equal(t, "user-123", rpUsageSink.events[0].Subject) + assert.Equal(t, "client-app", rpUsageSink.events[0].ClientID) + assert.Equal(t, "Test App", rpUsageSink.events[0].ClientName) + assert.Equal(t, []string{"openid"}, []string(rpUsageSink.events[0].Scopes)) + assert.Equal(t, "hydra_consent", rpUsageSink.events[0].Source) } func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) { diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index af462748..85020233 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -109,12 +109,29 @@ func (m *mockAuditRepo) CountFailuresSince(ctx context.Context, since time.Time, return 0, nil } +func (m *mockAuditRepo) CountEventsSince(ctx context.Context, since time.Time) (int64, error) { + return 0, nil +} + func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { return 0, nil } func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil } +type mockRPUsageEventSink struct { + events []domain.RPUsageEvent + err error +} + +func (m *mockRPUsageEventSink) EmitRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error { + if m.err != nil { + return m.err + } + m.events = append(m.events, event) + return nil +} + type mockOathkeeperRepo struct { logs []domain.OathkeeperAccessLog } diff --git a/backend/internal/middleware/audit_middleware_test.go b/backend/internal/middleware/audit_middleware_test.go index 0244b64a..25bd1d30 100644 --- a/backend/internal/middleware/audit_middleware_test.go +++ b/backend/internal/middleware/audit_middleware_test.go @@ -40,6 +40,10 @@ func (m *MockAuditRepository) CountFailuresSince(ctx context.Context, since time return 0, nil } +func (m *MockAuditRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) { + return 0, nil +} + func (m *MockAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { return 0, nil } @@ -73,6 +77,10 @@ func (r *recordingAuditRepository) CountFailuresSince(ctx context.Context, since return 0, nil } +func (r *recordingAuditRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) { + return 0, nil +} + func (r *recordingAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { return 0, nil } diff --git a/backend/internal/repository/clickhouse_repo.go b/backend/internal/repository/clickhouse_repo.go index 54ca02a7..29039411 100644 --- a/backend/internal/repository/clickhouse_repo.go +++ b/backend/internal/repository/clickhouse_repo.go @@ -3,6 +3,7 @@ package repository import ( "baron-sso-backend/internal/domain" "context" + "encoding/json" "fmt" "time" @@ -77,9 +78,73 @@ func NewClickHouseRepository(host string, port int, user, password, db string) ( return nil, fmt.Errorf("failed to alter table: %w", err) } + if err := ensureRPUsageTables(context.Background(), conn); err != nil { + return nil, fmt.Errorf("failed to create rp usage tables: %w", err) + } + return &ClickHouseRepository{conn: conn}, nil } +func ensureRPUsageTables(ctx context.Context, conn driver.Conn) error { + factQuery := ` + CREATE TABLE IF NOT EXISTS rp_usage_events ( + event_id String, + occurred_at DateTime64(3) DEFAULT now64(3), + event_type String, + subject String, + tenant_id String, + tenant_type String, + client_id String, + client_name String, + session_id String, + scopes Array(String), + source String, + correlation_id String, + payload String + ) ENGINE = MergeTree() + ORDER BY (occurred_at, event_id) + ` + if err := conn.Exec(ctx, factQuery); err != nil { + return err + } + + aggregateQuery := ` + CREATE TABLE IF NOT EXISTS rp_usage_daily_aggregate ( + event_date Date, + tenant_id String, + tenant_type String, + client_id String, + client_name String, + event_type String, + events_count AggregateFunction(count), + unique_subjects AggregateFunction(uniqExact, String) + ) ENGINE = AggregatingMergeTree() + ORDER BY (event_date, tenant_id, client_id, event_type) + ` + if err := conn.Exec(ctx, aggregateQuery); err != nil { + return err + } + + viewQuery := ` + CREATE MATERIALIZED VIEW IF NOT EXISTS rp_usage_daily_aggregate_mv + TO rp_usage_daily_aggregate + AS + SELECT + toDate(occurred_at) AS event_date, + tenant_id, + tenant_type, + client_id, + any(client_name) AS client_name, + event_type, + countState() AS events_count, + uniqExactState(subject) AS unique_subjects + FROM rp_usage_events + WHERE tenant_type IN ('COMPANY', 'ORGANIZATION') + GROUP BY event_date, tenant_id, tenant_type, client_id, event_type + ` + return conn.Exec(ctx, viewQuery) +} + func (r *ClickHouseRepository) Create(log *domain.AuditLog) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -106,6 +171,125 @@ func (r *ClickHouseRepository) Create(log *domain.AuditLog) error { ) } +func (r *ClickHouseRepository) CreateRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error { + if r == nil || r.conn == nil { + return fmt.Errorf("clickhouse connection is nil") + } + if event.OccurredAt.IsZero() { + event.OccurredAt = time.Now() + } + payloadBytes, err := json.Marshal(event.Payload) + if err != nil { + return fmt.Errorf("failed to marshal rp usage payload: %w", err) + } + query := ` + INSERT INTO rp_usage_events ( + event_id, occurred_at, event_type, subject, tenant_id, tenant_type, + client_id, client_name, session_id, scopes, source, correlation_id, payload + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + return r.conn.Exec(ctx, query, + event.ID, + event.OccurredAt, + event.EventType, + event.Subject, + event.TenantID, + event.TenantType, + event.ClientID, + event.ClientName, + event.SessionID, + []string(event.Scopes), + event.Source, + event.CorrelationID, + string(payloadBytes), + ) +} + +func (r *ClickHouseRepository) FindRPUsage(ctx context.Context, rpQuery domain.RPUsageQuery) ([]domain.RPUsageDailyMetric, error) { + if r == nil || r.conn == nil { + return nil, fmt.Errorf("clickhouse connection is nil") + } + days := rpQuery.Days + if days <= 0 || days > 90 { + days = 14 + } + periodExpr := "event_date" + switch rpQuery.Period { + case "week": + periodExpr = "toMonday(event_date)" + case "month": + periodExpr = "toStartOfMonth(event_date)" + case "day", "": + periodExpr = "event_date" + default: + periodExpr = "event_date" + } + + query := fmt.Sprintf(` + SELECT + date, + tenant_id, + tenant_type, + client_id, + any(client_name) AS client_name, + sumIf(events, event_type = ?) AS login_requests, + sumIf(events, event_type != ?) AS other_requests, + max(unique_subjects) AS unique_subjects + FROM ( + SELECT + toString(%s) AS date, + tenant_id, + tenant_type, + client_id, + any(client_name) AS client_name, + event_type, + countMerge(events_count) AS events, + uniqExactMerge(unique_subjects) AS unique_subjects + FROM rp_usage_daily_aggregate + WHERE event_date >= today() - ? + AND tenant_type IN ('COMPANY', 'ORGANIZATION') +`, periodExpr) + args := []any{domain.RPUsageEventTypeAuthorizationGranted, domain.RPUsageEventTypeAuthorizationGranted, days - 1} + if rpQuery.TenantID != "" { + query += " AND tenant_id = ?\n" + args = append(args, rpQuery.TenantID) + } + query += fmt.Sprintf(` + GROUP BY %s, tenant_id, tenant_type, client_id, event_type + ) + GROUP BY date, tenant_id, tenant_type, client_id + ORDER BY date ASC, tenant_id ASC, client_id ASC + `, periodExpr) + rows, err := r.conn.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query rp usage daily aggregate: %w", err) + } + defer rows.Close() + + metrics := make([]domain.RPUsageDailyMetric, 0) + for rows.Next() { + var metric domain.RPUsageDailyMetric + if err := rows.Scan( + &metric.Date, + &metric.TenantID, + &metric.TenantType, + &metric.ClientID, + &metric.ClientName, + &metric.LoginRequests, + &metric.OtherRequests, + &metric.UniqueSubjects, + ); err != nil { + return nil, fmt.Errorf("failed to scan rp usage daily aggregate: %w", err) + } + if metric.ClientName == "" { + metric.ClientName = metric.ClientID + } + metrics = append(metrics, metric) + } + return metrics, nil +} + func (r *ClickHouseRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor, tenantID string) ([]domain.AuditLog, error) { if limit <= 0 { limit = 50 @@ -228,6 +412,21 @@ func (r *ClickHouseRepository) CountFailuresSince(ctx context.Context, since tim return count, nil } +func (r *ClickHouseRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) { + sinceUTC := since.UTC().Format("2006-01-02 15:04:05") + query := fmt.Sprintf(` + SELECT count() + FROM audit_logs + WHERE timestamp >= toDateTime('%s') + `, sinceUTC) + var count int64 + err := r.conn.QueryRow(ctx, query).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to count audit events: %w", err) + } + return count, nil +} + func (r *ClickHouseRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { // We use uniqExact(session_id) to count unique sessions that had success events recently. query := ` diff --git a/backend/internal/repository/main_test.go b/backend/internal/repository/main_test.go index c1c08d0c..e8de32c8 100644 --- a/backend/internal/repository/main_test.go +++ b/backend/internal/repository/main_test.go @@ -63,7 +63,7 @@ func TestMain(m *testing.M) { } // Auto-migrate - err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}) + err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}) if err != nil { log.Fatalf("failed to migrate database: %s", err) } diff --git a/backend/internal/repository/rp_usage_outbox_repository.go b/backend/internal/repository/rp_usage_outbox_repository.go new file mode 100644 index 00000000..58129a22 --- /dev/null +++ b/backend/internal/repository/rp_usage_outbox_repository.go @@ -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 +} diff --git a/backend/internal/service/rp_usage_event_emitter.go b/backend/internal/service/rp_usage_event_emitter.go new file mode 100644 index 00000000..73c0f8f3 --- /dev/null +++ b/backend/internal/service/rp_usage_event_emitter.go @@ -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[:]) +} diff --git a/backend/internal/service/rp_usage_event_emitter_test.go b/backend/internal/service/rp_usage_event_emitter_test.go new file mode 100644 index 00000000..976d1dc2 --- /dev/null +++ b/backend/internal/service/rp_usage_event_emitter_test.go @@ -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) +} diff --git a/backend/internal/service/rp_usage_projector_worker.go b/backend/internal/service/rp_usage_projector_worker.go new file mode 100644 index 00000000..286f4831 --- /dev/null +++ b/backend/internal/service/rp_usage_projector_worker.go @@ -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 +} diff --git a/docker/ory/vector/vector.toml b/docker/ory/vector/vector.toml index 83fd6a90..0a32fddf 100644 --- a/docker/ory/vector/vector.toml +++ b/docker/ory/vector/vector.toml @@ -8,55 +8,114 @@ inputs = ["oathkeeper_file"] source = ''' raw = to_string(.message) ?? "" - parsed = parse_json(raw) ?? {} + parsed = object!(parse_json(raw) ?? {}) request_method = to_string(get(parsed, ["request", "method"]) ?? "") ?? "" + if request_method == "" { request_method = to_string(get(parsed, ["http_request", "method"]) ?? "") ?? "" } request_path = to_string(get(parsed, ["request", "path"]) ?? "") ?? "" + if request_path == "" { request_path = to_string(get(parsed, ["http_request", "path"]) ?? "") ?? "" } request_url = to_string(get(parsed, ["request", "url"]) ?? "") ?? "" + if request_url == "" { request_url = to_string(get(parsed, ["http_url"]) ?? "") ?? "" } request_host = to_string(get(parsed, ["request", "host"]) ?? "") ?? "" + if request_host == "" { request_host = to_string(get(parsed, ["http_request", "host"]) ?? "") ?? "" } request_scheme = to_string(get(parsed, ["request", "scheme"]) ?? "") ?? "" + if request_scheme == "" { request_scheme = to_string(get(parsed, ["http_request", "scheme"]) ?? "") ?? "" } request_query = to_string(get(parsed, ["request", "query"]) ?? "") ?? "" - response_status = get(parsed, ["response", "status"]) ?? 0 + if request_query == "" { request_query = to_string(get(parsed, ["http_request", "query"]) ?? "") ?? "" } + response_status = to_int(get(parsed, ["response", "status"]) ?? 0) ?? 0 + if response_status == 0 { response_status = to_int(get(parsed, ["http_response", "status"]) ?? 0) ?? 0 } + response_size = to_int(get(parsed, ["response", "size"]) ?? 0) ?? 0 + if response_size == 0 { response_size = to_int(get(parsed, ["http_response", "size"]) ?? 0) ?? 0 } + response_took = to_int(get(parsed, ["response", "took"]) ?? 0) ?? 0 + if response_took == 0 { response_took = to_int(get(parsed, ["http_response", "took"]) ?? 0) ?? 0 } identity_id = to_string(get(parsed, ["identity", "id"]) ?? "") ?? "" - headers = get(parsed, ["headers"]) ?? {} + if identity_id == "" { identity_id = to_string(get(parsed, ["subject"]) ?? "") ?? "" } + headers = object(get(parsed, ["headers"]) ?? {}) ?? {} + if length(headers) == 0 { headers = object(get(parsed, ["http_request", "headers"]) ?? {}) ?? {} } user_agent = to_string(get(headers, ["User-Agent"]) ?? "") ?? "" + if user_agent == "" { user_agent = to_string(get(headers, ["user-agent"]) ?? "") ?? "" } referer = to_string(get(headers, ["Referer"]) ?? "") ?? "" + if referer == "" { referer = to_string(get(headers, ["referer"]) ?? "") ?? "" } rule_id = to_string(get(parsed, ["rule", "id"]) ?? "") ?? "" + if rule_id == "" { rule_id = to_string(get(parsed, ["rule_id"]) ?? "") ?? "" } upstream_url = to_string(get(parsed, ["upstream", "url"]) ?? "") ?? "" + if upstream_url == "" { upstream_url = to_string(get(parsed, ["http_url"]) ?? "") ?? "" } client_id = to_string(get(parsed, ["client", "id"]) ?? "") ?? "" parent_session_id = to_string(get(parsed, ["extra", "parent_session_id"]) ?? "") ?? "" parsed_url = parse_url(request_url) ?? {} query_params = get(parsed_url, ["query"]) ?? {} + url_path = to_string(get(parsed_url, ["path"]) ?? "") ?? "" + parsed_request_query = parse_url("http://localhost/?" + request_query) ?? {} + request_query_params = get(parsed_request_query, ["query"]) ?? {} event_path = to_string(parsed.path) ?? to_string(parsed.http_path) ?? "" if event_path == "" { event_path = request_path } + if event_path == "" { event_path = url_path } if event_path == "" { event_path = request_url } event_client_id = to_string(parsed.client_id) ?? "" if event_client_id == "" { event_client_id = client_id } if event_client_id == "" { event_client_id = to_string(get(query_params, ["client_id"]) ?? "") ?? "" } if event_client_id == "" { event_client_id = to_string(get(query_params, ["clientId"]) ?? "") ?? "" } + if event_client_id == "" { event_client_id = to_string(get(request_query_params, ["client_id"]) ?? "") ?? "" } + if event_client_id == "" { event_client_id = to_string(get(request_query_params, ["clientId"]) ?? "") ?? "" } + event_latency_ms = to_int(parsed.latency_ms) ?? to_int(parsed.duration_ms) ?? 0 + if event_latency_ms == 0 && response_took != 0 { + event_latency_ms = to_int(to_float(response_took) / 1000000.0) + } + event_client_ip = to_string(parsed.client_ip) ?? to_string(parsed.remote_ip) ?? to_string(parsed.ip) ?? "" + if event_client_ip == "" { event_client_ip = to_string(get(headers, ["X-Real-Ip"]) ?? "") ?? "" } + if event_client_ip == "" { event_client_ip = to_string(get(headers, ["x-real-ip"]) ?? "") ?? "" } + if event_client_ip == "" { event_client_ip = to_string(get(headers, ["X-Forwarded-For"]) ?? "") ?? "" } + if event_client_ip == "" { event_client_ip = to_string(get(headers, ["x-forwarded-for"]) ?? "") ?? "" } + event_decision = to_string(parsed.decision) ?? to_string(parsed.result) ?? "" + if event_decision == "" && exists(parsed.granted) { + if parsed.granted == true { + event_decision = "granted" + } else { + event_decision = "denied" + } + } + event_status = to_int(get(parsed, ["status"]) ?? 0) ?? 0 + if event_status == 0 { event_status = to_int(get(parsed, ["status_code"]) ?? 0) ?? 0 } + if event_status == 0 { event_status = response_status } + event_bytes_out = to_int(get(parsed, ["bytes_out"]) ?? 0) ?? 0 + if event_bytes_out == 0 { event_bytes_out = to_int(get(parsed, ["response_bytes"]) ?? 0) ?? 0 } + if event_bytes_out == 0 { event_bytes_out = response_size } + event_method = to_string(get(parsed, ["method"]) ?? "") ?? "" + if event_method == "" { event_method = to_string(get(parsed, ["http_method"]) ?? "") ?? "" } + if event_method == "" { event_method = request_method } + event_host = to_string(get(parsed, ["host"]) ?? "") ?? "" + if event_host == "" { event_host = to_string(get(parsed, ["http_host"]) ?? "") ?? "" } + if event_host == "" { event_host = request_host } + event_scheme = to_string(get(parsed, ["scheme"]) ?? "") ?? "" + if event_scheme == "" { event_scheme = request_scheme } + event_query = to_string(get(parsed, ["query"]) ?? "") ?? "" + if event_query == "" { event_query = request_query } + event_user_agent = to_string(get(parsed, ["user_agent"]) ?? "") ?? "" + if event_user_agent == "" { event_user_agent = to_string(get(parsed, ["http_user_agent"]) ?? "") ?? "" } + if event_user_agent == "" { event_user_agent = user_agent } . = { "request_id": to_string(parsed.request_id) ?? to_string(parsed.req_id) ?? "", - "method": to_string(parsed.method) ?? to_string(parsed.http_method) ?? request_method, + "method": event_method, "path": event_path, - "status": to_int(parsed.status) ?? to_int(parsed.status_code) ?? to_int(response_status) ?? 0, - "latency_ms": to_int(parsed.latency_ms) ?? to_int(parsed.duration_ms) ?? to_int(parsed.took) ?? 0, + "status": event_status, + "latency_ms": event_latency_ms, "client_id": event_client_id, "rp": to_string(parsed.rp) ?? "", "action": to_string(parsed.action) ?? "", "target": to_string(parsed.target) ?? "", "rule_id": to_string(parsed.rule_id) ?? rule_id, - "host": to_string(parsed.host) ?? request_host, - "scheme": to_string(parsed.scheme) ?? request_scheme, - "query": to_string(parsed.query) ?? request_query, + "host": event_host, + "scheme": event_scheme, + "query": event_query, "upstream_url": to_string(parsed.upstream_url) ?? upstream_url, "subject": to_string(parsed.subject) ?? identity_id, "parent_session_id": to_string(parsed.parent_session_id) ?? parent_session_id, - "client_ip": to_string(parsed.client_ip) ?? to_string(parsed.remote_ip) ?? to_string(parsed.ip) ?? "", - "user_agent": to_string(parsed.user_agent) ?? user_agent, + "client_ip": event_client_ip, + "user_agent": event_user_agent, "referer": referer, - "decision": to_string(parsed.decision) ?? to_string(parsed.result) ?? "", + "decision": event_decision, "bytes_in": to_int(parsed.bytes_in) ?? to_int(parsed.request_bytes) ?? 0, - "bytes_out": to_int(parsed.bytes_out) ?? to_int(parsed.response_bytes) ?? 0, + "bytes_out": event_bytes_out, "trace_id": to_string(parsed.trace_id) ?? "", "span_id": to_string(parsed.span_id) ?? "", "raw": raw @@ -73,3 +132,52 @@ auth.strategy = "basic" auth.user = "${ORY_CLICKHOUSE_USER}" auth.password = "${ORY_CLICKHOUSE_PASSWORD}" + +[[tests]] + name = "parses_oathkeeper_v26_completed_request" + + [[tests.inputs]] + insert_at = "oathkeeper_parse" + type = "log" + + [tests.inputs.log_fields] + message = '{"http_request":{"headers":{"user-agent":"Mozilla/5.0","referer":"http://localhost:5173/","x-real-ip":"172.19.0.1"},"host":"localhost","method":"GET","path":"/oauth2/auth","query":"client_id=orgfront&response_type=code","remote":"172.23.0.2:56744","scheme":"http"},"http_response":{"status":302,"size":1339,"took":4854092},"http_url":"http://hydra:4444/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback","level":"info","msg":"completed handling request","subject":"","time":"2026-05-06T01:40:51.46074548Z"}' + + [[tests.outputs]] + extract_from = "oathkeeper_parse" + + [[tests.outputs.conditions]] + type = "vrl" + source = ''' + assert_eq!(.method, "GET") + assert_eq!(.path, "/oauth2/auth") + assert_eq!(.status, 302) + assert_eq!(.client_id, "orgfront") + assert_eq!(.host, "localhost") + assert_eq!(.scheme, "http") + assert_eq!(.user_agent, "Mozilla/5.0") + assert_eq!(.referer, "http://localhost:5173/") + ''' + +[[tests]] + name = "parses_oathkeeper_v26_granted_request" + + [[tests.inputs]] + insert_at = "oathkeeper_parse" + type = "log" + + [tests.inputs.log_fields] + message = '{"audience":"application","granted":true,"http_host":"hydra:4444","http_method":"GET","http_url":"http://hydra:4444/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback&response_type=code","http_user_agent":"curl/8.10.1","level":"info","msg":"Access request granted","service_name":"ORY Oathkeeper","service_version":"v26.2.0","subject":"","time":"2026-05-06T01:52:25.431Z"}' + + [[tests.outputs]] + extract_from = "oathkeeper_parse" + + [[tests.outputs.conditions]] + type = "vrl" + source = ''' + assert_eq!(.method, "GET") + assert_eq!(.path, "/oauth2/auth") + assert_eq!(.status, 0) + assert_eq!(.client_id, "orgfront") + assert_eq!(.decision, "granted") + ''' diff --git a/test/oathkeeper_access_log_e2e_test.sh b/test/oathkeeper_access_log_e2e_test.sh index 14482b7c..c76768bd 100755 --- a/test/oathkeeper_access_log_e2e_test.sh +++ b/test/oathkeeper_access_log_e2e_test.sh @@ -58,3 +58,66 @@ if (( after_rows <= before_rows )); then docker logs --tail 100 ory_vector >&2 || true exit 1 fi + +before_auth_ts="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "SELECT now64(3)")" +auth_status="$(docker run --rm --network public_net curlimages/curl:8.10.1 \ + -sS -o /dev/null -w '%{http_code}' \ + 'http://ory_oathkeeper:4455/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback&response_type=code&scope=openid&state=access-log-e2e&code_challenge=accessloge2e&code_challenge_method=S256')" + +if [[ "$auth_status" != "302" ]]; then + echo "ERROR: expected Oathkeeper OIDC auth request to return 302, got: $auth_status" >&2 + exit 1 +fi + +deadline=$((SECONDS + 30)) +completed_rows=0 +granted_rows=0 +while (( SECONDS < deadline )); do + completed_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query " + SELECT count() + FROM ory.oathkeeper_access_logs + WHERE timestamp >= toDateTime64('$before_auth_ts', 3) + AND method = 'GET' + AND path = '/oauth2/auth' + AND status = 302 + ")" + granted_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query " + SELECT count() + FROM ory.oathkeeper_access_logs + WHERE timestamp >= toDateTime64('$before_auth_ts', 3) + AND method = 'GET' + AND path = '/oauth2/auth' + AND client_id = 'orgfront' + AND decision = 'granted' + ")" + if (( completed_rows > 0 && granted_rows > 0 )); then + break + fi + sleep 2 +done + +if (( completed_rows <= 0 )); then + echo "ERROR: Oathkeeper completed request log did not preserve method/path/status." >&2 + docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query " + SELECT timestamp, method, path, status, client_id, decision + FROM ory.oathkeeper_access_logs + WHERE timestamp >= toDateTime64('$before_auth_ts', 3) + ORDER BY timestamp DESC + LIMIT 20 + FORMAT Vertical + " >&2 || true + exit 1 +fi + +if (( granted_rows <= 0 )); then + echo "ERROR: Oathkeeper granted request log did not preserve client_id." >&2 + docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query " + SELECT timestamp, method, path, status, client_id, decision + FROM ory.oathkeeper_access_logs + WHERE timestamp >= toDateTime64('$before_auth_ts', 3) + ORDER BY timestamp DESC + LIMIT 20 + FORMAT Vertical + " >&2 || true + exit 1 +fi