forked from baron/baron-sso
README update
This commit is contained in:
105
README.md
105
README.md
@@ -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을 통해 진행됩니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,460 +1,562 @@
|
|||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Copy,
|
Copy,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import type { AuditLog } from "../../lib/adminApi";
|
import type { AuditLog } from "../../lib/adminApi";
|
||||||
import { fetchAuditLogs } from "../../lib/adminApi";
|
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||||
|
|
||||||
const defaultAuditFilters = [
|
const defaultAuditFilters = [
|
||||||
"method:POST path:/api/v1/*",
|
"method:POST path:/api/v1/*",
|
||||||
"status:failure",
|
"status:failure",
|
||||||
"latency_ms:>1000",
|
"latency_ms:>1000",
|
||||||
];
|
];
|
||||||
|
|
||||||
type AuditDetails = {
|
type AuditDetails = {
|
||||||
request_id?: string;
|
request_id?: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
latency_ms?: number;
|
latency_ms?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
tenant_id?: string;
|
tenant_id?: string;
|
||||||
actor_id?: string;
|
actor_id?: string;
|
||||||
action?: string;
|
action?: string;
|
||||||
target?: string;
|
target?: string;
|
||||||
before?: unknown;
|
before?: unknown;
|
||||||
after?: unknown;
|
after?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseDetails(details?: string): AuditDetails {
|
function parseDetails(details?: string): AuditDetails {
|
||||||
if (!details) {
|
if (!details) {
|
||||||
return {};
|
return {};
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(details);
|
|
||||||
if (parsed && typeof parsed === "object") {
|
|
||||||
return parsed as AuditDetails;
|
|
||||||
}
|
}
|
||||||
} catch {}
|
try {
|
||||||
return {};
|
const parsed = JSON.parse(details);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return parsed as AuditDetails;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCellValue(value: unknown) {
|
function formatCellValue(value: unknown) {
|
||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatIsoDateTime(value: string) {
|
function formatIsoDateTime(value: string) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return { date: "-", time: "-" };
|
return { date: "-", time: "-" };
|
||||||
}
|
}
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
return { date: value, time: "-" };
|
return { date: value, time: "-" };
|
||||||
}
|
}
|
||||||
const date = parsed.toISOString().slice(0, 10);
|
const date = parsed.toISOString().slice(0, 10);
|
||||||
const time = parsed.toLocaleTimeString("ko-KR", { hour12: false });
|
const time = parsed.toLocaleTimeString("ko-KR", { hour12: false });
|
||||||
return { date, time };
|
return { date, time };
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuditLogsPage() {
|
function AuditLogsPage() {
|
||||||
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
const [filters, setFilters] = React.useState(defaultAuditFilters);
|
||||||
const [filterDraft, setFilterDraft] = React.useState("");
|
const [filterDraft, setFilterDraft] = React.useState("");
|
||||||
const [expandedRows, setExpandedRows] = React.useState<
|
const [expandedRows, setExpandedRows] = React.useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
const handleCopy = (value: string) => {
|
const handleCopy = (value: string) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return;
|
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 <div className="p-8 text-center">Loading audit logs...</div>;
|
||||||
}
|
}
|
||||||
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 =
|
if (error) {
|
||||||
data?.pages?.flatMap((page) =>
|
const errMsg =
|
||||||
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||||
) ?? [];
|
(error as Error).message;
|
||||||
|
return (
|
||||||
const handleAddFilter = () => {
|
<div className="p-8 text-center text-red-500">
|
||||||
const trimmed = filterDraft.trim();
|
Error loading logs: {errMsg}
|
||||||
if (!trimmed) {
|
</div>
|
||||||
return;
|
);
|
||||||
}
|
}
|
||||||
setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed]));
|
|
||||||
setFilterDraft("");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="p-8 text-center">Loading audit logs...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
const errMsg =
|
|
||||||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
|
||||||
(error as Error).message;
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-red-500">
|
<div className="space-y-8">
|
||||||
Error loading logs: {errMsg}
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
</div>
|
<div>
|
||||||
);
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
}
|
<span>Audit</span>
|
||||||
|
<span>/</span>
|
||||||
return (
|
<span className="text-foreground">Logs</span>
|
||||||
<div className="space-y-8">
|
</div>
|
||||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
<h2 className="text-3xl font-semibold">감사 로그</h2>
|
||||||
<div>
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
Command 요청 기반 ClickHouse 로그를 조회합니다.
|
||||||
<span>Audit</span>
|
사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.
|
||||||
<span>/</span>
|
</p>
|
||||||
<span className="text-foreground">Logs</span>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-3xl font-semibold">감사 로그</h2>
|
<Button
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
variant="outline"
|
||||||
Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는
|
onClick={() => refetch()}
|
||||||
추후 세션 연동 시 자동 채워집니다.
|
disabled={isFetching}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
|
||||||
<RefreshCw size={16} />
|
|
||||||
새로고침
|
|
||||||
</Button>
|
|
||||||
<Button>
|
|
||||||
<ListChecks size={16} />
|
|
||||||
Export CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card className="bg-[var(--color-panel)]">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Audit registry</CardTitle>
|
|
||||||
<CardDescription>로드된 로그 {logs.length}건</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Badge variant="muted">Command only</Badge>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|
||||||
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
|
||||||
<Search size={14} />
|
|
||||||
<input
|
|
||||||
value={filterDraft}
|
|
||||||
onChange={(event) => 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"
|
|
||||||
/>
|
|
||||||
<Button size="sm" variant="outline" onClick={handleAddFilter}>
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{filters.length === 0 ? (
|
|
||||||
<span className="text-xs text-[var(--color-muted)]">
|
|
||||||
필터 없음
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
filters.map((filter) => (
|
|
||||||
<span
|
|
||||||
key={filter}
|
|
||||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
|
||||||
>
|
|
||||||
<Terminal size={12} />
|
|
||||||
{filter}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setFilters((prev) =>
|
|
||||||
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)]"
|
|
||||||
aria-label={`${filter} 필터 제거`}
|
|
||||||
>
|
>
|
||||||
×
|
<RefreshCw size={16} />
|
||||||
</button>
|
새로고침
|
||||||
</span>
|
</Button>
|
||||||
))
|
<Button>
|
||||||
)}
|
<ListChecks size={16} />
|
||||||
</div>
|
Export CSV
|
||||||
<Table className="table-fixed">
|
</Button>
|
||||||
<TableHeader>
|
</div>
|
||||||
<TableRow>
|
</header>
|
||||||
<TableHead className="w-[140px]">TIME</TableHead>
|
|
||||||
<TableHead className="w-[160px]">ACTOR (ID)</TableHead>
|
|
||||||
<TableHead>REQUEST</TableHead>
|
|
||||||
<TableHead>PATH</TableHead>
|
|
||||||
<TableHead className="w-[120px]">STATUS</TableHead>
|
|
||||||
<TableHead>Action / Target</TableHead>
|
|
||||||
<TableHead className="w-[80px]"></TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{isLoading && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={7}>로딩 중...</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{!isLoading && logs.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={7}>
|
|
||||||
아직 수집된 감사 로그가 없습니다.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{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 (
|
|
||||||
<React.Fragment key={rowKey}>
|
|
||||||
<TableRow className="bg-card/40">
|
|
||||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
|
||||||
{(() => {
|
|
||||||
const { date, time } = formatIsoDateTime(
|
|
||||||
row.timestamp,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div>{date}</div>
|
|
||||||
<div>{time}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
|
||||||
{row.user_id || details.actor_id || "-"}
|
|
||||||
</code>
|
|
||||||
{(row.user_id || details.actor_id) && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
|
||||||
aria-label="Copy actor id"
|
|
||||||
onClick={() =>
|
|
||||||
handleCopy(row.user_id || details.actor_id || "")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="break-all">
|
|
||||||
{formatCellValue(details.request_id)}
|
|
||||||
</span>
|
|
||||||
{details.request_id && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
|
||||||
aria-label="Copy request id"
|
|
||||||
onClick={() => handleCopy(details.request_id || "")}
|
|
||||||
>
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
|
||||||
<div className="font-semibold text-foreground">
|
|
||||||
{formatCellValue(details.method)}
|
|
||||||
</div>
|
|
||||||
<div className="break-all">
|
|
||||||
{formatCellValue(details.path)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
row.status === "success" || row.status === "ok"
|
|
||||||
? "success"
|
|
||||||
: "warning"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{row.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-[var(--color-muted)]">
|
|
||||||
<div className="font-semibold text-foreground">
|
|
||||||
{actionLabel}
|
|
||||||
</div>
|
|
||||||
{details.target && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="break-all">
|
|
||||||
Target · {details.target}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
|
||||||
aria-label="Copy target"
|
|
||||||
onClick={() => handleCopy(details.target || "")}
|
|
||||||
>
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedRows((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[rowKey]: !isExpanded,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{isExpanded && (
|
|
||||||
<TableRow className="bg-card/20">
|
|
||||||
<TableCell colSpan={7} className="text-xs">
|
|
||||||
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="uppercase tracking-[0.16em]">
|
|
||||||
Request
|
|
||||||
</div>
|
|
||||||
<div className="break-all">
|
|
||||||
Request ID · {formatCellValue(details.request_id)}
|
|
||||||
</div>
|
|
||||||
<div className="break-all">
|
|
||||||
Event ID · {formatCellValue(row.event_id)}
|
|
||||||
</div>
|
|
||||||
<div>IP · {formatCellValue(row.ip_address)}</div>
|
|
||||||
<div>
|
|
||||||
Latency ·{" "}
|
|
||||||
{details.latency_ms !== undefined
|
|
||||||
? `${details.latency_ms}ms`
|
|
||||||
: "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="uppercase tracking-[0.16em]">
|
|
||||||
Actor
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
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 className="space-y-1">
|
|
||||||
<div className="uppercase tracking-[0.16em]">
|
|
||||||
Result
|
|
||||||
</div>
|
|
||||||
<div className="break-all">
|
|
||||||
Error · {formatCellValue(details.error)}
|
|
||||||
</div>
|
|
||||||
<div className="break-all">
|
|
||||||
Before · {formatCellValue(details.before)}
|
|
||||||
</div>
|
|
||||||
<div className="break-all">
|
|
||||||
After · {formatCellValue(details.after)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<div className="pt-4 text-center">
|
|
||||||
{hasNextPage ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => fetchNextPage()}
|
|
||||||
disabled={isFetchingNextPage}
|
|
||||||
>
|
|
||||||
{isFetchingNextPage ? "Loading..." : "Load more"}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-[var(--color-muted)]">
|
|
||||||
End of audit feed
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
</div>
|
<Card className="glass-panel">
|
||||||
);
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Audit registry</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
로드된 로그 {logs.length}건
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">Command only</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
|
||||||
|
<Search size={14} />
|
||||||
|
<input
|
||||||
|
value={filterDraft}
|
||||||
|
onChange={(event) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleAddFilter}
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{filters.length === 0 ? (
|
||||||
|
<span className="text-xs text-[var(--color-muted)]">
|
||||||
|
필터 없음
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
filters.map((filter) => (
|
||||||
|
<span
|
||||||
|
key={filter}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||||
|
>
|
||||||
|
<Terminal size={12} />
|
||||||
|
{filter}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFilters((prev) =>
|
||||||
|
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)]"
|
||||||
|
aria-label={`${filter} 필터 제거`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Table className="table-fixed">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[140px]">
|
||||||
|
TIME
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[160px]">
|
||||||
|
ACTOR (ID)
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>REQUEST</TableHead>
|
||||||
|
<TableHead>PATH</TableHead>
|
||||||
|
<TableHead className="w-[120px]">
|
||||||
|
STATUS
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Action / Target</TableHead>
|
||||||
|
<TableHead className="w-[80px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7}>
|
||||||
|
로딩 중...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!isLoading && logs.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7}>
|
||||||
|
아직 수집된 감사 로그가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{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 (
|
||||||
|
<React.Fragment key={rowKey}>
|
||||||
|
<TableRow className="bg-card/40">
|
||||||
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
|
{(() => {
|
||||||
|
const { date, time } =
|
||||||
|
formatIsoDateTime(
|
||||||
|
row.timestamp,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>
|
||||||
|
{date}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{time}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||||
|
{row.user_id ||
|
||||||
|
details.actor_id ||
|
||||||
|
"-"}
|
||||||
|
</code>
|
||||||
|
{(row.user_id ||
|
||||||
|
details.actor_id) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label="Copy actor id"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopy(
|
||||||
|
row.user_id ||
|
||||||
|
details.actor_id ||
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="break-all">
|
||||||
|
{formatCellValue(
|
||||||
|
details.request_id,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{details.request_id && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label="Copy request id"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopy(
|
||||||
|
details.request_id ||
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
|
<div className="font-semibold text-foreground">
|
||||||
|
{formatCellValue(
|
||||||
|
details.method,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
{formatCellValue(
|
||||||
|
details.path,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
row.status ===
|
||||||
|
"success" ||
|
||||||
|
row.status === "ok"
|
||||||
|
? "success"
|
||||||
|
: "warning"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-[var(--color-muted)]">
|
||||||
|
<div className="font-semibold text-foreground">
|
||||||
|
{actionLabel}
|
||||||
|
</div>
|
||||||
|
{details.target && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="break-all">
|
||||||
|
Target ·{" "}
|
||||||
|
{details.target}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label="Copy target"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopy(
|
||||||
|
details.target ||
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedRows(
|
||||||
|
(prev) => ({
|
||||||
|
...prev,
|
||||||
|
[rowKey]:
|
||||||
|
!isExpanded,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isExpanded && (
|
||||||
|
<TableRow className="bg-card/20">
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="uppercase tracking-[0.16em]">
|
||||||
|
Request
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Request ID ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
details.request_id,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Event ID ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
row.event_id,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
IP ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
row.ip_address,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Latency ·{" "}
|
||||||
|
{details.latency_ms !==
|
||||||
|
undefined
|
||||||
|
? `${details.latency_ms}ms`
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="uppercase tracking-[0.16em]">
|
||||||
|
Actor
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
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 className="space-y-1">
|
||||||
|
<div className="uppercase tracking-[0.16em]">
|
||||||
|
Result
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Error ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
details.error,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
Before ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
details.before,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="break-all">
|
||||||
|
After ·{" "}
|
||||||
|
{formatCellValue(
|
||||||
|
details.after,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<div className="pt-4 text-center">
|
||||||
|
{hasNextPage ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage
|
||||||
|
? "Loading..."
|
||||||
|
: "Load more"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--color-muted)]">
|
||||||
|
End of audit feed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AuditLogsPage;
|
export default AuditLogsPage;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
22
docker/ory/clickhouse/init.sql
Normal file
22
docker/ory/clickhouse/init.sql
Normal 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;
|
||||||
22
docker/ory/oathkeeper/entrypoint.sh
Executable file
22
docker/ory/oathkeeper/entrypoint.sh
Executable 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"
|
||||||
@@ -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:
|
||||||
|
|||||||
112
docker/ory/oathkeeper/rules.draft.json
Normal file
112
docker/ory/oathkeeper/rules.draft.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|||||||
92
docker/ory/oathkeeper/rules.prod.json
Normal file
92
docker/ory/oathkeeper/rules.prod.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
92
docker/ory/oathkeeper/rules.stage.json
Normal file
92
docker/ory/oathkeeper/rules.stage.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
52
docker/ory/vector/vector.toml
Normal file
52
docker/ory/vector/vector.toml
Normal 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"
|
||||||
Reference in New Issue
Block a user