From 39594f8e2111f994c731887e28612986d55fee43 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Wed, 28 Jan 2026 17:30:23 +0900 Subject: [PATCH] README update --- README.md | 105 +- .../src/features/audit/AuditLogsPage.tsx | 938 ++++++++++-------- compose.ory.yaml | 38 +- docker/ory/clickhouse/init.sql | 22 + docker/ory/oathkeeper/entrypoint.sh | 22 + docker/ory/oathkeeper/oathkeeper.yml | 6 +- docker/ory/oathkeeper/rules.draft.json | 112 +++ docker/ory/oathkeeper/rules.json | 93 +- docker/ory/oathkeeper/rules.prod.json | 92 ++ docker/ory/oathkeeper/rules.stage.json | 92 ++ docker/ory/vector/vector.toml | 52 + 11 files changed, 1127 insertions(+), 445 deletions(-) create mode 100644 docker/ory/clickhouse/init.sql create mode 100755 docker/ory/oathkeeper/entrypoint.sh create mode 100644 docker/ory/oathkeeper/rules.draft.json create mode 100644 docker/ory/oathkeeper/rules.prod.json create mode 100644 docker/ory/oathkeeper/rules.stage.json create mode 100644 docker/ory/vector/vector.toml diff --git a/README.md b/README.md index bcf64e05..560e9604 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,48 @@ # Baron SSO -**Baron SSO**는 화이트 라벨링된 사용자 인증 허브이자 통합 런처입니다. -**Descope**를 활용하여 안전한 비밀번호 없는 인증(Enchanted Link)을 제공하며, Flutter로 구현된 커스텀 UI를 통해 매끄러운 사용자 경험을 보장합니다. Backend는 Go (Fiber)와 ClickHouse를 사용하여 대용량 감사 로그(Audit Log)를 관리합니다. +**Baron 통합로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다. + +* Ory Stack으로 모든 구성요소를 self-hosting 합니다. +* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다. +* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다. + * UserFront: Flutter로 구현된 커스텀 UI를 통해 매끄러운 사용자 경험을 보장합니다. + * 로그인 : 비밀번호, SMS, QR 스캔 등의 수단으로 로그인 가능 + * 향후 모바일 앱 지원으로 인증 Push 승인 등 MFA 확장 (예정) + * 정보변경, 앱 연동 이력 확인 등 개인화 기능 + * 사용 가능한 앱 리스트 (예정) + * AdminFront: 사용자 관리 등 Admin 기능 + * DevFront: RP 관리 등 개발자 기능 ## 🏗 아키텍처 (Architecture) ### 0. Ory Stack - Ory Kratos: 사용자 인증/계정 관리(Identity). -- Kratos Selfservice UI: Kratos 셀프서비스 플로우 UI. - Ory Hydra: OAuth2/OIDC 발급 및 토큰 관리. - Ory Keto: 권한/정책 기반 접근 제어. - Oathkeeper: 인증/인가 프록시 및 라우팅 게이트웨이. +```mermaid +flowchart + subgraph Edge + OK["Oathkeeper
(Only Public Entry)"] + end + + subgraph App + BE["Backend
(Only Upstream)"] + end + + subgraph OryStack + KR[Kratos] + HY[Hydra] + KE[Keto] + KR --- HY --- KE + end + BE -->|Command| OryStack + OK -->|Query| KR + OK -->|Query| HY + OK -->|Query| KE +``` + ### 1. Backend (Go Fiber) - **Language**: Go 1.25+ - **Framework**: Fiber v2.25+ @@ -23,7 +54,7 @@ - `POST /api/v1/audit`: 감사 로그 수집 API - userfront가 바라보는 backend -### 2. userfront(Flutter Web/App) +### 2. UserFront(Flutter Web/App) - **Framework**: Flutter 3.32+ - **Key Packages**: `flutter_riverpod`, `go_router` - **Features**: @@ -36,7 +67,7 @@ - 앱 별 사용량(호출량) 등 통계 - 핵심 Audit 대상 -### 4. devfront(Web) - 향후 분리 예정 +### 4. devfront(Web) - **Framework**: Vite, React 19+, Shadcn/ui 등 - **Features**: - RP 등록 및 관리 @@ -54,19 +85,61 @@ ### 전체 연결 구조도 ```mermaid -flowchart LR - AF[adminfront] -->|"OIDC authorize/token (PKCE)"| HY["Hydra (OIDC 엔진)"] - DF[devfront] -->|"OIDC authorize/token (PKCE)"| HY - UF["userfront (Login/Consent UI)"] <-->|Hydra login/consent redirect| HY - UF -->|Kratos Browser Flow| KR["Kratos (SoT: identities/traits)"] - KR -->|subject=identity.id| HY - HY -->|ID/Access Token| RP[Relying Party Apps] - MG["Magic Link Wrapper (Fiber)"] -->|1회용 토큰→Kratos CreateSession| KR - MG -->|"Hydra Login Accept (옵션)"| HY - DS["Descope/Social IDP"] -->|Kratos Social/OIDC| KR +flowchart TD + subgraph Clients ["External Clients"] + AF[adminfront] + DF[devfront] + UF["userfront"] + DS["일반SW"] + end + + subgraph AppService ["Control Plane"] + BE["Backend (Command/Audit Controller)"] + end + + subgraph OryBundle ["Ory Deployment Stack"] + direction TB + OK["Oathkeeper (Public Proxy/OIDC)"] + + subgraph OryEngines ["Ory Services"] + direction LR + HY["Hydra"] + KR["Kratos"] + KE["Keto"] + end + + ICH[(Internal Clickhouse)] + + %% Internal Flow within Bundle + OK -->|Routing/Queries| OryEngines + OK -.->|Access/Usage Log| ICH + end + + subgraph AuditDB ["Audit Storage"] + ECH[(External Clickhouse)] + end + + %% Key Command Path + AF & DF & UF & DS ==>|Actions / Commands| BE + + %% Backend Responsibilities + BE -->|Admin/State Control| OryEngines + BE -.->|Mandatory Audit Log| ECH + + %% Connection Note (Hidden flow mentioned in logic) + %% OK is technically the entry for OIDC, but removed as per request + + %% Styles + style OryBundle fill:#f8f9fa,stroke:#333,stroke-width:2px + style BE fill:#bbf,stroke:#333,stroke-width:2px + style ECH fill:#fdd,stroke:#333 + style ICH fill:#dfd,stroke:#333 + style OK fill:#f9f,stroke:#333 + style OryEngines fill:#fff,stroke:#999,stroke-dasharray: 5 5 ``` -Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. Magic Link는 Fiber 래퍼가 Kratos 세션을 만들고 필요 시 Hydra Login Accept를 수행합니다. Descope 등 보조 IDP는 Kratos Social/OIDC provider로 연결합니다. + +Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비지니스로직은 Backend를 통해서, 기본 인증 로직은 Ory Stack을 통해 진행됩니다. --- diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index a504554a..5bb32f46 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -1,460 +1,562 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { - ChevronDown, - ChevronUp, - Copy, - ListChecks, - RefreshCw, - Search, - Terminal, + ChevronDown, + ChevronUp, + Copy, + ListChecks, + RefreshCw, + Search, + Terminal, } from "lucide-react"; import * as React from "react"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "../../components/ui/card"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "../../components/ui/table"; import type { AuditLog } from "../../lib/adminApi"; import { fetchAuditLogs } from "../../lib/adminApi"; const defaultAuditFilters = [ - "method:POST path:/api/v1/*", - "status:failure", - "latency_ms:>1000", + "method:POST path:/api/v1/*", + "status:failure", + "latency_ms:>1000", ]; type AuditDetails = { - request_id?: string; - method?: string; - path?: string; - status?: number; - latency_ms?: number; - error?: string; - tenant_id?: string; - actor_id?: string; - action?: string; - target?: string; - before?: unknown; - after?: unknown; + request_id?: string; + method?: string; + path?: string; + status?: number; + latency_ms?: number; + error?: string; + tenant_id?: string; + actor_id?: string; + action?: string; + target?: string; + before?: unknown; + after?: unknown; }; function parseDetails(details?: string): AuditDetails { - if (!details) { - return {}; - } - try { - const parsed = JSON.parse(details); - if (parsed && typeof parsed === "object") { - return parsed as AuditDetails; + if (!details) { + return {}; } - } catch {} - return {}; + try { + const parsed = JSON.parse(details); + if (parsed && typeof parsed === "object") { + return parsed as AuditDetails; + } + } catch {} + return {}; } function formatCellValue(value: unknown) { - if (value === null || value === undefined || value === "") { - return "-"; - } - if (typeof value === "string") { - return value; - } - try { - return JSON.stringify(value); - } catch { - return String(value); - } + if (value === null || value === undefined || value === "") { + return "-"; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } } function formatIsoDateTime(value: string) { - if (!value) { - return { date: "-", time: "-" }; - } - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return { date: value, time: "-" }; - } - const date = parsed.toISOString().slice(0, 10); - const time = parsed.toLocaleTimeString("ko-KR", { hour12: false }); - return { date, time }; + if (!value) { + return { date: "-", time: "-" }; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return { date: value, time: "-" }; + } + const date = parsed.toISOString().slice(0, 10); + const time = parsed.toLocaleTimeString("ko-KR", { hour12: false }); + return { date, time }; } function AuditLogsPage() { - const [filters, setFilters] = React.useState(defaultAuditFilters); - const [filterDraft, setFilterDraft] = React.useState(""); - const [expandedRows, setExpandedRows] = React.useState< - Record - >({}); + const [filters, setFilters] = React.useState(defaultAuditFilters); + const [filterDraft, setFilterDraft] = React.useState(""); + const [expandedRows, setExpandedRows] = React.useState< + Record + >({}); - const handleCopy = (value: string) => { - if (!value) { - return; + const handleCopy = (value: string) => { + if (!value) { + return; + } + navigator.clipboard.writeText(value); + }; + const { + data, + isLoading, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching, + refetch, + } = useInfiniteQuery({ + queryKey: ["audit-logs"], + queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, + }); + + const logs = + data?.pages?.flatMap( + (page) => + page?.items?.filter((item): item is AuditLog => + Boolean(item), + ) ?? [], + ) ?? []; + + const handleAddFilter = () => { + const trimmed = filterDraft.trim(); + if (!trimmed) { + return; + } + setFilters((prev) => + prev.includes(trimmed) ? prev : [...prev, trimmed], + ); + setFilterDraft(""); + }; + + if (isLoading) { + return
Loading audit logs...
; } - navigator.clipboard.writeText(value); - }; - const { - data, - isLoading, - error, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isFetching, - refetch, - } = useInfiniteQuery({ - queryKey: ["audit-logs"], - queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, - }); - const logs = - data?.pages?.flatMap((page) => - page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [], - ) ?? []; - - const handleAddFilter = () => { - const trimmed = filterDraft.trim(); - if (!trimmed) { - return; + if (error) { + const errMsg = + (error as AxiosError<{ error?: string }>).response?.data?.error ?? + (error as Error).message; + return ( +
+ Error loading logs: {errMsg} +
+ ); } - setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed])); - setFilterDraft(""); - }; - if (isLoading) { - return
Loading audit logs...
; - } - - if (error) { - const errMsg = - (error as AxiosError<{ error?: string }>).response?.data?.error ?? - (error as Error).message; return ( -
- Error loading logs: {errMsg} -
- ); - } - - return ( -
-
-
-
- Audit - / - Logs -
-

감사 로그

-

- Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 - 추후 세션 연동 시 자동 채워집니다. -

-
-
- - -
-
- -
- - -
- Audit registry - 로드된 로그 {logs.length}건 -
- Command only -
- -
-
- - setFilterDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - handleAddFilter(); - } - }} - placeholder="필터 추가 (예: status:failure)" - className="w-full bg-transparent text-sm text-foreground outline-none" - /> - -
- {filters.length === 0 ? ( - - 필터 없음 - - ) : ( - filters.map((filter) => ( - - - {filter} - - - )) - )} -
- - - - TIME - ACTOR (ID) - REQUEST - PATH - STATUS - Action / Target - - - - - {isLoading && ( - - 로딩 중... - - )} - {!isLoading && logs.length === 0 && ( - - - 아직 수집된 감사 로그가 없습니다. - - - )} - {logs.map((row, index) => { - const details = parseDetails(row.details); - const actionLabel = - details.action || - (details.method && details.path - ? `${details.method} ${details.path}` - : row.event_type); - const rowKey = `${row.event_id}-${row.timestamp}-${index}`; - const isExpanded = Boolean(expandedRows[rowKey]); - return ( - - - - {(() => { - const { date, time } = formatIsoDateTime( - row.timestamp, - ); - return ( -
-
{date}
-
{time}
-
- ); - })()} -
- -
- - {row.user_id || details.actor_id || "-"} - - {(row.user_id || details.actor_id) && ( - - )} -
-
- -
- - {formatCellValue(details.request_id)} - - {details.request_id && ( - - )} -
-
- -
- {formatCellValue(details.method)} -
-
- {formatCellValue(details.path)} -
-
- - - {row.status} - - - -
- {actionLabel} -
- {details.target && ( -
- - Target · {details.target} - - -
- )} -
- - - -
- {isExpanded && ( - - -
-
-
- Request -
-
- Request ID · {formatCellValue(details.request_id)} -
-
- Event ID · {formatCellValue(row.event_id)} -
-
IP · {formatCellValue(row.ip_address)}
-
- Latency ·{" "} - {details.latency_ms !== undefined - ? `${details.latency_ms}ms` - : "-"} -
-
-
-
- Actor -
-
- Actor ID · {row.user_id || details.actor_id || "-"} -
-
Tenant · {formatCellValue(details.tenant_id)}
-
Device · {formatCellValue(row.device_id)}
-
-
-
- Result -
-
- Error · {formatCellValue(details.error)} -
-
- Before · {formatCellValue(details.before)} -
-
- After · {formatCellValue(details.after)} -
-
-
-
-
- )} -
- ); - })} -
-
-
- {hasNextPage ? ( - - ) : ( - - End of audit feed - - )} -
-
-
+ + 새로고침 + + +
+ -
- - ); +
+ + +
+ Audit registry + + 로드된 로그 {logs.length}건 + +
+ Command only +
+ +
+
+ + + setFilterDraft(event.target.value) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + handleAddFilter(); + } + }} + placeholder="필터 추가 (예: status:failure)" + className="w-full bg-transparent text-sm text-foreground outline-none" + /> + +
+ {filters.length === 0 ? ( + + 필터 없음 + + ) : ( + filters.map((filter) => ( + + + {filter} + + + )) + )} +
+ + + + + TIME + + + ACTOR (ID) + + REQUEST + PATH + + STATUS + + Action / Target + + + + + {isLoading && ( + + + 로딩 중... + + + )} + {!isLoading && logs.length === 0 && ( + + + 아직 수집된 감사 로그가 없습니다. + + + )} + {logs.map((row, index) => { + const details = parseDetails(row.details); + const actionLabel = + details.action || + (details.method && details.path + ? `${details.method} ${details.path}` + : row.event_type); + const rowKey = `${row.event_id}-${row.timestamp}-${index}`; + const isExpanded = Boolean( + expandedRows[rowKey], + ); + return ( + + + + {(() => { + const { date, time } = + formatIsoDateTime( + row.timestamp, + ); + return ( +
+
+ {date} +
+
+ {time} +
+
+ ); + })()} +
+ +
+ + {row.user_id || + details.actor_id || + "-"} + + {(row.user_id || + details.actor_id) && ( + + )} +
+
+ +
+ + {formatCellValue( + details.request_id, + )} + + {details.request_id && ( + + )} +
+
+ +
+ {formatCellValue( + details.method, + )} +
+
+ {formatCellValue( + details.path, + )} +
+
+ + + {row.status} + + + +
+ {actionLabel} +
+ {details.target && ( +
+ + Target ·{" "} + {details.target} + + +
+ )} +
+ + + +
+ {isExpanded && ( + + +
+
+
+ Request +
+
+ Request ID ·{" "} + {formatCellValue( + details.request_id, + )} +
+
+ Event ID ·{" "} + {formatCellValue( + row.event_id, + )} +
+
+ IP ·{" "} + {formatCellValue( + row.ip_address, + )} +
+
+ Latency ·{" "} + {details.latency_ms !== + undefined + ? `${details.latency_ms}ms` + : "-"} +
+
+
+
+ Actor +
+
+ Actor ID ·{" "} + {row.user_id || + details.actor_id || + "-"} +
+
+ Tenant ·{" "} + {formatCellValue( + details.tenant_id, + )} +
+
+ Device ·{" "} + {formatCellValue( + row.device_id, + )} +
+
+
+
+ Result +
+
+ Error ·{" "} + {formatCellValue( + details.error, + )} +
+
+ Before ·{" "} + {formatCellValue( + details.before, + )} +
+
+ After ·{" "} + {formatCellValue( + details.after, + )} +
+
+
+
+
+ )} +
+ ); + })} +
+
+
+ {hasNextPage ? ( + + ) : ( + + End of audit feed + + )} +
+
+
+
+ + ); } export default AuditLogsPage; diff --git a/compose.ory.yaml b/compose.ory.yaml index 3baa589f..7d92a4fd 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -90,8 +90,6 @@ services: kratos-ui: image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0} container_name: ory_kratos_ui - ports: - - "${KRATOS_UI_PORT:-4455}:4455" environment: - KRATOS_PUBLIC_URL=${KRATOS_PUBLIC_URL:-http://kratos:4433/} - KRATOS_BROWSER_URL=${KRATOS_BROWSER_URL:-http://localhost:${KRATOS_PUBLIC_PORT:-4433}} @@ -119,8 +117,6 @@ services: hydra: image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} container_name: ory_hydra - ports: - - "${HYDRA_PUBLIC_PORT:-4441}:4444" environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20 - URLS_SELF_ISSUER=${BACKEND_URL:-http://127.0.0.1:3000} @@ -188,9 +184,6 @@ services: keto: image: oryd/keto:${KETO_VERSION:-v25.4.0} container_name: ory_keto - ports: - - "${KETO_READ_PORT:-4466}:4466" - - "${KETO_WRITE_PORT:-4467}:4467" environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20 volumes: @@ -207,13 +200,39 @@ services: image: oryd/oathkeeper:v0.40.6 container_name: ory_oathkeeper ports: - - "4456:4456" # API - "4457:4455" # Proxy environment: - LOG_LEVEL=debug + - APP_ENV=${APP_ENV:-development} volumes: - ./docker/ory/oathkeeper:/etc/config/oathkeeper - command: serve proxy -c /etc/config/oathkeeper/oathkeeper.yml + - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper + command: ["/etc/config/oathkeeper/entrypoint.sh"] + networks: + - ory-net + + ory_clickhouse: + image: clickhouse/clickhouse-server:latest + container_name: ory_clickhouse + environment: + - CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory} + - CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass} + volumes: + - ory_clickhouse_data:/var/lib/clickhouse + - ./docker/ory/clickhouse:/docker-entrypoint-initdb.d + networks: + - ory-net + + ory_vector: + image: timberio/vector:0.36.0-alpine + container_name: ory_vector + volumes: + - ./docker/ory/vector:/etc/vector + - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper + command: ["-c", "/etc/vector/vector.toml"] + depends_on: + - oathkeeper + - ory_clickhouse networks: - ory-net @@ -268,6 +287,7 @@ services: volumes: ory_postgres_data: + ory_clickhouse_data: networks: ory-net: diff --git a/docker/ory/clickhouse/init.sql b/docker/ory/clickhouse/init.sql new file mode 100644 index 00000000..37dc8d1e --- /dev/null +++ b/docker/ory/clickhouse/init.sql @@ -0,0 +1,22 @@ +CREATE DATABASE IF NOT EXISTS ory; + +CREATE TABLE IF NOT EXISTS ory.oathkeeper_access_logs ( + timestamp DateTime64(3) DEFAULT now64(3), + request_id String DEFAULT '', + method String DEFAULT '', + path String DEFAULT '', + status UInt16 DEFAULT 0, + latency_ms UInt32 DEFAULT 0, + rp String DEFAULT '', + action String DEFAULT '', + target String DEFAULT '', + subject String DEFAULT '', + client_ip String DEFAULT '', + user_agent String DEFAULT '', + decision String DEFAULT '', + trace_id String DEFAULT '', + span_id String DEFAULT '', + raw String DEFAULT '' +) ENGINE = MergeTree() +ORDER BY (timestamp, request_id) +TTL timestamp + INTERVAL 30 DAY; diff --git a/docker/ory/oathkeeper/entrypoint.sh b/docker/ory/oathkeeper/entrypoint.sh new file mode 100755 index 00000000..99174eb6 --- /dev/null +++ b/docker/ory/oathkeeper/entrypoint.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +set -eu + +APP_ENV_VALUE="${APP_ENV:-}" + +case "$APP_ENV_VALUE" in + production|prod) + RULES_FILE="/etc/config/oathkeeper/rules.prod.json" + ;; + stage|staging) + RULES_FILE="/etc/config/oathkeeper/rules.stage.json" + ;; + *) + RULES_FILE="/etc/config/oathkeeper/rules.json" + ;; + esac + +export RULES_FILE + +echo "[oathkeeper] APP_ENV=$APP_ENV_VALUE rules=$RULES_FILE" + +exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml 2>&1 | tee /var/log/oathkeeper/access.log" diff --git a/docker/ory/oathkeeper/oathkeeper.yml b/docker/ory/oathkeeper/oathkeeper.yml index 044d7f21..cb3b787b 100644 --- a/docker/ory/oathkeeper/oathkeeper.yml +++ b/docker/ory/oathkeeper/oathkeeper.yml @@ -4,13 +4,17 @@ serve: api: port: 4456 +log: + level: info + format: json + errors: fallback: - json access_rules: repositories: - - file:///etc/config/oathkeeper/rules.json + - file://${RULES_FILE:-/etc/config/oathkeeper/rules.json} authenticators: noop: diff --git a/docker/ory/oathkeeper/rules.draft.json b/docker/ory/oathkeeper/rules.draft.json new file mode 100644 index 00000000..835689ec --- /dev/null +++ b/docker/ory/oathkeeper/rules.draft.json @@ -0,0 +1,112 @@ +[ + { + "id": "public-health", + "description": "공개 헬스체크 (TODO: 도메인 제한)", + "match": { + "url": "http://<.*>/health", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-auth", + "description": "인증/회원가입 등 공개 엔드포인트", + "match": { + "url": "http://<.*>/api/v1/auth/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-command", + "description": "Command 요청은 Backend로 전달 (Audit 강제)", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-query", + "description": "Backend Query (admin/dev 포함)", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "kratos-public", + "description": "Kratos Public API를 /kratos로 노출", + "match": { + "url": "http://<.*>/kratos/<.*>", + "methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + }, + "upstream": { + "url": "http://kratos:4433", + "strip_path": "/kratos" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "hydra-public", + "description": "Hydra Public API를 /hydra로 노출", + "match": { + "url": "http://<.*>/hydra/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path": "/hydra" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + } +] diff --git a/docker/ory/oathkeeper/rules.json b/docker/ory/oathkeeper/rules.json index 0637a088..e02c3382 100644 --- a/docker/ory/oathkeeper/rules.json +++ b/docker/ory/oathkeeper/rules.json @@ -1 +1,92 @@ -[] \ No newline at end of file +[ + { + "id": "public-health", + "description": "공개 헬스체크", + "match": { + "url": "http://<.*>/health", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-preflight", + "description": "CORS preflight", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-auth", + "description": "인증/회원가입 등 공개 엔드포인트", + "match": { + "url": "http://<.*>/api/v1/auth/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-command", + "description": "Command 요청은 Backend로 전달 (Audit 강제)", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-query", + "description": "Backend Query (admin/dev 포함)", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + } +] diff --git a/docker/ory/oathkeeper/rules.prod.json b/docker/ory/oathkeeper/rules.prod.json new file mode 100644 index 00000000..b84e202c --- /dev/null +++ b/docker/ory/oathkeeper/rules.prod.json @@ -0,0 +1,92 @@ +[ + { + "id": "public-health", + "description": "공개 헬스체크 (PROD 도메인)", + "match": { + "url": "https://auth.brsw.kr/health", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-preflight", + "description": "CORS preflight (PROD 도메인)", + "match": { + "url": "https://auth.brsw.kr/api/v1/<.*>", + "methods": ["OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-auth", + "description": "인증/회원가입 등 공개 엔드포인트 (PROD 도메인)", + "match": { + "url": "https://auth.brsw.kr/api/v1/auth/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-command", + "description": "Command 요청은 Backend로 전달 (Audit 강제)", + "match": { + "url": "https://auth.brsw.kr/api/v1/<.*>", + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-query", + "description": "Backend Query (admin/dev 포함)", + "match": { + "url": "https://auth.brsw.kr/api/v1/<.*>", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + } +] diff --git a/docker/ory/oathkeeper/rules.stage.json b/docker/ory/oathkeeper/rules.stage.json new file mode 100644 index 00000000..3dabd9a0 --- /dev/null +++ b/docker/ory/oathkeeper/rules.stage.json @@ -0,0 +1,92 @@ +[ + { + "id": "public-health", + "description": "공개 헬스체크 (STAGE 도메인)", + "match": { + "url": "https://sso.hmac.kr/health", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-preflight", + "description": "CORS preflight (STAGE 도메인)", + "match": { + "url": "https://sso.hmac.kr/api/v1/<.*>", + "methods": ["OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-auth", + "description": "인증/회원가입 등 공개 엔드포인트 (STAGE 도메인)", + "match": { + "url": "https://sso.hmac.kr/api/v1/auth/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-command", + "description": "Command 요청은 Backend로 전달 (Audit 강제)", + "match": { + "url": "https://sso.hmac.kr/api/v1/<.*>", + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-query", + "description": "Backend Query (admin/dev 포함)", + "match": { + "url": "https://sso.hmac.kr/api/v1/<.*>", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + } +] diff --git a/docker/ory/vector/vector.toml b/docker/ory/vector/vector.toml new file mode 100644 index 00000000..c237bece --- /dev/null +++ b/docker/ory/vector/vector.toml @@ -0,0 +1,52 @@ +[sources.oathkeeper_file] + type = "file" + include = ["/var/log/oathkeeper/access.log"] + read_from = "beginning" + +[transforms.oathkeeper_parse] + type = "remap" + inputs = ["oathkeeper_file"] + source = ''' + .raw = .message + parsed = parse_json(.message) ?? {} + + .timestamp = to_timestamp(.timestamp) ?? now() + .request_id = parsed.request_id ?? parsed.req_id ?? "" + request_method = get(parsed, ["request", "method"]) ?? "" + request_path = get(parsed, ["request", "path"]) ?? "" + request_url = get(parsed, ["request", "url"]) ?? "" + .method = parsed.method ?? parsed.http_method ?? request_method ?? "" + .path = parsed.path ?? parsed.http_path ?? request_path ?? request_url ?? "" + response_status = get(parsed, ["response", "status"]) ?? 0 + .status = to_int(parsed.status ?? parsed.status_code ?? response_status ?? 0) ?? 0 + .latency_ms = to_int(parsed.latency_ms ?? parsed.duration_ms ?? parsed.took ?? 0) ?? 0 + identity_id = get(parsed, ["identity", "id"]) ?? "" + .subject = parsed.subject ?? identity_id ?? "" + .client_ip = parsed.client_ip ?? parsed.remote_ip ?? parsed.ip ?? "" + headers = get(parsed, ["headers"]) ?? {} + .user_agent = parsed.user_agent + if is_null(.user_agent) { .user_agent = get(headers, ["User-Agent"]) } + if is_null(.user_agent) { .user_agent = "" } + + .decision = parsed.decision + if is_null(.decision) { .decision = parsed.result } + if is_null(.decision) { .decision = "" } + + .trace_id = parsed.trace_id + if is_null(.trace_id) { .trace_id = "" } + + .span_id = parsed.span_id + if is_null(.span_id) { .span_id = "" } + + .rp = "" + .action = "" + .target = "" + ''' + +[sinks.clickhouse] + type = "clickhouse" + inputs = ["oathkeeper_parse"] + endpoint = "http://ory_clickhouse:8123" + database = "ory" + table = "oathkeeper_access_logs" + compression = "gzip"