1
0
forked from baron/baron-sso

README update

This commit is contained in:
Lectom C Han
2026-01-28 17:30:23 +09:00
parent 3e95650024
commit 39594f8e21
11 changed files with 1127 additions and 445 deletions

105
README.md
View File

@@ -1,17 +1,48 @@
# Baron SSO # Baron SSO
**Baron SSO** 화이트 라벨링된 사용자 인증 허브이자 통합 런처입니다. **Baron 통합로그인** 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
**Descope**를 활용하여 안전한 비밀번호 없는 인증(Enchanted Link)을 제공하며, Flutter로 구현된 커스텀 UI를 통해 매끄러운 사용자 경험을 보장합니다. Backend는 Go (Fiber)와 ClickHouse를 사용하여 대용량 감사 로그(Audit Log)를 관리합니다.
* 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) ## 🏗 아키텍처 (Architecture)
### 0. Ory Stack ### 0. Ory Stack
- Ory Kratos: 사용자 인증/계정 관리(Identity). - Ory Kratos: 사용자 인증/계정 관리(Identity).
- Kratos Selfservice UI: Kratos 셀프서비스 플로우 UI.
- Ory Hydra: OAuth2/OIDC 발급 및 토큰 관리. - Ory Hydra: OAuth2/OIDC 발급 및 토큰 관리.
- Ory Keto: 권한/정책 기반 접근 제어. - Ory Keto: 권한/정책 기반 접근 제어.
- Oathkeeper: 인증/인가 프록시 및 라우팅 게이트웨이. - Oathkeeper: 인증/인가 프록시 및 라우팅 게이트웨이.
```mermaid
flowchart
subgraph Edge
OK["Oathkeeper<br/>(Only Public Entry)"]
end
subgraph App
BE["Backend<br/>(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) ### 1. Backend (Go Fiber)
- **Language**: Go 1.25+ - **Language**: Go 1.25+
- **Framework**: Fiber v2.25+ - **Framework**: Fiber v2.25+
@@ -23,7 +54,7 @@
- `POST /api/v1/audit`: 감사 로그 수집 API - `POST /api/v1/audit`: 감사 로그 수집 API
- userfront가 바라보는 backend - userfront가 바라보는 backend
### 2. userfront(Flutter Web/App) ### 2. UserFront(Flutter Web/App)
- **Framework**: Flutter 3.32+ - **Framework**: Flutter 3.32+
- **Key Packages**: `flutter_riverpod`, `go_router` - **Key Packages**: `flutter_riverpod`, `go_router`
- **Features**: - **Features**:
@@ -36,7 +67,7 @@
- 앱 별 사용량(호출량) 등 통계 - 앱 별 사용량(호출량) 등 통계
- 핵심 Audit 대상 - 핵심 Audit 대상
### 4. devfront(Web) - 향후 분리 예정 ### 4. devfront(Web)
- **Framework**: Vite, React 19+, Shadcn/ui 등 - **Framework**: Vite, React 19+, Shadcn/ui 등
- **Features**: - **Features**:
- RP 등록 및 관리 - RP 등록 및 관리
@@ -54,19 +85,61 @@
### 전체 연결 구조도 ### 전체 연결 구조도
```mermaid ```mermaid
flowchart LR flowchart TD
AF[adminfront] -->|"OIDC authorize/token (PKCE)"| HY["Hydra (OIDC 엔진)"] subgraph Clients ["External Clients"]
DF[devfront] -->|"OIDC authorize/token (PKCE)"| HY AF[adminfront]
UF["userfront (Login/Consent UI)"] <-->|Hydra login/consent redirect| HY DF[devfront]
UF -->|Kratos Browser Flow| KR["Kratos (SoT: identities/traits)"] UF["userfront"]
KR -->|subject=identity.id| HY DS["일반SW"]
HY -->|ID/Access Token| RP[Relying Party Apps] end
MG["Magic Link Wrapper (Fiber)"] -->|1회용 토큰→Kratos CreateSession| KR
MG -->|"Hydra Login Accept (옵션)"| HY subgraph AppService ["Control Plane"]
DS["Descope/Social IDP"] -->|Kratos Social/OIDC| KR 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을 통해 진행됩니다.
--- ---

View File

@@ -121,8 +121,11 @@ function AuditLogsPage() {
}); });
const logs = const logs =
data?.pages?.flatMap((page) => data?.pages?.flatMap(
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [], (page) =>
page?.items?.filter((item): item is AuditLog =>
Boolean(item),
) ?? [],
) ?? []; ) ?? [];
const handleAddFilter = () => { const handleAddFilter = () => {
@@ -130,7 +133,9 @@ function AuditLogsPage() {
if (!trimmed) { if (!trimmed) {
return; return;
} }
setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed])); setFilters((prev) =>
prev.includes(trimmed) ? prev : [...prev, trimmed],
);
setFilterDraft(""); setFilterDraft("");
}; };
@@ -160,12 +165,16 @@ function AuditLogsPage() {
</div> </div>
<h2 className="text-3xl font-semibold"> </h2> <h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]"> <p className="text-sm text-[var(--color-muted)]">
Command ClickHouse . / Command ClickHouse .
. / .
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}> <Button
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw size={16} /> <RefreshCw size={16} />
</Button> </Button>
@@ -177,11 +186,13 @@ function AuditLogsPage() {
</header> </header>
<div className="space-y-4"> <div className="space-y-4">
<Card className="bg-[var(--color-panel)]"> <Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle>Audit registry</CardTitle> <CardTitle>Audit registry</CardTitle>
<CardDescription> {logs.length}</CardDescription> <CardDescription>
{logs.length}
</CardDescription>
</div> </div>
<Badge variant="muted">Command only</Badge> <Badge variant="muted">Command only</Badge>
</CardHeader> </CardHeader>
@@ -191,7 +202,9 @@ function AuditLogsPage() {
<Search size={14} /> <Search size={14} />
<input <input
value={filterDraft} value={filterDraft}
onChange={(event) => setFilterDraft(event.target.value)} onChange={(event) =>
setFilterDraft(event.target.value)
}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
handleAddFilter(); handleAddFilter();
@@ -200,7 +213,11 @@ function AuditLogsPage() {
placeholder="필터 추가 (예: status:failure)" placeholder="필터 추가 (예: status:failure)"
className="w-full bg-transparent text-sm text-foreground outline-none" className="w-full bg-transparent text-sm text-foreground outline-none"
/> />
<Button size="sm" variant="outline" onClick={handleAddFilter}> <Button
size="sm"
variant="outline"
onClick={handleAddFilter}
>
</Button> </Button>
</div> </div>
@@ -220,7 +237,10 @@ function AuditLogsPage() {
type="button" type="button"
onClick={() => onClick={() =>
setFilters((prev) => setFilters((prev) =>
prev.filter((item) => item !== filter), prev.filter(
(item) =>
item !== filter,
),
) )
} }
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]" className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
@@ -235,11 +255,17 @@ function AuditLogsPage() {
<Table className="table-fixed"> <Table className="table-fixed">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[140px]">TIME</TableHead> <TableHead className="w-[140px]">
<TableHead className="w-[160px]">ACTOR (ID)</TableHead> TIME
</TableHead>
<TableHead className="w-[160px]">
ACTOR (ID)
</TableHead>
<TableHead>REQUEST</TableHead> <TableHead>REQUEST</TableHead>
<TableHead>PATH</TableHead> <TableHead>PATH</TableHead>
<TableHead className="w-[120px]">STATUS</TableHead> <TableHead className="w-[120px]">
STATUS
</TableHead>
<TableHead>Action / Target</TableHead> <TableHead>Action / Target</TableHead>
<TableHead className="w-[80px]"></TableHead> <TableHead className="w-[80px]"></TableHead>
</TableRow> </TableRow>
@@ -247,7 +273,9 @@ function AuditLogsPage() {
<TableBody> <TableBody>
{isLoading && ( {isLoading && (
<TableRow> <TableRow>
<TableCell colSpan={7}> ...</TableCell> <TableCell colSpan={7}>
...
</TableCell>
</TableRow> </TableRow>
)} )}
{!isLoading && logs.length === 0 && ( {!isLoading && logs.length === 0 && (
@@ -265,19 +293,26 @@ function AuditLogsPage() {
? `${details.method} ${details.path}` ? `${details.method} ${details.path}`
: row.event_type); : row.event_type);
const rowKey = `${row.event_id}-${row.timestamp}-${index}`; const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const isExpanded = Boolean(expandedRows[rowKey]); const isExpanded = Boolean(
expandedRows[rowKey],
);
return ( return (
<React.Fragment key={rowKey}> <React.Fragment key={rowKey}>
<TableRow className="bg-card/40"> <TableRow className="bg-card/40">
<TableCell className="text-xs text-[var(--color-muted)]"> <TableCell className="text-xs text-[var(--color-muted)]">
{(() => { {(() => {
const { date, time } = formatIsoDateTime( const { date, time } =
formatIsoDateTime(
row.timestamp, row.timestamp,
); );
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<div>{date}</div> <div>
<div>{time}</div> {date}
</div>
<div>
{time}
</div>
</div> </div>
); );
})()} })()}
@@ -285,16 +320,23 @@ function AuditLogsPage() {
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground"> <code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{row.user_id || details.actor_id || "-"} {row.user_id ||
details.actor_id ||
"-"}
</code> </code>
{(row.user_id || details.actor_id) && ( {(row.user_id ||
details.actor_id) && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary" className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label="Copy actor id" aria-label="Copy actor id"
onClick={() => onClick={() =>
handleCopy(row.user_id || details.actor_id || "") handleCopy(
row.user_id ||
details.actor_id ||
"",
)
} }
> >
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
@@ -305,7 +347,9 @@ function AuditLogsPage() {
<TableCell className="text-xs text-[var(--color-muted)]"> <TableCell className="text-xs text-[var(--color-muted)]">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<span className="break-all"> <span className="break-all">
{formatCellValue(details.request_id)} {formatCellValue(
details.request_id,
)}
</span> </span>
{details.request_id && ( {details.request_id && (
<Button <Button
@@ -313,7 +357,12 @@ function AuditLogsPage() {
size="icon" size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary" className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label="Copy request id" aria-label="Copy request id"
onClick={() => handleCopy(details.request_id || "")} onClick={() =>
handleCopy(
details.request_id ||
"",
)
}
> >
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
</Button> </Button>
@@ -322,16 +371,22 @@ function AuditLogsPage() {
</TableCell> </TableCell>
<TableCell className="text-xs text-[var(--color-muted)]"> <TableCell className="text-xs text-[var(--color-muted)]">
<div className="font-semibold text-foreground"> <div className="font-semibold text-foreground">
{formatCellValue(details.method)} {formatCellValue(
details.method,
)}
</div> </div>
<div className="break-all"> <div className="break-all">
{formatCellValue(details.path)} {formatCellValue(
details.path,
)}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant={ variant={
row.status === "success" || row.status === "ok" row.status ===
"success" ||
row.status === "ok"
? "success" ? "success"
: "warning" : "warning"
} }
@@ -346,14 +401,20 @@ function AuditLogsPage() {
{details.target && ( {details.target && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="break-all"> <span className="break-all">
Target · {details.target} Target ·{" "}
{details.target}
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary" className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label="Copy target" aria-label="Copy target"
onClick={() => handleCopy(details.target || "")} onClick={() =>
handleCopy(
details.target ||
"",
)
}
> >
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
</Button> </Button>
@@ -365,10 +426,13 @@ function AuditLogsPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onClick={() =>
setExpandedRows((prev) => ({ setExpandedRows(
(prev) => ({
...prev, ...prev,
[rowKey]: !isExpanded, [rowKey]:
})) !isExpanded,
}),
)
} }
> >
{isExpanded ? ( {isExpanded ? (
@@ -381,22 +445,37 @@ function AuditLogsPage() {
</TableRow> </TableRow>
{isExpanded && ( {isExpanded && (
<TableRow className="bg-card/20"> <TableRow className="bg-card/20">
<TableCell colSpan={7} className="text-xs"> <TableCell
colSpan={7}
className="text-xs"
>
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3"> <div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
<div className="space-y-1"> <div className="space-y-1">
<div className="uppercase tracking-[0.16em]"> <div className="uppercase tracking-[0.16em]">
Request Request
</div> </div>
<div className="break-all"> <div className="break-all">
Request ID · {formatCellValue(details.request_id)} Request ID ·{" "}
{formatCellValue(
details.request_id,
)}
</div> </div>
<div className="break-all"> <div className="break-all">
Event ID · {formatCellValue(row.event_id)} Event ID ·{" "}
{formatCellValue(
row.event_id,
)}
</div>
<div>
IP ·{" "}
{formatCellValue(
row.ip_address,
)}
</div> </div>
<div>IP · {formatCellValue(row.ip_address)}</div>
<div> <div>
Latency ·{" "} Latency ·{" "}
{details.latency_ms !== undefined {details.latency_ms !==
undefined
? `${details.latency_ms}ms` ? `${details.latency_ms}ms`
: "-"} : "-"}
</div> </div>
@@ -406,23 +485,45 @@ function AuditLogsPage() {
Actor Actor
</div> </div>
<div> <div>
Actor ID · {row.user_id || details.actor_id || "-"} Actor ID ·{" "}
{row.user_id ||
details.actor_id ||
"-"}
</div>
<div>
Tenant ·{" "}
{formatCellValue(
details.tenant_id,
)}
</div>
<div>
Device ·{" "}
{formatCellValue(
row.device_id,
)}
</div> </div>
<div>Tenant · {formatCellValue(details.tenant_id)}</div>
<div>Device · {formatCellValue(row.device_id)}</div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<div className="uppercase tracking-[0.16em]"> <div className="uppercase tracking-[0.16em]">
Result Result
</div> </div>
<div className="break-all"> <div className="break-all">
Error · {formatCellValue(details.error)} Error ·{" "}
{formatCellValue(
details.error,
)}
</div> </div>
<div className="break-all"> <div className="break-all">
Before · {formatCellValue(details.before)} Before ·{" "}
{formatCellValue(
details.before,
)}
</div> </div>
<div className="break-all"> <div className="break-all">
After · {formatCellValue(details.after)} After ·{" "}
{formatCellValue(
details.after,
)}
</div> </div>
</div> </div>
</div> </div>
@@ -441,7 +542,9 @@ function AuditLogsPage() {
onClick={() => fetchNextPage()} onClick={() => fetchNextPage()}
disabled={isFetchingNextPage} disabled={isFetchingNextPage}
> >
{isFetchingNextPage ? "Loading..." : "Load more"} {isFetchingNextPage
? "Loading..."
: "Load more"}
</Button> </Button>
) : ( ) : (
<span className="text-xs text-[var(--color-muted)]"> <span className="text-xs text-[var(--color-muted)]">
@@ -451,7 +554,6 @@ function AuditLogsPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
); );

View File

@@ -90,8 +90,6 @@ services:
kratos-ui: kratos-ui:
image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0} image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0}
container_name: ory_kratos_ui container_name: ory_kratos_ui
ports:
- "${KRATOS_UI_PORT:-4455}:4455"
environment: environment:
- KRATOS_PUBLIC_URL=${KRATOS_PUBLIC_URL:-http://kratos:4433/} - KRATOS_PUBLIC_URL=${KRATOS_PUBLIC_URL:-http://kratos:4433/}
- KRATOS_BROWSER_URL=${KRATOS_BROWSER_URL:-http://localhost:${KRATOS_PUBLIC_PORT:-4433}} - KRATOS_BROWSER_URL=${KRATOS_BROWSER_URL:-http://localhost:${KRATOS_PUBLIC_PORT:-4433}}
@@ -119,8 +117,6 @@ services:
hydra: hydra:
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
container_name: ory_hydra container_name: ory_hydra
ports:
- "${HYDRA_PUBLIC_PORT:-4441}:4444"
environment: environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20 - 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} - URLS_SELF_ISSUER=${BACKEND_URL:-http://127.0.0.1:3000}
@@ -188,9 +184,6 @@ services:
keto: keto:
image: oryd/keto:${KETO_VERSION:-v25.4.0} image: oryd/keto:${KETO_VERSION:-v25.4.0}
container_name: ory_keto container_name: ory_keto
ports:
- "${KETO_READ_PORT:-4466}:4466"
- "${KETO_WRITE_PORT:-4467}:4467"
environment: environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20 - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20
volumes: volumes:
@@ -207,13 +200,39 @@ services:
image: oryd/oathkeeper:v0.40.6 image: oryd/oathkeeper:v0.40.6
container_name: ory_oathkeeper container_name: ory_oathkeeper
ports: ports:
- "4456:4456" # API
- "4457:4455" # Proxy - "4457:4455" # Proxy
environment: environment:
- LOG_LEVEL=debug - LOG_LEVEL=debug
- APP_ENV=${APP_ENV:-development}
volumes: volumes:
- ./docker/ory/oathkeeper:/etc/config/oathkeeper - ./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: networks:
- ory-net - ory-net
@@ -268,6 +287,7 @@ services:
volumes: volumes:
ory_postgres_data: ory_postgres_data:
ory_clickhouse_data:
networks: networks:
ory-net: ory-net:

View File

@@ -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;

View File

@@ -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"

View File

@@ -4,13 +4,17 @@ serve:
api: api:
port: 4456 port: 4456
log:
level: info
format: json
errors: errors:
fallback: fallback:
- json - json
access_rules: access_rules:
repositories: repositories:
- file:///etc/config/oathkeeper/rules.json - file://${RULES_FILE:-/etc/config/oathkeeper/rules.json}
authenticators: authenticators:
noop: noop:

View File

@@ -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" }
]
}
]

View File

@@ -1 +1,92 @@
[] [
{
"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" }
]
}
]

View File

@@ -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" }
]
}
]

View File

@@ -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" }
]
}
]

View File

@@ -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"