1
0
forked from baron/baron-sso

Merge remote-tracking branch 'origin/dev' into fix/issue-637

This commit is contained in:
2026-05-07 13:53:14 +09:00
103 changed files with 6332 additions and 1539 deletions

View File

@@ -76,20 +76,20 @@ HYDRA_DB=ory_hydra
KETO_DB=ory_keto
# Ory Kratos Configuration
KRATOS_VERSION=v25.4.0-distroless
KRATOS_VERSION=v26.2.0-distroless
# KRATOS_PUBLIC_PORT=4433 # Internal only
# KRATOS_ADMINFRONT_PORT=4434 # Internal only
KRATOS_UI_NODE_VERSION=v25.4.0
KRATOS_UI_NODE_VERSION=v26.2.0
# KRATOS_UI_PORT=4455 # Internal only
# Ory Hydra Configuration
HYDRA_VERSION=v25.4.0-distroless
HYDRA_VERSION=v26.2.0-distroless
# HYDRA_PUBLIC_PORT=4441 # Internal only
# HYDRA_ADMINFRONT_PORT=4445 # Internal only
# Ory Keto Configuration
KETO_VERSION=v25.4.0-distroless
KETO_VERSION=v26.2.0-distroless
# KETO_READ_PORT=4466 # Internal only
# KETO_WRITE_PORT=4467 # Internal only
KETO_READ_URL=http://keto:4466
@@ -109,16 +109,21 @@ KRATOS_UI_URL=http://localhost:5000
HYDRA_ADMIN_URL=http://hydra:4445
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
# 선택: Hydra 화면 핸드오프 URL을 USERFRONT_URL 기준 기본값과 다르게 둘 때만 설정합니다.
# HYDRA_LOGIN_URL=https://sso.hmac.kr/login
# HYDRA_CONSENT_URL=https://sso.hmac.kr/consent
# HYDRA_ERROR_URL=https://sso.hmac.kr/error
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
KRATOS_ALLOWED_RETURN_URLS_JSON=["http://localhost:5000","http://localhost:5000/","https://sso.hmac.kr","https://sso.hmac.kr/","https://sso.hmac.kr/ko","https://sso.hmac.kr/ko/","https://sso.hmac.kr/en","https://sso.hmac.kr/en/","https://sso.hmac.kr/auth/callback","https://sso.hmac.kr/ko/auth/callback","https://sso.hmac.kr/en/auth/callback","http://localhost:5173/auth/callback","http://localhost:5174/auth/callback","http://localhost:5175/auth/callback","https://sso.hmac.kr/orgfront/auth/callback"]
# Oathkeeper JWKS (내부 통신용)
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
# Oathkeeper 실행 사용자/프로브 설정
OATHKEEPER_VERSION=v25.4.0
OATHKEEPER_VERSION=v26.2.0
OATHKEEPER_UID=1001
OATHKEEPER_GID=1001
OATHKEEPER_HEALTH_URL=http://oathkeeper:4456/health/ready
@@ -140,5 +145,5 @@ VITE_OIDC_CLIENT_ID=devfront
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
DEVFRONT_URL=http://localhost:5174
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback
VITE_ORGCHART_URL=
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback
VITE_ORGCHART_URL=

View File

@@ -124,11 +124,13 @@ jobs:
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
# Frontend OIDC configs for Staging
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback,http://172.16.10.176:5173/auth/callback,https://sadmin.hmac.kr/auth/callback
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback,http://172.16.10.176:5174/auth/callback,https://sdev.hmac.kr/auth/callback
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback
# Frontend/Ory URL configs for Staging
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}
DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}
ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}
KRATOS_ALLOWED_RETURN_URLS_JSON=${{ vars.KRATOS_ALLOWED_RETURN_URLS_JSON }}
KRATOS_ALLOWED_RETURN_URLS_EXTRA=${{ vars.KRATOS_ALLOWED_RETURN_URLS_EXTRA }}
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
EOF
@@ -161,8 +163,9 @@ jobs:
done
# [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)
chmod -R 777 docker/ory || true
# Ory 컨테이너가 직접 읽는 설정은 env 기반으로 완성한 뒤 mount합니다.
bash scripts/render_ory_config.sh
chmod -R 777 config/.generated/ory || true
cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@
.codex/
.serena/
.generated/
config/.generated/
*.swp
*.log
*.out

View File

@@ -10,7 +10,7 @@ endif
COMPOSE_INFRA := compose.infra.yaml
COMPOSE_ORY := compose.ory.yaml
COMPOSE_APP := docker-compose.yaml
AUTH_CONFIG_ENV := .generated/auth-config.env
AUTH_CONFIG_ENV := config/.generated/auth-config.env
DEV_SERVICES ?= backend adminfront devfront orgfront userfront
DEV_NETWORKS := baron_net ory-net hydranet kratosnet public_net
INFRA_CONTAINERS := baron_postgres baron_clickhouse baron_redis baron_gateway
@@ -29,12 +29,12 @@ ifneq (,$(wildcard ./.env))
COMPOSE_DROP_ENV_ARGS += --env-file .env
endif
.PHONY: build-auth-config validate-auth-config verify-auth-config up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app
.PHONY: build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app
# --- 인증 설정 빌드/검증 ---
build-auth-config:
@echo "Building auth config..."
@mkdir -p .generated
@mkdir -p config/.generated
@bash scripts/auth_config.sh build
validate-auth-config: build-auth-config
@@ -45,28 +45,34 @@ verify-auth-config: validate-auth-config
@echo "Verifying auth config wiring..."
@bash scripts/auth_config.sh verify
render-ory-config: validate-auth-config
@echo "Rendering Ory config..."
@bash scripts/render_ory_config.sh
# --- 기본 실행 ---
# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음)
up-all: ensure-networks validate-auth-config
up: up-all
up-all: ensure-networks render-ory-config
@echo "Starting ALL stacks (infra + ory + app)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d
# --- 개별 스택 실행 ---
up-infra: ensure-networks
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
docker compose -f $(COMPOSE_INFRA) up -d
up-ory: ensure-networks validate-auth-config
up-ory: ensure-networks render-ory-config
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
up-app: ensure-networks validate-auth-config
@echo "Starting App stack (backend/userfront/adminfront/devfront)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d
up-app: ensure-networks render-ory-config
@echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d
up-backend: ensure-networks validate-auth-config
up-backend: ensure-networks render-ory-config
@echo "Starting Backend only..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d backend
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d backend
ensure-networks:
@echo "Ensuring Docker networks..."
@@ -95,7 +101,7 @@ ensure-infra: ensure-networks
echo "Infra stack is already running."; \
fi
ensure-ory: ensure-networks validate-auth-config
ensure-ory: ensure-networks render-ory-config
@echo "Ensuring Ory stack..."
@missing=0; \
for container in $(ORY_CONTAINERS); do \
@@ -119,7 +125,7 @@ up-front-dev: up-infra up-ory up-backend
dev: up-dev
@echo "Starting development app containers in foreground attach mode..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up $(DEV_SERVICES)
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
# --- 종료 (Down) ---
down:
@@ -184,7 +190,7 @@ PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATI
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi'
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-orgfront-tests code-check-userfront-e2e-tests
CODE_CHECK_TEST_JOBS ?= 1
PLAYWRIGHT_WORKERS ?= 1
@@ -202,7 +208,8 @@ code-check-test-jobs:
code-check-userfront-tests \
code-check-userfront-e2e-tests \
code-check-adminfront-tests \
code-check-devfront-tests
code-check-devfront-tests \
code-check-orgfront-tests
code-check-i18n:
@echo "==> i18n resource check"
@@ -257,6 +264,11 @@ code-check-front-lint:
cd devfront && npm ci --ignore-scripts
cd devfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd devfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
@echo "==> orgfront biome lint/format check"
rm -rf orgfront/playwright-report orgfront/test-results
cd orgfront && npm ci --ignore-scripts
cd orgfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd orgfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
code-check-backend-tests:
@echo "==> backend tests"
@@ -304,6 +316,22 @@ code-check-devfront-tests:
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
exit $$status
code-check-orgfront-tests:
@echo "==> orgfront tests"
@mkdir -p reports/orgfront
@rm -rf reports/orgfront/playwright-report reports/orgfront/test-results
@status=0; \
(cd orgfront && npm ci --ignore-scripts) || status=$$?; \
if [ $$status -eq 0 ]; then \
(cd orgfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd orgfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
fi; \
[ -d orgfront/playwright-report ] && cp -R orgfront/playwright-report reports/orgfront/ || true; \
[ -d orgfront/test-results ] && cp -R orgfront/test-results reports/orgfront/ || true; \
exit $$status
code-check-userfront-e2e-tests:
@echo "==> userfront wasm playwright e2e tests (isolated workspace)"
@mkdir -p reports/userfront-e2e

View File

@@ -66,7 +66,7 @@ flowchart
```
### 1. Backend (Go Fiber)
- **Language**: Go 1.25+
- **Language**: Go 1.26.2+
- **Framework**: Fiber v2.25+
- **Database**:
- **ClickHouse**: 감사 로그 (고성능 데이터 수집)
@@ -395,11 +395,13 @@ USERFRONT_URL=https://sso.example.com
- `KRATOS_BROWSER_URL`: 보통 `${OATHKEEPER_PUBLIC_URL}/auth`
- `KRATOS_UI_URL`: UserFront UI URL (로컬 예: `http://localhost:5000`)
- `ADMINFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5173/auth/callback`)
- `DEVFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5174/callback`)
- `DEVFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5174/auth/callback`)
- 주의: callback URL 끝에 `/`가 붙으면 `make validate-auth-config`에서 실패 처리됩니다.
- `KRATOS_ALLOWED_RETURN_URLS_EXTRA`: 추가 허용 return URL (선택)
- 빈값: `[]`
- 다중값: `["https://a.example.com/callback","https://b.example.com/callback"]` 또는 `https://a.example.com/callback,https://b.example.com/callback`
- `KRATOS_ALLOWED_RETURN_URLS_JSON`: stage/prod에서 권장하는 전체 허용 return URL 목록
- 공개 도메인, `/ko`, `/en`, `/auth/callback`, `/ko/auth/callback`, `/en/auth/callback`, 각 front callback을 포함해야 합니다.
- `CLIENT_LOG_DEBUG`: 클라이언트 로그 디버그 모드 강제 (기본: 비운영 `true`, 운영 `false`)
- 운영(`APP_ENV=production|prod`)에서 `true|1|on|yes` 설정 시 `INFO/DEBUG` 클라이언트 로그 수집 허용
- 미설정(기본) 시 운영에서는 `WARN/ERROR`만 수집
@@ -434,7 +436,7 @@ USERFRONT_URL=https://sso.example.com
```bash
make validate-auth-config
```
위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `.generated/auth-config.env`를 생성합니다.
위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `config/.generated/auth-config.env`를 생성합니다.
### 전체 스택 실행 (Running the Stack)
@@ -476,7 +478,7 @@ make validate-auth-config
make verify-auth-config
```
- 생성 파일: `.generated/auth-config.env` (compose 실행 시 자동 주입)
- 생성 파일: `config/.generated/auth-config.env` (compose 실행 시 자동 주입)
- 게이트웨이 경유 환경은 URL 문자열 완전일치 대신 매핑 유효성(`direct_match` / `mapped_match`) 기준으로 검증합니다.
- 관련 정책 문서: `docs/oidc_redirect_mapping_validation_policy.md`
@@ -491,8 +493,8 @@ make up-app
직접 Compose를 사용하려면 다음처럼 env 파일을 함께 주입하세요.
```bash
docker compose --env-file .env --env-file .generated/auth-config.env -f compose.infra.yaml -f compose.ory.yaml up -d
docker compose --env-file .env --env-file .generated/auth-config.env -f docker-compose.yaml up -d
docker compose --env-file .env --env-file config/.generated/auth-config.env -f compose.infra.yaml -f compose.ory.yaml up -d
docker compose --env-file .env --env-file config/.generated/auth-config.env -f docker-compose.yaml up -d
```
- **gateway (UserFront 프록시)**: http://localhost:5000 접속

View File

@@ -14,7 +14,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
- Descope SDK Integration (Enchanted Link, Magic Link)
### 2. Backend (Go Fiber)
- **Language**: Go 1.25+
- **Language**: Go 1.26.2+
- **Framework**: Fiber v2.25+
- **Database**:
- **ClickHouse**: Audit Logs (High performance ingestion)

View File

@@ -534,7 +534,7 @@ function AppLayout() {
</nav>
</aside>
<div className="relative">
<div className="relative min-w-0">
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
@@ -730,7 +730,7 @@ function AppLayout() {
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<main className="min-w-0 px-5 py-6 md:px-10 md:py-10">
<Outlet />
</main>
<RoleSwitcher />

View File

@@ -1,109 +1,25 @@
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
const flows = [
{
title: "Admin login",
description:
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
pill: "15m TTL",
},
{
title: "Tenant pick",
description:
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
pill: "Header-ready",
},
{
title: "Device approval",
description:
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
pill: "App session",
},
];
import { KeyRound } from "lucide-react";
import PermissionChecker from "./components/PermissionChecker";
function AuthPage() {
return (
<div className="space-y-8">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Admin auth
</p>
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
<p className="text-sm text-[var(--color-muted)]">
Build the admin-only login flow first, keeping app login separate.
Respect the fallback only when user chooses rule for SMS/email
vs app approval.
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
IDP session placeholder
</span>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
>
<Sparkles size={14} />
Connect auth layer
</button>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
{flows.map((flow) => (
<div
key={flow.title}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
>
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
<span>{flow.pill}</span>
<Fingerprint size={14} />
</div>
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
<p className="text-sm text-[var(--color-muted)]">
{flow.description}
</p>
</div>
))}
</section>
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Smartphone size={16} />
<span className="text-xs uppercase tracking-[0.18em]">
App-based approvals
</span>
</div>
<h3 className="mt-2 text-xl font-semibold">
App session as MFA replacement
</h3>
<p className="text-sm text-[var(--color-muted)]">
If the admin keeps the mobile app signed in and opts in, use
push/deeplink approval instead of OTP. Otherwise fall back to
SMS/email based on user choice.
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Admin auth
</p>
<h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<KeyRound size={22} className="text-primary" />
</h2>
<p className="text-sm text-muted-foreground">
ReBAC .
</p>
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<ArrowRight size={16} />
<span className="text-xs uppercase tracking-[0.18em]">
TTL discipline
</span>
</div>
<h3 className="mt-2 text-xl font-semibold">
Keep admin sessions short
</h3>
<p className="text-sm text-[var(--color-muted)]">
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
with step-up MFA when critical actions (rotate secret, export logs)
happen.
</p>
</div>
</section>
</div>
<PermissionChecker />
</div>
);
}

View File

@@ -44,7 +44,7 @@ function PermissionChecker() {
const result = checkMutation.data;
return (
<Card className="bg-[var(--color-panel)] border-primary/20">
<Card className="border-primary/20 bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldAlert size={20} className="text-primary" />
@@ -100,7 +100,7 @@ function PermissionChecker() {
<Button
onClick={() => checkMutation.mutate()}
disabled={!object || !subject || checkMutation.isPending}
className="w-full md:w-auto px-12"
className="w-full px-12 md:w-auto"
>
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
</Button>
@@ -108,17 +108,17 @@ function PermissionChecker() {
{checkMutation.isSuccess && result && (
<div
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
className={`flex flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 animate-in zoom-in duration-300 ${
result.allowed
? "bg-green-500/10 border-green-500/50 text-green-600"
: "bg-destructive/10 border-destructive/50 text-destructive"
? "border-green-500/50 bg-green-500/10 text-green-600"
: "border-destructive/50 bg-destructive/10 text-destructive"
}`}
>
{result.allowed ? (
<>
<CheckCircle2 size={48} />
<div className="text-xl font-bold">Access ALLOWED</div>
<p className="text-sm opacity-80 text-center">
<p className="text-center text-sm opacity-80">
. (
)
</p>
@@ -127,7 +127,7 @@ function PermissionChecker() {
<>
<XCircle size={48} />
<div className="text-xl font-bold">Access DENIED</div>
<p className="text-sm opacity-80 text-center">
<p className="text-center text-sm opacity-80">
.
</p>
</>

View File

@@ -0,0 +1,186 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchAdminRPUsageDaily } from "../../lib/adminApi";
import AuthPage from "../auth/AuthPage";
import GlobalOverviewPage from "./GlobalOverviewPage";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
fetchAdminOverviewStats: vi.fn(async () => ({
totalTenants: 10,
oidcClients: 3,
auditEvents24h: 18,
})),
fetchTenants: vi.fn(async () => ({
items: [
{
id: "company-1",
type: "COMPANY",
name: "한맥",
slug: "hanmac",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "org-1",
type: "ORGANIZATION",
name: "개발팀",
slug: "dev-team",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "personal-1",
type: "PERSONAL",
name: "개인",
slug: "personal",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
],
limit: 1000,
offset: 0,
total: 3,
})),
fetchAdminRPUsageDaily: vi.fn(async () => ({
days: 14,
period: "day",
items: [
{
date: "2026-05-05",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "orgfront",
clientName: "OrgFront",
loginRequests: 12,
otherRequests: 4,
uniqueSubjects: 8,
},
{
date: "2026-05-06",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "adminfront",
clientName: "AdminFront",
loginRequests: 7,
otherRequests: 3,
uniqueSubjects: 5,
},
{
date: "2026-09-28",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "devfront",
clientName: "DevFront",
loginRequests: 2,
otherRequests: 1,
uniqueSubjects: 2,
},
],
})),
}));
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin overview and auth guard pages", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders usage trend chart without quick navigation or permission checker", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"),
).toBeInTheDocument();
expect(
await screen.findByLabelText("일 단위 RP 요청 현황"),
).toBeInTheDocument();
expect(await screen.findByText("05.05")).toBeInTheDocument();
expect(await screen.findByText("05.06")).toBeInTheDocument();
expect(screen.queryByText("빠른 작업")).not.toBeInTheDocument();
expect(screen.queryByText("빠른 이동")).not.toBeInTheDocument();
expect(screen.queryByText("테넌트 추가")).not.toBeInTheDocument();
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
});
it("renders overview summary metrics from the admin stats API", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
(await screen.findByText("전체 테넌트 수")).parentElement,
).toHaveTextContent("10");
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
"3",
);
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
"18",
);
});
it("changes the RP usage perspective and targets a permitted organization", async () => {
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
fireEvent.click(screen.getByRole("button", { name: "주" }));
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "월" }));
fireEvent.change(screen.getByLabelText("조직 검색"), {
target: { value: "개발" },
});
fireEvent.change(screen.getByLabelText("대상 조직"), {
target: { value: "org-1" },
});
await waitFor(() => {
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
days: 90,
period: "month",
tenantId: "org-1",
});
});
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
});
it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
renderWithProviders(<AuthPage />);
expect(screen.getByText("인증가드")).toBeInTheDocument();
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
expect(
screen.queryByText("IDP session placeholder"),
).not.toBeInTheDocument();
expect(screen.queryByText("Admin login")).not.toBeInTheDocument();
});
});

View File

@@ -1,33 +1,433 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
ArrowUpRight,
BarChart3,
Database,
Key,
PlusCircle,
ShieldCheck,
Users,
} from "lucide-react";
import { Link } from "react-router-dom";
import { type ReactNode, useMemo, useState } from "react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
type RPUsageDailyMetric,
type RPUsagePeriod,
type TenantSummary,
fetchAdminOverviewStats,
fetchAdminRPUsageDaily,
fetchTenants,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
type DailyPoint = {
date: string;
loginRequests: number;
otherRequests: number;
};
type SeriesSummary = {
key: string;
tenantLabel: string;
clientLabel: string;
loginRequests: number;
otherRequests: number;
uniqueSubjects: number;
};
function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
const byDate = new Map<string, DailyPoint>();
for (const row of rows) {
const current =
byDate.get(row.date) ??
({
date: row.date,
loginRequests: 0,
otherRequests: 0,
} satisfies DailyPoint);
current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
byDate.set(row.date, current);
}
return Array.from(byDate.values()).sort((a, b) =>
a.date.localeCompare(b.date),
);
}
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
const bySeries = new Map<string, SeriesSummary>();
for (const row of rows) {
const key = `${row.tenantId}:${row.clientId}`;
const current =
bySeries.get(key) ??
({
key,
tenantLabel: row.tenantName || row.tenantId || "-",
clientLabel: row.clientName || row.clientId,
loginRequests: 0,
otherRequests: 0,
uniqueSubjects: 0,
} satisfies SeriesSummary);
current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
current.uniqueSubjects = Math.max(
current.uniqueSubjects,
row.uniqueSubjects,
);
bySeries.set(key, current);
}
return Array.from(bySeries.values())
.sort(
(a, b) =>
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
)
.slice(0, 5);
}
function parseDateParts(date: string) {
const parts = date.split("-");
if (parts.length === 3) {
return {
year: Number(parts[0]),
month: Number(parts[1]),
day: Number(parts[2]),
monthText: parts[1],
dayText: parts[2],
};
}
return null;
}
function getISOWeekNumber(year: number, month: number, day: number) {
const date = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
function getISOWeekThursday(year: number, month: number, day: number) {
const date = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
return date;
}
function formatPeriodLabel(date: string, period: RPUsagePeriod) {
const parts = parseDateParts(date);
if (!parts) {
return date;
}
if (period === "month") {
return `${parts.monthText}`;
}
if (period === "week") {
const weekNumber = String(
getISOWeekNumber(parts.year, parts.month, parts.day),
).padStart(2, "0");
const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day);
const weekMonth = weekThursday.getUTCMonth() + 1;
const weekDay = weekThursday.getUTCDate();
const weekMonthText = String(weekMonth).padStart(2, "0");
const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7)));
return `${weekNumber}(${weekMonthText}${weekOfMonth}주)`;
}
return `${parts.monthText}.${parts.dayText}`;
}
function OverviewMetric({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
<span className="text-muted-foreground">{icon}</span>
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums">{value}</span>
</span>
);
}
function RPUsageMixedChart({
rows,
filters,
period,
}: {
rows: RPUsageDailyMetric[];
filters: ReactNode;
period: RPUsagePeriod;
}) {
const daily = summarizeDaily(rows);
const series = summarizeSeries(rows);
const chartWidth = 720;
const chartHeight = 230;
const padX = 48;
const padTop = 32;
const padBottom = 34;
const innerWidth = chartWidth - padX * 2;
const innerHeight = chartHeight - padTop - padBottom;
const maxValue = Math.max(
1,
...daily.map((point) => point.loginRequests + point.otherRequests),
...daily.map((point) => point.loginRequests),
);
const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth;
const barWidth = Math.min(28, Math.max(10, slot * 0.42));
const y = (value: number) =>
padTop + innerHeight - (value / maxValue) * innerHeight;
const x = (index: number) => padX + slot * index + slot / 2;
const linePoints = daily
.map((point, index) => `${x(index)},${y(point.loginRequests)}`)
.join(" ");
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 size={18} className="text-primary" />
<h3 className="text-base font-semibold">
/
</h3>
</div>
{filters}
</div>
{daily.length === 0 ? (
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
RP .
</div>
) : (
<div className="overflow-x-auto">
<svg
role="img"
aria-label="일 단위 RP 요청 현황"
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
className="h-[235px] min-w-[720px] w-full"
>
<title> RP </title>
<g transform="translate(510 10)">
<rect
x="0"
y="3"
width="10"
height="10"
rx="2"
className="fill-sky-500/70"
/>
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
</text>
<line
x1="78"
x2="98"
y1="8"
y2="8"
className="stroke-emerald-500"
strokeWidth="3"
strokeLinecap="round"
/>
<text
x="104"
y="12"
className="fill-muted-foreground text-[11px]"
>
</text>
</g>
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const gridY = padTop + innerHeight * ratio;
const label = Math.round(maxValue * (1 - ratio));
return (
<g key={ratio}>
<line
x1={padX}
x2={chartWidth - padX}
y1={gridY}
y2={gridY}
stroke="currentColor"
className="text-border"
strokeWidth="1"
/>
<text
x={padX - 12}
y={gridY + 4}
textAnchor="end"
className="fill-muted-foreground text-[11px]"
>
{label}
</text>
</g>
);
})}
{daily.map((point, index) => {
const center = x(index);
const otherHeight =
(point.otherRequests / maxValue) * innerHeight;
return (
<g key={point.date}>
<rect
x={center - barWidth / 2}
y={padTop + innerHeight - otherHeight}
width={barWidth}
height={otherHeight}
rx="3"
className="fill-sky-500/70"
/>
<text
x={center}
y={chartHeight - 12}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
{formatPeriodLabel(point.date, period)}
</text>
</g>
);
})}
<polyline
points={linePoints}
fill="none"
className="stroke-emerald-500"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{daily.map((point, index) => (
<circle
key={`${point.date}-login`}
cx={x(index)}
cy={y(point.loginRequests)}
r="4"
className="fill-emerald-500 stroke-background"
strokeWidth="2"
/>
))}
</svg>
</div>
)}
{series.length > 0 && (
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
{series.map((item) => (
<div key={item.key} className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{item.clientLabel}</span>
<span className="truncate text-muted-foreground">
{item.tenantLabel}
</span>
<span className="ml-auto whitespace-nowrap tabular-nums">
{item.loginRequests.toLocaleString()} / {" "}
{item.otherRequests.toLocaleString()} / {" "}
{item.uniqueSubjects.toLocaleString()}
</span>
</div>
))}
</div>
)}
</section>
);
}
function GlobalOverviewPage() {
const [period, setPeriod] = useState<RPUsagePeriod>("day");
const [tenantSearch, setTenantSearch] = useState("");
const [selectedTenantId, setSelectedTenantId] = useState("");
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
const statsQuery = useQuery({
queryKey: ["admin-overview-stats"],
queryFn: fetchAdminOverviewStats,
retry: false,
});
const tenantsQuery = useQuery({
queryKey: ["admin-overview-tenant-options"],
queryFn: () => fetchTenants(1000, 0),
retry: false,
});
const tenantOptions = useMemo(() => {
const term = tenantSearch.trim().toLowerCase();
return (tenantsQuery.data?.items ?? [])
.filter(
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
)
.filter((tenant) => {
if (!term) return true;
return (
tenant.name.toLowerCase().includes(term) ||
tenant.slug.toLowerCase().includes(term) ||
tenant.id.toLowerCase().includes(term)
);
});
}, [tenantSearch, tenantsQuery.data?.items]);
const usageQuery = useQuery({
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId],
queryFn: () =>
fetchAdminRPUsageDaily({
days: usageDays,
period,
tenantId: selectedTenantId || undefined,
}),
retry: false,
});
const stats = statsQuery.data;
const usageRows = usageQuery.data?.items ?? [];
const metric = (value: number | undefined) =>
value === undefined ? "-" : value.toLocaleString();
const chartFilters = (
<div className="flex flex-wrap items-center gap-2">
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
{[
["day", "일"],
["week", "주"],
["month", "월"],
].map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={period === value}
onClick={() => setPeriod(value as RPUsagePeriod)}
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
period === value
? "bg-primary text-primary-foreground"
: "bg-muted/60 hover:bg-muted"
}`}
>
{label}
</button>
))}
</div>
<input
aria-label="조직 검색"
value={tenantSearch}
onChange={(event) => setTenantSearch(event.target.value)}
placeholder="조직 검색"
className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44"
/>
<select
aria-label="대상 조직"
value={selectedTenantId}
onChange={(event) => setSelectedTenantId(event.target.value)}
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-52"
>
<option value=""> </option>
{tenantOptions.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name} ({tenant.slug})
</option>
))}
</select>
</div>
);
return (
<div className="space-y-8 animate-in fade-in duration-500">
<div className="space-y-4 animate-in fade-in duration-500">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">
<h2 className="text-2xl font-semibold tracking-tight">
{t("ui.admin.overview.title", "Dashboard")}
</h2>
<p className="text-muted-foreground">
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.overview.description",
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
@@ -36,166 +436,61 @@ function GlobalOverviewPage() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
<RoleGuard roles={["super_admin"]}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.total_tenants", "총 테넌트")}
</CardTitle>
<div className="rounded-full bg-primary/10 p-2 text-primary">
<Users size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")}
</CardTitle>
<div className="rounded-full bg-blue-500/10 p-2 text-blue-500">
<ShieldCheck size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
OIDC
</p>
</CardContent>
</Card>
<OverviewMetric
icon={<Users size={14} />}
label={t(
"ui.admin.overview.summary.total_tenants",
"전체 테넌트 수",
)}
value={metric(stats?.totalTenants)}
/>
<OverviewMetric
icon={<ShieldCheck size={14} />}
label={t(
"ui.admin.overview.summary.oidc_clients",
"OIDC 클라이언트",
)}
value={metric(stats?.oidcClients)}
/>
</RoleGuard>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t(
"ui.admin.overview.summary.audit_events_24h",
"최근 감사 로그 (24h)",
)}
</CardTitle>
<div className="rounded-full bg-orange-500/10 p-2 text-orange-500">
<Activity size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.policy_gate", "정책 상태")}
</CardTitle>
<div className="rounded-full bg-green-500/10 p-2 text-green-500">
<Database size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600 dark:text-green-500">
Active
</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<OverviewMetric
icon={<Activity size={14} />}
label={t(
"ui.admin.overview.summary.audit_events_24h",
"24시간 이벤트",
)}
value={metric(stats?.auditEvents24h)}
/>
<OverviewMetric
icon={<Database size={14} />}
label={t("ui.admin.overview.summary.policy_gate", "정책 상태")}
value="Active"
/>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold tracking-tight">
{t("ui.admin.overview.quick_links.title", "빠른 작업")}
</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<RoleGuard roles={["super_admin"]}>
<Link
to="/tenants/new"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<PlusCircle size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-primary">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</RoleGuard>
<Link
to="/users"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-blue-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500 transition-colors group-hover:bg-blue-500 group-hover:text-white">
<Users size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-blue-500">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
<RoleGuard roles={["super_admin"]}>
<Link
to="/api-keys"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-purple-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 text-purple-500 transition-colors group-hover:bg-purple-500 group-hover:text-white">
<Key size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-purple-500">
API
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</RoleGuard>
<Link
to="/audit-logs"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-orange-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-orange-500/10 text-orange-500 transition-colors group-hover:bg-orange-500 group-hover:text-white">
<Activity size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-orange-500">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</div>
</div>
<RoleGuard roles={["super_admin"]}>
<div className="pt-4">
<PermissionChecker />
</div>
</RoleGuard>
{usageQuery.isError ? (
<section className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold">
/
</h3>
{chartFilters}
</div>
<div className="text-sm text-muted-foreground">
RP Query API . backend
`rp_usage_daily_aggregate`
.
</div>
</section>
) : (
<RPUsageMixedChart
rows={usageRows}
filters={chartFilters}
period={period}
/>
)}
</div>
);
}

View File

@@ -556,10 +556,10 @@ function TenantListPage() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<Table className="min-w-[1180px]">
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[40px]">
<TableHead className="w-[48px] whitespace-nowrap">
<Checkbox
checked={
tenants.length > 0 &&
@@ -634,7 +634,7 @@ function TenantListPage() {
{getSortIcon("updatedAt")}
</div>
</TableHead>
<TableHead className="text-right">
<TableHead className="w-[160px] whitespace-nowrap text-right">
{t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
@@ -690,21 +690,18 @@ function TenantListPage() {
)}
</div>
</TableCell>
<TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant="outline"
className="text-[10px] font-mono"
>
{t(
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
tenant.type,
)}
{tenant.type}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{tenant.slug}
</TableCell>
<TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant={
tenant.status === "active"
@@ -723,12 +720,12 @@ function TenantListPage() {
<TableCell className="font-medium">
{tenant.recursiveMemberCount}
</TableCell>
<TableCell className="text-xs">
<TableCell className="whitespace-nowrap text-xs">
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<TableCell className="whitespace-nowrap text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"

View File

@@ -1,10 +1,17 @@
import { describe, expect, it } from "vitest";
import {
buildWorksmobilePasswordManageUrl,
canOpenWorksmobilePasswordManage,
canCreateWorksmobileRow,
canSelectWorksmobileRow,
filterWorksmobileComparisonRows,
formatWorksmobileOrgDetails,
formatWorksmobilePersonName,
getDefaultWorksmobileComparisonColumns,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
getWorksmobileComparisonStatusLabel,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
userFilterOptions,
} from "./TenantWorksmobilePage";
@@ -69,6 +76,121 @@ describe("TenantWorksmobilePage comparison helpers", () => {
).toBe(false);
});
it("allows selection for Baron-only, WORKS-only, and matched rows", () => {
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "user-1",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-user-1",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "matched",
baronId: "user-2",
worksmobileId: "works-user-2",
}),
).toBe(true);
});
it("does not allow selection for immutable WORKS accounts", () => {
expect(
isImmutableWorksmobileAccount({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "cyhan@samaneng.com",
worksmobileId: "works-cyhan",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "CYHAN1@HANMACENG.CO.KR",
worksmobileId: "works-cyhan1",
}),
).toBe(false);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "normal@samaneng.com",
worksmobileId: "works-normal",
}),
).toBe(true);
});
it("does not allow password management for immutable WORKS accounts", () => {
expect(
canOpenWorksmobilePasswordManage(
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "su-@samaneng.com",
worksmobileDomainId: 300285955,
worksmobileId: "works-su",
},
"works-tenant-1",
),
).toBe(false);
});
it("keeps row selection keys separate from Baron action ids", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "baron-only",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-only",
},
{
resourceType: "USER",
status: "matched",
baronId: "matched-baron",
worksmobileId: "matched-works",
},
];
const selectedKeys = rows.map(getWorksmobileRowSelectionKey);
expect(selectedKeys).toEqual([
"USER:baron:baron-only",
"USER:works:works-only",
"USER:baron:matched-baron",
]);
expect(getWorksmobileSelectedActionIds(rows, selectedKeys)).toEqual([
"baron-only",
"matched-baron",
]);
});
it("uses compact comparison columns by default", () => {
expect(getDefaultWorksmobileComparisonColumns()).toEqual({
status: true,
baronId: false,
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
manage: true,
});
});
it("filters user comparison rows by selected relationship", () => {
const rows = [
{
@@ -149,4 +271,77 @@ describe("TenantWorksmobilePage comparison helpers", () => {
}),
).toEqual(["직책 팀장", "직무 기술검토", "조직장"]);
});
it("builds the WORKS admin password management URL from remote user identifiers", () => {
const url = buildWorksmobilePasswordManageUrl({
tenantId: " works-tenant-1 ",
domainId: 300285955,
userIdNo: " works-user-1 ",
});
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe(
"https://auth.worksmobile.com/integrate/password/manage",
);
expect(parsed.searchParams.get("usage")).toBe("admin");
expect(parsed.searchParams.get("targetUserTenantId")).toBe(
"works-tenant-1",
);
expect(parsed.searchParams.get("targetUserDomainId")).toBe("300285955");
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
expect(parsed.searchParams.get("accessUrl")).toBe(
"https://admin.worksmobile.com/assets/self-close.html",
);
});
it("does not open WORKS password management without required identifiers", () => {
const row = {
resourceType: "USER",
status: "matched",
worksmobileDomainId: 300285955,
worksmobileId: "works-user-1",
};
expect(canOpenWorksmobilePasswordManage(row, "works-tenant-1")).toBe(true);
expect(canOpenWorksmobilePasswordManage(row, undefined)).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, worksmobileDomainId: undefined },
"works-tenant-1",
),
).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, worksmobileId: undefined },
"works-tenant-1",
),
).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, resourceType: "GROUP" },
"works-tenant-1",
),
).toBe(false);
expect(
buildWorksmobilePasswordManageUrl({
tenantId: "works-tenant-1",
domainId: 0,
userIdNo: "works-user-1",
}),
).toBe("");
});
it("allows WORKS password management for WORKS-only user rows", () => {
expect(
canOpenWorksmobilePasswordManage(
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileDomainId: 300285955,
worksmobileId: "works-user-1",
},
"works-tenant-1",
),
).toBe(true);
});
});

View File

@@ -1,5 +1,11 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Download, RefreshCw, RotateCcw } from "lucide-react";
import {
Download,
KeyRound,
RefreshCw,
RotateCcw,
Settings2,
} from "lucide-react";
import * as React from "react";
import { useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
@@ -12,6 +18,15 @@ import {
CardTitle,
} from "../../../components/ui/card";
import { Checkbox } from "../../../components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
@@ -47,8 +62,18 @@ export function TenantWorksmobilePage() {
const [userFilters, setUserFilters] = React.useState<
WorksmobileComparisonFilter[]
>(["baron_only", "works_only"]);
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const [selectedGroupIds, setSelectedGroupIds] = React.useState<string[]>([]);
const [selectedUserRowKeys, setSelectedUserRowKeys] = React.useState<
string[]
>([]);
const [selectedGroupRowKeys, setSelectedGroupRowKeys] = React.useState<
string[]
>([]);
const [userVisibleColumns, setUserVisibleColumns] = React.useState(
getDefaultWorksmobileComparisonColumns,
);
const [groupVisibleColumns, setGroupVisibleColumns] = React.useState(
getDefaultWorksmobileComparisonColumns,
);
const overviewQuery = useQuery({
queryKey: ["worksmobile", tenantId],
@@ -152,9 +177,9 @@ export function TenantWorksmobilePage() {
},
onSuccess: ({ resourceKind, count }) => {
if (resourceKind === "users") {
setSelectedUserIds([]);
setSelectedUserRowKeys([]);
} else {
setSelectedGroupIds([]);
setSelectedGroupRowKeys([]);
}
toast.success("WORKS 생성 작업을 등록했습니다.", {
description: `${count}`,
@@ -198,7 +223,7 @@ export function TenantWorksmobilePage() {
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
return (
<div className="space-y-6">
<div className="min-w-0 max-w-full space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-xl font-semibold">
@@ -247,7 +272,7 @@ export function TenantWorksmobilePage() {
</div>
</header>
<Card>
<Card className="min-w-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
<CardTitle className="text-base">
@@ -261,7 +286,7 @@ export function TenantWorksmobilePage() {
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="min-w-0 space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<ComparisonSummary
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
@@ -275,41 +300,26 @@ export function TenantWorksmobilePage() {
summary={groupSummary}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{userFilterOptions.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={
userFilters.includes(option.value) ? "default" : "outline"
}
aria-pressed={userFilters.includes(option.value)}
onClick={() => {
setUserFilters((current) =>
current.includes(option.value)
? current.filter((value) => value !== option.value)
: [...current, option.value],
);
setSelectedUserIds([]);
}}
>
{option.label}
</Button>
))}
</div>
<ComparisonTable
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
rows={filteredComparisonUsers}
loading={comparisonQuery.isLoading}
selectedIds={selectedUserIds}
onSelectedIdsChange={setSelectedUserIds}
selectedKeys={selectedUserRowKeys}
onSelectedKeysChange={setSelectedUserRowKeys}
filters={userFilters}
onFiltersChange={(nextFilters) => {
setUserFilters(nextFilters);
setSelectedUserRowKeys([]);
}}
visibleColumns={userVisibleColumns}
onVisibleColumnsChange={setUserVisibleColumns}
passwordManageTenantId={overview?.config.adminTenantId}
actionLabel="선택 구성원 WORKS에 생성"
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
onCreateSelected={() =>
onCreateSelected={(ids) =>
createSelectedMutation.mutate({
resourceKind: "users",
ids: selectedUserIds,
ids,
})
}
/>
@@ -320,16 +330,21 @@ export function TenantWorksmobilePage() {
)}
rows={comparisonGroups}
loading={comparisonQuery.isLoading}
selectedIds={selectedGroupIds}
onSelectedIdsChange={setSelectedGroupIds}
selectedKeys={selectedGroupRowKeys}
onSelectedKeysChange={setSelectedGroupRowKeys}
filters={undefined}
onFiltersChange={undefined}
visibleColumns={groupVisibleColumns}
onVisibleColumnsChange={setGroupVisibleColumns}
passwordManageTenantId={undefined}
actionLabel="선택 조직 WORKS에 생성"
actionDisabled={
isCreatingGroups || createSelectedMutation.isPending
}
onCreateSelected={() =>
onCreateSelected={(ids) =>
createSelectedMutation.mutate({
resourceKind: "groups",
ids: selectedGroupIds,
ids,
})
}
/>
@@ -435,6 +450,54 @@ export type WorksmobileComparisonSummary = {
missingExternalKey: number;
};
export type WorksmobileComparisonColumnKey =
| "status"
| "baronId"
| "baron"
| "baronOrg"
| "worksmobileId"
| "externalKey"
| "worksmobileDomain"
| "worksmobile"
| "worksmobileOrg"
| "manage";
export type WorksmobileComparisonColumnVisibility = Record<
WorksmobileComparisonColumnKey,
boolean
>;
const worksmobileComparisonColumnOptions: Array<{
key: WorksmobileComparisonColumnKey;
label: string;
}> = [
{ key: "status", label: "상태" },
{ key: "baronId", label: "Baron ID" },
{ key: "baron", label: "Baron" },
{ key: "baronOrg", label: "Baron 조직" },
{ key: "worksmobileId", label: "WORKS ID" },
{ key: "externalKey", label: "external_key" },
{ key: "worksmobileDomain", label: "WORKS 도메인" },
{ key: "worksmobile", label: "WORKS" },
{ key: "worksmobileOrg", label: "WORKS 조직" },
{ key: "manage", label: "관리" },
];
export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
return {
status: true,
baronId: false,
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
manage: true,
};
}
export function summarizeWorksmobileComparison(
rows: WorksmobileComparisonItem[],
): WorksmobileComparisonSummary {
@@ -480,6 +543,54 @@ export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
}
const immutableWorksmobileAccountEmails = new Set([
"cyhan@samaneng.com",
"cyhan1@hanmaceng.co.kr",
"cyhan2@baroncs.co.kr",
"cyhan3@brsw.kr",
"su-@samaneng.com",
]);
export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) {
return (
row.resourceType === "USER" &&
immutableWorksmobileAccountEmails.has(
row.worksmobileEmail?.trim().toLowerCase() ?? "",
)
);
}
export function getWorksmobileRowSelectionKey(row: WorksmobileComparisonItem) {
if (row.baronId) {
return `${row.resourceType}:baron:${row.baronId}`;
}
if (row.worksmobileId) {
return `${row.resourceType}:works:${row.worksmobileId}`;
}
if (row.externalKey) {
return `${row.resourceType}:external:${row.externalKey}`;
}
return "";
}
export function canSelectWorksmobileRow(row: WorksmobileComparisonItem) {
return (
Boolean(getWorksmobileRowSelectionKey(row)) &&
!isImmutableWorksmobileAccount(row)
);
}
export function getWorksmobileSelectedActionIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter((row) => selected.has(getWorksmobileRowSelectionKey(row)))
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
}
export function filterWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[],
filters: WorksmobileComparisonFilter[],
@@ -519,13 +630,61 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
return details;
}
export function buildWorksmobilePasswordManageUrl({
tenantId,
domainId,
userIdNo,
}: {
tenantId?: string;
domainId?: number;
userIdNo?: string;
}) {
const normalizedTenantId = tenantId?.trim();
const normalizedUserIdNo = userIdNo?.trim();
if (
!normalizedTenantId ||
!domainId ||
domainId <= 0 ||
!normalizedUserIdNo
) {
return "";
}
const url = new URL("https://auth.worksmobile.com/integrate/password/manage");
url.searchParams.set("usage", "admin");
url.searchParams.set("targetUserTenantId", normalizedTenantId);
url.searchParams.set("targetUserDomainId", String(domainId));
url.searchParams.set("targetUserIdNo", normalizedUserIdNo);
url.searchParams.set(
"accessUrl",
"https://admin.worksmobile.com/assets/self-close.html",
);
return url.toString();
}
export function canOpenWorksmobilePasswordManage(
row: WorksmobileComparisonItem,
tenantId?: string,
) {
return (
row.resourceType === "USER" &&
!isImmutableWorksmobileAccount(row) &&
Boolean(
buildWorksmobilePasswordManageUrl({
tenantId,
domainId: row.worksmobileDomainId,
userIdNo: row.worksmobileId,
}),
)
);
}
export const userFilterOptions: Array<{
value: WorksmobileComparisonFilter;
label: string;
}> = [
{ value: "baron_only", label: "Baron에만 있음" },
{ value: "works_only", label: "WORKS에만 있음" },
{ value: "matched", label: "양쪽 다 있음" },
{ value: "baron_only", label: "바론에만 있음" },
{ value: "works_only", label: "웍스에만 있음" },
{ value: "matched", label: "양쪽 다 있음" },
];
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
@@ -605,8 +764,13 @@ function ComparisonTable({
title,
rows,
loading,
selectedIds,
onSelectedIdsChange,
selectedKeys,
onSelectedKeysChange,
filters,
onFiltersChange,
visibleColumns,
onVisibleColumnsChange,
passwordManageTenantId,
actionLabel,
actionDisabled,
onCreateSelected,
@@ -614,101 +778,235 @@ function ComparisonTable({
title: string;
rows: WorksmobileComparisonItem[];
loading: boolean;
selectedIds: string[];
onSelectedIdsChange: (ids: string[]) => void;
selectedKeys: string[];
onSelectedKeysChange: (ids: string[]) => void;
filters?: WorksmobileComparisonFilter[];
onFiltersChange?: (filters: WorksmobileComparisonFilter[]) => void;
visibleColumns: WorksmobileComparisonColumnVisibility;
onVisibleColumnsChange: React.Dispatch<
React.SetStateAction<WorksmobileComparisonColumnVisibility>
>;
passwordManageTenantId?: string;
actionLabel: string;
actionDisabled: boolean;
onCreateSelected: () => void;
onCreateSelected: (ids: string[]) => void;
}) {
const creatableIds = rows
.filter(canCreateWorksmobileRow)
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
const allCreatableSelected =
creatableIds.length > 0 &&
creatableIds.every((id) => selectedIds.includes(id));
const selectableKeys = rows
.filter(canSelectWorksmobileRow)
.map(getWorksmobileRowSelectionKey)
.filter(Boolean);
const selectedActionIds = getWorksmobileSelectedActionIds(rows, selectedKeys);
const allSelectableSelected =
selectableKeys.length > 0 &&
selectableKeys.every((key) => selectedKeys.includes(key));
const visibleColumnCount = worksmobileComparisonColumnOptions.filter(
(column) => visibleColumns[column.key] !== false,
).length;
const tableColSpan = visibleColumnCount + 1;
const toggleAll = (checked: boolean | "indeterminate") => {
onSelectedIdsChange(checked === true ? creatableIds : []);
onSelectedKeysChange(checked === true ? selectableKeys : []);
};
const toggleRow = (
id: string | undefined,
row: WorksmobileComparisonItem,
checked: boolean | "indeterminate",
) => {
if (!id) {
const key = getWorksmobileRowSelectionKey(row);
if (!key) {
return;
}
if (checked === true) {
onSelectedIdsChange([...new Set([...selectedIds, id])]);
onSelectedKeysChange([...new Set([...selectedKeys, key])]);
return;
}
onSelectedIdsChange(selectedIds.filter((selectedId) => selectedId !== id));
onSelectedKeysChange(
selectedKeys.filter((selectedKey) => selectedKey !== key),
);
};
const openPasswordManage = (row: WorksmobileComparisonItem) => {
const url = buildWorksmobilePasswordManageUrl({
tenantId: passwordManageTenantId,
domainId: row.worksmobileDomainId,
userIdNo: row.worksmobileId,
});
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
};
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
onVisibleColumnsChange((current) => ({
...current,
[key]: current[key] === false,
}));
};
const isColumnVisible = (key: WorksmobileComparisonColumnKey) =>
visibleColumns[key] !== false;
const toggleFilter = (filter: WorksmobileComparisonFilter) => {
if (!filters || !onFiltersChange) {
return;
}
onFiltersChange(
filters.includes(filter)
? filters.filter((value) => value !== filter)
: [...filters, filter],
);
};
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-sm font-medium">{title}</h4>
<Button
type="button"
size="sm"
onClick={onCreateSelected}
disabled={selectedIds.length === 0 || actionDisabled}
>
{actionLabel}
</Button>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<h4 className="text-lg font-semibold leading-none">{title}</h4>
{filters && onFiltersChange && (
<div className="flex flex-wrap items-center gap-2">
{userFilterOptions.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={
filters.includes(option.value) ? "default" : "outline"
}
aria-pressed={filters.includes(option.value)}
onClick={() => toggleFilter(option.value)}
>
{option.label}
</Button>
))}
</div>
)}
</div>
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Settings2 size={16} />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title} </DialogTitle>
<DialogDescription>
.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-2">
{worksmobileComparisonColumnOptions.map((column) => (
<label
key={column.key}
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-muted/50"
>
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
checked={isColumnVisible(column.key)}
onChange={() => toggleColumn(column.key)}
/>
<span className="text-sm font-medium">{column.label}</span>
</label>
))}
</div>
<DialogFooter>
<DialogTrigger asChild>
<Button type="button" variant="secondary">
</Button>
</DialogTrigger>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
type="button"
size="sm"
onClick={() => onCreateSelected(selectedActionIds)}
disabled={selectedActionIds.length === 0 || actionDisabled}
>
{actionLabel}
</Button>
</div>
</div>
<div className="overflow-x-auto">
<Table>
<div className="w-full max-w-full overflow-x-auto rounded-md border">
<Table className="min-w-max">
<TableHeader>
<TableRow>
<TableHead className="w-10 whitespace-nowrap">
<Checkbox
aria-label={`${title} 전체 선택`}
checked={allCreatableSelected}
disabled={creatableIds.length === 0}
checked={allSelectableSelected}
disabled={selectableKeys.length === 0}
onCheckedChange={toggleAll}
/>
</TableHead>
<TableHead className="w-24 whitespace-nowrap"></TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
Baron ID
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
WORKS ID
</TableHead>
<TableHead className="min-w-40 whitespace-nowrap">
external_key
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
<TableHead className="min-w-52 whitespace-nowrap">
WORKS
</TableHead>
{isColumnVisible("status") && (
<TableHead className="w-24 whitespace-nowrap"></TableHead>
)}
{isColumnVisible("baronId") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron ID
</TableHead>
)}
{isColumnVisible("baron") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
)}
{isColumnVisible("baronOrg") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
)}
{isColumnVisible("worksmobileId") && (
<TableHead className="min-w-44 whitespace-nowrap">
WORKS ID
</TableHead>
)}
{isColumnVisible("externalKey") && (
<TableHead className="min-w-40 whitespace-nowrap">
external_key
</TableHead>
)}
{isColumnVisible("worksmobileDomain") && (
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
)}
{isColumnVisible("worksmobile") && (
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
)}
{isColumnVisible("worksmobileOrg") && (
<TableHead className="min-w-52 whitespace-nowrap">
WORKS
</TableHead>
)}
{isColumnVisible("manage") && (
<TableHead className="w-14 whitespace-nowrap"></TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{loading && (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
<TableCell
colSpan={tableColSpan}
className="text-muted-foreground"
>
...
</TableCell>
</TableRow>
)}
{!loading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
<TableCell
colSpan={tableColSpan}
className="text-muted-foreground"
>
.
</TableCell>
</TableRow>
@@ -720,87 +1018,126 @@ function ComparisonTable({
<TableCell className="whitespace-nowrap">
<Checkbox
aria-label={`${row.baronName ?? row.baronId ?? row.worksmobileName ?? row.worksmobileId ?? "row"} 선택`}
checked={Boolean(
row.baronId && selectedIds.includes(row.baronId),
checked={selectedKeys.includes(
getWorksmobileRowSelectionKey(row),
)}
disabled={!canCreateWorksmobileRow(row)}
onCheckedChange={(checked) =>
toggleRow(row.baronId, checked)
}
disabled={!canSelectWorksmobileRow(row)}
onCheckedChange={(checked) => toggleRow(row, checked)}
/>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
className="whitespace-nowrap"
variant={getWorksmobileComparisonStatusVariant(row.status)}
>
{getWorksmobileComparisonStatusLabel(row.status)}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{row.baronId ?? "-"}
</TableCell>
<TableCell>
<div className="space-y-1">
<div>{row.baronName ?? "-"}</div>
<div className="text-xs text-muted-foreground">
{row.baronEmail ?? ""}
{isColumnVisible("status") && (
<TableCell className="whitespace-nowrap">
<Badge
className="whitespace-nowrap"
variant={getWorksmobileComparisonStatusVariant(
row.status,
)}
>
{getWorksmobileComparisonStatusLabel(row.status)}
</Badge>
</TableCell>
)}
{isColumnVisible("baronId") && (
<TableCell className="font-mono text-xs">
{row.baronId ?? "-"}
</TableCell>
)}
{isColumnVisible("baron") && (
<TableCell>
<div className="space-y-1">
<div>{row.baronName ?? "-"}</div>
<div className="text-xs text-muted-foreground">
{row.baronEmail ?? ""}
</div>
</div>
</div>
</TableCell>
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.baronParentName
: row.baronPrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.baronParentId
: row.baronPrimaryOrgId
}
/>
</TableCell>
<TableCell className="font-mono text-xs">
{row.worksmobileId ?? "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{row.externalKey ?? "-"}
</TableCell>
<TableCell>
<ComparisonDomainCell
name={row.worksmobileDomainName}
id={row.worksmobileDomainId}
/>
</TableCell>
<TableCell>
<div className="space-y-1">
<div>{formatWorksmobilePersonName(row) || "-"}</div>
<div className="text-xs text-muted-foreground">
{row.worksmobileEmail ?? ""}
</TableCell>
)}
{isColumnVisible("baronOrg") && (
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.baronParentName
: row.baronPrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.baronParentId
: row.baronPrimaryOrgId
}
/>
</TableCell>
)}
{isColumnVisible("worksmobileId") && (
<TableCell className="font-mono text-xs">
{row.worksmobileId ?? "-"}
</TableCell>
)}
{isColumnVisible("externalKey") && (
<TableCell className="font-mono text-xs">
{row.externalKey ?? "-"}
</TableCell>
)}
{isColumnVisible("worksmobileDomain") && (
<TableCell>
<ComparisonDomainCell
name={row.worksmobileDomainName}
id={row.worksmobileDomainId}
/>
</TableCell>
)}
{isColumnVisible("worksmobile") && (
<TableCell>
<div className="space-y-1">
<div>{formatWorksmobilePersonName(row) || "-"}</div>
<div className="text-xs text-muted-foreground">
{row.worksmobileEmail ?? ""}
</div>
</div>
</div>
</TableCell>
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.worksmobileParentName
: row.worksmobilePrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.worksmobileParentId
: row.worksmobilePrimaryOrgId
}
details={
row.resourceType === "GROUP"
? []
: formatWorksmobileOrgDetails(row)
}
/>
</TableCell>
</TableCell>
)}
{isColumnVisible("worksmobileOrg") && (
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.worksmobileParentName
: row.worksmobilePrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.worksmobileParentId
: row.worksmobilePrimaryOrgId
}
details={
row.resourceType === "GROUP"
? []
: formatWorksmobileOrgDetails(row)
}
/>
</TableCell>
)}
{isColumnVisible("manage") && (
<TableCell className="whitespace-nowrap">
{row.resourceType === "USER" && (
<Button
type="button"
variant="ghost"
size="sm"
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 관리`}
disabled={
!canOpenWorksmobilePasswordManage(
row,
passwordManageTenantId,
)
}
onClick={() => openPasswordManage(row)}
>
<KeyRound size={16} />
</Button>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>

View File

@@ -101,6 +101,33 @@ export type RoleListResponse = {
total: number;
};
export type RPUsageDailyMetric = {
date: string;
tenantId: string;
tenantType: string;
tenantName?: string;
clientId: string;
clientName: string;
loginRequests: number;
otherRequests: number;
uniqueSubjects: number;
};
export type RPUsagePeriod = "day" | "week" | "month";
export type RPUsageDailyResponse = {
items: RPUsageDailyMetric[];
days: number;
period: RPUsagePeriod;
tenantId?: string;
};
export type AdminOverviewStats = {
totalTenants: number;
oidcClients: number;
auditEvents24h: number;
};
export async function fetchAuditLogs(limit = 50, cursor?: string) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor },
@@ -108,6 +135,29 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
return data;
}
export async function fetchAdminOverviewStats() {
const { data } = await apiClient.get<AdminOverviewStats>("/v1/admin/stats");
return data;
}
export async function fetchAdminRPUsageDaily({
days = 14,
period = "day",
tenantId,
}: {
days?: number;
period?: RPUsagePeriod;
tenantId?: string;
} = {}) {
const { data } = await apiClient.get<RPUsageDailyResponse>(
"/v1/admin/rp-usage/daily",
{
params: { days, period, tenantId: tenantId || undefined },
},
);
return data;
}
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
@@ -510,7 +560,9 @@ export type WorksmobileOverview = {
tenant: TenantSummary;
config: {
enabled: boolean;
domainMappings?: Record<string, number>;
tokenConfigured: boolean;
adminTenantId?: string;
};
recentJobs: WorksmobileOutboxItem[];
};

View File

@@ -57,6 +57,7 @@ test.describe("Tenants Management", () => {
});
test("should list tenants", async ({ page }) => {
await page.setViewportSize({ width: 900, height: 700 });
const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea";
await page.route("**/api/v1/admin/tenants**", async (route) => {
@@ -93,6 +94,15 @@ test.describe("Tenants Management", () => {
timeout: 10000,
});
await expect(page.locator("table")).toContainText(internalTenantId);
await expect(page.locator("table")).toContainText("COMPANY");
await expect(page.locator("table")).not.toContainText("일반 기업");
const headerWhiteSpace = await page
.locator("table thead th")
.evaluateAll((headers) =>
headers.map((header) => window.getComputedStyle(header).whiteSpace),
);
expect(headerWhiteSpace.every((value) => value === "nowrap")).toBe(true);
});
test("should create a new tenant", async ({ page }) => {

View File

@@ -462,7 +462,8 @@ test.describe("User Management", () => {
"John Doe john@test.com 010-1111-2222",
);
await page.getByTestId("user-status-toggle-u-1").click();
await page.getByTestId("user-status-select-u-1").click();
await page.getByRole("option", { name: /비활성|inactive/i }).click();
await expect
.poll(() => updatePayload)
.toMatchObject({ status: "inactive" });
@@ -816,22 +817,27 @@ test.describe("User Management", () => {
(form as HTMLFormElement).requestSubmit();
});
await expect.poll(() => updatePayload).toMatchObject({
tenantSlug: "hanmac-team",
primaryTenantId: "hanmac-team-id",
primaryTenantName: "한맥팀",
primaryTenantIsOwner: true,
metadata: {
await expect
.poll(() => updatePayload)
.toMatchObject({
tenantSlug: "hanmac-team",
primaryTenantId: "hanmac-team-id",
primaryTenantName: "한맥팀",
primaryTenantSlug: "hanmac-team",
primaryTenantIsOwner: true,
additionalAppointments: [
{ tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35", isPrimary: false },
{ tenantId: "hanmac-team-id", isPrimary: true },
],
},
});
metadata: {
primaryTenantId: "hanmac-team-id",
primaryTenantName: "한맥팀",
primaryTenantSlug: "hanmac-team",
primaryTenantIsOwner: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
isPrimary: false,
},
{ tenantId: "hanmac-team-id", isPrimary: true },
],
},
});
});
test("should show conflict error when creating with an existing Login ID", async ({

View File

@@ -197,7 +197,7 @@ test.describe("Worksmobile tenant management", () => {
await expect(page.getByText("domainMappings")).not.toBeVisible();
await expect(page.getByText("SCIM token")).not.toBeVisible();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).not.toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("WORKS 전용 조직")).toBeVisible();
await expect(page.getByText("기술본부", { exact: true })).toBeVisible();
await expect(page.getByText("parent-tech", { exact: true })).toBeVisible();
@@ -208,47 +208,45 @@ test.describe("Worksmobile tenant management", () => {
const filterButtons = page
.getByRole("button", {
name: /Baron에만 있음|WORKS에만 있음|양쪽 다 있음/,
name: /바론에만 있음|웍스에만 있음|양쪽 다 있음/,
})
.allTextContents();
await expect.poll(() => filterButtons).toEqual([
"Baron에만 있음",
"WORKS에만 있음",
"양쪽에 다 있음",
]);
await expect
.poll(() => filterButtons)
.toEqual(["바론에만 있음", "웍스에만 있음", "양쪽 다 있음"]);
await page.getByRole("button", { name: "WORKS에만 있음" }).click();
await expect(page.getByText("박웍스")).toBeVisible();
await page.getByRole("button", { name: "웍스에만 있음" }).click();
await expect(page.getByText("박웍스")).not.toBeVisible();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
await page.getByRole("button", { name: "양쪽 다 있음" }).click();
await page.getByRole("button", { name: "양쪽 다 있음" }).click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("기술기획", { exact: true })).toBeVisible();
await expect(page.getByText("team-tech", { exact: true })).toBeVisible();
await expect(page.getByText("WORKS 기술기획")).toBeVisible();
await expect(page.getByText("works-team-tech")).toBeVisible();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("박웍스")).not.toBeVisible();
await page.getByRole("button", { name: "Baron에만 있음" }).click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await page.getByRole("button", { name: "WORKS에만 있음" }).click();
await page.getByRole("button", { name: "바론에만 있음" }).click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).not.toBeVisible();
await page.getByRole("button", { name: "양쪽에 다 있음" }).click();
await page.getByRole("button", { name: "웍스에만 있음" }).click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await page.getByRole("button", { name: "양쪽 다 있음" }).click();
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
await page.getByRole("button", { name: "바론에만 있음" }).click();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("홍길동")).toHaveCount(2);
await page.getByRole("button", { name: "Baron에만 있음" }).click();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).not.toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
await page
@@ -360,4 +358,149 @@ test.describe("Worksmobile tenant management", () => {
page.getByText(/WORKS API rejected user creation/),
).toBeVisible();
});
test("keeps wide comparison columns inside table scroll and blocks immutable WORKS accounts", async ({
page,
}) => {
await page.setViewportSize({ width: 900, height: 700 });
await page.route("**/api/v1/**", async (route) => {
const url = new URL(route.request().url());
const method = route.request().method();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.pathname.endsWith("/user/me")) {
return route.fulfill({
json: { id: "admin-user", name: "Admin", role: "super_admin" },
headers,
});
}
if (
url.pathname.endsWith("/admin/tenants/hanmac-family-id") &&
method === "GET"
) {
return route.fulfill({
json: {
id: "hanmac-family-id",
name: "한맥 가족",
slug: "hanmac-family",
parentId: null,
},
headers,
});
}
if (
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
method === "GET"
) {
return route.fulfill({
json: {
tenant: {
id: "hanmac-family-id",
name: "한맥 가족",
slug: "hanmac-family",
parentId: null,
},
config: {
adminTenantId: "works-tenant-1",
},
recentJobs: [],
},
headers,
});
}
if (
url.pathname.endsWith(
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
) &&
method === "GET"
) {
return route.fulfill({
json: {
users: [
{
resourceType: "USER",
worksmobileId:
"works-user-with-extra-long-identifier-for-scroll-check",
externalKey: "external-key-with-extra-long-identifier",
worksmobileName: "긴 WORKS 사용자",
worksmobileEmail:
"long-works-user-name-for-scroll@samaneng.com",
worksmobileDomainId: 300285955,
worksmobileDomainName: "samaneng.com",
worksmobilePrimaryOrgId:
"works-primary-org-with-extra-long-identifier",
worksmobilePrimaryOrgName: "긴 WORKS 조직",
status: "missing_in_baron",
},
{
resourceType: "USER",
worksmobileId: "works-cyhan",
worksmobileName: "변경 불가 계정",
worksmobileEmail: "cyhan@samaneng.com",
worksmobileDomainId: 300285955,
worksmobileDomainName: "samaneng.com",
status: "missing_in_baron",
},
],
groups: [],
},
headers,
});
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.goto("/tenants/hanmac-family-id/worksmobile");
await expect(page.getByText("긴 WORKS 사용자")).toBeVisible();
const userColumnButton = page
.getByRole("heading", { name: "구성원" })
.locator("xpath=ancestor::div[contains(@class, 'space-y-2')][1]")
.getByRole("button", { name: "컬럼 설정" });
await userColumnButton.click();
const dialog = page.getByRole("dialog", { name: "구성원 컬럼 설정" });
await dialog.getByLabel("Baron ID").check();
await dialog.getByLabel("WORKS ID").check();
await dialog.getByLabel("external_key").check();
await dialog.getByRole("button", { name: "닫기" }).click();
const pageOverflow = await page.evaluate(() => ({
documentScrollWidth: document.documentElement.scrollWidth,
bodyScrollWidth: document.body.scrollWidth,
viewportWidth: document.documentElement.clientWidth,
}));
expect(
Math.max(pageOverflow.documentScrollWidth, pageOverflow.bodyScrollWidth),
).toBeLessThanOrEqual(pageOverflow.viewportWidth + 1);
const userTableScroll = await page
.locator("table")
.first()
.evaluate((table) => {
const container = table.parentElement?.parentElement as HTMLElement;
return {
clientWidth: container.clientWidth,
overflowX: window.getComputedStyle(container).overflowX,
scrollWidth: container.scrollWidth,
};
});
expect(userTableScroll.overflowX).toBe("auto");
expect(userTableScroll.scrollWidth).toBeGreaterThan(
userTableScroll.clientWidth,
);
const immutableRow = page.getByRole("row", {
name: /cyhan@samaneng\.com/,
});
await expect(immutableRow.getByRole("checkbox")).toBeDisabled();
await expect(
immutableRow.getByRole("button", { name: /비밀번호 관리/ }),
).toBeDisabled();
});
});

View File

@@ -1,4 +1,4 @@
FROM golang:1.25-alpine
FROM golang:1.26.2-alpine
WORKDIR /app

View File

@@ -183,11 +183,15 @@ func main() {
chDB := getEnv("CLICKHOUSE_DB", "baron_sso")
var auditRepo domain.AuditRepository
var rpUsageProjectionRepo domain.RPUsageProjectionRepository
var rpUsageQueryRepo domain.RPUsageQueryRepository
if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil {
slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err)
auditRepo = nil // Explicitly set to nil interface
} else {
auditRepo = repo
rpUsageProjectionRepo = repo
rpUsageQueryRepo = repo
slog.Info("✅ Connected to ClickHouse")
}
@@ -297,6 +301,7 @@ func main() {
userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db)
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db)
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
sharedLinkRepo := repository.NewSharedLinkRepository(db)
kratosAdminService := service.NewKratosAdminService()
@@ -323,6 +328,14 @@ func main() {
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient)
go worksmobileRelayWorker.Start(context.Background())
slog.Info("✅ Worksmobile Relay Worker started")
rpUsageEmitter := service.NewRPUsageEventEmitter(rpUsageOutboxRepo)
if rpUsageProjectionRepo != nil {
rpUsageProjectorWorker := service.NewRPUsageProjectorWorker(rpUsageOutboxRepo, rpUsageProjectionRepo)
go rpUsageProjectorWorker.Start(context.Background())
slog.Info("✅ RP Usage Projector Worker started")
} else {
slog.Warn("RP Usage Projector Worker skipped because ClickHouse is unavailable")
}
sharedLinkService := service.NewSharedLinkService(sharedLinkRepo)
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
tenantService.SetKetoService(ketoService) // Keto 주입
@@ -342,7 +355,12 @@ func main() {
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
authHandler.HeadlessJWKS = headlessJWKSCache
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
authHandler.RPUsageSink = rpUsageEmitter
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
adminHandler.RPUsageQueries = rpUsageQueryRepo
adminHandler.TenantRepo = tenantRepo
adminHandler.Hydra = hydraService
adminHandler.AuditRepo = auditRepo
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
devHandler.HeadlessJWKS = headlessJWKSCache
devHandler.AuditRepo = auditRepo
@@ -674,6 +692,7 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily)
// Tenant Management (Mixed roles, handler filters results)
admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants)

View File

@@ -1,6 +1,6 @@
module baron-sso-backend
go 1.25.4
go 1.26.2
require (
github.com/ClickHouse/clickhouse-go/v2 v2.42.0

View File

@@ -18,7 +18,6 @@ func TestEnsureSuperAdminCreatesIdentityLocalUserAndKetoRelation(t *testing.T) {
Name: "New Admin",
Source: "test",
})
if err != nil {
t.Fatalf("EnsureSuperAdmin returned error: %v", err)
}
@@ -67,7 +66,6 @@ func TestEnsureSuperAdminPromotesExistingLocalUser(t *testing.T) {
Name: "Existing Admin",
Source: "test",
})
if err != nil {
t.Fatalf("EnsureSuperAdmin returned error: %v", err)
}

View File

@@ -45,6 +45,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.ClientSecret{},
&domain.ClientConsent{},
&domain.KetoOutbox{},
&domain.RPUsageEvent{},
&domain.WorksmobileOutbox{},
&domain.WorksmobileResourceMapping{},
&domain.SharedLink{},

View File

@@ -26,6 +26,7 @@ type AuditRepository interface {
Create(log *AuditLog) error
FindPage(ctx context.Context, limit int, cursor *AuditCursor, tenantID string) ([]AuditLog, error)
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
CountEventsSince(ctx context.Context, since time.Time) (int64, error)
CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
Ping(ctx context.Context) error

View File

@@ -0,0 +1,101 @@
package domain
import (
"context"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"gorm.io/gorm"
)
const (
RPUsageOutboxStatusPending = "pending"
RPUsageOutboxStatusProcessing = "processing"
RPUsageOutboxStatusProcessed = "processed"
RPUsageOutboxStatusFailed = "failed"
)
const (
RPUsageEventTypeAuthorizationGranted = "rp_usage.authorization_granted"
RPUsageEventTypeAuthorizationRevoked = "rp_usage.authorization_revoked"
)
const (
RPUsageTenantTypeCompany = TenantTypeCompany
RPUsageTenantTypeOrganization = TenantTypeOrganization
)
type RPUsageEvent struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
EventType string `gorm:"not null;index:idx_rp_usage_outbox_event" json:"eventType"`
Subject string `gorm:"not null;index:idx_rp_usage_outbox_subject" json:"subject"`
TenantID string `gorm:"index:idx_rp_usage_outbox_tenant" json:"tenantId,omitempty"`
TenantType string `gorm:"index:idx_rp_usage_outbox_tenant" json:"tenantType,omitempty"`
ClientID string `gorm:"not null;index:idx_rp_usage_outbox_client" json:"clientId"`
ClientName string `json:"clientName,omitempty"`
SessionID string `gorm:"index" json:"sessionId,omitempty"`
Scopes pq.StringArray `gorm:"type:text[]" json:"scopes,omitempty"`
Source string `gorm:"not null;index" json:"source"`
CorrelationID string `gorm:"index" json:"correlationId,omitempty"`
Payload JSONMap `gorm:"type:jsonb" json:"payload,omitempty"`
DedupeKey string `gorm:"uniqueIndex" json:"dedupeKey"`
Status string `gorm:"default:'pending';index" json:"status"`
RetryCount int `gorm:"default:0" json:"retryCount"`
LastError string `json:"lastError,omitempty"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
OccurredAt time.Time `gorm:"not null;index" json:"occurredAt"`
ProcessedAt *time.Time `json:"processedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (e *RPUsageEvent) TableName() string {
return "rp_usage_outbox"
}
func (e *RPUsageEvent) BeforeCreate(tx *gorm.DB) error {
if e.ID == "" {
e.ID = uuid.NewString()
}
if e.Status == "" {
e.Status = RPUsageOutboxStatusPending
}
if e.OccurredAt.IsZero() {
e.OccurredAt = time.Now()
}
if e.Payload == nil {
e.Payload = JSONMap{}
}
return nil
}
type RPUsageEventSink interface {
EmitRPUsageEvent(ctx context.Context, event RPUsageEvent) error
}
type RPUsageProjectionRepository interface {
CreateRPUsageEvent(ctx context.Context, event RPUsageEvent) error
}
type RPUsageDailyMetric struct {
Date string `json:"date"`
TenantID string `json:"tenantId"`
TenantType string `json:"tenantType"`
TenantName string `json:"tenantName,omitempty"`
ClientID string `json:"clientId"`
ClientName string `json:"clientName"`
LoginRequests uint64 `json:"loginRequests"`
OtherRequests uint64 `json:"otherRequests"`
UniqueSubjects uint64 `json:"uniqueSubjects"`
}
type RPUsageQuery struct {
Days int
Period string
TenantID string
}
type RPUsageQueryRepository interface {
FindRPUsage(ctx context.Context, query RPUsageQuery) ([]RPUsageDailyMetric, error)
}

View File

@@ -1,17 +1,29 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"context"
"runtime"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
type adminHydraClientLister interface {
ListClients(ctx context.Context, limit, offset int) ([]domain.HydraClient, error)
}
type AdminHandler struct {
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
RPUsageQueries domain.RPUsageQueryRepository
TenantRepo repository.TenantRepository
Hydra adminHydraClientLister
AuditRepo domain.AuditRepository
}
func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler {
@@ -21,6 +33,76 @@ func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxR
}
}
func (h *AdminHandler) GetRPUsageDaily(c *fiber.Ctx) error {
if h == nil || h.RPUsageQueries == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "rp usage query service unavailable",
})
}
days := 14
if raw := c.Query("days"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
days = parsed
}
}
period := normalizeRPUsagePeriod(c.Query("period"))
tenantID, allowed := h.authorizedRPUsageTenantID(c, strings.TrimSpace(c.Query("tenantId")))
if !allowed {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "forbidden: tenant rp usage stats permission denied",
})
}
items, err := h.RPUsageQueries.FindRPUsage(c.Context(), domain.RPUsageQuery{
Days: days,
Period: period,
TenantID: tenantID,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
return c.JSON(fiber.Map{
"items": items,
"days": days,
"period": period,
"tenantId": tenantID,
})
}
func normalizeRPUsagePeriod(period string) string {
switch strings.ToLower(strings.TrimSpace(period)) {
case "week":
return "week"
case "month":
return "month"
default:
return "day"
}
}
func (h *AdminHandler) authorizedRPUsageTenantID(c *fiber.Ctx, requestedTenantID string) (string, bool) {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin {
return requestedTenantID, true
}
tenantID := requestedTenantID
if tenantID == "" && profile != nil && profile.TenantID != nil {
tenantID = strings.TrimSpace(*profile.TenantID)
}
if tenantID == "" {
return "", false
}
if h == nil || h.Keto == nil || profile == nil || strings.TrimSpace(profile.ID) == "" {
return "", false
}
allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+profile.ID, "Tenant", tenantID, "view_rp_usage_stats")
if err != nil || !allowed {
return "", false
}
return tenantID, true
}
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
}
@@ -29,10 +111,14 @@ func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats
runtime.ReadMemStats(&m)
ctx := c.Context()
stats := fiber.Map{
"goroutines": runtime.NumGoroutine(),
"cpus": runtime.NumCPU(),
"totalTenants": h.countTenants(ctx),
"oidcClients": h.countOIDCClients(ctx),
"auditEvents24h": h.countAuditEventsSince(ctx, time.Now().UTC().Add(-24*time.Hour)),
"goroutines": runtime.NumGoroutine(),
"cpus": runtime.NumCPU(),
"memory": fiber.Map{
"alloc": m.Alloc,
"totalAlign": m.TotalAlloc,
@@ -44,3 +130,59 @@ func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(stats)
}
func (h *AdminHandler) countTenants(ctx context.Context) int64 {
if h == nil || h.TenantRepo == nil {
return 0
}
_, total, err := h.TenantRepo.List(ctx, 1, 0, "")
if err != nil {
return 0
}
return total
}
func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 {
if h == nil || h.Hydra == nil {
return 0
}
const pageSize = 500
var total int64
for offset := 0; ; offset += pageSize {
clients, err := h.Hydra.ListClients(ctx, pageSize, offset)
if err != nil {
return total
}
for _, client := range clients {
if isHiddenSystemClient(client) {
continue
}
total++
}
if len(clients) < pageSize {
break
}
}
return total
}
func (h *AdminHandler) countAuditEventsSince(ctx context.Context, since time.Time) int64 {
if h == nil || h.AuditRepo == nil {
return 0
}
count, err := h.AuditRepo.CountEventsSince(ctx, since)
if err == nil && count > 0 {
return count
}
logs, pageErr := h.AuditRepo.FindPage(ctx, 10000, nil, "")
if pageErr != nil {
return count
}
var fallbackCount int64
for _, log := range logs {
if !log.Timestamp.Before(since) {
fallbackCount++
}
}
return fallbackCount
}

View File

@@ -0,0 +1,156 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/require"
)
type fakeRPUsageQueryRepo struct {
query domain.RPUsageQuery
items []domain.RPUsageDailyMetric
}
func (f *fakeRPUsageQueryRepo) FindRPUsage(ctx context.Context, query domain.RPUsageQuery) ([]domain.RPUsageDailyMetric, error) {
f.query = query
return f.items, nil
}
type fakeAdminKeto struct {
allowed bool
subject string
object string
relation string
}
func (f *fakeAdminKeto) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
f.subject = subject
f.object = object
f.relation = relation
return f.allowed, nil
}
func (f *fakeAdminKeto) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
return nil
}
func (f *fakeAdminKeto) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return nil
}
func (f *fakeAdminKeto) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
return nil, nil
}
func (f *fakeAdminKeto) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
return nil, nil
}
type fakeOverviewAuditRepo struct {
mockAuditRepo
since time.Time
count int64
}
func (f *fakeOverviewAuditRepo) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
f.since = since
return f.count, nil
}
func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
repo := &fakeRPUsageQueryRepo{
items: []domain.RPUsageDailyMetric{
{
Date: "2026-05-06",
TenantID: "tenant-1",
TenantType: domain.TenantTypeCompany,
ClientID: "orgfront",
ClientName: "OrgFront",
LoginRequests: 12,
OtherRequests: 4,
UniqueSubjects: 8,
},
},
}
h := &AdminHandler{RPUsageQueries: repo}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/admin/rp-usage/daily", h.GetRPUsageDaily)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/rp-usage/daily?days=7&period=week&tenantId=tenant-1", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 7, repo.query.Days)
require.Equal(t, "week", repo.query.Period)
require.Equal(t, "tenant-1", repo.query.TenantID)
var body struct {
Items []domain.RPUsageDailyMetric `json:"items"`
Days int `json:"days"`
Period string `json:"period"`
TenantID string `json:"tenantId"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, 7, body.Days)
require.Equal(t, "week", body.Period)
require.Equal(t, "tenant-1", body.TenantID)
require.Len(t, body.Items, 1)
require.Equal(t, "orgfront", body.Items[0].ClientID)
require.Equal(t, uint64(12), body.Items[0].LoginRequests)
}
func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) {
repo := &fakeRPUsageQueryRepo{}
keto := &fakeAdminKeto{allowed: true}
h := &AdminHandler{RPUsageQueries: repo, Keto: keto}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
})
return c.Next()
})
app.Get("/api/v1/admin/rp-usage/daily", h.GetRPUsageDaily)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/rp-usage/daily?tenantId=tenant-allowed", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "User:user-1", keto.subject)
require.Equal(t, "tenant-allowed", keto.object)
require.Equal(t, "view_rp_usage_stats", keto.relation)
require.Equal(t, "tenant-allowed", repo.query.TenantID)
}
func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
auditRepo := &fakeOverviewAuditRepo{count: 22}
h := &AdminHandler{AuditRepo: auditRepo}
app := fiber.New()
app.Get("/api/v1/admin/stats", h.GetSystemStats)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/stats", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Contains(t, body, "totalTenants")
require.Contains(t, body, "oidcClients")
require.Contains(t, body, "auditEvents24h")
require.Equal(t, float64(22), body["auditEvents24h"])
require.Equal(t, time.UTC, auditRepo.since.Location())
}

View File

@@ -29,6 +29,7 @@ import (
"github.com/go-jose/go-jose/v4"
josejwt "github.com/go-jose/go-jose/v4/jwt"
"github.com/gofiber/fiber/v2"
"github.com/lib/pq"
)
const (
@@ -101,6 +102,7 @@ type AuthHandler struct {
UserRepo repository.UserRepository
ConsentRepo repository.ClientConsentRepository
RPUserMetadataRepo repository.RPUserMetadataRepository
RPUsageSink domain.RPUsageEventSink
}
type signupState struct {
@@ -245,6 +247,92 @@ func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.Iden
}
}
func (h *AuthHandler) emitRPUsageAuthorizationGranted(c *fiber.Ctx, consentRequest *domain.HydraConsentRequest, profile *domain.UserProfileResponse, sessionID string, autoAccepted bool, correlationID string) error {
if consentRequest == nil {
return nil
}
return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationGranted, consentRequest.Subject, consentRequest.Client, consentRequest.RequestedScope, profile, sessionID, "hydra_consent", correlationID, domain.JSONMap{
"auto_accepted": autoAccepted,
"scopes": consentRequest.RequestedScope,
})
}
func (h *AuthHandler) emitRPUsageAuthorizationRevoked(c *fiber.Ctx, subject string, clientID string, profile *domain.UserProfileResponse, sessionID string) error {
return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationRevoked, subject, domain.HydraClient{ClientID: clientID}, nil, profile, sessionID, "hydra_consent", clientID, domain.JSONMap{})
}
func (h *AuthHandler) emitRPUsageEvent(c *fiber.Ctx, eventType string, subject string, client domain.HydraClient, scopes []string, profile *domain.UserProfileResponse, sessionID string, source string, correlationID string, payload domain.JSONMap) error {
if h.RPUsageSink == nil {
return nil
}
clientID := strings.TrimSpace(client.ClientID)
if clientID == "" || strings.TrimSpace(subject) == "" {
return nil
}
tenantID, tenantType := rpUsageTenantFromProfile(profile)
event := domain.RPUsageEvent{
EventType: eventType,
Subject: strings.TrimSpace(subject),
TenantID: tenantID,
TenantType: tenantType,
ClientID: clientID,
ClientName: strings.TrimSpace(client.ClientName),
SessionID: strings.TrimSpace(sessionID),
Scopes: pq.StringArray(scopes),
Source: source,
CorrelationID: strings.TrimSpace(correlationID),
Payload: payload,
OccurredAt: time.Now(),
}
if event.Payload == nil {
event.Payload = domain.JSONMap{}
}
if event.ClientName != "" {
event.Payload["client_name"] = event.ClientName
}
if tenantID != "" {
event.Payload["tenant_id"] = tenantID
}
if tenantType != "" {
event.Payload["tenant_type"] = tenantType
}
if c != nil {
event.Payload["ip_address"] = c.IP()
event.Payload["user_agent"] = string(c.Request().Header.UserAgent())
}
ctx := context.Background()
if c != nil && c.UserContext() != nil {
ctx = c.UserContext()
}
return h.RPUsageSink.EmitRPUsageEvent(ctx, event)
}
func rpUsageTenantFromProfile(profile *domain.UserProfileResponse) (string, string) {
if profile == nil {
return "", ""
}
tenantID := ""
if profile.SessionTenantID != nil {
tenantID = strings.TrimSpace(*profile.SessionTenantID)
}
if tenantID == "" && profile.TenantID != nil {
tenantID = strings.TrimSpace(*profile.TenantID)
}
tenantType := ""
if profile.Tenant != nil {
switch strings.ToUpper(strings.TrimSpace(profile.Tenant.Type)) {
case domain.TenantTypeCompany, domain.TenantTypeOrganization:
tenantType = strings.ToUpper(strings.TrimSpace(profile.Tenant.Type))
if tenantID == "" {
tenantID = strings.TrimSpace(profile.Tenant.ID)
}
case domain.TenantTypeUserGroup, domain.TenantTypePersonal:
return "", ""
}
}
return tenantID, tenantType
}
// --- Signup Flow Handlers ---
// CheckEmail - 이메일 사용 가능 여부를 확인합니다.
@@ -5323,6 +5411,12 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
if err != nil || subject == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
}
profile, profileErr := h.resolveCurrentProfile(c)
if (profileErr != nil || profile == nil) && subject != "" {
if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), subject); fallbackErr == nil {
profile = fallbackProfile
}
}
slog.Info("RevokeLinkedRp called", "subject", subject, "client_id", clientID)
@@ -5354,6 +5448,11 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
})
}
if err := h.emitRPUsageAuthorizationRevoked(c, subject, clientID, profile, h.resolveCurrentSessionID(c)); err != nil {
slog.Error("failed to emit rp usage event for revoked consent", "error", err, "client_id", clientID, "subject", subject)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
}
h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "")
return c.Status(fiber.StatusOK).JSON(fiber.Map{
@@ -5434,6 +5533,10 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
if err == nil {
if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil {
slog.Error("failed to emit rp usage event for local consent auto-accept", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
}
return c.JSON(acceptResp)
}
slog.Error("failed to force auto-accept based on local DB", "error", err)
@@ -5516,6 +5619,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
})
}
if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil {
slog.Error("failed to emit rp usage event for skip consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
}
slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID, "session_id", currentSessionID)
return c.JSON(acceptResp)
}
@@ -5705,6 +5813,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
})
}
if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, false, req.ConsentChallenge); err != nil {
slog.Error("failed to emit rp usage event for accepted consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event")
}
return c.JSON(acceptResp)
}

View File

@@ -3,6 +3,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"encoding/json"
"io"
"net/http"
@@ -38,12 +39,14 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
defer func() { http.DefaultClient = origDefault }()
auditRepo := &mockAuditRepo{}
rpUsageSink := &mockRPUsageEventSink{}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
AuditRepo: auditRepo,
AuditRepo: auditRepo,
RPUsageSink: rpUsageSink,
}
app := fiber.New()
app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp)
@@ -54,6 +57,16 @@ func TestRevokeLinkedRp_Success(t *testing.T) {
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, 1, len(auditRepo.logs))
assert.Equal(t, "consent.revoked", auditRepo.logs[0].EventType)
assert.Equal(t, "user-123", auditRepo.logs[0].UserID)
assert.Equal(t, "success", auditRepo.logs[0].Status)
auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details)
assert.NoError(t, err)
assert.Equal(t, "app-1", auditDetails["client_id"])
assert.Equal(t, 1, len(rpUsageSink.events))
assert.Equal(t, domain.RPUsageEventTypeAuthorizationRevoked, rpUsageSink.events[0].EventType)
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
assert.Equal(t, "app-1", rpUsageSink.events[0].ClientID)
}
func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) {

View File

@@ -3,6 +3,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/json"
@@ -305,6 +306,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
defer func() { http.DefaultClient = origDefault }()
consentRepo := &mockConsentRepo{}
rpUsageSink := &mockRPUsageEventSink{}
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
h := &AuthHandler{
@@ -314,6 +316,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
},
KratosAdmin: mockKratosAdmin,
ConsentRepo: consentRepo,
RPUsageSink: rpUsageSink,
}
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123",
@@ -332,6 +335,11 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
assert.Equal(t, "http://rp/cb", body["redirectTo"])
assert.Equal(t, 1, len(rpUsageSink.events))
assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType)
assert.Equal(t, "client-app", rpUsageSink.events[0].ClientID)
assert.Equal(t, "challenge-skip", rpUsageSink.events[0].CorrelationID)
assert.Equal(t, true, rpUsageSink.events[0].Payload["auto_accepted"])
}
func TestAcceptConsentRequest_Normal(t *testing.T) {
@@ -370,6 +378,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
auditRepo := &mockAuditRepo{}
consentRepo := &mockConsentRepo{}
rpUsageSink := &mockRPUsageEventSink{}
mockKratosAdmin := &MockKratosAdminServiceForConsent{}
h := &AuthHandler{
@@ -380,6 +389,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
KratosAdmin: mockKratosAdmin,
AuditRepo: auditRepo,
ConsentRepo: consentRepo,
RPUsageSink: rpUsageSink,
}
mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123",
@@ -402,6 +412,21 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, 1, len(auditRepo.logs))
assert.Equal(t, "consent.granted", auditRepo.logs[0].EventType)
assert.Equal(t, "user-123", auditRepo.logs[0].UserID)
assert.Equal(t, "success", auditRepo.logs[0].Status)
auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details)
assert.NoError(t, err)
assert.Equal(t, "client-app", auditDetails["client_id"])
assert.Equal(t, "Test App", auditDetails["client_name"])
assert.Equal(t, []interface{}{"openid"}, auditDetails["scopes"])
assert.Equal(t, 1, len(rpUsageSink.events))
assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType)
assert.Equal(t, "user-123", rpUsageSink.events[0].Subject)
assert.Equal(t, "client-app", rpUsageSink.events[0].ClientID)
assert.Equal(t, "Test App", rpUsageSink.events[0].ClientName)
assert.Equal(t, []string{"openid"}, []string(rpUsageSink.events[0].Scopes))
assert.Equal(t, "hydra_consent", rpUsageSink.events[0].Source)
}
func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {

View File

@@ -109,12 +109,29 @@ func (m *mockAuditRepo) CountFailuresSince(ctx context.Context, since time.Time,
return 0, nil
}
func (m *mockAuditRepo) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
return 0, nil
}
func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
return 0, nil
}
func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil }
type mockRPUsageEventSink struct {
events []domain.RPUsageEvent
err error
}
func (m *mockRPUsageEventSink) EmitRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
if m.err != nil {
return m.err
}
m.events = append(m.events, event)
return nil
}
type mockOathkeeperRepo struct {
logs []domain.OathkeeperAccessLog
}

View File

@@ -13,6 +13,7 @@ import (
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
@@ -2749,16 +2750,35 @@ func validateBackchannelLogoutURI(raw string) error {
case "https":
return nil
case "http":
host := strings.ToLower(parsed.Hostname())
if host == "localhost" || host == "127.0.0.1" {
if isAllowedLocalBackchannelLogoutHost(parsed.Hostname()) {
return nil
}
return fmt.Errorf("backchannelLogoutUri must use https outside localhost development")
return fmt.Errorf("backchannelLogoutUri must use https outside local development")
default:
return fmt.Errorf("backchannelLogoutUri must use http or https")
}
}
func isAllowedLocalBackchannelLogoutHost(rawHost string) bool {
host := strings.ToLower(strings.TrimSpace(rawHost))
if host == "" {
return false
}
switch host {
case "localhost", "127.0.0.1", "::1", "host.docker.internal":
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()
}
// Docker service names and other single-label local hostnames are
// permitted only for local HTTP development workflows.
return !strings.Contains(host, ".")
}
func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
if metadata == nil {
return metadata, nil

View File

@@ -40,6 +40,10 @@ func (m *MockAuditRepository) CountFailuresSince(ctx context.Context, since time
return 0, nil
}
func (m *MockAuditRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
return 0, nil
}
func (m *MockAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
return 0, nil
}
@@ -73,6 +77,10 @@ func (r *recordingAuditRepository) CountFailuresSince(ctx context.Context, since
return 0, nil
}
func (r *recordingAuditRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
return 0, nil
}
func (r *recordingAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
return 0, nil
}

View File

@@ -3,6 +3,7 @@ package repository
import (
"baron-sso-backend/internal/domain"
"context"
"encoding/json"
"fmt"
"time"
@@ -77,9 +78,73 @@ func NewClickHouseRepository(host string, port int, user, password, db string) (
return nil, fmt.Errorf("failed to alter table: %w", err)
}
if err := ensureRPUsageTables(context.Background(), conn); err != nil {
return nil, fmt.Errorf("failed to create rp usage tables: %w", err)
}
return &ClickHouseRepository{conn: conn}, nil
}
func ensureRPUsageTables(ctx context.Context, conn driver.Conn) error {
factQuery := `
CREATE TABLE IF NOT EXISTS rp_usage_events (
event_id String,
occurred_at DateTime64(3) DEFAULT now64(3),
event_type String,
subject String,
tenant_id String,
tenant_type String,
client_id String,
client_name String,
session_id String,
scopes Array(String),
source String,
correlation_id String,
payload String
) ENGINE = MergeTree()
ORDER BY (occurred_at, event_id)
`
if err := conn.Exec(ctx, factQuery); err != nil {
return err
}
aggregateQuery := `
CREATE TABLE IF NOT EXISTS rp_usage_daily_aggregate (
event_date Date,
tenant_id String,
tenant_type String,
client_id String,
client_name String,
event_type String,
events_count AggregateFunction(count),
unique_subjects AggregateFunction(uniqExact, String)
) ENGINE = AggregatingMergeTree()
ORDER BY (event_date, tenant_id, client_id, event_type)
`
if err := conn.Exec(ctx, aggregateQuery); err != nil {
return err
}
viewQuery := `
CREATE MATERIALIZED VIEW IF NOT EXISTS rp_usage_daily_aggregate_mv
TO rp_usage_daily_aggregate
AS
SELECT
toDate(occurred_at) AS event_date,
tenant_id,
tenant_type,
client_id,
any(client_name) AS client_name,
event_type,
countState() AS events_count,
uniqExactState(subject) AS unique_subjects
FROM rp_usage_events
WHERE tenant_type IN ('COMPANY', 'ORGANIZATION')
GROUP BY event_date, tenant_id, tenant_type, client_id, event_type
`
return conn.Exec(ctx, viewQuery)
}
func (r *ClickHouseRepository) Create(log *domain.AuditLog) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -106,6 +171,125 @@ func (r *ClickHouseRepository) Create(log *domain.AuditLog) error {
)
}
func (r *ClickHouseRepository) CreateRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
if r == nil || r.conn == nil {
return fmt.Errorf("clickhouse connection is nil")
}
if event.OccurredAt.IsZero() {
event.OccurredAt = time.Now()
}
payloadBytes, err := json.Marshal(event.Payload)
if err != nil {
return fmt.Errorf("failed to marshal rp usage payload: %w", err)
}
query := `
INSERT INTO rp_usage_events (
event_id, occurred_at, event_type, subject, tenant_id, tenant_type,
client_id, client_name, session_id, scopes, source, correlation_id, payload
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
return r.conn.Exec(ctx, query,
event.ID,
event.OccurredAt,
event.EventType,
event.Subject,
event.TenantID,
event.TenantType,
event.ClientID,
event.ClientName,
event.SessionID,
[]string(event.Scopes),
event.Source,
event.CorrelationID,
string(payloadBytes),
)
}
func (r *ClickHouseRepository) FindRPUsage(ctx context.Context, rpQuery domain.RPUsageQuery) ([]domain.RPUsageDailyMetric, error) {
if r == nil || r.conn == nil {
return nil, fmt.Errorf("clickhouse connection is nil")
}
days := rpQuery.Days
if days <= 0 || days > 90 {
days = 14
}
periodExpr := "event_date"
switch rpQuery.Period {
case "week":
periodExpr = "toMonday(event_date)"
case "month":
periodExpr = "toStartOfMonth(event_date)"
case "day", "":
periodExpr = "event_date"
default:
periodExpr = "event_date"
}
query := fmt.Sprintf(`
SELECT
date,
tenant_id,
tenant_type,
client_id,
any(client_name) AS client_name,
sumIf(events, event_type = ?) AS login_requests,
sumIf(events, event_type != ?) AS other_requests,
max(unique_subjects) AS unique_subjects
FROM (
SELECT
toString(%s) AS date,
tenant_id,
tenant_type,
client_id,
any(client_name) AS client_name,
event_type,
countMerge(events_count) AS events,
uniqExactMerge(unique_subjects) AS unique_subjects
FROM rp_usage_daily_aggregate
WHERE event_date >= today() - ?
AND tenant_type IN ('COMPANY', 'ORGANIZATION')
`, periodExpr)
args := []any{domain.RPUsageEventTypeAuthorizationGranted, domain.RPUsageEventTypeAuthorizationGranted, days - 1}
if rpQuery.TenantID != "" {
query += " AND tenant_id = ?\n"
args = append(args, rpQuery.TenantID)
}
query += fmt.Sprintf(`
GROUP BY %s, tenant_id, tenant_type, client_id, event_type
)
GROUP BY date, tenant_id, tenant_type, client_id
ORDER BY date ASC, tenant_id ASC, client_id ASC
`, periodExpr)
rows, err := r.conn.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query rp usage daily aggregate: %w", err)
}
defer rows.Close()
metrics := make([]domain.RPUsageDailyMetric, 0)
for rows.Next() {
var metric domain.RPUsageDailyMetric
if err := rows.Scan(
&metric.Date,
&metric.TenantID,
&metric.TenantType,
&metric.ClientID,
&metric.ClientName,
&metric.LoginRequests,
&metric.OtherRequests,
&metric.UniqueSubjects,
); err != nil {
return nil, fmt.Errorf("failed to scan rp usage daily aggregate: %w", err)
}
if metric.ClientName == "" {
metric.ClientName = metric.ClientID
}
metrics = append(metrics, metric)
}
return metrics, nil
}
func (r *ClickHouseRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor, tenantID string) ([]domain.AuditLog, error) {
if limit <= 0 {
limit = 50
@@ -228,6 +412,21 @@ func (r *ClickHouseRepository) CountFailuresSince(ctx context.Context, since tim
return count, nil
}
func (r *ClickHouseRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) {
sinceUTC := since.UTC().Format("2006-01-02 15:04:05")
query := fmt.Sprintf(`
SELECT count()
FROM audit_logs
WHERE timestamp >= toDateTime('%s')
`, sinceUTC)
var count int64
err := r.conn.QueryRow(ctx, query).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to count audit events: %w", err)
}
return count, nil
}
func (r *ClickHouseRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) {
// We use uniqExact(session_id) to count unique sessions that had success events recently.
query := `

View File

@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
}
// Auto-migrate
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{})
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{})
if err != nil {
log.Fatalf("failed to migrate database: %s", err)
}

View File

@@ -0,0 +1,91 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type RPUsageOutboxRepository interface {
Create(ctx context.Context, event *domain.RPUsageEvent) error
ListReady(ctx context.Context, limit int) ([]domain.RPUsageEvent, error)
MarkProcessing(ctx context.Context, id string) error
MarkProcessed(ctx context.Context, id string) error
MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error
}
type rpUsageOutboxRepository struct {
db *gorm.DB
}
func NewRPUsageOutboxRepository(db *gorm.DB) RPUsageOutboxRepository {
return &rpUsageOutboxRepository{db: db}
}
func (r *rpUsageOutboxRepository) Create(ctx context.Context, event *domain.RPUsageEvent) error {
if event.Payload == nil {
event.Payload = domain.JSONMap{}
}
if event.Status == "" {
event.Status = domain.RPUsageOutboxStatusPending
}
if event.OccurredAt.IsZero() {
event.OccurredAt = time.Now()
}
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "dedupe_key"}},
DoNothing: true,
}).Create(event).Error
}
func (r *rpUsageOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.RPUsageEvent, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
var rows []domain.RPUsageEvent
err := r.db.WithContext(ctx).
Where("status = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)", domain.RPUsageOutboxStatusPending, time.Now()).
Order("occurred_at asc, created_at asc").
Limit(limit).
Find(&rows).Error
return rows, err
}
func (r *rpUsageOutboxRepository) MarkProcessing(ctx context.Context, id string) error {
return r.db.WithContext(ctx).
Model(&domain.RPUsageEvent{}).
Where("id = ? AND status = ?", id, domain.RPUsageOutboxStatusPending).
Updates(map[string]any{
"status": domain.RPUsageOutboxStatusProcessing,
"updated_at": time.Now(),
}).Error
}
func (r *rpUsageOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
now := time.Now()
return r.db.WithContext(ctx).
Model(&domain.RPUsageEvent{}).
Where("id = ?", id).
Updates(map[string]any{
"status": domain.RPUsageOutboxStatusProcessed,
"last_error": "",
"processed_at": &now,
"updated_at": now,
}).Error
}
func (r *rpUsageOutboxRepository) MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error {
return r.db.WithContext(ctx).
Model(&domain.RPUsageEvent{}).
Where("id = ?", id).
Updates(map[string]any{
"status": domain.RPUsageOutboxStatusFailed,
"retry_count": gorm.Expr("retry_count + 1"),
"last_error": message,
"next_attempt_at": &nextAttemptAt,
"updated_at": time.Now(),
}).Error
}

View File

@@ -0,0 +1,67 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
)
type RPUsageEventEmitter struct {
repo repository.RPUsageOutboxRepository
}
func NewRPUsageEventEmitter(repo repository.RPUsageOutboxRepository) *RPUsageEventEmitter {
return &RPUsageEventEmitter{repo: repo}
}
func (e *RPUsageEventEmitter) EmitRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
if e == nil || e.repo == nil {
return nil
}
event.EventType = strings.TrimSpace(event.EventType)
event.Subject = strings.TrimSpace(event.Subject)
event.ClientID = strings.TrimSpace(event.ClientID)
event.Source = strings.TrimSpace(event.Source)
event.CorrelationID = strings.TrimSpace(event.CorrelationID)
if event.EventType == "" {
return fmt.Errorf("rp usage event type is required")
}
if event.Subject == "" {
return fmt.Errorf("rp usage subject is required")
}
if event.ClientID == "" {
return fmt.Errorf("rp usage client_id is required")
}
if event.Source == "" {
event.Source = "backend"
}
if event.OccurredAt.IsZero() {
event.OccurredAt = time.Now()
}
if event.DedupeKey == "" {
event.DedupeKey = buildRPUsageDedupeKey(event)
}
if event.Payload == nil {
event.Payload = domain.JSONMap{}
}
return e.repo.Create(ctx, &event)
}
func buildRPUsageDedupeKey(event domain.RPUsageEvent) string {
raw := strings.Join([]string{
event.EventType,
event.Subject,
event.ClientID,
event.SessionID,
event.Source,
event.CorrelationID,
event.OccurredAt.UTC().Format("2006-01-02T15:04:05.000Z"),
}, "|")
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,132 @@
package service
import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type fakeRPUsageOutboxRepo struct {
created []domain.RPUsageEvent
ready []domain.RPUsageEvent
processing []string
processed []string
failed []string
createErr error
projectErr error
}
func (f *fakeRPUsageOutboxRepo) Create(ctx context.Context, event *domain.RPUsageEvent) error {
if f.createErr != nil {
return f.createErr
}
f.created = append(f.created, *event)
return nil
}
func (f *fakeRPUsageOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.RPUsageEvent, error) {
return f.ready, nil
}
func (f *fakeRPUsageOutboxRepo) MarkProcessing(ctx context.Context, id string) error {
f.processing = append(f.processing, id)
return nil
}
func (f *fakeRPUsageOutboxRepo) MarkProcessed(ctx context.Context, id string) error {
f.processed = append(f.processed, id)
return nil
}
func (f *fakeRPUsageOutboxRepo) MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error {
f.failed = append(f.failed, id)
return nil
}
type fakeRPUsageProjectionRepo struct {
created []domain.RPUsageEvent
err error
}
func (f *fakeRPUsageProjectionRepo) CreateRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
if f.err != nil {
return f.err
}
f.created = append(f.created, event)
return nil
}
func TestRPUsageEventEmitterRequiresCanonicalFields(t *testing.T) {
repo := &fakeRPUsageOutboxRepo{}
emitter := NewRPUsageEventEmitter(repo)
err := emitter.EmitRPUsageEvent(context.Background(), domain.RPUsageEvent{
EventType: domain.RPUsageEventTypeAuthorizationGranted,
ClientID: "client-app",
})
require.Error(t, err)
require.Empty(t, repo.created)
}
func TestRPUsageEventEmitterCreatesPendingOutboxEvent(t *testing.T) {
repo := &fakeRPUsageOutboxRepo{}
emitter := NewRPUsageEventEmitter(repo)
err := emitter.EmitRPUsageEvent(context.Background(), domain.RPUsageEvent{
EventType: domain.RPUsageEventTypeAuthorizationGranted,
Subject: "user-123",
ClientID: "client-app",
Source: "hydra_consent",
CorrelationID: "challenge-1",
})
require.NoError(t, err)
require.Len(t, repo.created, 1)
require.NotEmpty(t, repo.created[0].DedupeKey)
require.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, repo.created[0].EventType)
require.Equal(t, "hydra_consent", repo.created[0].Source)
}
func TestRPUsageProjectorWorkerMarksProcessedAfterProjection(t *testing.T) {
outbox := &fakeRPUsageOutboxRepo{
ready: []domain.RPUsageEvent{{
ID: "event-1",
EventType: domain.RPUsageEventTypeAuthorizationGranted,
Subject: "user-123",
ClientID: "client-app",
}},
}
projection := &fakeRPUsageProjectionRepo{}
worker := NewRPUsageProjectorWorker(outbox, projection)
worker.processOnce(context.Background())
require.Equal(t, []string{"event-1"}, outbox.processing)
require.Equal(t, []string{"event-1"}, outbox.processed)
require.Empty(t, outbox.failed)
require.Len(t, projection.created, 1)
}
func TestRPUsageProjectorWorkerMarksFailedWhenProjectionFails(t *testing.T) {
outbox := &fakeRPUsageOutboxRepo{
ready: []domain.RPUsageEvent{{
ID: "event-1",
EventType: domain.RPUsageEventTypeAuthorizationGranted,
Subject: "user-123",
ClientID: "client-app",
}},
}
projection := &fakeRPUsageProjectionRepo{err: errors.New("clickhouse unavailable")}
worker := NewRPUsageProjectorWorker(outbox, projection)
worker.processOnce(context.Background())
require.Equal(t, []string{"event-1"}, outbox.processing)
require.Empty(t, outbox.processed)
require.Equal(t, []string{"event-1"}, outbox.failed)
}

View File

@@ -0,0 +1,82 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"log/slog"
"time"
)
type RPUsageProjectorWorker struct {
outbox repository.RPUsageOutboxRepository
projection domain.RPUsageProjectionRepository
interval time.Duration
batchSize int
}
func NewRPUsageProjectorWorker(outbox repository.RPUsageOutboxRepository, projection domain.RPUsageProjectionRepository) *RPUsageProjectorWorker {
return &RPUsageProjectorWorker{
outbox: outbox,
projection: projection,
interval: 5 * time.Second,
batchSize: 50,
}
}
func (w *RPUsageProjectorWorker) Start(ctx context.Context) {
if w == nil || w.outbox == nil || w.projection == nil {
return
}
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
default:
w.processOnce(ctx)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (w *RPUsageProjectorWorker) processOnce(ctx context.Context) {
events, err := w.outbox.ListReady(ctx, w.batchSize)
if err != nil {
slog.Warn("failed to list rp usage outbox", "error", err)
return
}
for _, event := range events {
if err := w.outbox.MarkProcessing(ctx, event.ID); err != nil {
slog.Warn("failed to mark rp usage event processing", "event_id", event.ID, "error", err)
continue
}
if err := w.projection.CreateRPUsageEvent(ctx, event); err != nil {
nextAttempt := time.Now().Add(backoffDuration(event.RetryCount))
_ = w.outbox.MarkFailed(ctx, event.ID, err.Error(), nextAttempt)
slog.Warn("failed to project rp usage event", "event_id", event.ID, "error", err)
continue
}
if err := w.outbox.MarkProcessed(ctx, event.ID); err != nil {
slog.Warn("failed to mark rp usage event processed", "event_id", event.ID, "error", err)
}
}
}
func backoffDuration(retryCount int) time.Duration {
if retryCount < 0 {
retryCount = 0
}
delay := time.Duration(retryCount+1) * time.Minute
if delay > 30*time.Minute {
return 30 * time.Minute
}
return delay
}

View File

@@ -20,9 +20,11 @@ import (
"time"
)
const defaultWorksmobileAPIBaseURL = "https://www.worksapis.com"
const defaultWorksmobileOAuthTokenURL = "https://auth.worksmobile.com/oauth2/v2.0/token"
const defaultWorksmobileOAuthScope = "directory"
const (
defaultWorksmobileAPIBaseURL = "https://www.worksapis.com"
defaultWorksmobileOAuthTokenURL = "https://auth.worksmobile.com/oauth2/v2.0/token"
defaultWorksmobileOAuthScope = "directory"
)
type WorksmobileDirectoryClient interface {
CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error

View File

@@ -33,6 +33,7 @@ type WorksmobileConfigSummary struct {
Enabled bool `json:"enabled"`
DomainMappings map[string]int64 `json:"domainMappings"`
TokenConfigured bool `json:"tokenConfigured"`
AdminTenantID string `json:"adminTenantId,omitempty"`
}
type WorksmobileTenantOverview struct {
@@ -115,6 +116,7 @@ func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID
Enabled: WorksmobileEnabled(tenant.Config),
DomainMappings: WorksmobileDomainMappings(tenant.Config),
TokenConfigured: worksmobileDirectoryAuthConfigured(),
AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")),
},
RecentJobs: jobs,
}, nil

View File

@@ -56,6 +56,26 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
require.Empty(t, outboxRepo.created)
}
func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) {
t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1")
root := domain.Tenant{
ID: "root-tenant",
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{root.ID: root}},
&fakeWorksmobileUserRepo{},
&fakeWorksmobileOutboxRepo{},
nil,
)
overview, err := service.GetTenantOverview(context.Background(), root.ID)
require.NoError(t, err)
require.Equal(t, "works-tenant-1", overview.Config.AdminTenantID)
}
func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) {
parentID := "root-tenant"
root := domain.Tenant{
@@ -343,32 +363,41 @@ func (f *fakeWorksmobileUserRepo) Update(ctx context.Context, user *domain.User)
func (f *fakeWorksmobileUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) {
user := f.byID[id]
return &user, nil
}
func (f *fakeWorksmobileUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
return nil, 0, nil
}
func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
return 0, nil
}
func (f *fakeWorksmobileUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
f.requestedTenantIDs = append([]string(nil), tenantIDs...)
return f.byTenant, nil
}
func (f *fakeWorksmobileUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
return nil, nil
}
@@ -376,12 +405,15 @@ func (f *fakeWorksmobileUserRepo) Delete(ctx context.Context, id string) error {
func (f *fakeWorksmobileUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return nil
}
func (f *fakeWorksmobileUserRepo) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
return false, nil
}
func (f *fakeWorksmobileUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
return "", nil
}

View File

@@ -23,22 +23,22 @@ services:
# --- Kratos ---
kratos-migrate:
image: oryd/kratos:${KRATOS_VERSION:-v25.4.0}
image: oryd/kratos:${KRATOS_VERSION:-v26.2.0}
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433}
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["http://localhost:5000","http://localhost:5000/"]}
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL}
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL}
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/"]}
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login
volumes:
- ./docker/ory/kratos:/etc/config/kratos
- ./config/.generated/ory/kratos:/etc/config/kratos
command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes
depends_on:
postgres_ory:
@@ -47,24 +47,24 @@ services:
- ory-net
kratos:
image: oryd/kratos:${KRATOS_VERSION:-v25.4.0}
image: oryd/kratos:${KRATOS_VERSION:-v26.2.0}
container_name: ory_kratos
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
- COOKIE_SECRET=${COOKIE_SECRET:-localcookie123}
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433}
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["http://localhost:5000","http://localhost:5000/"]}
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL}
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL}
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/"]}
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login
volumes:
- ./docker/ory/kratos:/etc/config/kratos
- ./config/.generated/ory/kratos:/etc/config/kratos
command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier
depends_on:
kratos-migrate:
@@ -75,7 +75,7 @@ services:
# --- Hydra ---
hydra-migrate:
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
image: oryd/hydra:${HYDRA_VERSION:-v26.2.0}
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
command: migrate sql up -e --yes
@@ -86,17 +86,17 @@ services:
- ory-net
hydra:
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
image: oryd/hydra:${HYDRA_VERSION:-v26.2.0}
container_name: ory_hydra
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
- URLS_SELF_ISSUER=${USERFRONT_URL:-http://localhost:5000}/oidc
- URLS_LOGIN=${USERFRONT_URL:-http://localhost:5000}/login
- URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent
- URLS_ERROR=${USERFRONT_URL:-http://localhost:5000}/error
- URLS_SELF_ISSUER=${HYDRA_PUBLIC_URL}
- URLS_LOGIN=${HYDRA_LOGIN_URL:-${USERFRONT_URL}/login}
- URLS_CONSENT=${HYDRA_CONSENT_URL:-${USERFRONT_URL}/consent}
- URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error}
- SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD}
volumes:
- ./docker/ory/hydra:/etc/config/hydra
- ./config/.generated/ory/hydra:/etc/config/hydra
command: serve -c /etc/config/hydra/hydra.yml all --dev
depends_on:
hydra-migrate:
@@ -107,11 +107,11 @@ services:
# --- Keto ---
keto-migrate:
image: oryd/keto:${KETO_VERSION:-v25.4.0}
image: oryd/keto:${KETO_VERSION:-v26.2.0}
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
volumes:
- ./docker/ory/keto:/etc/config/keto
- ./config/.generated/ory/keto:/etc/config/keto
command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"]
depends_on:
postgres_ory:
@@ -120,12 +120,12 @@ services:
- ory-net
keto:
image: oryd/keto:${KETO_VERSION:-v25.4.0}
image: oryd/keto:${KETO_VERSION:-v26.2.0}
container_name: ory_keto
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
volumes:
- ./docker/ory/keto:/etc/config/keto
- ./config/.generated/ory/keto:/etc/config/keto
command: serve -c /etc/config/keto/keto.yml
depends_on:
keto-migrate:
@@ -136,14 +136,19 @@ services:
# --- Oathkeeper ---
oathkeeper_logs_init:
image: alpine:latest
command: ["sh", "-c", "mkdir -p /var/log/oathkeeper && chown -R ${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001} /var/log/oathkeeper"]
command:
[
"sh",
"-c",
"mkdir -p /var/log/oathkeeper && chown -R ${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001} /var/log/oathkeeper",
]
volumes:
- oathkeeper_logs:/var/log/oathkeeper
networks:
- ory-net
oathkeeper:
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v25.4.0}
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v26.2.0}
container_name: ory_oathkeeper
user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}"
ports:
@@ -154,7 +159,7 @@ services:
- OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}
- OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}
volumes:
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
- ./config/.generated/ory/oathkeeper:/etc/config/oathkeeper
- oathkeeper_logs:/var/log/oathkeeper
entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"]
depends_on:
@@ -200,9 +205,27 @@ services:
/bin/sh -c "
apk add --no-cache curl;
echo 'Wait for services...';
until curl -s http://kratos:4433/health/ready; do sleep 1; done;
until curl -s http://hydra:4444/health/ready; do sleep 1; done;
until curl -s http://keto:4466/health/ready; do sleep 1; done;
check_ready() {
name=\"$$1\";
url=\"$$2\";
max=\"$${ORY_STACK_CHECK_MAX_ATTEMPTS:-60}\";
i=1;
while [ \"$$i\" -le \"$$max\" ]; do
if curl --connect-timeout 2 --max-time 3 -fsS \"$$url\" >/dev/null; then
echo \"Ory service ready: $$name\";
return 0;
fi;
echo \"Waiting for Ory service: $$name ($$i/$$max)\";
i=$$((i + 1));
sleep 1;
done;
echo \"ERROR: Ory service not ready: $$name after $$max attempts ($$url)\" >&2;
echo \"ERROR: Check service logs: docker logs ory_$$name\" >&2;
return 1;
};
check_ready kratos http://kratos:4433/health/ready || exit 1;
check_ready hydra http://hydra:4444/health/ready || exit 1;
check_ready keto http://keto:4466/health/ready || exit 1;
echo 'Ory Stack is fully operational!';"
depends_on:
- kratos
@@ -220,56 +243,56 @@ services:
- /bin/sh
- -ec
- |
apk add --no-cache curl tar
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
rm /tmp/hydra.tar.gz
apk add --no-cache curl tar
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
rm /tmp/hydra.tar.gz
hydra delete oauth2-client --endpoint http://hydra:4445 adminfront >/dev/null 2>&1 || true
hydra delete oauth2-client --endpoint http://hydra:4445 devfront >/dev/null 2>&1 || true
hydra delete oauth2-client --endpoint http://hydra:4445 orgfront >/dev/null 2>&1 || true
hydra delete oauth2-client --endpoint http://hydra:4445 ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} >/dev/null 2>&1 || true
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" adminfront >/dev/null 2>&1 || true
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" devfront >/dev/null 2>&1 || true
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" orgfront >/dev/null 2>&1 || true
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" >/dev/null 2>&1 || true
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id adminfront \
--name "AdminFront" \
hydra create oauth2-client \
--endpoint "$${HYDRA_ADMIN_URL}" \
--id adminfront \
--name "AdminFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri ${ADMINFRONT_CALLBACK_URLS}
hydra create oauth2-client \
--endpoint "$${HYDRA_ADMIN_URL}" \
--id devfront \
--name "DevFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri ${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}
--redirect-uri ${DEVFRONT_CALLBACK_URLS}
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id devfront \
--name "DevFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri ${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback}
hydra create oauth2-client \
--endpoint "$${HYDRA_ADMIN_URL}" \
--id orgfront \
--name "OrgFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri ${ORGFRONT_CALLBACK_URLS}
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id orgfront \
--name "OrgFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri ${ORGFRONT_CALLBACK_URLS:-http://localhost:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback}
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} \
--secret ${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} \
--grant-type client_credentials \
--response-type token \
--scope openid,offline_access,profile,email
hydra create oauth2-client \
--endpoint "$${HYDRA_ADMIN_URL}" \
--id "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \
--secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \
--grant-type client_credentials \
--response-type token \
--scope openid,offline_access,profile,email
depends_on:
ory_stack_check:
condition: service_completed_successfully

View File

@@ -18,11 +18,16 @@ echo "🚀 Creating instance: ${INSTANCE_NAME} (Port Prefix: ${PORT_PREFIX}xxx)"
# 1. 폴더 구조 생성
mkdir -p "${TARGET_DIR}/gateway"
mkdir -p "${TARGET_DIR}/ory/kratos"
mkdir -p "${TARGET_DIR}/ory/oathkeeper"
mkdir -p "${TARGET_DIR}/config/.generated"
mkdir -p "${TARGET_DIR}/ory/init-db"
mkdir -p "${TARGET_DIR}/ory/templates/kratos"
mkdir -p "${TARGET_DIR}/ory/templates/hydra"
mkdir -p "${TARGET_DIR}/ory/templates/keto"
mkdir -p "${TARGET_DIR}/ory/templates/oathkeeper"
mkdir -p "${TARGET_DIR}/userfront"
mkdir -p "${TARGET_DIR}/adminfront"
mkdir -p "${TARGET_DIR}/devfront"
mkdir -p "${TARGET_DIR}/orgfront"
# 2. .env 생성 및 변수 로드
sed "s/{{INSTANCE_NAME}}/${INSTANCE_NAME}/g; s/{{PORT_PREFIX}}/${PORT_PREFIX}/g" \
@@ -34,6 +39,7 @@ USERFRONT_PORT="${PORT_PREFIX}500"
DOMAIN_SUFFIX=$(grep "DOMAIN_SUFFIX=" "${TARGET_DIR}/.env" | cut -d'=' -f2 | tr -d '\r')
ADMINFRONT_DOMAIN="${INSTANCE_NAME}-admin.${DOMAIN_SUFFIX}"
DEVFRONT_DOMAIN="${INSTANCE_NAME}-dev.${DOMAIN_SUFFIX}"
ORGFRONT_DOMAIN="${INSTANCE_NAME}-org.${DOMAIN_SUFFIX}"
# 3. Docker Compose & Config 복사 및 치환
cp "${BASE_DIR}/templates/docker-compose.yaml" "${TARGET_DIR}/"
@@ -42,29 +48,44 @@ cp "${BASE_DIR}/templates/docker-compose.yaml" "${TARGET_DIR}/"
sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" "${BASE_DIR}/templates/gateway/nginx.conf" > "${TARGET_DIR}/gateway/nginx.conf"
sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" "${BASE_DIR}/templates/userfront/nginx.conf" > "${TARGET_DIR}/userfront/nginx.conf"
# Oathkeeper Rules
sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" "${BASE_DIR}/templates/ory/oathkeeper/rules.json" > "${TARGET_DIR}/ory/oathkeeper/rules.json"
cp "${TARGET_DIR}/ory/oathkeeper/rules.json" "${TARGET_DIR}/ory/oathkeeper/rules.active.json"
# Oathkeeper Rules template
sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" "${BASE_DIR}/templates/ory/oathkeeper/rules.json" > "${TARGET_DIR}/ory/templates/oathkeeper/rules.json"
cp "${TARGET_DIR}/ory/templates/oathkeeper/rules.json" "${TARGET_DIR}/ory/templates/oathkeeper/rules.stage.json"
cp "${TARGET_DIR}/ory/templates/oathkeeper/rules.json" "${TARGET_DIR}/ory/templates/oathkeeper/rules.prod.json"
cp "${TARGET_DIR}/ory/templates/oathkeeper/rules.json" "${TARGET_DIR}/ory/templates/oathkeeper/rules.active.json"
# Kratos Config
# Kratos Config template
sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g; s/{{USERFRONT_PORT}}/${USERFRONT_PORT}/g" \
"${BASE_DIR}/templates/ory/kratos/kratos.yml" > "${TARGET_DIR}/ory/kratos/kratos.yml"
"${BASE_DIR}/templates/ory/kratos/kratos.yml.template" > "${TARGET_DIR}/ory/templates/kratos/kratos.yml.template"
# Vite Configs
sed "s/{{ADMINFRONT_DOMAIN}}/${ADMINFRONT_DOMAIN}/g; s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" \
"${BASE_DIR}/templates/adminfront/vite.config.ts" > "${TARGET_DIR}/adminfront/vite.config.ts"
sed "s/{{DEVFRONT_DOMAIN}}/${DEVFRONT_DOMAIN}/g; s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" \
"${BASE_DIR}/templates/devfront/vite.config.ts" > "${TARGET_DIR}/devfront/vite.config.ts"
sed "s/{{ORGFRONT_DOMAIN}}/${ORGFRONT_DOMAIN}/g; s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" \
"${BASE_DIR}/templates/orgfront/vite.config.ts" > "${TARGET_DIR}/orgfront/vite.config.ts"
# 4. 프론트엔드 auth.ts 주입 (하드코딩된 포트 해결)
sed "s/{{USERFRONT_PORT}}/${USERFRONT_PORT}/g; s/{{CLIENT_ID}}/adminfront/g" \
"${BASE_DIR}/templates/auth.template.ts" > "${TARGET_DIR}/adminfront/auth.ts"
sed "s/{{USERFRONT_PORT}}/${USERFRONT_PORT}/g; s/{{CLIENT_ID}}/devfront/g" \
"${BASE_DIR}/templates/auth.template.ts" > "${TARGET_DIR}/devfront/auth.ts"
sed "s/{{USERFRONT_PORT}}/${USERFRONT_PORT}/g" \
"${BASE_DIR}/templates/orgfront/auth.ts" > "${TARGET_DIR}/orgfront/auth.ts"
# 5. Ory 정적 설정 복사
if [ -d "${BASE_DIR}/../docker/ory/kratos" ]; then cp -n "${BASE_DIR}/../docker/ory/kratos/"* "${TARGET_DIR}/ory/kratos/" 2>/dev/null || true; fi
if [ -d "${BASE_DIR}/../docker/ory/oathkeeper" ]; then cp -n "${BASE_DIR}/../docker/ory/oathkeeper/"* "${TARGET_DIR}/ory/oathkeeper/" 2>/dev/null || true; fi
# 5. Ory template 복사 및 완성 config 렌더링
if [ -d "${BASE_DIR}/../docker/ory/init-db" ]; then cp -n "${BASE_DIR}/../docker/ory/init-db/"* "${TARGET_DIR}/ory/init-db/" 2>/dev/null || true; fi
if [ -d "${BASE_DIR}/../docker/ory/kratos" ]; then cp -n "${BASE_DIR}/../docker/ory/kratos/"* "${TARGET_DIR}/ory/templates/kratos/" 2>/dev/null || true; fi
if [ -d "${BASE_DIR}/../docker/ory/kratos/courier-templates" ]; then cp -a "${BASE_DIR}/../docker/ory/kratos/courier-templates" "${TARGET_DIR}/ory/templates/kratos/" 2>/dev/null || true; fi
if [ -d "${BASE_DIR}/../docker/ory/hydra" ]; then cp -n "${BASE_DIR}/../docker/ory/hydra/"* "${TARGET_DIR}/ory/templates/hydra/" 2>/dev/null || true; fi
if [ -d "${BASE_DIR}/../docker/ory/keto" ]; then cp -n "${BASE_DIR}/../docker/ory/keto/"* "${TARGET_DIR}/ory/templates/keto/" 2>/dev/null || true; fi
if [ -d "${BASE_DIR}/../docker/ory/oathkeeper" ]; then cp -n "${BASE_DIR}/../docker/ory/oathkeeper/"* "${TARGET_DIR}/ory/templates/oathkeeper/" 2>/dev/null || true; fi
ORY_CONFIG_ENV_FILES="${TARGET_DIR}/.env" \
ORY_CONFIG_TEMPLATE_ROOT="${TARGET_DIR}/ory/templates" \
ORY_CONFIG_OUTPUT_DIR="${TARGET_DIR}/config/.generated/ory" \
bash "${BASE_DIR}/../scripts/render_ory_config.sh"
# 6. 마무리
chmod +x "${TARGET_DIR}/.env"

View File

@@ -17,6 +17,7 @@ BACKEND_PORT=${P}000
USERFRONT_PORT=${P}500
ADMINFRONT_PORT=${P}173
DEVFRONT_PORT=${P}174
ORGFRONT_PORT=${P}175
OATHKEEPER_PROXY_PORT=${P}467
# === [3] 도메인 설정 (별도 도메인 구조) ===
@@ -25,23 +26,44 @@ DOMAIN_SUFFIX=hmac.kr
USERFRONT_URL=https://{{INSTANCE_NAME}}-sso.${DOMAIN_SUFFIX}
ADMINFRONT_URL=https://{{INSTANCE_NAME}}-admin.${DOMAIN_SUFFIX}
DEVFRONT_URL=https://{{INSTANCE_NAME}}-dev.${DOMAIN_SUFFIX}
ORGFRONT_URL=https://{{INSTANCE_NAME}}-org.${DOMAIN_SUFFIX}
# OIDC/Auth URL
VITE_OIDC_AUTHORITY=${USERFRONT_URL}/oidc
ADMINFRONT_CALLBACK_URLS=${ADMINFRONT_URL}/auth/callback
DEVFRONT_CALLBACK_URLS=${DEVFRONT_URL}/auth/callback
ORGFRONT_CALLBACK_URLS=${ORGFRONT_URL}/auth/callback
# Ory URL
KRATOS_UI_URL=${USERFRONT_URL}/auth
KRATOS_BROWSER_URL=${USERFRONT_URL}/auth
KRATOS_ADMIN_URL=http://kratos:4434
HYDRA_PUBLIC_URL=${USERFRONT_URL}/oidc
HYDRA_ADMIN_URL=http://hydra:4445
OATHKEEPER_PUBLIC_URL=${USERFRONT_URL}
KETO_READ_URL=http://keto:4466
KETO_WRITE_URL=http://keto:4467
# Ory versions
KRATOS_VERSION=v26.2.0
HYDRA_VERSION=v26.2.0
KETO_VERSION=v26.2.0
OATHKEEPER_VERSION=v26.2.0
ORY_POSTGRES_TAG=17-alpine
# === [4] IDP 및 DB Config ===
IDP_PROVIDER=ory
DB_PASSWORD=password
ORY_POSTGRES_USER=ory
ORY_POSTGRES_PASSWORD=generated_secret_here
ORY_POSTGRES_DB=ory
KRATOS_DB=ory_kratos
HYDRA_DB=ory_hydra
KETO_DB=ory_keto
OATHKEEPER_UID=1001
OATHKEEPER_GID=1001
OATHKEEPER_INTROSPECT_CLIENT_ID=oathkeeper-introspect
OATHKEEPER_INTROSPECT_CLIENT_SECRET=oathkeeper-secret
CLICKHOUSE_PASSWORD=password
REDIS_ADDR=redis:6379

View File

@@ -10,7 +10,7 @@ services:
ports:
- "${DB_PORT}:5432"
volumes:
- db_data_${INSTANCE_NAME}:/var/lib/postgresql/data
- db_data:/var/lib/postgresql/data
networks: [app_net]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
@@ -33,42 +33,246 @@ services:
- "${CLICKHOUSE_PORT_HTTP}:8123"
- "${CLICKHOUSE_PORT_NATIVE}:9000"
volumes:
- clickhouse_data_${INSTANCE_NAME}:/var/lib/clickhouse
- clickhouse_data:/var/lib/clickhouse
networks: [app_net]
# --- Ory Stack ---
postgres_ory:
image: postgres:17-alpine
image: postgres:${ORY_POSTGRES_TAG:-17-alpine}
container_name: ${COMPOSE_PROJECT_NAME}_ory_db
environment:
- POSTGRES_USER=${ORY_POSTGRES_USER}
- POSTGRES_USER=${ORY_POSTGRES_USER:-ory}
- POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD}
- POSTGRES_DB=${ORY_POSTGRES_DB:-ory}
volumes:
- ory_db_data_${INSTANCE_NAME}:/var/lib/postgresql/data
- ory_db_data:/var/lib/postgresql/data
- ./ory/init-db:/docker-entrypoint-initdb.d:ro
networks: [app_net]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${ORY_POSTGRES_USER}"]
test: ["CMD-SHELL", "pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}"]
interval: 5s
kratos:
image: oryd/kratos:v25.4.0
container_name: ${COMPOSE_PROJECT_NAME}_kratos
kratos-migrate:
image: oryd/kratos:${KRATOS_VERSION:-v26.2.0}
env_file: .env
environment:
- DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL}
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/","${USERFRONT_URL}","${USERFRONT_URL}/","${USERFRONT_URL}/ko","${USERFRONT_URL}/ko/","${USERFRONT_URL}/en","${USERFRONT_URL}/en/","${USERFRONT_URL}/auth/callback","${USERFRONT_URL}/ko/auth/callback","${USERFRONT_URL}/en/auth/callback","${ADMINFRONT_URL}/auth/callback","${DEVFRONT_URL}/auth/callback","${ORGFRONT_URL}/auth/callback"]}
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login
volumes:
- ./ory/kratos:/etc/config/kratos:ro
command: serve -c /etc/config/kratos/kratos.yml --dev
- ./config/.generated/ory/kratos:/etc/config/kratos:ro
command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes
networks: [app_net]
depends_on:
postgres_ory: { condition: service_healthy }
kratos:
image: oryd/kratos:${KRATOS_VERSION:-v26.2.0}
container_name: ${COMPOSE_PROJECT_NAME}_kratos
env_file: .env
environment:
- DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
- COOKIE_SECRET=${COOKIE_SECRET}
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL}
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/","${USERFRONT_URL}","${USERFRONT_URL}/","${USERFRONT_URL}/ko","${USERFRONT_URL}/ko/","${USERFRONT_URL}/en","${USERFRONT_URL}/en/","${USERFRONT_URL}/auth/callback","${USERFRONT_URL}/ko/auth/callback","${USERFRONT_URL}/en/auth/callback","${ADMINFRONT_URL}/auth/callback","${DEVFRONT_URL}/auth/callback","${ORGFRONT_URL}/auth/callback"]}
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login
volumes:
- ./config/.generated/ory/kratos:/etc/config/kratos:ro
command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier
networks: [app_net]
depends_on:
kratos-migrate: { condition: service_completed_successfully }
hydra-migrate:
image: oryd/hydra:${HYDRA_VERSION:-v26.2.0}
env_file: .env
environment:
- DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
command: migrate sql up -e --yes
networks: [app_net]
depends_on:
postgres_ory: { condition: service_healthy }
hydra:
image: oryd/hydra:${HYDRA_VERSION:-v26.2.0}
container_name: ${COMPOSE_PROJECT_NAME}_hydra
env_file: .env
environment:
- DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
- URLS_SELF_ISSUER=${HYDRA_PUBLIC_URL}
- URLS_LOGIN=${HYDRA_LOGIN_URL:-${USERFRONT_URL}/login}
- URLS_CONSENT=${HYDRA_CONSENT_URL:-${USERFRONT_URL}/consent}
- URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error}
- SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD}
volumes:
- ./config/.generated/ory/hydra:/etc/config/hydra:ro
command: serve -c /etc/config/hydra/hydra.yml all --dev
networks: [app_net]
depends_on:
hydra-migrate: { condition: service_completed_successfully }
keto-migrate:
image: oryd/keto:${KETO_VERSION:-v26.2.0}
env_file: .env
environment:
- DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
volumes:
- ./config/.generated/ory/keto:/etc/config/keto:ro
command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"]
networks: [app_net]
depends_on:
postgres_ory: { condition: service_healthy }
keto:
image: oryd/keto:${KETO_VERSION:-v26.2.0}
container_name: ${COMPOSE_PROJECT_NAME}_keto
env_file: .env
environment:
- DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
volumes:
- ./config/.generated/ory/keto:/etc/config/keto:ro
command: serve -c /etc/config/keto/keto.yml
networks: [app_net]
depends_on:
keto-migrate: { condition: service_completed_successfully }
oathkeeper_logs_init:
image: alpine:latest
command: ["sh", "-c", "mkdir -p /var/log/oathkeeper && chown -R ${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001} /var/log/oathkeeper"]
volumes:
- oathkeeper_logs:/var/log/oathkeeper
networks: [app_net]
oathkeeper:
image: oryd/oathkeeper:v25.4.0
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v26.2.0}
container_name: ${COMPOSE_PROJECT_NAME}_oathkeeper
env_file: .env
user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}"
ports:
- "${OATHKEEPER_PROXY_PORT}:4455"
environment:
- APP_ENV=${APP_ENV:-production}
- LOG_LEVEL=debug
- OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}
- OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}
volumes:
- ./ory/oathkeeper:/etc/config/oathkeeper:ro
- ./config/.generated/ory/oathkeeper:/etc/config/oathkeeper:ro
- oathkeeper_logs:/var/log/oathkeeper
entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"]
networks: [app_net]
depends_on:
oathkeeper_logs_init: { condition: service_completed_successfully }
kratos: { condition: service_started }
hydra: { condition: service_started }
ory_stack_check:
image: alpine:latest
container_name: ${COMPOSE_PROJECT_NAME}_ory_stack_check
command: >
/bin/sh -c "
apk add --no-cache curl;
echo 'Wait for Ory services...';
check_ready() {
name=\"$$1\";
url=\"$$2\";
max=\"$${ORY_STACK_CHECK_MAX_ATTEMPTS:-60}\";
i=1;
while [ \"$$i\" -le \"$$max\" ]; do
if curl --connect-timeout 2 --max-time 3 -fsS \"$$url\" >/dev/null; then
echo \"Ory service ready: $$name\";
return 0;
fi;
echo \"Waiting for Ory service: $$name ($$i/$$max)\";
i=$$((i + 1));
sleep 1;
done;
echo \"ERROR: Ory service not ready: $$name after $$max attempts ($$url)\" >&2;
echo \"ERROR: Check service logs: docker logs $${COMPOSE_PROJECT_NAME}_$$name\" >&2;
return 1;
};
check_ready kratos http://kratos:4433/health/ready || exit 1;
check_ready hydra http://hydra:4444/health/ready || exit 1;
check_ready keto http://keto:4466/health/ready || exit 1;
echo 'Ory stack is ready.';"
depends_on:
- kratos
- hydra
- keto
networks: [app_net]
init-rp:
image: alpine:latest
env_file: .env
command:
- /bin/sh
- -ec
- |
apk add --no-cache curl tar
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
rm /tmp/hydra.tar.gz
upsert_client() {
ID=$$1
shift
if hydra get oauth2-client --endpoint "$${HYDRA_ADMIN_URL:-http://hydra:4445}" "$$ID" >/dev/null 2>&1; then
hydra update oauth2-client --endpoint "$${HYDRA_ADMIN_URL:-http://hydra:4445}" "$$ID" "$$@"
else
hydra create oauth2-client --endpoint "$${HYDRA_ADMIN_URL:-http://hydra:4445}" --id "$$ID" "$$@"
fi
}
upsert_client "adminfront" \
--name "AdminFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri "$${ADMINFRONT_CALLBACK_URLS:-$${ADMINFRONT_URL}/auth/callback}"
upsert_client "devfront" \
--name "DevFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri "$${DEVFRONT_CALLBACK_URLS:-$${DEVFRONT_URL}/auth/callback}"
upsert_client "orgfront" \
--name "OrgFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri "$${ORGFRONT_CALLBACK_URLS:-$${ORGFRONT_URL}/auth/callback}"
upsert_client "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \
--secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \
--grant-type client_credentials \
--response-type token \
--scope openid,offline_access,profile,email
depends_on:
ory_stack_check: { condition: service_completed_successfully }
networks: [app_net]
# --- Application Services ---
@@ -78,6 +282,14 @@ services:
env_file: .env
environment:
- PORT=${BACKEND_PORT}
- APP_ENV=${APP_ENV:-production}
- IDP_PROVIDER=${IDP_PROVIDER:-ory}
- USERFRONT_URL=${USERFRONT_URL}
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
- HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL}
- KETO_READ_URL=${KETO_READ_URL:-http://keto:4466}
- KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467}
- DB_HOST=postgres
- REDIS_ADDR=redis:6379
- CLICKHOUSE_HOST=clickhouse
@@ -90,6 +302,7 @@ services:
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
oathkeeper: { condition: service_started }
gateway:
image: nginx:alpine
@@ -147,6 +360,11 @@ networks:
name: ${COMPOSE_PROJECT_NAME}_net
volumes:
db_data_${INSTANCE_NAME}:
ory_db_data_${INSTANCE_NAME}:
clickhouse_data_${INSTANCE_NAME}:
db_data:
name: db_data_${INSTANCE_NAME}
ory_db_data:
name: ory_db_data_${INSTANCE_NAME}
clickhouse_data:
name: clickhouse_data_${INSTANCE_NAME}
oathkeeper_logs:
name: oathkeeper_logs_${INSTANCE_NAME}

View File

@@ -29,7 +29,6 @@ http {
}
location /oidc {
rewrite ^/oidc/(.*)$ /$1 break;
proxy_pass http://oathkeeper_srv;
proxy_set_header Host $host;
}

View File

@@ -1,6 +1,6 @@
version: v25.4.0
version: v26.2.0
dsn: ${DSN}
dsn: ${KRATOS_DSN}
serve:
public:
@@ -9,6 +9,7 @@ serve:
enabled: true
allowed_origins:
- http://backend:{{BACKEND_PORT}}
- http://localhost:{{USERFRONT_PORT}}
admin:
base_url: http://localhost:4434/
@@ -21,28 +22,15 @@ session:
selfservice:
default_browser_return_url: http://localhost:{{USERFRONT_PORT}}/
allowed_return_urls:
- http://backend:{{BACKEND_PORT}}
- http://backend:{{BACKEND_PORT}}/
- http://localhost:{{USERFRONT_PORT}}
- https://app.brsw.kr
- https://app.brsw.kr/
- https://sss.hmac.kr
- https://sss.hmac.kr/
- https://sso.hmac.kr
- https://sso.hmac.kr/
- https://ssologin.hmac.kr
- https://ssologin.hmac.kr/
- https://sso-test.hmac.kr
- https://sso-test.hmac.kr/
- https://ssob.hmac.kr
- https://ssob.hmac.kr/
- https://ssob.hmac.kr/ko
- https://ssob.hmac.kr/ko/
- https://ssob.hmac.kr/en
- https://ssob.hmac.kr/en/
- https://ssob.hmac.kr/auth/callback
- https://ssob.hmac.kr/ko/auth/callback
- https://ssob.hmac.kr/en/auth/callback
- http://localhost:{{USERFRONT_PORT}}/
- http://localhost:{{USERFRONT_PORT}}/ko
- http://localhost:{{USERFRONT_PORT}}/ko/
- http://localhost:{{USERFRONT_PORT}}/en
- http://localhost:{{USERFRONT_PORT}}/en/
- http://localhost:{{USERFRONT_PORT}}/auth/callback
- http://localhost:{{USERFRONT_PORT}}/ko/auth/callback
- http://localhost:{{USERFRONT_PORT}}/en/auth/callback
methods:
password:

View File

@@ -1,9 +1,52 @@
[
{
"id": "backend-api-rule",
"id": "public-health",
"description": "공개 헬스체크",
"match": {
"url": "<.*>://<.*>/api/v1/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH"]
"url": "<.*>://<[^/]+>/health",
"methods": ["GET"]
},
"upstream": {
"url": "http://backend:{{BACKEND_PORT}}"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "public-preflight",
"description": "CORS preflight",
"match": {
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["OPTIONS"]
},
"upstream": {
"url": "http://backend:{{BACKEND_PORT}}"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "public-auth",
"description": "인증/회원가입 등 공개 엔드포인트",
"match": {
"url": "<.*>://<[^/]+>/api/v1/auth/<.*>",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://backend:{{BACKEND_PORT}}"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "backend-command",
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
"match": {
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["POST", "PUT", "PATCH", "DELETE"]
},
"upstream": {
"url": "http://backend:{{BACKEND_PORT}}"
@@ -11,5 +54,106 @@
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "backend-query",
"description": "Backend Query (admin/dev 포함)",
"match": {
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["GET"]
},
"upstream": {
"url": "http://backend:{{BACKEND_PORT}}"
},
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-well-known",
"description": "Hydra OIDC Discovery & JWKS",
"match": {
"url": "<.*>://<[^/]+>/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-well-known-oidc",
"description": "Hydra OIDC Discovery & JWKS (with /oidc prefix)",
"match": {
"url": "<.*>://<[^/]+>/oidc/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-oauth2",
"description": "Hydra OAuth2 Endpoints",
"match": {
"url": "<.*>://<[^/]+>/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-oauth2-oidc",
"description": "Hydra OAuth2 Endpoints (with /oidc prefix)",
"match": {
"url": "<.*>://<[^/]+>/oidc/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-userinfo",
"description": "Hydra Userinfo",
"match": {
"url": "<.*>://<[^/]+>/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-userinfo-oidc",
"description": "Hydra Userinfo (with /oidc prefix)",
"match": {
"url": "<.*>://<[^/]+>/oidc/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
}
]

View File

@@ -14,34 +14,34 @@ export function ForbiddenMessage({ resourceToken }: Props) {
let explanation = t(
"msg.dev.forbidden.default",
"해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요.",
"You do not have permission to access this resource. Contact your administrator.",
);
if (role === "rp_admin") {
explanation = t(
"msg.dev.forbidden.rp_admin",
"RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다.",
"RP administrators can only access resources for their assigned applications.",
);
} else if (role === "tenant_admin") {
explanation = t(
"msg.dev.forbidden.tenant_admin",
"테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다.",
"Your tenant administrator permission is missing, misconfigured, or expired.",
);
} else if (role === "user" || role === "tenant_member") {
if (resourceToken === "consents") {
explanation = t(
"msg.dev.forbidden.user.consents",
"해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.",
"Viewing consent records for this application requires an RP administrator, consent read, or consent revoke relationship. Request access from an administrator if needed.",
);
} else if (resourceToken === "audit") {
explanation = t(
"msg.dev.forbidden.user.audit",
"해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.",
"Viewing audit logs for this application requires an RP administrator or audit read relationship. Request access from an administrator if needed.",
);
} else {
explanation = t(
"msg.dev.forbidden.user.clients",
"일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.",
"Standard user accounts can use this feature only when an operational or administrative relationship is granted for the target RP. Request access from an administrator if needed.",
);
}
}
@@ -51,9 +51,9 @@ export function ForbiddenMessage({ resourceToken }: Props) {
? t("ui.dev.audit.title", "Audit Logs")
: resourceToken === "consents"
? t("ui.dev.clients.consents.title", "User Consent Grants")
: t("ui.dev.clients.registry.subtitle", "연동 앱");
: t("ui.dev.clients.registry.subtitle", "Connected Applications");
const title = t("msg.dev.forbidden.title", "{{resource}} 접근 권한 없음", {
const title = t("msg.dev.forbidden.title", "Access denied: {{resource}}", {
resource: resourceLabel,
});

View File

@@ -32,7 +32,7 @@ const navItems = [
},
{
labelKey: "ui.dev.nav.developer_request",
labelFallback: "개발자 권한 신청",
labelFallback: "Developer Access Request",
to: "/developer-requests",
icon: ClipboardCheck,
},
@@ -71,7 +71,11 @@ function AppLayout() {
});
const handleLogout = () => {
if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) {
if (
window.confirm(
t("msg.dev.logout_confirm", "Are you sure you want to log out?"),
)
) {
auth.removeUser();
navigate("/login");
}
@@ -136,7 +140,7 @@ function AppLayout() {
try {
await auth.signinSilent();
} catch (error) {
console.error("세션 자동 연장에 실패했습니다.", error);
console.error("Silent session renewal failed.", error);
} finally {
isRenewInFlightRef.current = false;
}
@@ -184,7 +188,7 @@ function AppLayout() {
try {
await auth.signinSilent();
} catch (error) {
console.error("세션 무제한 유지 갱신에 실패했습니다.", error);
console.error("Unlimited session keepalive renewal failed.", error);
} finally {
isRenewInFlightRef.current = false;
}
@@ -241,7 +245,7 @@ function AppLayout() {
void auth
.signinSilent()
.catch((error) => {
console.error("세션 자동 연장에 실패했습니다.", error);
console.error("Silent session renewal failed.", error);
})
.finally(() => {
isRenewInFlightRef.current = false;
@@ -289,15 +293,15 @@ function AppLayout() {
let sessionToneClass =
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
let sessionText = t("ui.dev.session.active", "세션 활성");
let sessionText = t("ui.dev.session.active", "Session active");
if (remainingMs === null) {
sessionToneClass = "border-border bg-card text-muted-foreground";
sessionText = t("ui.dev.session.unknown", "알 수 없음");
sessionText = t("ui.dev.session.unknown", "Unknown");
} else if (remainingMs <= 0) {
sessionToneClass =
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
sessionText = t("ui.dev.session.expired", "세션 만료");
sessionText = t("ui.dev.session.expired", "Session expired");
} else if (
remainingMinutes !== null &&
remainingSeconds !== null &&
@@ -307,7 +311,7 @@ function AppLayout() {
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
sessionText = t(
"ui.dev.session.expiring",
"만료 임박: {{minutes}} {{seconds}}초 남음",
"Expiring soon: {{minutes}}m {{seconds}}s left",
{
minutes: remainingMinutes,
seconds: remainingSeconds,
@@ -316,7 +320,7 @@ function AppLayout() {
} else {
sessionText = t(
"ui.dev.session.remaining",
"만료 예정: {{minutes}} {{seconds}}초 남음",
"Expires in {{minutes}}m {{seconds}}s",
{
minutes: remainingMinutes ?? 0,
seconds: remainingSeconds ?? 0,
@@ -343,7 +347,7 @@ function AppLayout() {
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("ui.dev.brand", "Baron 로그인")}
{t("ui.dev.brand", "Baron Sign In")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.dev.console_title", "Developer Console")}
@@ -423,7 +427,7 @@ function AppLayout() {
type="button"
onClick={toggleTheme}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
aria-label={t("ui.common.theme_toggle", "테마 전환")}
aria-label={t("ui.common.theme_toggle", "Toggle theme")}
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
{theme === "light"
@@ -447,7 +451,10 @@ function AppLayout() {
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
aria-haspopup="menu"
aria-expanded={isProfileMenuOpen}
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
aria-label={t(
"ui.dev.profile.menu_aria",
"Open account menu",
)}
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{profileInitial}
@@ -496,14 +503,14 @@ function AppLayout() {
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
{t("ui.dev.session.auto_extend", "Session expiry")}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionText
: t(
"ui.dev.session.disabled",
"세션 만료 비활성화",
"Session expiry disabled",
)}
</p>
</div>
@@ -539,7 +546,7 @@ function AppLayout() {
}}
>
<UserIcon size={16} className="text-muted-foreground" />
<span>{t("ui.dev.profile.title", "내 정보")}</span>
<span>{t("ui.dev.profile.title", "My Profile")}</span>
</button>
<button
type="button"

View File

@@ -288,7 +288,26 @@ function isValidBackchannelLogoutUrl(value: string): boolean {
if (url.protocol !== "http:") {
return false;
}
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
const host = url.hostname.toLowerCase();
if (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host === "host.docker.internal"
) {
return true;
}
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
return (
host.startsWith("10.") ||
host.startsWith("192.168.") ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host) ||
host.startsWith("169.254.")
);
}
// Docker service names and other single-label local hosts are allowed
// only for HTTP local development use.
return !host.includes(".");
} catch {
return false;
}
@@ -949,7 +968,7 @@ function ClientGeneralPage() {
throw new Error(
t(
"msg.dev.clients.general.backchannel_logout.invalid",
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.",
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다.",
),
);
}
@@ -1590,7 +1609,7 @@ function ClientGeneralPage() {
<p className="text-xs text-destructive">
{t(
"msg.dev.clients.general.backchannel_logout.invalid",
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.",
"Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다.",
)}
</p>
) : null}

View File

@@ -433,9 +433,14 @@ subtitle = "Set the application name, description, and logo."
[msg.dev.clients.general.redirect]
help = "Enter the redirect URIs. You can modify them in the Federation tab after creation."
[msg.dev.clients.general.auto_login]
subtitle = "If the RP can build an OIDC request from its own login start URL, userfront can jump directly into that flow."
help = "This URL must generate state, nonce, and PKCE values on the RP side and then redirect to Baron OIDC."
invalid_url = "The auto login URL format is invalid. Enter an http or https URL."
[msg.dev.clients.general.backchannel_logout]
uri_help = "RP endpoint that receives Baron's session termination event via server-to-server POST."
invalid = "The Back-Channel Logout URI format is invalid. Production requires https, and local development only allows http on localhost/127.0.0.1."
invalid = "The Back-Channel Logout URI format is invalid. Production requires https, and local development only allows http on localhost/127.0.0.1, host.docker.internal, Docker service names, and private IPs."
session_required_help = "Use this when the RP should process logout_token only if the sid claim is included."
session_required_on = "On: process logout only when the logout_token contains a sid."
session_required_off = "Off: process logout using sub even if sid is missing."
@@ -496,7 +501,7 @@ docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and auth methods."
[msg.dev.clients.registry]
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs."
[msg.dev.clients.scopes]
email = "Email"
@@ -1256,7 +1261,9 @@ copy = "Copy"
create = "Create"
delete = "Delete"
details = "Details"
disabled = "Disabled"
edit = "Edit"
enabled = "Enabled"
export = "Export"
fail = "Fail"
go_home = "Go Home"
@@ -1492,6 +1499,13 @@ title = "Application Identity"
label = "Redirect URIs"
placeholder = "Placeholder"
[ui.dev.clients.general.auto_login]
title = "Auto Login"
supported = "Auto Login Enabled"
unsupported = "Auto Login Disabled"
url = "Auto Login Start URL"
url_placeholder = "https://app.example.com/login?auto=1"
[ui.dev.clients.general.backchannel_logout]
uri = "Back-Channel Logout URI"
uri_placeholder = "https://rp.example.com/oidc/backchannel-logout"
@@ -1668,7 +1682,7 @@ subtitle = "Tenant admin on-call"
title = "Owner"
[ui.dev.clients.registry]
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs."
subtitle = "Applications"
title = "RP registry"

View File

@@ -433,9 +433,14 @@ subtitle = "앱 이름과 설명, 로고를 설정합니다."
[msg.dev.clients.general.redirect]
help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동 설정 탭에서 수정 가능합니다."
[msg.dev.clients.general.auto_login]
subtitle = "RP가 자체 로그인 시작 URL에서 OIDC 요청을 만들 수 있으면 userfront에서 바로 로그인 진입을 제공합니다."
help = "이 URL은 RP가 state, nonce, PKCE 값을 직접 생성한 뒤 Baron OIDC로 리다이렉트해야 합니다."
invalid_url = "자동 로그인 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요."
[msg.dev.clients.general.backchannel_logout]
uri_help = "Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다."
invalid = "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다."
invalid = "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다."
session_required_help = "RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다."
session_required_on = "켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리"
session_required_off = "끄면: sid가 없어도 sub만으로 로그아웃 처리 가능"
@@ -1256,7 +1261,9 @@ copy = "복사"
create = "생성"
delete = "삭제"
details = "상세정보"
disabled = "사용 안 함"
edit = "편집"
enabled = "사용"
export = "내보내기"
fail = "실패"
go_home = "홈으로"
@@ -1491,6 +1498,13 @@ title = "애플리케이션 정보"
label = "리디렉션 URI"
placeholder = "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)"
[ui.dev.clients.general.auto_login]
title = "자동 로그인"
supported = "자동 로그인 지원"
unsupported = "자동 로그인 미지원"
url = "자동 로그인 시작 URL"
url_placeholder = "https://app.example.com/login?auto=1"
[ui.dev.clients.general.backchannel_logout]
uri = "Back-Channel Logout URI"
uri_placeholder = "https://rp.example.com/oidc/backchannel-logout"

View File

@@ -480,6 +480,11 @@ subtitle = ""
[msg.dev.clients.general.redirect]
help = ""
[msg.dev.clients.general.auto_login]
subtitle = ""
help = ""
invalid_url = ""
[msg.dev.clients.general.backchannel_logout]
uri_help = ""
invalid = ""
@@ -1309,7 +1314,9 @@ copy = ""
create = ""
delete = ""
details = ""
disabled = ""
edit = ""
enabled = ""
export = ""
fail = ""
go_home = ""
@@ -1547,6 +1554,13 @@ title = ""
label = ""
placeholder = ""
[ui.dev.clients.general.auto_login]
title = ""
supported = ""
unsupported = ""
url = ""
url_placeholder = ""
[ui.dev.clients.general.backchannel_logout]
uri = ""
uri_placeholder = ""

View File

@@ -22,15 +22,15 @@ services:
retries: 5
kratos-migrate:
image: oryd/kratos:${KRATOS_VERSION:-v25.4.0}
image: oryd/kratos:${KRATOS_VERSION:-v26.2.0}
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
- KRATOS_SERVE_PUBLIC_BASE_URL="${KRATOS_BROWSER_URL:-http://localhost:4433}"
- KRATOS_SERVE_ADMIN_BASE_URL="${KRATOS_ADMIN_URL:-http://kratos:4434}"
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL="${KRATOS_UI_URL:-http://localhost:5000}"
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS='["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"]'
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL}
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL}
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${USERFRONT_URL}"]}
volumes:
- ./docker/ory/kratos:/etc/config/kratos
- ../config/.generated/ory/kratos:/etc/config/kratos
command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes
depends_on:
postgres_ory:
@@ -39,17 +39,17 @@ services:
- ory-net
kratos:
image: oryd/kratos:${KRATOS_VERSION:-v25.4.0}
image: oryd/kratos:${KRATOS_VERSION:-v26.2.0}
container_name: ory_kratos
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
- COOKIE_SECRET="${COOKIE_SECRET:-localcookie123}"
- KRATOS_SERVE_PUBLIC_BASE_URL="${KRATOS_BROWSER_URL:-http://localhost:4433}"
- KRATOS_SERVE_ADMIN_BASE_URL="${KRATOS_ADMIN_URL:-http://kratos:4434}"
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL="${KRATOS_UI_URL:-http://localhost:5000}"
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS='["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"]'
- COOKIE_SECRET=${COOKIE_SECRET:-localcookie123}
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL}
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL}
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${USERFRONT_URL}"]}
volumes:
- ./docker/ory/kratos:/etc/config/kratos
- ../config/.generated/ory/kratos:/etc/config/kratos
command: serve -c /etc/config/kratos/kratos.yml
depends_on:
kratos-migrate:
@@ -59,7 +59,7 @@ services:
- kratosnet
hydra-migrate:
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
image: oryd/hydra:${HYDRA_VERSION:-v26.2.0}
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
command: migrate sql up -e --yes
@@ -70,16 +70,17 @@ services:
- ory-net
hydra:
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
image: oryd/hydra:${HYDRA_VERSION:-v26.2.0}
container_name: ory_hydra
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
- URLS_SELF_ISSUER=${USERFRONT_URL:-http://localhost:5000}/oidc
- URLS_LOGIN=${USERFRONT_URL:-http://localhost:5000}/login
- URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent
- URLS_SELF_ISSUER=${HYDRA_PUBLIC_URL}
- URLS_LOGIN=${HYDRA_LOGIN_URL:-${USERFRONT_URL}/login}
- URLS_CONSENT=${HYDRA_CONSENT_URL:-${USERFRONT_URL}/consent}
- URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error}
- SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD}
volumes:
- ./docker/ory/hydra:/etc/config/hydra
- ../config/.generated/ory/hydra:/etc/config/hydra
command: serve -c /etc/config/hydra/hydra.yml all --dev
depends_on:
hydra-migrate:
@@ -89,11 +90,11 @@ services:
- hydranet
keto-migrate:
image: oryd/keto:${KETO_VERSION:-v25.4.0}
image: oryd/keto:${KETO_VERSION:-v26.2.0}
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
volumes:
- ./docker/ory/keto:/etc/config/keto
- ../config/.generated/ory/keto:/etc/config/keto
command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"]
depends_on:
postgres_ory:
@@ -102,12 +103,12 @@ services:
- ory-net
keto:
image: oryd/keto:${KETO_VERSION:-v25.4.0}
image: oryd/keto:${KETO_VERSION:-v26.2.0}
container_name: ory_keto
environment:
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
volumes:
- ./docker/ory/keto:/etc/config/keto
- ../config/.generated/ory/keto:/etc/config/keto
command: serve -c /etc/config/keto/keto.yml
depends_on:
keto-migrate:
@@ -116,21 +117,24 @@ services:
- ory-net
oathkeeper:
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v0.40.6}
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v26.2.0}
container_name: oathkeeper
restart: unless-stopped
depends_on:
kratos:
condition: service_started
environment:
- APP_ENV=${APP_ENV:-development}
- LOG_LEVEL=debug
command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml
- OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}
- OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}
volumes:
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
- ../config/.generated/ory/oathkeeper:/etc/config/oathkeeper
- oathkeeper_logs:/var/log/oathkeeper
entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"]
networks:
- ory-net
- baron_net
- baron_net
- public_net
ports:
- "4455:4455"
@@ -148,9 +152,27 @@ services:
/bin/sh -c "
apk add --no-cache curl;
echo 'Wait for services...';
until curl -s http://kratos:4433/health/ready; do sleep 1; done;
until curl -s http://hydra:4444/health/ready; do sleep 1; done;
until curl -s http://keto:4466/health/ready; do sleep 1; done;
check_ready() {
name=\"$$1\";
url=\"$$2\";
max=\"$${ORY_STACK_CHECK_MAX_ATTEMPTS:-60}\";
i=1;
while [ \"$$i\" -le \"$$max\" ]; do
if curl --connect-timeout 2 --max-time 3 -fsS \"$$url\" >/dev/null; then
echo \"Ory service ready: $$name\";
return 0;
fi;
echo \"Waiting for Ory service: $$name ($$i/$$max)\";
i=$$((i + 1));
sleep 1;
done;
echo \"ERROR: Ory service not ready: $$name after $$max attempts ($$url)\" >&2;
echo \"ERROR: Check service logs: docker logs ory_$$name\" >&2;
return 1;
};
check_ready kratos http://kratos:4433/health/ready || exit 1;
check_ready hydra http://hydra:4444/health/ready || exit 1;
check_ready keto http://keto:4466/health/ready || exit 1;
echo 'Ory Stack is fully operational!';"
depends_on:
- kratos
@@ -168,47 +190,47 @@ services:
- /bin/sh
- -ec
- |
apk add --no-cache curl tar
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
rm /tmp/hydra.tar.gz
apk add --no-cache curl tar
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
rm /tmp/hydra.tar.gz
echo "Creating/Updating OAuth2 Clients..."
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id adminfront \
--name "AdminFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri ${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback,http://172.16.10.176:5173/auth/callback}
echo "Creating/Updating OAuth2 Clients..."
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id devfront \
--name "DevFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri ${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback,http://172.16.10.176:5174/auth/callback}
hydra create oauth2-client \
--endpoint "$${HYDRA_ADMIN_URL}" \
--id adminfront \
--name "AdminFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri "$${ADMINFRONT_CALLBACK_URLS}"
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id orgfront \
--name "OrgFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri ${ORGFRONT_CALLBACK_URLS:-http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback}
hydra create oauth2-client \
--endpoint "$${HYDRA_ADMIN_URL}" \
--id devfront \
--name "DevFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri "$${DEVFRONT_CALLBACK_URLS}"
echo "All RP clients initialized successfully."
hydra create oauth2-client \
--endpoint "$${HYDRA_ADMIN_URL}" \
--id orgfront \
--name "OrgFront" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri "$${ORGFRONT_CALLBACK_URLS}"
echo "All RP clients initialized successfully."
depends_on:
ory_stack_check:
condition: service_completed_successfully

View File

@@ -1,4 +1,4 @@
dsn: ${DSN}
dsn: ${HYDRA_DSN}
serve:
cookies:
@@ -77,7 +77,7 @@ urls:
secrets:
system:
- ${SECRETS_SYSTEM}
- ${HYDRA_SYSTEM_SECRET}
webfinger:
oidc_discovery:

View File

@@ -1,5 +1,5 @@
version: v0.11.0
dsn: ${DSN}
dsn: ${KETO_DSN}
serve:
read:
host: 0.0.0.0

View File

@@ -1,6 +1,6 @@
version: v26.2.0
dsn: ${DSN}
dsn: ${KRATOS_DSN}
serve:
public:
@@ -8,17 +8,12 @@ serve:
cors:
enabled: true
allowed_origins:
- http://localhost:5000
- http://localhost:5173
- http://localhost:5174
- http://localhost:5175
- http://localhost:5000
- http://backend:3000
- http://baron_backend:3000
- https://ssologin.hmac.kr
- https://sso-test.hmac.kr
- https://app.brsw.kr
- https://sss.hmac.kr
- https://sso.hmac.kr
admin:
base_url: http://localhost:4434/
@@ -31,19 +26,18 @@ session:
selfservice:
default_browser_return_url: http://localhost:5000/
allowed_return_urls:
- http://baron_backend:3000
- http://baron_backend:3000/
- http://localhost:5000
- https://app.brsw.kr
- https://app.brsw.kr/
- https://sss.hmac.kr
- https://sss.hmac.kr/
- https://sso.hmac.kr
- https://sso.hmac.kr/
- https://ssologin.hmac.kr
- https://ssologin.hmac.kr/
- https://sso-test.hmac.kr
- https://sso-test.hmac.kr/
- http://localhost:5000/
- http://localhost:5000/ko
- http://localhost:5000/ko/
- http://localhost:5000/en
- http://localhost:5000/en/
- http://localhost:5000/auth/callback
- http://localhost:5000/ko/auth/callback
- http://localhost:5000/en/auth/callback
- http://localhost:5173/auth/callback
- http://localhost:5174/auth/callback
- http://localhost:5175/auth/callback
methods:
password:

View File

@@ -3,7 +3,7 @@
"id": "public-health",
"description": "공개 헬스체크",
"match": {
"url": "<.*>://<.*>/health",
"url": "<.*>://<[^/]+>/health",
"methods": ["GET"]
},
"upstream": {
@@ -17,7 +17,7 @@
"id": "public-preflight",
"description": "CORS preflight",
"match": {
"url": "<.*>://<.*>/api/v1/<.*>",
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["OPTIONS"]
},
"upstream": {
@@ -31,7 +31,7 @@
"id": "public-auth",
"description": "인증/회원가입 등 공개 엔드포인트",
"match": {
"url": "<.*>://<.*>/api/v1/auth/<.*>",
"url": "<.*>://<[^/]+>/api/v1/auth/<.*>",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
@@ -45,7 +45,7 @@
"id": "backend-command",
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
"match": {
"url": "<.*>://<.*>/api/v1/<.*>",
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["POST", "PUT", "PATCH", "DELETE"]
},
"upstream": {
@@ -59,7 +59,7 @@
"id": "backend-query",
"description": "Backend Query (admin/dev 포함)",
"match": {
"url": "<.*>://<.*>/api/v1/<.*>",
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["GET"]
},
"upstream": {
@@ -73,7 +73,7 @@
"id": "hydra-well-known",
"description": "Hydra OIDC Discovery & JWKS",
"match": {
"url": "<.*>://<.*>/.well-known/<.*>",
"url": "<.*>://<[^/]+>/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
@@ -87,12 +87,12 @@
"id": "hydra-well-known-oidc",
"description": "Hydra OIDC Discovery & JWKS (with /oidc prefix)",
"match": {
"url": "<.*>://<.*>/oidc/.well-known/<.*>",
"url": "<.*>://<[^/]+>/oidc/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path_prefix": "/oidc"
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
@@ -102,7 +102,7 @@
"id": "hydra-oauth2",
"description": "Hydra OAuth2 Endpoints",
"match": {
"url": "<.*>://<.*>/oauth2/<.*>",
"url": "<.*>://<[^/]+>/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
@@ -116,12 +116,12 @@
"id": "hydra-oauth2-oidc",
"description": "Hydra OAuth2 Endpoints (with /oidc prefix)",
"match": {
"url": "<.*>://<.*>/oidc/oauth2/<.*>",
"url": "<.*>://<[^/]+>/oidc/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path_prefix": "/oidc"
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
@@ -131,7 +131,7 @@
"id": "hydra-userinfo",
"description": "Hydra Userinfo",
"match": {
"url": "<.*>://<.*>/userinfo",
"url": "<.*>://<[^/]+>/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
@@ -145,12 +145,12 @@
"id": "hydra-userinfo-oidc",
"description": "Hydra Userinfo (with /oidc prefix)",
"match": {
"url": "<.*>://<.*>/oidc/userinfo",
"url": "<.*>://<[^/]+>/oidc/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path_prefix": "/oidc"
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },

View File

@@ -3,7 +3,7 @@
"id": "public-health",
"description": "공개 헬스체크",
"match": {
"url": "<.*>://<.*>/health",
"url": "<.*>://<[^/]+>/health",
"methods": ["GET"]
},
"upstream": {
@@ -17,7 +17,7 @@
"id": "public-preflight",
"description": "CORS preflight",
"match": {
"url": "<.*>://<.*>/api/v1/<.*>",
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["OPTIONS"]
},
"upstream": {
@@ -31,7 +31,7 @@
"id": "public-auth",
"description": "인증/회원가입 등 공개 엔드포인트",
"match": {
"url": "<.*>://<.*>/api/v1/auth/<.*>",
"url": "<.*>://<[^/]+>/api/v1/auth/<.*>",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
@@ -45,7 +45,7 @@
"id": "backend-command",
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
"match": {
"url": "<.*>://<.*>/api/v1/<.*>",
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["POST", "PUT", "PATCH", "DELETE"]
},
"upstream": {
@@ -59,7 +59,7 @@
"id": "backend-query",
"description": "Backend Query (admin/dev 포함)",
"match": {
"url": "<.*>://<.*>/api/v1/<.*>",
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["GET"]
},
"upstream": {
@@ -73,7 +73,7 @@
"id": "hydra-well-known",
"description": "Hydra OIDC Discovery & JWKS",
"match": {
"url": "<.*>://<.*>/.well-known/<.*>",
"url": "<.*>://<[^/]+>/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
@@ -87,12 +87,12 @@
"id": "hydra-well-known-oidc",
"description": "Hydra OIDC Discovery & JWKS (with /oidc prefix)",
"match": {
"url": "<.*>://<.*>/oidc/.well-known/<.*>",
"url": "<.*>://<[^/]+>/oidc/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path_prefix": "/oidc"
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
@@ -102,7 +102,7 @@
"id": "hydra-oauth2",
"description": "Hydra OAuth2 Endpoints",
"match": {
"url": "<.*>://<.*>/oauth2/<.*>",
"url": "<.*>://<[^/]+>/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
@@ -116,12 +116,12 @@
"id": "hydra-oauth2-oidc",
"description": "Hydra OAuth2 Endpoints (with /oidc prefix)",
"match": {
"url": "<.*>://<.*>/oidc/oauth2/<.*>",
"url": "<.*>://<[^/]+>/oidc/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path_prefix": "/oidc"
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
@@ -131,7 +131,7 @@
"id": "hydra-userinfo",
"description": "Hydra Userinfo",
"match": {
"url": "<.*>://<.*>/userinfo",
"url": "<.*>://<[^/]+>/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
@@ -145,12 +145,12 @@
"id": "hydra-userinfo-oidc",
"description": "Hydra Userinfo (with /oidc prefix)",
"match": {
"url": "<.*>://<.*>/oidc/userinfo",
"url": "<.*>://<[^/]+>/oidc/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path_prefix": "/oidc"
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },

View File

@@ -1,9 +1,9 @@
[
{
"id": "public-health",
"description": "공개 헬스체크 (PROD 도메인)",
"description": "공개 헬스체크 (PROD)",
"match": {
"url": "https://app.brsw.kr/health",
"url": "<.*>://<[^/]+>/health",
"methods": ["GET"]
},
"upstream": {
@@ -15,9 +15,9 @@
},
{
"id": "public-preflight",
"description": "CORS preflight (PROD 도메인)",
"description": "CORS preflight (PROD)",
"match": {
"url": "https://app.brsw.kr/api/v1/<.*>",
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["OPTIONS"]
},
"upstream": {
@@ -29,9 +29,9 @@
},
{
"id": "public-auth",
"description": "인증/회원가입 등 공개 엔드포인트 (PROD 도메인)",
"description": "인증/회원가입 등 공개 엔드포인트 (PROD)",
"match": {
"url": "https://app.brsw.kr/api/v1/auth/<.*>",
"url": "<.*>://<[^/]+>/api/v1/auth/<.*>",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
@@ -45,7 +45,7 @@
"id": "backend-command",
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
"match": {
"url": "https://app.brsw.kr/api/v1/<.*>",
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["POST", "PUT", "PATCH", "DELETE"]
},
"upstream": {
@@ -59,7 +59,7 @@
"id": "backend-query",
"description": "Backend Query (admin/dev 포함)",
"match": {
"url": "https://app.brsw.kr/api/v1/<.*>",
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["GET"]
},
"upstream": {
@@ -68,5 +68,92 @@
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-well-known",
"description": "Hydra OIDC Discovery & JWKS (PROD)",
"match": {
"url": "<.*>://<[^/]+>/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-well-known-oidc",
"description": "Hydra OIDC Discovery & JWKS with /oidc prefix (PROD)",
"match": {
"url": "<.*>://<[^/]+>/oidc/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-oauth2",
"description": "Hydra OAuth2 Endpoints (PROD)",
"match": {
"url": "<.*>://<[^/]+>/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-oauth2-oidc",
"description": "Hydra OAuth2 Endpoints with /oidc prefix (PROD 도메인)",
"match": {
"url": "<.*>://<[^/]+>/oidc/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-userinfo",
"description": "Hydra Userinfo (PROD)",
"match": {
"url": "<.*>://<[^/]+>/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-userinfo-oidc",
"description": "Hydra Userinfo with /oidc prefix (PROD 도메인)",
"match": {
"url": "<.*>://<[^/]+>/oidc/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
}
]

View File

@@ -3,7 +3,7 @@
"id": "public-health",
"description": "공개 헬스체크",
"match": {
"url": "<.*>://<.*>/health",
"url": "<.*>://<[^/]+>/health",
"methods": ["GET"]
},
"upstream": {
@@ -17,7 +17,7 @@
"id": "public-preflight",
"description": "CORS preflight",
"match": {
"url": "<.*>://<.*>/api/v1/<.*>",
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["OPTIONS"]
},
"upstream": {
@@ -31,7 +31,7 @@
"id": "public-auth",
"description": "인증/회원가입 등 공개 엔드포인트",
"match": {
"url": "<.*>://<.*>/api/v1/auth/<.*>",
"url": "<.*>://<[^/]+>/api/v1/auth/<.*>",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
@@ -45,7 +45,7 @@
"id": "backend-command",
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
"match": {
"url": "<.*>://<.*>/api/v1/<.*>",
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["POST", "PUT", "PATCH", "DELETE"]
},
"upstream": {
@@ -59,7 +59,7 @@
"id": "backend-query",
"description": "Backend Query (admin/dev 포함)",
"match": {
"url": "<.*>://<.*>/api/v1/<.*>",
"url": "<.*>://<[^/]+>/api/v1/<.*>",
"methods": ["GET"]
},
"upstream": {
@@ -73,7 +73,7 @@
"id": "hydra-well-known",
"description": "Hydra OIDC Discovery & JWKS",
"match": {
"url": "<.*>://<.*>/.well-known/<.*>",
"url": "<.*>://<[^/]+>/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
@@ -87,12 +87,12 @@
"id": "hydra-well-known-oidc",
"description": "Hydra OIDC Discovery & JWKS (with /oidc prefix)",
"match": {
"url": "<.*>://<.*>/oidc/.well-known/<.*>",
"url": "<.*>://<[^/]+>/oidc/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path_prefix": "/oidc"
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
@@ -102,7 +102,7 @@
"id": "hydra-oauth2",
"description": "Hydra OAuth2 Endpoints",
"match": {
"url": "<.*>://<.*>/oauth2/<.*>",
"url": "<.*>://<[^/]+>/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
@@ -116,12 +116,12 @@
"id": "hydra-oauth2-oidc",
"description": "Hydra OAuth2 Endpoints (with /oidc prefix)",
"match": {
"url": "<.*>://<.*>/oidc/oauth2/<.*>",
"url": "<.*>://<[^/]+>/oidc/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path_prefix": "/oidc"
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
@@ -131,7 +131,7 @@
"id": "hydra-userinfo",
"description": "Hydra Userinfo",
"match": {
"url": "<.*>://<.*>/userinfo",
"url": "<.*>://<[^/]+>/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
@@ -145,12 +145,12 @@
"id": "hydra-userinfo-oidc",
"description": "Hydra Userinfo (with /oidc prefix)",
"match": {
"url": "<.*>://<.*>/oidc/userinfo",
"url": "<.*>://<[^/]+>/oidc/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444",
"strip_path_prefix": "/oidc"
"strip_path": "/oidc"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },

View File

@@ -8,55 +8,114 @@
inputs = ["oathkeeper_file"]
source = '''
raw = to_string(.message) ?? ""
parsed = parse_json(raw) ?? {}
parsed = object!(parse_json(raw) ?? {})
request_method = to_string(get(parsed, ["request", "method"]) ?? "") ?? ""
if request_method == "" { request_method = to_string(get(parsed, ["http_request", "method"]) ?? "") ?? "" }
request_path = to_string(get(parsed, ["request", "path"]) ?? "") ?? ""
if request_path == "" { request_path = to_string(get(parsed, ["http_request", "path"]) ?? "") ?? "" }
request_url = to_string(get(parsed, ["request", "url"]) ?? "") ?? ""
if request_url == "" { request_url = to_string(get(parsed, ["http_url"]) ?? "") ?? "" }
request_host = to_string(get(parsed, ["request", "host"]) ?? "") ?? ""
if request_host == "" { request_host = to_string(get(parsed, ["http_request", "host"]) ?? "") ?? "" }
request_scheme = to_string(get(parsed, ["request", "scheme"]) ?? "") ?? ""
if request_scheme == "" { request_scheme = to_string(get(parsed, ["http_request", "scheme"]) ?? "") ?? "" }
request_query = to_string(get(parsed, ["request", "query"]) ?? "") ?? ""
response_status = get(parsed, ["response", "status"]) ?? 0
if request_query == "" { request_query = to_string(get(parsed, ["http_request", "query"]) ?? "") ?? "" }
response_status = to_int(get(parsed, ["response", "status"]) ?? 0) ?? 0
if response_status == 0 { response_status = to_int(get(parsed, ["http_response", "status"]) ?? 0) ?? 0 }
response_size = to_int(get(parsed, ["response", "size"]) ?? 0) ?? 0
if response_size == 0 { response_size = to_int(get(parsed, ["http_response", "size"]) ?? 0) ?? 0 }
response_took = to_int(get(parsed, ["response", "took"]) ?? 0) ?? 0
if response_took == 0 { response_took = to_int(get(parsed, ["http_response", "took"]) ?? 0) ?? 0 }
identity_id = to_string(get(parsed, ["identity", "id"]) ?? "") ?? ""
headers = get(parsed, ["headers"]) ?? {}
if identity_id == "" { identity_id = to_string(get(parsed, ["subject"]) ?? "") ?? "" }
headers = object(get(parsed, ["headers"]) ?? {}) ?? {}
if length(headers) == 0 { headers = object(get(parsed, ["http_request", "headers"]) ?? {}) ?? {} }
user_agent = to_string(get(headers, ["User-Agent"]) ?? "") ?? ""
if user_agent == "" { user_agent = to_string(get(headers, ["user-agent"]) ?? "") ?? "" }
referer = to_string(get(headers, ["Referer"]) ?? "") ?? ""
if referer == "" { referer = to_string(get(headers, ["referer"]) ?? "") ?? "" }
rule_id = to_string(get(parsed, ["rule", "id"]) ?? "") ?? ""
if rule_id == "" { rule_id = to_string(get(parsed, ["rule_id"]) ?? "") ?? "" }
upstream_url = to_string(get(parsed, ["upstream", "url"]) ?? "") ?? ""
if upstream_url == "" { upstream_url = to_string(get(parsed, ["http_url"]) ?? "") ?? "" }
client_id = to_string(get(parsed, ["client", "id"]) ?? "") ?? ""
parent_session_id = to_string(get(parsed, ["extra", "parent_session_id"]) ?? "") ?? ""
parsed_url = parse_url(request_url) ?? {}
query_params = get(parsed_url, ["query"]) ?? {}
url_path = to_string(get(parsed_url, ["path"]) ?? "") ?? ""
parsed_request_query = parse_url("http://localhost/?" + request_query) ?? {}
request_query_params = get(parsed_request_query, ["query"]) ?? {}
event_path = to_string(parsed.path) ?? to_string(parsed.http_path) ?? ""
if event_path == "" { event_path = request_path }
if event_path == "" { event_path = url_path }
if event_path == "" { event_path = request_url }
event_client_id = to_string(parsed.client_id) ?? ""
if event_client_id == "" { event_client_id = client_id }
if event_client_id == "" { event_client_id = to_string(get(query_params, ["client_id"]) ?? "") ?? "" }
if event_client_id == "" { event_client_id = to_string(get(query_params, ["clientId"]) ?? "") ?? "" }
if event_client_id == "" { event_client_id = to_string(get(request_query_params, ["client_id"]) ?? "") ?? "" }
if event_client_id == "" { event_client_id = to_string(get(request_query_params, ["clientId"]) ?? "") ?? "" }
event_latency_ms = to_int(parsed.latency_ms) ?? to_int(parsed.duration_ms) ?? 0
if event_latency_ms == 0 && response_took != 0 {
event_latency_ms = to_int(to_float(response_took) / 1000000.0)
}
event_client_ip = to_string(parsed.client_ip) ?? to_string(parsed.remote_ip) ?? to_string(parsed.ip) ?? ""
if event_client_ip == "" { event_client_ip = to_string(get(headers, ["X-Real-Ip"]) ?? "") ?? "" }
if event_client_ip == "" { event_client_ip = to_string(get(headers, ["x-real-ip"]) ?? "") ?? "" }
if event_client_ip == "" { event_client_ip = to_string(get(headers, ["X-Forwarded-For"]) ?? "") ?? "" }
if event_client_ip == "" { event_client_ip = to_string(get(headers, ["x-forwarded-for"]) ?? "") ?? "" }
event_decision = to_string(parsed.decision) ?? to_string(parsed.result) ?? ""
if event_decision == "" && exists(parsed.granted) {
if parsed.granted == true {
event_decision = "granted"
} else {
event_decision = "denied"
}
}
event_status = to_int(get(parsed, ["status"]) ?? 0) ?? 0
if event_status == 0 { event_status = to_int(get(parsed, ["status_code"]) ?? 0) ?? 0 }
if event_status == 0 { event_status = response_status }
event_bytes_out = to_int(get(parsed, ["bytes_out"]) ?? 0) ?? 0
if event_bytes_out == 0 { event_bytes_out = to_int(get(parsed, ["response_bytes"]) ?? 0) ?? 0 }
if event_bytes_out == 0 { event_bytes_out = response_size }
event_method = to_string(get(parsed, ["method"]) ?? "") ?? ""
if event_method == "" { event_method = to_string(get(parsed, ["http_method"]) ?? "") ?? "" }
if event_method == "" { event_method = request_method }
event_host = to_string(get(parsed, ["host"]) ?? "") ?? ""
if event_host == "" { event_host = to_string(get(parsed, ["http_host"]) ?? "") ?? "" }
if event_host == "" { event_host = request_host }
event_scheme = to_string(get(parsed, ["scheme"]) ?? "") ?? ""
if event_scheme == "" { event_scheme = request_scheme }
event_query = to_string(get(parsed, ["query"]) ?? "") ?? ""
if event_query == "" { event_query = request_query }
event_user_agent = to_string(get(parsed, ["user_agent"]) ?? "") ?? ""
if event_user_agent == "" { event_user_agent = to_string(get(parsed, ["http_user_agent"]) ?? "") ?? "" }
if event_user_agent == "" { event_user_agent = user_agent }
. = {
"request_id": to_string(parsed.request_id) ?? to_string(parsed.req_id) ?? "",
"method": to_string(parsed.method) ?? to_string(parsed.http_method) ?? request_method,
"method": event_method,
"path": event_path,
"status": to_int(parsed.status) ?? to_int(parsed.status_code) ?? to_int(response_status) ?? 0,
"latency_ms": to_int(parsed.latency_ms) ?? to_int(parsed.duration_ms) ?? to_int(parsed.took) ?? 0,
"status": event_status,
"latency_ms": event_latency_ms,
"client_id": event_client_id,
"rp": to_string(parsed.rp) ?? "",
"action": to_string(parsed.action) ?? "",
"target": to_string(parsed.target) ?? "",
"rule_id": to_string(parsed.rule_id) ?? rule_id,
"host": to_string(parsed.host) ?? request_host,
"scheme": to_string(parsed.scheme) ?? request_scheme,
"query": to_string(parsed.query) ?? request_query,
"host": event_host,
"scheme": event_scheme,
"query": event_query,
"upstream_url": to_string(parsed.upstream_url) ?? upstream_url,
"subject": to_string(parsed.subject) ?? identity_id,
"parent_session_id": to_string(parsed.parent_session_id) ?? parent_session_id,
"client_ip": to_string(parsed.client_ip) ?? to_string(parsed.remote_ip) ?? to_string(parsed.ip) ?? "",
"user_agent": to_string(parsed.user_agent) ?? user_agent,
"client_ip": event_client_ip,
"user_agent": event_user_agent,
"referer": referer,
"decision": to_string(parsed.decision) ?? to_string(parsed.result) ?? "",
"decision": event_decision,
"bytes_in": to_int(parsed.bytes_in) ?? to_int(parsed.request_bytes) ?? 0,
"bytes_out": to_int(parsed.bytes_out) ?? to_int(parsed.response_bytes) ?? 0,
"bytes_out": event_bytes_out,
"trace_id": to_string(parsed.trace_id) ?? "",
"span_id": to_string(parsed.span_id) ?? "",
"raw": raw
@@ -73,3 +132,52 @@
auth.strategy = "basic"
auth.user = "${ORY_CLICKHOUSE_USER}"
auth.password = "${ORY_CLICKHOUSE_PASSWORD}"
[[tests]]
name = "parses_oathkeeper_v26_completed_request"
[[tests.inputs]]
insert_at = "oathkeeper_parse"
type = "log"
[tests.inputs.log_fields]
message = '{"http_request":{"headers":{"user-agent":"Mozilla/5.0","referer":"http://localhost:5173/","x-real-ip":"172.19.0.1"},"host":"localhost","method":"GET","path":"/oauth2/auth","query":"client_id=orgfront&response_type=code","remote":"172.23.0.2:56744","scheme":"http"},"http_response":{"status":302,"size":1339,"took":4854092},"http_url":"http://hydra:4444/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback","level":"info","msg":"completed handling request","subject":"","time":"2026-05-06T01:40:51.46074548Z"}'
[[tests.outputs]]
extract_from = "oathkeeper_parse"
[[tests.outputs.conditions]]
type = "vrl"
source = '''
assert_eq!(.method, "GET")
assert_eq!(.path, "/oauth2/auth")
assert_eq!(.status, 302)
assert_eq!(.client_id, "orgfront")
assert_eq!(.host, "localhost")
assert_eq!(.scheme, "http")
assert_eq!(.user_agent, "Mozilla/5.0")
assert_eq!(.referer, "http://localhost:5173/")
'''
[[tests]]
name = "parses_oathkeeper_v26_granted_request"
[[tests.inputs]]
insert_at = "oathkeeper_parse"
type = "log"
[tests.inputs.log_fields]
message = '{"audience":"application","granted":true,"http_host":"hydra:4444","http_method":"GET","http_url":"http://hydra:4444/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback&response_type=code","http_user_agent":"curl/8.10.1","level":"info","msg":"Access request granted","service_name":"ORY Oathkeeper","service_version":"v26.2.0","subject":"","time":"2026-05-06T01:52:25.431Z"}'
[[tests.outputs]]
extract_from = "oathkeeper_parse"
[[tests.outputs.conditions]]
type = "vrl"
source = '''
assert_eq!(.method, "GET")
assert_eq!(.path, "/oauth2/auth")
assert_eq!(.status, 0)
assert_eq!(.client_id, "orgfront")
assert_eq!(.decision, "granted")
'''

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
## 1. 준비 사항
테스트를 실행하기 위해 다음 도구들이 설치되어 있어야 합니다.
- **Docker & Docker Compose** (백엔드 인프라 의존성용)
- **Go 1.25+**
- **Go 1.26.2+**
- **Flutter SDK**
- **Node.js 24 LTS+**

View File

@@ -23,7 +23,7 @@ sequenceDiagram
UF->>HY: 로그인 승인 요청
HY->>User: 권한 동의(Consent) 화면 표시
User->>HY: '허용' 클릭
HY-->>DF: 인증 코드와 함께 리다이렉트 (/callback?code=...)
HY-->>DF: 인증 코드와 함께 리다이렉트 (/auth/callback?code=...)
DF->>HY: 토큰 교환 요청 (Code -> ID/Access Token)
HY-->>DF: 토큰 발급
Note over DF: [FIX] 백엔드 /api/me 호출 대신<br/>ID Token에서 프로필 정보 직접 추출
@@ -46,7 +46,7 @@ sequenceDiagram
* 사용자가 '허용'을 누르면 Hydra는 `devfront`가 신뢰할 수 있는 앱임을 기록합니다.
4. **인증 코드 전달 및 토큰 교환 (Callback)**:
* Hydra는 사용자를 `devfront`의 콜백 페이지(`http://localhost:5174/callback?code=...`)로 보냅니다.
* Hydra는 사용자를 `devfront`의 콜백 페이지(`http://localhost:5174/auth/callback?code=...`)로 보냅니다.
* `devfront`는 이 코드를 Hydra의 토큰 엔드포인트로 보내 **ID Token**과 **Access Token**을 발급받습니다.
5. **사용자 정보 로드 (Profile Recovery)**:
@@ -71,9 +71,9 @@ hydra clients create
--response-types code
--scope openid,offline_access,profile,email
--token-endpoint-auth-method none \ # Public Client (PKCE 사용)
--callbacks http://localhost:5174/callback;
--callbacks http://localhost:5174/auth/callback;
```
이 설정으로 인해 `devfront`라는 ID의 클라이언트가 미리 존재하게 되며, `localhost:5174`로의 리다이렉션이 안전하게 허용됩니다.
이 설정으로 인해 `devfront`라는 ID의 클라이언트가 미리 존재하게 되며, `localhost:5174/auth/callback`로의 리다이렉션이 안전하게 허용됩니다.
---

View File

@@ -7,7 +7,8 @@
## 적용 범위
- UserFront, AdminFront, DevFront의 로그인/콜백 경로
- Ory Stack(Hydra/Kratos/Oathkeeper) 설정
- `compose.ory.yaml`, `gateway/nginx.conf`, `docker/ory/oathkeeper/rules*.json`
- `compose.ory.yaml`, `docker/compose.ory.yaml`, `docker/staging_pull_compose.template.yaml`
- `gateway/nginx.conf`, `deploy/templates/gateway/nginx.conf`, `docker/ory/oathkeeper/rules*.json`
- `Makefile` 기반 사전 검증/스모크 검증 단계
## 핵심 원칙
@@ -27,8 +28,8 @@
2. `mapped_match`
- Public URL과 Internal URL이 다르지만, 아래가 모두 성립
- Gateway 라우팅 규칙 존재 (예: `/oidc` rewrite)
- Oathkeeper `match``upstream` 규칙 존재 (예: `strip_path_prefix=/oidc`)
- Gateway 라우팅 규칙 존재: `/oidc` prefix를 제거하지 않고 Oathkeeper로 전달
- Oathkeeper `match``upstream` 규칙 존재: `/oidc/*` rule이 `strip_path=/oidc`로 Hydra에 전달
- 최종 업스트림이 기대 서비스(Hydra/Kratos)로 연결
3. `unmapped_fail`
@@ -42,6 +43,8 @@
- `ADMINFRONT_CALLBACK_URLS`, `DEVFRONT_CALLBACK_URLS` URL 유효성/중복/경로 규약
- Gateway `/oidc`, `/auth` 라우팅 규칙 존재 여부
- Oathkeeper `rules*.json`의 Hydra/Kratos 매핑 규칙 존재 여부
- staging pull/deploy template의 Oathkeeper entrypoint 사용 여부
- `KRATOS_ALLOWED_RETURN_URLS_JSON`에 공개 도메인, locale path, callback/return path가 포함되는지 여부
2. 런타임 검증 (`make verify-oidc-config`)
- OIDC Discovery endpoint 조회 가능 여부
@@ -49,9 +52,38 @@
- 필요 시 Gateway 경유 endpoint probe로 매핑 체인 확인
## 경로 규약
- DevFront callback: `/callback`
- DevFront callback: `/auth/callback`
- AdminFront callback: `/auth/callback`
- OrgFront callback: `/auth/callback`
- UserFront OIDC 진입점: `/oidc/*` (Gateway 경유)
- locale return path: `/ko`, `/en`, `/ko/auth/callback`, `/en/auth/callback`
## `/oidc` 책임 경계
- Gateway는 `/oidc` prefix를 보존합니다.
- Oathkeeper는 `/oidc/.well-known/*`, `/oidc/oauth2/*`, `/oidc/userinfo` rule에서 `strip_path=/oidc`를 적용합니다.
- Hydra는 prefix가 제거된 내부 경로(`/.well-known/*`, `/oauth2/*`, `/userinfo`)를 받습니다.
- 따라서 gateway template이나 staging pull compose에서 `rewrite ^/oidc`가 다시 들어가면 dev/stage/prod 간 책임 경계가 달라지므로 실패로 간주합니다.
## Oathkeeper rules 선택 정책
- Oathkeeper는 직접 `command: serve proxy ...`로 시작하지 않고 `/etc/config/oathkeeper/entrypoint.sh`를 통해 시작합니다.
- entrypoint는 `APP_ENV`에 따라 다음 파일을 선택하고 `/tmp/oathkeeper/rules.active.json`으로 복사합니다.
- `stage|staging`: `rules.stage.json`
- `production|prod`: `rules.prod.json`
- 그 외: `rules.json`
- `oathkeeper.yml``file:///tmp/oathkeeper/rules.active.json`만 읽습니다.
## Kratos allowed return URL 정책
- stage/prod에서는 `KRATOS_ALLOWED_RETURN_URLS_JSON`을 명시하는 것을 우선합니다.
- 최소 포함 대상:
- `KRATOS_UI_URL`, `KRATOS_UI_URL/`
- `USERFRONT_URL`, `USERFRONT_URL/`
- `USERFRONT_URL/ko`, `USERFRONT_URL/ko/`
- `USERFRONT_URL/en`, `USERFRONT_URL/en/`
- `USERFRONT_URL/auth/callback`
- `USERFRONT_URL/ko/auth/callback`
- `USERFRONT_URL/en/auth/callback`
- `ADMINFRONT_CALLBACK_URLS`, `DEVFRONT_CALLBACK_URLS`, `ORGFRONT_CALLBACK_URLS`
- private IP, legacy domain, comma-space가 포함된 URI 항목은 stage/prod 기본값으로 두지 않습니다.
## 운영 지침
1. 환경별 URL은 동일할 필요가 없고, 매핑 체인이 검증 가능해야 합니다.
@@ -65,3 +97,4 @@
- #272
- #274
- #276
- #710

View File

@@ -33,12 +33,14 @@ Ory 구성은 **컨테이너 내부 통신 URL**과 **브라우저 접근 URL**
### 내부 통신용 URL(컨테이너 네트워크)
- `KRATOS_PUBLIC_URL=http://kratos:4433`
- `KRATOS_ADMIN_URL=http://kratos:4434`
- `HYDRA_PUBLIC_URL=http://hydra:4444`
- `HYDRA_ADMIN_URL=http://hydra:4445`
- Hydra public upstream은 Oathkeeper rule 내부에서 `http://hydra:4444`로 전달합니다.
### 브라우저 접근용 URL(외부 도메인/프록시)
- `KRATOS_BROWSER_URL` : Kratos Public의 외부 URL
- `KRATOS_BROWSER_URL` : Kratos Public의 외부 URL. 보통 `${OATHKEEPER_PUBLIC_URL}/auth`
- `KRATOS_UI_URL` : UserFront의 외부 URL (Kratos UI 역할)
- `HYDRA_PUBLIC_URL` : Hydra issuer/OIDC discovery의 외부 URL. 보통 `${OATHKEEPER_PUBLIC_URL}/oidc`
- `VITE_OIDC_AUTHORITY` : 프론트엔드 OIDC authority. `HYDRA_PUBLIC_URL`과 같아야 합니다.
예시(로컬):
```env
@@ -48,8 +50,11 @@ KRATOS_UI_URL=http://localhost:5000
예시(리버스 프록시/도메인):
```env
KRATOS_BROWSER_URL=https://sso.example.com
OATHKEEPER_PUBLIC_URL=https://sso.example.com
KRATOS_BROWSER_URL=https://sso.example.com/auth
KRATOS_UI_URL=https://sso.example.com
HYDRA_PUBLIC_URL=https://sso.example.com/oidc
VITE_OIDC_AUTHORITY=https://sso.example.com/oidc
```
### 포트 노출 정책
@@ -64,6 +69,7 @@ Kratos는 self-service UI URL을 설정값으로 사용합니다. **UserFront의
- `KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL`
- `KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS`
- `KRATOS_SELFSERVICE_FLOWS_*_UI_URL`
- `KRATOS_ALLOWED_RETURN_URLS_JSON`
compose에서 기본적으로 다음과 같이 오버라이드합니다:
- `KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login`
@@ -72,18 +78,44 @@ compose에서 기본적으로 다음과 같이 오버라이드합니다:
- `KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery`
- `KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification`
## 5) 트러블슈팅
### 5.1 로그인 클릭 시 동작 없음
stage/prod에서는 `KRATOS_ALLOWED_RETURN_URLS_JSON`에 공개 도메인과 callback/locale 경로를 명시합니다.
필수 후보:
- `${KRATOS_UI_URL}`, `${KRATOS_UI_URL}/`
- `${USERFRONT_URL}`, `${USERFRONT_URL}/`
- `${USERFRONT_URL}/ko`, `${USERFRONT_URL}/ko/`
- `${USERFRONT_URL}/en`, `${USERFRONT_URL}/en/`
- `${USERFRONT_URL}/auth/callback`
- `${USERFRONT_URL}/ko/auth/callback`
- `${USERFRONT_URL}/en/auth/callback`
- `${ADMINFRONT_CALLBACK_URLS}`, `${DEVFRONT_CALLBACK_URLS}`, `${ORGFRONT_CALLBACK_URLS}`
## 5) `/oidc` Gateway/Oathkeeper 책임 경계
Gateway는 `/oidc` prefix를 rewrite하지 않습니다. `/oidc/*` 요청은 prefix를 보존한 채 Oathkeeper로 전달하고, Oathkeeper rule이 `strip_path=/oidc`로 Hydra 내부 upstream(`http://hydra:4444`)에 전달합니다.
이 정책은 `gateway/nginx.conf`, `deploy/templates/gateway/nginx.conf`, `docker/ory/oathkeeper/rules*.json`, `docker/staging_pull_compose.template.yaml`에서 동일해야 합니다.
## 6) Oathkeeper active rules
Oathkeeper는 `/etc/config/oathkeeper/entrypoint.sh`를 통해 시작해야 합니다. entrypoint는 `APP_ENV`에 따라 env별 rules 파일을 고르고 `/tmp/oathkeeper/rules.active.json`을 생성합니다.
- `APP_ENV=stage|staging`: `rules.stage.json`
- `APP_ENV=production|prod`: `rules.prod.json`
- 그 외: `rules.json`
`docker/ory/oathkeeper/oathkeeper.yml``file:///tmp/oathkeeper/rules.active.json`을 읽습니다. compose나 배포 템플릿이 entrypoint를 우회해 `oathkeeper serve proxy`를 직접 실행하면 active rules 생성이 누락될 수 있습니다.
## 7) 트러블슈팅
### 7.1 로그인 클릭 시 동작 없음
- 원인: Kratos 기동 실패(설정 파싱 실패 등) 또는 브라우저용 URL이 내부 도메인(`kratos:...`)으로 설정됨
- 확인:
- `docker logs ory_kratos`에서 config 오류 여부 확인
- 브라우저 네트워크 탭에서 `/self-service/login/browser` 응답 확인(302 Location 헤더)
### 5.2 kratos.yml에 ${...} 환경 변수 치환 실패
### 7.2 kratos.yml에 ${...} 환경 변수 치환 실패
- Kratos 설정 파일은 `${ENV}` 치환을 지원하지 않음
- 해결: compose 환경 변수로 `KRATOS_SELFSERVICE_*`, `KRATOS_SERVE_*` 오버라이드 사용
## 6) 네트워크 접근 테스트
## 8) 네트워크 접근 테스트
아래 스크립트는 **ory-net에서 Admin 포트 접근 가능** / **baron_net(Frontend 영역)에서 접근 불가**를 검증합니다.
```bash
@@ -101,7 +133,7 @@ docker run --rm --network baron_net curlimages/curl:8.10.1 -fsS http://hydra:444
docker run --rm --network baron_net curlimages/curl:8.10.1 -fsS http://kratos:4434/health/ready
```
## 7) 참고 파일
## 9) 참고 파일
- `compose.ory.yaml`
- `docker/ory/kratos/kratos.yml`
- `.env.sample`

View File

@@ -0,0 +1,321 @@
# PKCE RP Back-Channel Logout 구현 가이드
이 문서는 Baron SSO와 연동하는 PKCE RP가 `Back-Channel Logout`을 지원하려고 할 때 필요한 구현 기준을 정리합니다.
## 목적
PKCE RP도 OIDC `Authorization Code + PKCE` 흐름을 사용하면서 Baron SSO의 원격 세션 종료 이벤트를 받을 수 있어야 합니다. 다만 `Back-Channel Logout`은 브라우저가 아니라 OP(Baron)가 RP 서버로 직접 `logout_token`을 보내는 방식이므로, **순수 frontend-only PKCE 앱만으로는 구현할 수 없습니다.**
즉, PKCE RP가 `Back-Channel Logout`을 사용하려면 다음 둘을 모두 가져야 합니다.
1. PKCE 로그인 플로우를 시작하고 callback을 처리하는 RP
2. `logout_token`을 수신하는 서버 endpoint
## 적용 대상
이 가이드는 다음 경우를 대상으로 합니다.
- 브라우저에서 `Authorization Code + PKCE`를 사용하는 RP
- RP가 자체 세션 또는 BFF 세션을 보유하는 경우
- RP가 `Back-Channel Logout URI`를 등록하고 Baron의 세션 종료 이벤트를 직접 수신하려는 경우
다음 경우는 이 가이드의 직접 대상이 아닙니다.
- 순수 frontend-only SPA
- 서버 없이 `localStorage`/`sessionStorage`만 사용하는 PKCE 앱
이 경우에는 `Back-Channel Logout` 대신 front-channel logout, 세션 재검증, 짧은 token TTL 같은 별도 전략을 사용해야 합니다.
## devfront 등록 기준
PKCE RP는 devfront에서 아래 항목을 등록합니다.
1. `Type`: `pkce`
2. `Redirect URI`: RP callback URL
3. `Back-Channel Logout URI`: RP 서버 endpoint
4. 필요 시 `SID Claim Required`
예시:
```text
Type: pkce
Redirect URI: https://rp.example.com/callback
Back-Channel Logout URI: https://rp.example.com/backchannel-logout
SID Claim Required: off
```
로컬 Docker 개발 예시:
```text
Redirect URI: http://localhost:3333/callback
Back-Channel Logout URI: http://baron-sso-login-demo:3333/backchannel-logout
```
주의:
- `Back-Channel Logout URI`는 **브라우저 기준 주소가 아니라 Baron backend가 실제로 접근 가능한 주소**여야 합니다.
- Docker 환경에서는 `localhost`가 backend 컨테이너 자신을 가리킬 수 있으므로, Docker 서비스명이나 사설 IP를 사용해야 할 수 있습니다.
## 구현 요구사항
PKCE RP는 최소한 아래를 구현해야 합니다.
### 1. 로그인 후 세션 매핑 저장
RP는 callback 이후 아래 정보 중 하나 이상을 로컬 세션과 연결해야 합니다.
- `sid -> rpSessionId`
- `sub -> rpSessionId`
권장 순서는 다음과 같습니다.
1. `sid`를 우선 저장
2. `sub`도 함께 저장
3. 한 사용자가 여러 브라우저 세션을 가질 수 있으므로 `1:N` 구조를 가정
예시:
```text
sid: 796f5cf7-37e7-494b-9b4c-26cc0c217a6a
sub: 8150cb83-a905-4b50-bdcf-d22046ecdc30
rpSessionId: DqKlQ8MbsGnn_jfOus1k03MFRDpuXCrj
```
### 2. `POST /backchannel-logout` endpoint
RP는 Baron이 서버 간으로 호출할 endpoint를 제공해야 합니다.
예:
```text
POST /backchannel-logout
Content-Type: application/x-www-form-urlencoded
Body: logout_token=<jwt>
```
RP는 이 endpoint에서:
1. `logout_token` 존재 여부 확인
2. JWT 서명 및 claim 검증
3. `sid` 또는 `sub`로 로컬 세션 탐색
4. 세션 스토어에서 직접 세션 파기
5. 성공 시 `2xx` 응답
을 수행해야 합니다.
### 3. `logout_token` 검증
RP는 Baron이 노출하는 Back-Channel Logout JWKS로 `logout_token`을 검증해야 합니다.
현재 Baron의 JWKS endpoint 예시는 다음과 같습니다.
```text
GET /api/v1/auth/backchannel/jwks.json
```
검증 필수 항목:
1. JWT 서명 검증
2. `iss`가 Baron OIDC issuer와 일치
3. `aud`에 현재 RP `client_id` 포함
4. `iat` 존재
5. `jti` 존재
6. `events``http://schemas.openid.net/event/backchannel-logout` 포함
7. `nonce`가 없어야 함
8. `sid` 또는 `sub`가 있어야 함
추가 권장 항목:
- `jti` replay 방지 캐시
- 시계 오차 허용 범위 설정
- 검증 실패 시 `400`
## 세션 종료 기준
### 권장 순서
1. `sid`로 매칭 시도
2. 매칭 실패 시 `sub`로 fallback
이 기준은 `SID Claim Required` 정책에 따라 달라집니다.
### `SID Claim Required = true`
- `logout_token``sid`가 있어야만 처리
- `sub` fallback 금지
- 세션 모델이 `sid` 중심으로 안정적으로 유지되는 RP에 적합
### `SID Claim Required = false`
- `sid`가 있으면 우선 사용
- `sid` 매칭이 안 되거나 `sid`가 없어도 `sub`로 fallback 가능
- 실제 운영에서는 이 모드가 더 현실적일 수 있음
## 세션 파기 방식
`Back-Channel Logout`에서는 현재 브라우저 요청의 `req.session.destroy()`로는 부족합니다.
반드시 **세션 스토어에서 session id를 찾아 직접 파기**해야 합니다.
예:
```text
store.destroy(rpSessionId)
```
필수 조건:
- 로그아웃 대상 세션 ID를 매핑 테이블에서 찾을 수 있어야 함
- 이미 삭제된 세션은 idempotent success 처리
## 권장 로그 항목
RP는 아래 정도의 로그를 남기는 것을 권장합니다.
1. 요청 수신
2. 토큰 검증 성공/실패
3. `sid`, `sub`, `jti`
4. 매칭된 `rpSessionId` 목록
5. 세션 파기 성공/실패 수
예시:
```text
[백채널 로그아웃] 요청 수신
[백채널 로그아웃] 토큰 검증 성공
[백채널 로그아웃] 세션 탐색 결과
[백채널 로그아웃] 세션 파기 완료
[백채널 로그아웃] 처리 완료
```
주의:
- raw `logout_token` 전체를 로그에 남기지 않습니다.
- access token, refresh token, cookie raw value도 남기지 않습니다.
## 테스트 체크리스트
### 기본 성공 시나리오
1. PKCE RP 로그인
2. callback 후 `sid/sub -> rpSessionId` 매핑 생성 확인
3. UserFront에서 `세션 종료`
4. Baron이 RP의 `Back-Channel Logout URI`로 POST
5. RP가 `logout_token` 검증 성공
6. RP 세션 파기 성공
7. 보호 페이지 접근 시 비로그인 상태 확인
### 확인 포인트
1. devfront에 `Back-Channel Logout URI`가 실제 저장됐는가
2. Baron backend가 해당 URI에 실제로 도달 가능한가
3. RP 로그에 `요청 수신``토큰 검증 성공`이 찍히는가
4. 세션 스토어에서 실제 세션이 삭제됐는가
5. `SID Claim Required=true`일 때와 `false`일 때 결과가 의도대로 다른가
## 구현 예시 구조
Node.js/Express 기준 최소 구조 예시는 다음과 같습니다.
```text
GET /login
GET /callback
GET /profile
GET /logout
POST /backchannel-logout
```
내부 저장 예시:
```text
sidToSessionIds: Map<string, Set<string>>
subToSessionIds: Map<string, Set<string>>
sessionIdToBinding: Map<string, { sid: string, sub: string }>
```
실제 분리 예시는 아래 데모 코드를 참고할 수 있습니다.
- 백채널 로그아웃 모듈: `https://gitea.hmac.kr/kyy/pkce-login-demo/src/branch/main/backchannel-logout.js`
- 데모 앱 엔트리포인트: `https://gitea.hmac.kr/kyy/pkce-login-demo/src/branch/main/app.js`
이 데모는:
1. callback 이후 `registerSessionBinding()`으로 `sid/sub -> sessionId`를 등록
2. `POST /backchannel-logout`에서 `handleBackchannelLogout`를 그대로 연결
3. 로컬 `/logout` 또는 세션 정리 시 `removeSessionBinding()` 호출
구조로 동작합니다.
## 자주 생기는 문제
### 1. `localhost`로는 안 되는데 입력은 저장됨
입력 validation을 통과하는 것과 Baron backend가 실제로 그 주소에 도달하는 것은 다릅니다.
예:
```text
http://localhost:3333/backchannel-logout
```
이 값은 backend 컨테이너 기준으로는 자기 자신을 가리킬 수 있습니다. Docker 환경에서는 Docker 서비스명 또는 사설 IP를 사용해야 할 수 있습니다.
### 2. `sid`가 로그인 시 값과 다름
실제 운영에서는 `logout_token.sid`가 RP가 저장한 `sid`와 항상 같다고 가정하면 안 됩니다.
따라서:
1. `sid` 우선
2. `sub` fallback
구현을 권장합니다. 다만 보안 정책상 `SID Claim Required=true`를 선택한 경우에는 fallback 없이 `sid`만 사용해야 합니다.
### 3. 순수 frontend-only PKCE인데 endpoint를 만들 수 없음
그 경우는 `Back-Channel Logout` 자체를 구현할 수 없습니다. 최소한 logout 수신용 서버 컴포넌트를 추가해야 합니다.
## 로직 흐름
```mermaid
sequenceDiagram
autonumber
participant Browser as 브라우저
participant RP as PKCE RP
participant Baron as Baron SSO
participant Store as 세션 스토어
Browser->>RP: GET /login 호출
RP->>Browser: Baron authorize endpoint로 리다이렉트
Browser->>Baron: Authorization Code + PKCE 로그인
Baron->>Browser: /callback?code=... 으로 리다이렉트
Browser->>RP: GET /callback 호출
RP->>Baron: code_verifier 포함 token 요청
Baron-->>RP: ID Token / Access Token 반환
RP->>Store: RP 세션 생성
RP->>RP: registerSessionBinding(sessionId, sid, sub)
RP-->>Browser: 로그인 완료 응답
Browser->>Baron: UserFront 또는 연동 서비스에서 세션 종료
Baron->>RP: POST /backchannel-logout (logout_token)
RP->>Baron: Back-Channel JWKS로 logout_token 검증
Baron-->>RP: 서명 / issuer / audience 검증 기준 제공
RP->>RP: sid 또는 sub로 sessionId 탐색
RP->>Store: destroy(sessionId)
RP->>RP: removeSessionBinding(sessionId)
RP-->>Baron: 200 OK
Browser->>RP: GET /profile 호출
RP-->>Browser: 루트 리다이렉트 또는 비로그인 응답
```
## 권장 결론
PKCE RP에서 `Back-Channel Logout`을 쓰려면, 다음 원칙을 따르십시오.
1. PKCE 로그인 플로우는 그대로 유지
2. logout 수신용 서버 endpoint 별도 구현
3. `sid``sub`를 모두 저장
4. 세션 스토어에서 직접 세션 파기
5. 로컬 개발 시 Baron backend가 도달 가능한 URI를 사용
이 다섯 가지가 갖춰져야 Baron의 원격 세션 종료가 RP 로컬 세션 종료까지 이어집니다.

View File

@@ -0,0 +1,322 @@
# Server-Side App RP Back-Channel Logout 구현 가이드
이 문서는 Baron SSO와 연동하는 `server-side-app` RP가 `Back-Channel Logout`을 지원하려고 할 때 필요한 구현 기준을 정리합니다.
## 목적
`server-side-app` RP는 confidential client로 동작하면서, Baron SSO의 원격 세션 종료 이벤트를 받아 RP 로컬 세션을 즉시 정리할 수 있어야 합니다.
즉, `server-side-app` RP는 다음 둘을 모두 구현해야 합니다.
1. OIDC Authorization Code 로그인과 callback 처리
2. `logout_token`을 수신하는 `Back-Channel Logout URI`
## 적용 대상
이 가이드는 다음 경우를 대상으로 합니다.
- `server-side-app` 타입 RP
- confidential client
- `client_secret_basic` 또는 `client_secret_post`를 사용하는 RP
- 자체 서버 세션 또는 BFF 세션을 보유하는 RP
다음 경우는 이 가이드의 직접 대상이 아닙니다.
- 순수 frontend-only SPA
- public client 기반 PKCE 앱
## devfront 등록 기준
`server-side-app` RP는 devfront에서 아래 항목을 등록합니다.
1. `Type`: `server-side-app`
2. `Redirect URI`: RP callback URL
3. `Back-Channel Logout URI`: RP 서버 endpoint
4. 필요 시 `SID Claim Required`
예시:
```text
Type: server-side-app
Redirect URI: http://localhost:4444/callback
Back-Channel Logout URI: http://172.16.9.208:4444/backchannel-logout
SID Claim Required: off
```
주의:
- `Back-Channel Logout URI`는 **브라우저 기준 주소가 아니라 Baron backend가 실제로 접근 가능한 주소**여야 합니다.
- Docker 환경에서는 `localhost`가 backend 컨테이너 자신을 가리킬 수 있으므로, 필요하면 사설 IP 또는 Docker 서비스명을 사용해야 합니다.
## 구현 요구사항
`server-side-app` RP는 최소한 아래를 구현해야 합니다.
### 1. confidential client 구성
RP는 일반적으로 아래 중 하나의 인증 방식을 사용합니다.
1. `client_secret_basic`
2. `client_secret_post`
즉 token 교환 시:
- `client_id`
- `client_secret`
가 함께 사용됩니다.
PKCE와 달리 `code_verifier`, `code_challenge`는 필수가 아닙니다.
### 2. 로그인 후 세션 매핑 저장
RP는 callback 이후 아래 정보 중 하나 이상을 로컬 세션과 연결해야 합니다.
- `sid -> rpSessionId`
- `sub -> rpSessionId`
권장 순서는 다음과 같습니다.
1. `sid`를 우선 저장
2. `sub`도 함께 저장
3. 한 사용자가 여러 브라우저 세션을 가질 수 있으므로 `1:N` 구조를 가정
예시:
```text
sid: 796f5cf7-37e7-494b-9b4c-26cc0c217a6a
sub: 8150cb83-a905-4b50-bdcf-d22046ecdc30
rpSessionId: DqKlQ8MbsGnn_jfOus1k03MFRDpuXCrj
```
### 3. `POST /backchannel-logout` endpoint
RP는 Baron이 서버 간으로 호출할 endpoint를 제공해야 합니다.
예:
```text
POST /backchannel-logout
Content-Type: application/x-www-form-urlencoded
Body: logout_token=<jwt>
```
RP는 이 endpoint에서:
1. `logout_token` 존재 여부 확인
2. JWT 서명 및 claim 검증
3. `sid` 또는 `sub`로 로컬 세션 탐색
4. 세션 스토어에서 직접 세션 파기
5. 성공 시 `2xx` 응답
을 수행해야 합니다.
### 4. `logout_token` 검증
RP는 Baron이 노출하는 Back-Channel Logout JWKS로 `logout_token`을 검증해야 합니다.
현재 Baron의 JWKS endpoint 예시는 다음과 같습니다.
```text
GET /api/v1/auth/backchannel/jwks.json
```
검증 필수 항목:
1. JWT 서명 검증
2. `iss`가 Baron OIDC issuer와 일치
3. `aud`에 현재 RP `client_id` 포함
4. `iat` 존재
5. `jti` 존재
6. `events``http://schemas.openid.net/event/backchannel-logout` 포함
7. `nonce`가 없어야 함
8. `sid` 또는 `sub`가 있어야 함
추가 권장 항목:
- `jti` replay 방지 캐시
- 시계 오차 허용 범위 설정
- 검증 실패 시 `400`
## 세션 종료 기준
### 권장 순서
1. `sid`로 매칭 시도
2. 매칭 실패 시 `sub`로 fallback
이 기준은 `SID Claim Required` 정책에 따라 달라집니다.
### `SID Claim Required = true`
- `logout_token``sid`가 있어야만 처리
- `sub` fallback 금지
- `sid` 중심 세션 모델을 운영하는 RP에 적합
### `SID Claim Required = false`
- `sid`가 있으면 우선 사용
- `sid` 매칭이 안 되거나 `sid`가 없어도 `sub`로 fallback 가능
- 실제 운영에서는 이 모드가 더 유연할 수 있음
## 세션 파기 방식
`Back-Channel Logout`에서는 현재 브라우저 요청의 `req.session.destroy()`로는 부족합니다.
반드시 **세션 스토어에서 session id를 찾아 직접 파기**해야 합니다.
예:
```text
store.destroy(rpSessionId)
```
필수 조건:
- 로그아웃 대상 세션 ID를 매핑 테이블에서 찾을 수 있어야 함
- 이미 삭제된 세션은 idempotent success 처리
## 권장 로그 항목
RP는 아래 정도의 로그를 남기는 것을 권장합니다.
1. 요청 수신
2. 토큰 검증 성공/실패
3. `sid`, `sub`, `jti`
4. 매칭된 `rpSessionId` 목록
5. 세션 파기 성공/실패 수
예시:
```text
[백채널 로그아웃] 요청 수신
[백채널 로그아웃] 토큰 검증 성공
[백채널 로그아웃] 세션 탐색 결과
[백채널 로그아웃] 세션 파기 완료
[백채널 로그아웃] 처리 완료
```
주의:
- raw `logout_token` 전체를 로그에 남기지 않습니다.
- access token, refresh token, cookie raw value도 남기지 않습니다.
## 테스트 체크리스트
### 기본 성공 시나리오
1. server-side-app RP 로그인
2. callback 후 `sid/sub -> rpSessionId` 매핑 생성 확인
3. UserFront에서 `세션 종료`
4. Baron이 RP의 `Back-Channel Logout URI`로 POST
5. RP가 `logout_token` 검증 성공
6. RP 세션 파기 성공
7. 보호 페이지 접근 시 비로그인 상태 확인
### 확인 포인트
1. devfront에 `Back-Channel Logout URI`가 실제 저장됐는가
2. Baron backend가 해당 URI에 실제로 도달 가능한가
3. RP 로그에 `요청 수신``토큰 검증 성공`이 찍히는가
4. 세션 스토어에서 실제 세션이 삭제됐는가
5. `SID Claim Required=true`일 때와 `false`일 때 결과가 의도대로 다른가
## 구현 예시 구조
Node.js/Express 기준 최소 구조 예시는 다음과 같습니다.
```text
GET /login
GET /callback
GET /profile
GET /logout
POST /backchannel-logout
```
내부 저장 예시:
```text
sidToSessionIds: Map<string, Set<string>>
subToSessionIds: Map<string, Set<string>>
sessionIdToBinding: Map<string, { sid: string, sub: string }>
```
실제 분리 예시는 아래 데모 코드를 참고할 수 있습니다.
- 백채널 로그아웃 모듈: `/home/kyy/workspace/baron-sso-server-side-demo/backchannel-logout.js`
- 데모 앱 엔트리포인트: `/home/kyy/workspace/baron-sso-server-side-demo/app.js`
이 데모는:
1. callback 이후 `registerSessionBinding()`으로 `sid/sub -> sessionId`를 등록
2. `POST /backchannel-logout`에서 `handleBackchannelLogout`를 그대로 연결
3. 로컬 `/logout` 또는 세션 정리 시 `removeSessionBinding()` 호출
구조로 동작합니다.
## 자주 생기는 문제
### 1. `localhost`로는 안 되는데 입력은 저장됨
입력 validation을 통과하는 것과 Baron backend가 실제로 그 주소에 도달하는 것은 다릅니다.
예:
```text
http://localhost:4444/backchannel-logout
```
이 값은 backend 컨테이너 기준으로는 자기 자신을 가리킬 수 있습니다. Docker 환경에서는 Docker 서비스명 또는 사설 IP를 사용해야 할 수 있습니다.
### 2. `sid`가 로그인 시 값과 다름
실제 운영에서는 `logout_token.sid`가 RP가 저장한 `sid`와 항상 같다고 가정하면 안 됩니다.
따라서:
1. `sid` 우선
2. `sub` fallback
구현을 권장합니다. 다만 보안 정책상 `SID Claim Required=true`를 선택한 경우에는 fallback 없이 `sid`만 사용해야 합니다.
### 3. `client_secret` 또는 auth method가 잘못되어 callback에서 실패함
`server-side-app`은 confidential client이므로 아래 값이 정확해야 합니다.
1. `client_id`
2. `client_secret`
3. `token_endpoint_auth_method`
4. `redirect_uri`
이 중 하나라도 다르면 authorization code 교환 단계에서 실패할 수 있습니다.
## 시퀀스 다이어그램
```mermaid
sequenceDiagram
autonumber
participant Browser as 브라우저
participant RP as Server-Side RP
participant Baron as Baron SSO
participant Store as 세션 스토어
Browser->>RP: GET /login 호출
RP->>Browser: Baron authorize endpoint로 리다이렉트
Browser->>Baron: Authorization Code 로그인
Baron->>Browser: /callback?code=... 으로 리다이렉트
Browser->>RP: GET /callback 호출
RP->>Baron: client_secret 포함 token 요청
Baron-->>RP: ID Token / Access Token 반환
RP->>Store: RP 세션 생성
RP->>RP: registerSessionBinding(sessionId, sid, sub)
RP-->>Browser: 로그인 완료 응답
Browser->>Baron: UserFront 또는 연동 서비스에서 세션 종료
Baron->>RP: POST /backchannel-logout (logout_token)
RP->>Baron: Back-Channel JWKS로 logout_token 검증
Baron-->>RP: 서명 / issuer / audience 검증 기준 제공
RP->>RP: sid 또는 sub로 sessionId 탐색
RP->>Store: destroy(sessionId)
RP->>RP: removeSessionBinding(sessionId)
RP-->>Baron: 200 OK
Browser->>RP: GET /profile 호출
RP-->>Browser: 루트 리다이렉트 또는 비로그인 응답
```

View File

@@ -192,6 +192,25 @@ Worksmobile 구성원 수정 API에는 PUT(`user-update-put`)과 PATCH(`user-upd
- 기존 WORKS Mobile 구성원에 대한 일반 속성/조직/겸직 동기화는 생성 효율을 위해 먼저 `POST /v1.0/users`를 시도하고, `409 Conflict`일 때 `PATCH /v1.0/users/{email}`로 전환합니다.
- PUT은 전체 교체 성격이 강하고 누락 필드 초기화 위험이 있으므로 현 scope에서는 사용하지 않습니다. 모든 Baron -> WORKS 변경 반영은 부분 수정 PATCH를 우선합니다.
### 구성원 비밀번호 관리 링크
Baron SSO는 생성 이후 WORKS Mobile 비밀번호 값을 직접 수정하지 않습니다. 운영자가 비밀번호 수정을 요청할 때는 해당 WORKS 계정의 식별자를 이용해 WORKS Mobile 관리자 비밀번호 관리 화면을 새 창으로 엽니다.
사용 URL:
```text
https://auth.worksmobile.com/integrate/password/manage?usage=admin&targetUserTenantId={회사테넌트}&targetUserDomainId={회사도메인}&targetUserIdNo={변경대상works_USER_ID}&accessUrl=https://admin.worksmobile.com/assets/self-close.html
```
전제와 기준:
- 브라우저 사용자는 `auth.worksmobile.com`에 관리자 권한으로 로그인되어 있어야 합니다.
- `targetUserTenantId`는 Baron tenant UUID가 아니라 WORKS Mobile 회사 tenant 식별자입니다. Baron SSO backend는 `WORKS_ADMIN_TENANT_ID` 환경 변수로 이 값을 adminfront overview에 노출합니다.
- `targetUserDomainId`는 WORKS Mobile 비교 결과의 `worksmobileDomainId`를 사용합니다.
- `targetUserIdNo`는 WORKS Mobile 비교 결과의 `worksmobileId`를 사용합니다.
- adminfront는 세 값이 모두 있을 때만 비밀번호 관리 버튼을 활성화합니다.
- 이 링크는 WORKS Mobile 관리자 화면을 여는 기능이며, Baron SSO backend에서 password 또는 `passwordConfig` 변경 API를 호출하지 않습니다.
## 비동기 아키텍처 권장안
Worksmobile API를 handler에서 직접 호출하지 않고, 별도 outbox와 relay worker를 둡니다.

View File

@@ -88,7 +88,7 @@ flowchart
```
### 1. Backend (Go Fiber)
- **Language**: Go 1.25+
- **Language**: Go 1.26.2+
- **Framework**: Fiber v2.25+
- **Database**:
- **ClickHouse**: 감사 로그 (고성능 데이터 수집)

View File

@@ -61,7 +61,6 @@ server {
# Hydra Public API
location /oidc {
rewrite ^/oidc/(.*)$ /$1 break;
proxy_pass $oathkeeper_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -16,6 +16,7 @@ saman = "Saman"
[domain.tenant_type]
company = "Company"
company_group = "Company Group"
organization = "Organization"
personal = "Personal"
user_group = "User Group"
@@ -1166,8 +1167,26 @@ tab_organization = "Organization Manage"
tab_permissions = "Permissions"
tab_profile = "Profile"
tab_schema = "Tab Schema"
tab_worksmobile = "Worksmobile"
title = "Details"
[ui.admin.tenants.worksmobile]
compare = "Baron / Works Comparison"
compare_description = "Users show entries that exist only in Baron or only in WORKS by default."
compare_groups = "Organizations / Groups"
compare_users = "Users"
dry_run = "Backfill Dry-run"
forbidden = "You do not have permission to manage the Worksmobile integration."
initial_password_csv = "Initial Password CSV"
recent_jobs = "Recent Jobs"
refresh = "Refresh"
single_sync = "Single-item Sync"
single_sync_description = "Create an organization or user sync job using a Baron UUID."
subtitle = "Review Hanmac Family Directory sync status for organizations and users, and retry failed jobs."
sync_orgunit = "Organization Sync"
sync_user = "User Sync"
title = "Worksmobile Integration"
[ui.admin.tenants.list]
search_placeholder = "Search tenant by name or slug..."
select_placeholder = "Select a tenant"
@@ -1613,6 +1632,7 @@ bulk_import = "Bulk Import"
empty = "No users found."
fetch_error = "Failed to load the user list."
search_placeholder = "Search Placeholder"
status_select = "{{name}} status"
subtitle = "Browse and manage registered users."
toggle_status = "{{name}} active status"
title = "User Manage"

View File

@@ -16,6 +16,7 @@ saman = "삼안"
[domain.tenant_type]
company = "COMPANY (일반 기업)"
company_group = "COMPANY_GROUP (그룹사/지주사)"
organization = "ORGANIZATION (정규 조직)"
personal = "PERSONAL (개인 워크스페이스)"
user_group = "USER_GROUP (내부 부서/팀)"
@@ -1626,8 +1627,26 @@ tab_organization = "조직 관리"
tab_permissions = "권한"
tab_profile = "프로필"
tab_schema = "사용자 스키마"
tab_worksmobile = "Worksmobile"
title = "상세"
[ui.admin.tenants.worksmobile]
compare = "Baron / Works 비교"
compare_description = "구성원은 기본적으로 Baron 또는 WORKS 한쪽에만 있는 항목을 보여줍니다."
compare_groups = "조직/그룹"
compare_users = "구성원"
dry_run = "Backfill Dry-run"
forbidden = "Worksmobile 연동 권한이 없습니다."
initial_password_csv = "초기 비밀번호 CSV"
recent_jobs = "최근 작업"
refresh = "새로고침"
single_sync = "단건 동기화"
single_sync_description = "Baron UUID 기준으로 조직 또는 구성원 sync 작업을 생성합니다."
subtitle = "한맥가족 Directory 조직/구성원 동기화 상태를 확인하고 실패 작업을 재시도합니다."
sync_orgunit = "조직 Sync"
sync_user = "구성원 Sync"
title = "Worksmobile 연동"
[ui.admin.tenants.list]
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
select_placeholder = "테넌트를 선택하세요"
@@ -2075,6 +2094,7 @@ bulk_import = "일괄 임포트"
empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다."
search_placeholder = "이름 또는 이메일 검색..."
status_select = "{{name}} 상태"
subtitle = "시스템 사용자를 조회하고 관리합니다."
toggle_status = "{{name}} 활성 상태"
title = "사용자 관리"

View File

@@ -16,6 +16,7 @@ saman = ""
[domain.tenant_type]
company = ""
company_group = ""
organization = ""
personal = ""
user_group = ""
@@ -1495,6 +1496,24 @@ tab_organization = ""
tab_permissions = ""
tab_profile = ""
tab_schema = ""
tab_worksmobile = ""
title = ""
[ui.admin.tenants.worksmobile]
compare = ""
compare_description = ""
compare_groups = ""
compare_users = ""
dry_run = ""
forbidden = ""
initial_password_csv = ""
recent_jobs = ""
refresh = ""
single_sync = ""
single_sync_description = ""
subtitle = ""
sync_orgunit = ""
sync_user = ""
title = ""
[ui.admin.tenants.list]
@@ -1950,6 +1969,7 @@ bulk_import = ""
empty = ""
fetch_error = ""
search_placeholder = ""
status_select = ""
subtitle = ""
toggle_status = ""
title = ""

View File

@@ -3,6 +3,11 @@ import { defineConfig, devices } from "@playwright/test";
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
const port = Number.parseInt(process.env.PORT ?? "4175", 10);
const defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
const reuseExistingServer = !process.env.CI && !process.env.PORT;
const testOidcAuthority = "http://localhost:5000/oidc";
/**
* Read environment variables from file.
@@ -35,7 +40,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:5175",
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
@@ -60,11 +65,14 @@ export default defineConfig({
],
/* Run your local dev server before starting the tests */
webServer: {
command: process.env.CI
? "npm run build && npm run preview -- --host 0.0.0.0 --port 5175"
: "npm run dev -- --host 0.0.0.0 --port 5175",
url: "http://localhost:5175",
reuseExistingServer: !process.env.CI,
},
webServer: process.env.BASE_URL
? undefined
: {
command: process.env.CI
? `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run build && VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run preview -- --host 127.0.0.1 --port ${port}`
: `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run dev -- --host 127.0.0.1 --port ${port}`,
url: defaultBaseUrl,
reuseExistingServer,
timeout: 120 * 1000,
},
});

View File

@@ -7,9 +7,14 @@ export default function AuthGuard() {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const shareToken = searchParams.get("token");
const isPlaywrightBypass =
typeof window !== "undefined" &&
(window.location.hostname === "127.0.0.1" ||
window.location.hostname === "localhost") &&
window.localStorage.getItem("playwright_auth_bypass") === "1";
// 공유 토큰이 있는 경우 인증 체크를 건너뜁니다 (Public View)
if (shareToken) {
if (shareToken || isPlaywrightBypass) {
return <Outlet />;
}

View File

@@ -10,7 +10,7 @@ function getUserTenantSlug(user: UserSummary) {
}
function isOrgFrontTenantType(tenant: TenantSummary) {
return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION"].includes(
return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION", "USER_GROUP"].includes(
tenant.type.toUpperCase(),
);
}

View File

@@ -298,7 +298,7 @@ function isVisibleOrgChartUser(user: UserSummary) {
}
function isOrgFrontTenantType(tenant: TenantSummary) {
return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION"].includes(
return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION", "USER_GROUP"].includes(
tenant.type.toUpperCase(),
);
}

View File

@@ -143,13 +143,17 @@ function PickerScenarioControls({
export function OrgPickerEmbedPreviewPage() {
const location = useLocation();
const shareToken = new URLSearchParams(location.search).get("token");
const [options, setOptions] = React.useState<OrgPickerEmbedOptions>(() =>
parseOrgPickerEmbedOptions(location.search),
);
const [lastMessage, setLastMessage] = React.useState<PickerMessage | null>(
null,
);
const pickerSrc = buildOrgPickerEmbedSrc(options);
const pickerSrcBase = buildOrgPickerEmbedSrc(options);
const pickerSrc = shareToken
? `${pickerSrcBase}&token=${encodeURIComponent(shareToken)}`
: pickerSrcBase;
React.useEffect(() => {
const handleMessage = (event: MessageEvent<PickerMessage>) => {

View File

@@ -555,10 +555,14 @@ export function OrgPickerEmbedPage() {
export function OrgPickerPage() {
const location = useLocation();
const shareToken = new URLSearchParams(location.search).get("token");
const [options, setOptions] = React.useState<OrgPickerEmbedOptions>(() =>
parseOrgPickerEmbedOptions(location.search),
);
const pickerSrc = buildOrgPickerEmbedSrc(options);
const pickerSrcBase = buildOrgPickerEmbedSrc(options);
const pickerSrc = shareToken
? `${pickerSrcBase}&token=${encodeURIComponent(shareToken)}`
: pickerSrcBase;
return (
<div className="space-y-4">

View File

@@ -1,5 +1,13 @@
import { expect, test } from "@playwright/test";
const shareToken = "playwright";
function withShareToken(path: string) {
return path.includes("?")
? `${path}&token=${shareToken}`
: `${path}?token=${shareToken}`;
}
type TenantFixture = {
id: string;
type: string;
@@ -75,41 +83,58 @@ async function seedOrgfrontAuth(page: Parameters<typeof test>[0]["page"]) {
},
expires_at: issuedAt + 3600,
};
window.localStorage.setItem(
const storageKeys = [
"user:http://localhost:5000/oidc:orgfront",
"user:http://localhost:5000/oidc/:orgfront",
"user:http://localhost:5000/oidc:devfront",
"user:http://localhost:5000/oidc/:devfront",
"user:http://172.16.9.189:5000/oidc:orgfront",
"user:http://172.16.9.189:5000/oidc/:orgfront",
"oidc.user:http://localhost:5000/oidc:orgfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:orgfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc:devfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:devfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://172.16.9.189:5000/oidc:orgfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://172.16.9.189:5000/oidc/:orgfront",
JSON.stringify(mockOidcUser),
);
];
for (const key of storageKeys) {
window.localStorage.setItem(key, JSON.stringify(mockOidcUser));
}
window.localStorage.setItem("playwright_auth_bypass", "1");
window.localStorage.setItem("dev_tenant_id", "group-hmac");
},
{ issuedAt: nowInSeconds },
);
await page.route("**/oidc/**", async (route) => {
const url = route.request().url();
if (url.includes(".well-known/openid-configuration")) {
await route.fulfill({
json: {
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
jwks_uri: "http://localhost:5000/oidc/jwks",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
end_session_endpoint: "http://localhost:5000/oidc/session/end",
},
headers: { "Access-Control-Allow-Origin": "*" },
});
return;
}
if (url.includes("/jwks")) {
await route.fulfill({
json: { keys: [] },
headers: { "Access-Control-Allow-Origin": "*" },
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ keys: [] }),
body: "ok",
headers: { "Access-Control-Allow-Origin": "*" },
});
});
}
@@ -186,7 +211,7 @@ test.beforeEach(async ({ page }) => {
test("developer navigation exposes chart, picker, and embed preview", async ({
page,
}) => {
await page.goto("/");
await page.goto(withShareToken("/chart"));
await expect(page.getByRole("link", { name: "조직도" })).toBeVisible();
await expect(page.getByRole("link", { name: "조직 선택기" })).toBeVisible();
@@ -207,7 +232,7 @@ test("developer navigation exposes chart, picker, and embed preview", async ({
test("picker menu lets developers switch selection mode and selectable type", async ({
page,
}) => {
await page.goto("/picker");
await page.goto(withShareToken("/picker"));
await expect(page.getByLabel("선택 모드")).toHaveValue("multiple");
await expect(page.getByLabel("선택 대상")).toHaveValue("both");
@@ -230,7 +255,7 @@ test("picker menu lets developers switch selection mode and selectable type", as
test("picker displays user names with job title and position", async ({
page,
}) => {
await page.goto("/embed/picker?mode=single&select=user");
await page.goto(withShareToken("/embed/picker?mode=single&select=user"));
await expect(
page.getByRole("button", {
@@ -242,7 +267,7 @@ test("picker displays user names with job title and position", async ({
test("embed preview menu updates the iframe picker source", async ({
page,
}) => {
await page.goto("/embed-preview");
await page.goto(withShareToken("/embed-preview"));
await expect(page.getByLabel("선택 모드")).toHaveValue("multiple");
await expect(page.getByLabel("선택 대상")).toHaveValue("both");
@@ -297,7 +322,7 @@ test("embed preview menu updates the iframe picker source", async ({
test("embed preview passes tenant id and custom dimensions through the picker url", async ({
page,
}) => {
await page.goto("/embed-preview");
await page.goto(withShareToken("/embed-preview"));
await page.getByLabel("tenant ID").fill("company-baron");
await page.getByLabel("임베딩 너비").fill("520");
@@ -325,7 +350,9 @@ test("embed preview passes tenant id and custom dimensions through the picker ur
test("embed picker scopes the tree by tenant id, hides users for tenant selection, and keeps direct members before child tenants", async ({
page,
}) => {
await page.goto("/embed-preview?tenantId=company-baron&select=tenant");
await page.goto(
withShareToken("/embed-preview?tenantId=company-baron&select=tenant"),
);
await expect(page.getByLabel("tenant ID")).toHaveValue("company-baron");
await expect(page.getByTestId("embed-preview-src")).toContainText(
@@ -352,7 +379,7 @@ test("embed picker scopes the tree by tenant id, hides users for tenant selectio
test("embed picker keeps the lightweight search controls inside the picker section at the default embed width", async ({
page,
}) => {
await page.goto("/embed-preview");
await page.goto(withShareToken("/embed-preview"));
const picker = page.frameLocator("iframe");
const searchSection = picker.getByTestId("org-picker-search-section");
@@ -379,7 +406,7 @@ test("embed picker keeps the lightweight search controls inside the picker secti
test("embed picker keeps only the lightweight picker surface scrollable", async ({
page,
}) => {
await page.goto("/embed-preview");
await page.goto(withShareToken("/embed-preview"));
const picker = page.frameLocator("iframe");
await expect(
@@ -415,7 +442,7 @@ test("embed picker keeps only the lightweight picker surface scrollable", async
test("embed preview can hide the descendant selection switch", async ({
page,
}) => {
await page.goto("/embed-preview?mode=multiple&select=both");
await page.goto(withShareToken("/embed-preview?mode=multiple&select=both"));
await expect(page.getByLabel("하위 선택 스위치 표시")).toBeChecked();
await page.getByLabel("하위 선택 스위치 표시").uncheck();
@@ -434,7 +461,7 @@ test("embed preview can hide the descendant selection switch", async ({
test("embed picker renders compact tree rows with member emails", async ({
page,
}) => {
await page.goto("/embed-preview?mode=single&select=user");
await page.goto(withShareToken("/embed-preview?mode=single&select=user"));
const picker = page.frameLocator("iframe");
await expect(picker.getByText("user-eng@example.com")).toBeVisible();
@@ -451,7 +478,7 @@ test("embed picker renders compact tree rows with member emails", async ({
test("embed picker filters organizations and users by id, name, and metadata", async ({
page,
}) => {
await page.goto("/embed-preview?mode=multiple&select=both");
await page.goto(withShareToken("/embed-preview?mode=multiple&select=both"));
const picker = page.frameLocator("iframe");
const search = picker.getByLabel("조직/구성원 검색");
@@ -475,7 +502,7 @@ test("embed picker filters organizations and users by id, name, and metadata", a
test("embed picker search does not keep unmatched descendants under a matching organization", async ({
page,
}) => {
await page.goto("/embed-preview?mode=multiple&select=both");
await page.goto(withShareToken("/embed-preview?mode=multiple&select=both"));
const picker = page.frameLocator("iframe");
await picker.getByLabel("조직/구성원 검색").fill("센");
@@ -489,7 +516,7 @@ test("embed picker search does not keep unmatched descendants under a matching o
test("embed picker posts a single user selection with type, id, and name", async ({
page,
}) => {
await page.goto("/embed-preview?mode=single&select=user");
await page.goto(withShareToken("/embed-preview?mode=single&select=user"));
const picker = page.frameLocator("iframe");
await picker
@@ -507,7 +534,7 @@ test("embed picker posts a single user selection with type, id, and name", async
test("embed picker single selection counts only the selected node without descendants", async ({
page,
}) => {
await page.goto("/embed-preview?mode=single&select=both");
await page.goto(withShareToken("/embed-preview?mode=single&select=both"));
const picker = page.frameLocator("iframe");
await picker
@@ -528,7 +555,7 @@ test("embed picker single selection counts only the selected node without descen
test("embed picker highlights a single selected item without tree connectors", async ({
page,
}) => {
await page.goto("/embed-preview?mode=single&select=both");
await page.goto(withShareToken("/embed-preview?mode=single&select=both"));
const picker = page.frameLocator("iframe");
await expect(
@@ -548,7 +575,7 @@ test("embed picker highlights a single selected item without tree connectors", a
test("embed picker renders tenant names with the dedicated tenant text color", async ({
page,
}) => {
await page.goto("/embed-preview?mode=single&select=both");
await page.goto(withShareToken("/embed-preview?mode=single&select=both"));
const picker = page.frameLocator("iframe");
const tenantName = picker.getByTestId("org-picker-node-name-tenant").first();
@@ -563,7 +590,7 @@ test("embed picker renders tenant names with the dedicated tenant text color", a
test("embed picker includes descendants by default and can disable descendant inclusion", async ({
page,
}) => {
await page.goto("/embed-preview?mode=multiple&select=both");
await page.goto(withShareToken("/embed-preview?mode=multiple&select=both"));
let picker = page.frameLocator("iframe");
await expect(
@@ -582,7 +609,9 @@ test("embed picker includes descendants by default and can disable descendant in
await expect(output).toContainText('"id": "user-platform"');
await page.goto(
"/embed-preview?mode=multiple&select=both&includeDescendants=false",
withShareToken(
"/embed-preview?mode=multiple&select=both&includeDescendants=false",
),
);
picker = page.frameLocator("iframe");
await picker.getByLabel("Engineering 선택").check();

View File

@@ -214,41 +214,58 @@ test("org chart places multi-tenant users only on leaf memberships without dupli
},
expires_at: seededIssuedAt + 3600,
};
window.localStorage.setItem(
const storageKeys = [
"user:http://localhost:5000/oidc:orgfront",
"user:http://localhost:5000/oidc/:orgfront",
"user:http://localhost:5000/oidc:devfront",
"user:http://localhost:5000/oidc/:devfront",
"user:http://172.16.9.189:5000/oidc:orgfront",
"user:http://172.16.9.189:5000/oidc/:orgfront",
"oidc.user:http://localhost:5000/oidc:orgfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:orgfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc:devfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:devfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://172.16.9.189:5000/oidc:orgfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://172.16.9.189:5000/oidc/:orgfront",
JSON.stringify(mockOidcUser),
);
];
for (const key of storageKeys) {
window.localStorage.setItem(key, JSON.stringify(mockOidcUser));
}
window.localStorage.setItem("playwright_auth_bypass", "1");
window.localStorage.setItem("dev_tenant_id", "group");
},
{ issuedAt },
);
await page.route("**/oidc/**", async (route) => {
const url = route.request().url();
if (url.includes(".well-known/openid-configuration")) {
await route.fulfill({
json: {
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
jwks_uri: "http://localhost:5000/oidc/jwks",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
end_session_endpoint: "http://localhost:5000/oidc/session/end",
},
headers: { "Access-Control-Allow-Origin": "*" },
});
return;
}
if (url.includes("/jwks")) {
await route.fulfill({
json: { keys: [] },
headers: { "Access-Control-Allow-Origin": "*" },
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ keys: [] }),
body: "ok",
headers: { "Access-Control-Allow-Origin": "*" },
});
});

View File

@@ -56,7 +56,7 @@ test("orgfront login auto parameter starts OIDC authorization", async ({
const parsed = new URL(oidc.authorizationURL());
expect(parsed.searchParams.get("client_id")).toBe("orgfront");
expect(parsed.searchParams.get("redirect_uri")).toBe(
"http://localhost:5175/auth/callback",
"http://127.0.0.1:4175/auth/callback",
);
expect(parsed.searchParams.get("response_type")).toBe("code");
expect(parsed.searchParams.get("scope") ?? "").toContain("openid");

View File

@@ -2,7 +2,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUTPUT_DIR="$ROOT_DIR/.generated"
OUTPUT_DIR="$ROOT_DIR/config/.generated"
OUTPUT_FILE="$OUTPUT_DIR/auth-config.env"
MODE="${1:-build}"
@@ -16,11 +16,12 @@ fi
USERFRONT_URL="${USERFRONT_URL:-http://localhost:5000}"
OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}"
HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}"
HYDRA_ADMIN_URL="${HYDRA_ADMIN_URL:-http://hydra:4445}"
KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}"
ADMINFRONT_URL="${ADMINFRONT_URL:-https://sadmin.hmac.kr}"
DEVFRONT_URL="${DEVFRONT_URL:-https://sdev.hmac.kr}"
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://172.16.10.176:5173/auth/callback}"
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://172.16.10.176:5174/auth/callback}"
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-${ADMINFRONT_URL%/}/auth/callback}"
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-${DEVFRONT_URL%/}/auth/callback}"
KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}"
declare -a WARNINGS=()
@@ -258,13 +259,10 @@ validate_gateway_mapping() {
if ! grep -Eq 'location /oidc' "$ROOT_DIR/gateway/nginx.conf"; then
mode="unmapped_fail"
fi
if ! grep -Eq 'rewrite \^/oidc/\(\.\*\)\$ /\$1 break;' "$ROOT_DIR/gateway/nginx.conf"; then
if ! grep -Eq '"url": "<\.\*>://<(\.\*|\[\^/\]\+)>/oidc/oauth2/<\.\*>"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then
mode="unmapped_fail"
fi
if ! grep -Eq '"url": "<\.\*>://<\.\*>/oidc/oauth2/<\.\*>"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then
mode="unmapped_fail"
fi
if ! grep -Eq '"strip_path_prefix": "/oidc"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then
if ! grep -Eq '"strip_path(_prefix)?": "/oidc"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then
mode="unmapped_fail"
fi
fi
@@ -358,10 +356,10 @@ verify_runtime_hydra_clients() {
fi
local admin_info dev_info
if ! admin_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint http://hydra:4445 adminfront 2>/dev/null)"; then
if ! admin_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" adminfront 2>/dev/null)"; then
fail "failed to read hydra client 'adminfront' from running container"
fi
if ! dev_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint http://hydra:4445 devfront 2>/dev/null)"; then
if ! dev_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" devfront 2>/dev/null)"; then
fail "failed to read hydra client 'devfront' from running container"
fi
@@ -382,6 +380,7 @@ run_validation() {
validate_dotenv_line_safety "BACKEND_URL"
validate_dotenv_line_safety "OATHKEEPER_PUBLIC_URL"
validate_dotenv_line_safety "HYDRA_PUBLIC_URL"
validate_dotenv_line_safety "HYDRA_ADMIN_URL"
validate_dotenv_line_safety "KRATOS_BROWSER_URL"
validate_dotenv_line_safety "KRATOS_UI_URL"
validate_dotenv_line_safety "ADMINFRONT_URL"

100
scripts/render_ory_config.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUTPUT_DIR="${ORY_CONFIG_OUTPUT_DIR:-$ROOT_DIR/config/.generated/ory}"
TEMPLATE_ROOT="${ORY_CONFIG_TEMPLATE_ROOT:-$ROOT_DIR/docker/ory}"
load_env_file() {
local env_file="$1"
if [[ -f "$env_file" ]]; then
set -a
# shellcheck disable=SC1090
source "$env_file"
set +a
fi
}
fail() {
echo "[ory-config] ERROR: $1" >&2
exit 1
}
render_template() {
local src="$1"
local dst="$2"
mkdir -p "$(dirname "$dst")"
perl -pe '
s/\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}/
exists $ENV{$1} ? $ENV{$1} : defined $3 ? $3 : die "missing env var: $1\n"
/gex
' "$src" > "$dst"
}
copy_if_exists() {
local src="$1"
local dst="$2"
if [[ -e "$src" ]]; then
mkdir -p "$(dirname "$dst")"
cp -a "$src" "$dst"
fi
}
if [[ -n "${ORY_CONFIG_ENV_FILES:-}" ]]; then
IFS=':' read -r -a env_files <<<"$ORY_CONFIG_ENV_FILES"
for env_file in "${env_files[@]}"; do
load_env_file "$env_file"
done
else
load_env_file "$ROOT_DIR/.env"
load_env_file "$ROOT_DIR/config/.generated/auth-config.env"
fi
ORY_POSTGRES_USER="${ORY_POSTGRES_USER:-ory}"
ORY_POSTGRES_PASSWORD="${ORY_POSTGRES_PASSWORD:-secret}"
KRATOS_DB="${KRATOS_DB:-ory_kratos}"
HYDRA_DB="${HYDRA_DB:-ory_hydra}"
KETO_DB="${KETO_DB:-ory_keto}"
KRATOS_DSN="${KRATOS_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB}?sslmode=disable&max_conns=20}"
HYDRA_DSN="${HYDRA_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20}"
KETO_DSN="${KETO_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20}"
HYDRA_SYSTEM_SECRET="${HYDRA_SYSTEM_SECRET:-${SECRETS_SYSTEM:-${ORY_POSTGRES_PASSWORD}}}"
OATHKEEPER_INTROSPECT_CLIENT_ID="${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}"
OATHKEEPER_INTROSPECT_CLIENT_SECRET="${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}"
export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET
export OATHKEEPER_INTROSPECT_CLIENT_ID OATHKEEPER_INTROSPECT_CLIENT_SECRET
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
render_template "$TEMPLATE_ROOT/kratos/kratos.yml.template" "$OUTPUT_DIR/kratos/kratos.yml"
copy_if_exists "$TEMPLATE_ROOT/kratos/identity.schema.json" "$OUTPUT_DIR/kratos/identity.schema.json"
copy_if_exists "$TEMPLATE_ROOT/kratos/courier-http.jsonnet" "$OUTPUT_DIR/kratos/courier-http.jsonnet"
if [[ -d "$TEMPLATE_ROOT/kratos/courier-templates" ]]; then
mkdir -p "$OUTPUT_DIR/kratos"
cp -a "$TEMPLATE_ROOT/kratos/courier-templates" "$OUTPUT_DIR/kratos/courier-templates"
fi
render_template "$TEMPLATE_ROOT/hydra/hydra.yml.template" "$OUTPUT_DIR/hydra/hydra.yml"
render_template "$TEMPLATE_ROOT/keto/keto.yml.template" "$OUTPUT_DIR/keto/keto.yml"
copy_if_exists "$TEMPLATE_ROOT/keto/namespaces.ts" "$OUTPUT_DIR/keto/namespaces.ts"
copy_if_exists "$TEMPLATE_ROOT/keto/namespaces.yml" "$OUTPUT_DIR/keto/namespaces.yml"
render_template "$TEMPLATE_ROOT/oathkeeper/oathkeeper.yml.template" "$OUTPUT_DIR/oathkeeper/oathkeeper.yml"
copy_if_exists "$TEMPLATE_ROOT/oathkeeper/entrypoint.sh" "$OUTPUT_DIR/oathkeeper/entrypoint.sh"
chmod +x "$OUTPUT_DIR/oathkeeper/entrypoint.sh"
for rules_file in "$TEMPLATE_ROOT"/oathkeeper/rules*.json; do
[[ -e "$rules_file" ]] || continue
copy_if_exists "$rules_file" "$OUTPUT_DIR/oathkeeper/$(basename "$rules_file")"
done
if find "$OUTPUT_DIR" -type f \( -name '*.yml' -o -name '*.yaml' -o -name '*.json' -o -name '*.toml' \) -print0 | xargs -0 grep -n '\${' >/tmp/ory-render-unresolved.$$ 2>/dev/null; then
cat /tmp/ory-render-unresolved.$$ >&2
rm -f /tmp/ory-render-unresolved.$$
fail "rendered Ory config contains unresolved placeholders"
fi
rm -f /tmp/ory-render-unresolved.$$
echo "[ory-config] wrote: $OUTPUT_DIR"

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TARGET_GO_VERSION="1.26.2"
GO_MOD="$ROOT_DIR/backend/go.mod"
BACKEND_DOCKERFILE="$ROOT_DIR/backend/Dockerfile"
LOCAL_COMPOSE="$ROOT_DIR/docker-compose.yaml"
STAGING_COMPOSE="$ROOT_DIR/docker/docker-compose.staging.template.yaml"
PULL_COMPOSE="$ROOT_DIR/docker/staging_pull_compose.template.yaml"
DEPLOY_TEMPLATE="$ROOT_DIR/deploy/templates/docker-compose.yaml"
README="$ROOT_DIR/README.md"
README_EN="$ROOT_DIR/README_en.md"
TEST_GUIDE="$ROOT_DIR/docs/TEST_GUIDE.md"
COMPLETION_REPORT="$ROOT_DIR/docs/개발완료보고서.md"
for file in \
"$GO_MOD" \
"$BACKEND_DOCKERFILE" \
"$LOCAL_COMPOSE" \
"$STAGING_COMPOSE" \
"$PULL_COMPOSE" \
"$DEPLOY_TEMPLATE" \
"$README" \
"$README_EN" \
"$TEST_GUIDE" \
"$COMPLETION_REPORT"
do
if [[ ! -f "$file" ]]; then
echo "ERROR: expected file not found: $file" >&2
exit 1
fi
done
if ! grep -Eq "^go ${TARGET_GO_VERSION}$" "$GO_MOD"; then
echo "ERROR: backend go.mod must use go ${TARGET_GO_VERSION}." >&2
exit 1
fi
if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine$" "$BACKEND_DOCKERFILE"; then
echo "ERROR: backend Dockerfile must use golang:${TARGET_GO_VERSION}-alpine." >&2
exit 1
fi
for file in "$LOCAL_COMPOSE" "$PULL_COMPOSE"; do
if ! grep -Fq "context: ./backend" "$file" && ! grep -Fq "context: ../../backend" "$file"; then
echo "ERROR: backend compose build context is missing in $file." >&2
exit 1
fi
done
for file in "$STAGING_COMPOSE" "$DEPLOY_TEMPLATE"; do
if ! grep -Eq "^[[:space:]]+backend:$" "$file"; then
echo "ERROR: backend service is missing in $file." >&2
exit 1
fi
done
legacy_refs="$(
grep -R -nE "golang:1\\.25|^go 1\\.25" \
"$ROOT_DIR/backend" \
"$ROOT_DIR/docker-compose.yaml" \
"$ROOT_DIR/docker" \
"$ROOT_DIR/deploy/templates" \
"$README" \
"$README_EN" \
"$TEST_GUIDE" \
"$COMPLETION_REPORT" || true
)"
if [[ -n "$legacy_refs" ]]; then
echo "ERROR: legacy backend Go version references remain." >&2
echo "$legacy_refs" >&2
exit 1
fi
echo "OK: backend Go base version policy is ${TARGET_GO_VERSION}"

View File

@@ -3,6 +3,19 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
dry_run_default_dev="$(
make --dry-run --always-make -C "$repo_root" dev 2>&1
)"
default_app_up_line="$(
grep -E "docker compose .* -f docker-compose.yaml up .*backend.*adminfront.*devfront.*orgfront.*userfront" <<<"$dry_run_default_dev" | tail -1
)"
if [[ -z "$default_app_up_line" ]]; then
echo "make dev must include orgfront in the default development app services." >&2
exit 1
fi
dry_run_dev="$(
make --dry-run --always-make -C "$repo_root" dev DEV_SERVICES="backend adminfront" 2>&1
)"
@@ -17,6 +30,11 @@ if ! grep -q "Ensuring Ory stack" <<<"$dry_run_dev"; then
exit 1
fi
if ! grep -q "Rendering Ory config" <<<"$dry_run_dev"; then
echo "make dev must render Ory config before starting services." >&2
exit 1
fi
app_up_line="$(
grep -E "docker compose .* -f docker-compose.yaml up .*backend.*adminfront" <<<"$dry_run_dev" | tail -1
)"
@@ -31,6 +49,11 @@ if grep -q -- " -d" <<<"$app_up_line"; then
exit 1
fi
if ! grep -q -- " --build" <<<"$app_up_line"; then
echo "make dev must rebuild app service images before starting development containers." >&2
exit 1
fi
dry_run_up_dev="$(
make --dry-run --always-make -C "$repo_root" up-dev 2>&1
)"
@@ -45,10 +68,56 @@ if ! grep -q "Ensuring Ory stack" <<<"$dry_run_up_dev"; then
exit 1
fi
dry_run_up_app="$(
make --dry-run --always-make -C "$repo_root" up-app 2>&1
)"
if ! grep -q "Starting App stack (backend/userfront/adminfront/devfront/orgfront)" <<<"$dry_run_up_app"; then
echo "make up-app must announce orgfront as part of the app stack." >&2
exit 1
fi
if ! grep -q "Rendering Ory config" <<<"$dry_run_up_app"; then
echo "make up-app must render Ory config before starting services." >&2
exit 1
fi
up_app_line="$(
grep -E "docker compose .* -f docker-compose.yaml up .*backend.*adminfront.*devfront.*orgfront.*userfront|docker compose .* -f docker-compose.yaml up " <<<"$dry_run_up_app" | tail -1
)"
if ! grep -q -- " --build" <<<"$up_app_line"; then
echo "make up-app must rebuild app service images before starting containers." >&2
exit 1
fi
dry_run_up_all="$(
make --dry-run --always-make -C "$repo_root" up-all 2>&1
)"
if ! dry_run_up="$(
make --dry-run --always-make -C "$repo_root" up 2>&1
)"; then
echo "make up must be available as the default full-stack startup target." >&2
echo "$dry_run_up" >&2
exit 1
fi
if ! grep -q "Starting ALL stacks (infra + ory + app)" <<<"$dry_run_up"; then
echo "make up must delegate to the full-stack startup flow." >&2
exit 1
fi
if ! grep -q "config/.generated/auth-config.env" <<<"$dry_run_up"; then
echo "make up must use generated env from config/.generated." >&2
exit 1
fi
if ! grep -q "Rendering Ory config" <<<"$dry_run_up"; then
echo "make up must render Ory config before compose up." >&2
exit 1
fi
if ! grep -q "Ensuring Docker networks" <<<"$dry_run_up_all"; then
echo "make up-all must ensure external Docker networks before compose up." >&2
exit 1

View File

@@ -58,3 +58,66 @@ if (( after_rows <= before_rows )); then
docker logs --tail 100 ory_vector >&2 || true
exit 1
fi
before_auth_ts="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "SELECT now64(3)")"
auth_status="$(docker run --rm --network public_net curlimages/curl:8.10.1 \
-sS -o /dev/null -w '%{http_code}' \
'http://ory_oathkeeper:4455/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback&response_type=code&scope=openid&state=access-log-e2e&code_challenge=accessloge2e&code_challenge_method=S256')"
if [[ "$auth_status" != "302" ]]; then
echo "ERROR: expected Oathkeeper OIDC auth request to return 302, got: $auth_status" >&2
exit 1
fi
deadline=$((SECONDS + 30))
completed_rows=0
granted_rows=0
while (( SECONDS < deadline )); do
completed_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
SELECT count()
FROM ory.oathkeeper_access_logs
WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
AND method = 'GET'
AND path = '/oauth2/auth'
AND status = 302
")"
granted_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
SELECT count()
FROM ory.oathkeeper_access_logs
WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
AND method = 'GET'
AND path = '/oauth2/auth'
AND client_id = 'orgfront'
AND decision = 'granted'
")"
if (( completed_rows > 0 && granted_rows > 0 )); then
break
fi
sleep 2
done
if (( completed_rows <= 0 )); then
echo "ERROR: Oathkeeper completed request log did not preserve method/path/status." >&2
docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
SELECT timestamp, method, path, status, client_id, decision
FROM ory.oathkeeper_access_logs
WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
ORDER BY timestamp DESC
LIMIT 20
FORMAT Vertical
" >&2 || true
exit 1
fi
if (( granted_rows <= 0 )); then
echo "ERROR: Oathkeeper granted request log did not preserve client_id." >&2
docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "
SELECT timestamp, method, path, status, client_id, decision
FROM ory.oathkeeper_access_logs
WHERE timestamp >= toDateTime64('$before_auth_ts', 3)
ORDER BY timestamp DESC
LIMIT 20
FORMAT Vertical
" >&2 || true
exit 1
fi

View File

@@ -3,6 +3,8 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
"$repo_root/scripts/render_ory_config.sh" >/dev/null
docker run --rm \
-e ORY_CLICKHOUSE_USER=ory \
-e ORY_CLICKHOUSE_PASSWORD=orypass \
@@ -14,12 +16,12 @@ if grep -q '/etc/config/oathkeeper/rules.active.json' "$repo_root/docker/ory/oat
exit 1
fi
if ! grep -q 'file:///tmp/oathkeeper/rules.active.json' "$repo_root/docker/ory/oathkeeper/oathkeeper.yml"; then
if ! grep -q 'file:///tmp/oathkeeper/rules.active.json' "$repo_root/config/.generated/ory/oathkeeper/oathkeeper.yml"; then
echo "ERROR: Oathkeeper config must load active rules from writable runtime storage." >&2
exit 1
fi
if ! grep -q '^version: v26.2.0$' "$repo_root/docker/ory/kratos/kratos.yml"; then
if ! grep -q '^version: v26.2.0$' "$repo_root/config/.generated/ory/kratos/kratos.yml"; then
echo "ERROR: Kratos config version must match the v26.2.0 runtime." >&2
exit 1
fi

View File

@@ -10,6 +10,26 @@ docker_config="$(
docker compose --env-file "$repo_root/.env" -f "$repo_root/docker/compose.ory.yaml" config
)"
override_env="$(mktemp)"
cp "$repo_root/.env" "$override_env"
cat >> "$override_env" <<'EOF'
USERFRONT_URL=https://compose-policy.example.test/sso
HYDRA_PUBLIC_URL=https://compose-policy.example.test/sso/oidc
KRATOS_UI_URL=https://compose-policy.example.test/ui
KRATOS_BROWSER_URL=https://compose-policy.example.test/auth
ADMINFRONT_CALLBACK_URLS=https://compose-policy.example.test/admin/callback
DEVFRONT_CALLBACK_URLS=https://compose-policy.example.test/dev/callback
ORGFRONT_CALLBACK_URLS=https://compose-policy.example.test/org/callback
EOF
trap 'rm -f "$override_env"' EXIT
override_config="$(
docker compose --env-file "$override_env" -f "$repo_root/compose.ory.yaml" config
)"
override_docker_config="$(
docker compose --env-file "$override_env" -f "$repo_root/docker/compose.ory.yaml" config
)"
for service in kratos hydra keto oathkeeper; do
version_key="$(tr '[:lower:]' '[:upper:]' <<<"$service")_VERSION"
expected_version="$(grep -E "^${version_key}=" "$repo_root/.env" | cut -d= -f2-)"
@@ -28,6 +48,64 @@ if grep -q "oryd/hydra:v25.4.0" <<<"$root_config"; then
exit 1
fi
for compose_file in "$repo_root/compose.ory.yaml" "$repo_root/docker/compose.ory.yaml"; do
if grep -Eq 'redirect-uri .*:-.*https?://' "$compose_file"; then
echo "ERROR: $compose_file must not hard-code external redirect URI fallbacks; use .env variables." >&2
exit 1
fi
if grep -Eq 'KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=.*https?://localhost' "$compose_file"; then
echo "ERROR: $compose_file must not hard-code Kratos allowed return URL fallbacks; use .env variables." >&2
exit 1
fi
if awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^ init-rp:/ { in_block=1 } in_block { print }' "$compose_file" | grep -q -- '--endpoint http://hydra:4445'; then
echo "ERROR: $compose_file init-rp must use HYDRA_ADMIN_URL instead of hard-coded Hydra admin endpoint." >&2
exit 1
fi
if awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^[[:space:]]+oathkeeper:/ { in_block=1 } in_block { print }' "$compose_file" | grep -q "command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml"; then
echo "ERROR: $compose_file Oathkeeper must use entrypoint.sh instead of bypassing rules.active.json generation." >&2
exit 1
fi
done
for stack_check_file in \
"$repo_root/compose.ory.yaml" \
"$repo_root/docker/compose.ory.yaml" \
"$repo_root/docker/staging_pull_compose.template.yaml" \
"$repo_root/deploy/templates/docker-compose.yaml"
do
if grep -q 'until curl -s http://' "$stack_check_file"; then
echo "ERROR: Ory stack check must not wait forever; use bounded readiness checks in $stack_check_file." >&2
exit 1
fi
if ! grep -q 'ORY_STACK_CHECK_MAX_ATTEMPTS' "$stack_check_file"; then
echo "ERROR: Ory stack check must expose ORY_STACK_CHECK_MAX_ATTEMPTS in $stack_check_file." >&2
exit 1
fi
if ! grep -q 'ERROR: Ory service not ready' "$stack_check_file"; then
echo "ERROR: Ory stack check must report the failed service name in $stack_check_file." >&2
exit 1
fi
if ! grep -q 'check_ready kratos .* || exit 1' "$stack_check_file"; then
echo "ERROR: Ory stack check must raise a non-zero exit when Kratos is not ready in $stack_check_file." >&2
exit 1
fi
done
for expected_url in \
"https://compose-policy.example.test/sso/oidc" \
"https://compose-policy.example.test/sso/login" \
"https://compose-policy.example.test/sso/consent" \
"https://compose-policy.example.test/sso/error" \
"https://compose-policy.example.test/admin/callback" \
"https://compose-policy.example.test/dev/callback" \
"https://compose-policy.example.test/org/callback"
do
if ! grep -q "$expected_url" <<<"$override_config$override_docker_config"; then
echo "ERROR: Ory compose config must render env override URL: $expected_url" >&2
exit 1
fi
done
root_init_rp="$(
awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^ init-rp:/ { in_block=1 } in_block { print }' "$repo_root/compose.ory.yaml"
)"
@@ -53,3 +131,204 @@ if grep -q "releases/download/v25.4.0" "$repo_root/docker/staging_pull_compose.t
echo "ERROR: staging pull compose must not download a hard-coded Hydra v25.4.0 CLI." >&2
exit 1
fi
staging_pull_template="$repo_root/docker/staging_pull_compose.template.yaml"
if ! grep -q 'entrypoint: \["/etc/config/oathkeeper/entrypoint.sh"\]' "$staging_pull_template"; then
echo "ERROR: staging pull Oathkeeper must use the env-aware entrypoint." >&2
exit 1
fi
if grep -q "command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml" "$staging_pull_template"; then
echo "ERROR: staging pull Oathkeeper must not bypass entrypoint.sh with a direct command." >&2
exit 1
fi
if ! grep -q "URLS_SELF_ISSUER=\${HYDRA_PUBLIC_URL}" "$staging_pull_template"; then
echo "ERROR: staging pull Hydra issuer must use HYDRA_PUBLIC_URL." >&2
exit 1
fi
if grep -Eq '(KRATOS_(SERVE|SELFSERVICE|UI|BROWSER|PUBLIC|ADMIN).*:-http://localhost|URLS_.*:-http://localhost)' "$staging_pull_template"; then
echo "ERROR: staging pull Ory browser URLs must not fall back to localhost." >&2
exit 1
fi
if ! grep -q 'KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON' "$staging_pull_template"; then
echo "ERROR: staging pull Kratos allowed_return_urls must be driven by KRATOS_ALLOWED_RETURN_URLS_JSON." >&2
exit 1
fi
for return_path in '/ko' '/en' '/auth/callback' '/ko/auth/callback' '/en/auth/callback'; do
if ! grep -q "$return_path" "$staging_pull_template" "$repo_root/deploy/templates/.env.template" "$repo_root/.gitea/workflows/staging_code_pull.yml"; then
echo "ERROR: staging/prod allowed_return_urls must include locale/callback path: $return_path" >&2
exit 1
fi
done
if grep -Eq 'ORGFRONT_CALLBACK_URLS=.*(172\.16\.10\.176|baron-orgchart\.hmac\.kr|, https?://)' "$staging_pull_template" "$repo_root/.gitea/workflows/staging_code_pull.yml"; then
echo "ERROR: staging pull OrgFront callbacks must not keep private IP, legacy orgchart domain, or comma-space URI entries." >&2
exit 1
fi
if grep -q "rewrite \\^/oidc" "$repo_root/gateway/nginx.conf"; then
echo "ERROR: gateway must preserve the /oidc prefix and let Oathkeeper strip it." >&2
exit 1
fi
for rules_file in \
"$repo_root/docker/ory/oathkeeper/rules.json" \
"$repo_root/docker/ory/oathkeeper/rules.stage.json" \
"$repo_root/docker/ory/oathkeeper/rules.prod.json"
do
for rule_id in hydra-well-known hydra-well-known-oidc hydra-oauth2 hydra-oauth2-oidc hydra-userinfo hydra-userinfo-oidc; do
if ! grep -q "\"id\": \"$rule_id\"" "$rules_file"; then
echo "ERROR: Oathkeeper rules must expose Hydra public route in $rules_file: $rule_id" >&2
exit 1
fi
done
for prefixed_rule in hydra-well-known-oidc hydra-oauth2-oidc hydra-userinfo-oidc; do
if ! awk -v id="\"id\": \"$prefixed_rule\"" '
$0 ~ id { in_rule = 1 }
in_rule && /strip_path/ && /\/oidc/ { found = 1 }
in_rule && /^ }[,]?$/ { in_rule = 0 }
END { exit found ? 0 : 1 }
' "$rules_file"; then
echo "ERROR: prefixed Oathkeeper route must strip /oidc in $rules_file: $prefixed_rule" >&2
exit 1
fi
done
done
for wildcard_rules_file in \
"$repo_root/docker/ory/oathkeeper/rules.json" \
"$repo_root/docker/ory/oathkeeper/rules.stage.json"
do
if grep -q "<\\.\\*>://<\\.\\*>/" "$wildcard_rules_file"; then
echo "ERROR: wildcard Oathkeeper host must not swallow path segments in $wildcard_rules_file." >&2
exit 1
fi
done
deploy_template="$repo_root/deploy/templates/docker-compose.yaml"
deploy_env_template="$repo_root/deploy/templates/.env.template"
deploy_gateway_template="$repo_root/deploy/templates/gateway/nginx.conf"
deploy_kratos_template="$repo_root/deploy/templates/ory/kratos/kratos.yml.template"
deploy_oathkeeper_rules_template="$repo_root/deploy/templates/ory/oathkeeper/rules.json"
for required_template in \
"$repo_root/deploy/templates/orgfront/vite.config.ts" \
"$repo_root/deploy/templates/orgfront/auth.ts" \
"$repo_root/docker/ory/init-db/01_create_dbs.sh" \
"$repo_root/docker/ory/hydra/hydra.yml.template" \
"$repo_root/docker/ory/keto/keto.yml.template" \
"$repo_root/docker/ory/oathkeeper/entrypoint.sh" \
"$repo_root/docker/ory/oathkeeper/oathkeeper.yml.template"
do
if [[ ! -f "$required_template" ]]; then
echo "ERROR: deploy instance generation requires missing source file: $required_template" >&2
exit 1
fi
done
if grep -Eq "oryd/(kratos|hydra|keto|oathkeeper):v25\\.4\\.0" "$deploy_template"; then
echo "ERROR: deploy template Ory stack must not hard-code v25.4.0 images." >&2
exit 1
fi
for prod_sensitive_file in \
"$repo_root/docker/ory/oathkeeper/rules.prod.json" \
"$repo_root/docker/ory/kratos/kratos.yml.template" \
"$repo_root/deploy/templates/ory/kratos/kratos.yml.template"
do
if grep -q "app\\.brsw\\.kr" "$prod_sensitive_file"; then
echo "ERROR: Ory production-sensitive config must not hard-code app.brsw.kr: $prod_sensitive_file" >&2
exit 1
fi
done
for compose_file in "$repo_root/compose.ory.yaml" "$repo_root/docker/compose.ory.yaml" "$repo_root/docker/staging_pull_compose.template.yaml"; do
if grep -Eq './docker/ory/(kratos|hydra|keto|oathkeeper):/etc/config/' "$compose_file"; then
echo "ERROR: Ory compose must mount rendered config/.generated/ory config, not source templates: $compose_file" >&2
exit 1
fi
done
if grep -Eq '\./ory/(kratos|hydra|keto|oathkeeper):/etc/config/' "$deploy_template"; then
echo "ERROR: deploy template must mount rendered config/.generated/ory config, not source templates." >&2
exit 1
fi
if grep -q 'ory/generated' "$deploy_template" "$repo_root/deploy/create-instance.sh"; then
echo "ERROR: deploy template must use config/.generated/ory, not ory/generated." >&2
exit 1
fi
if ! grep -q '^render-ory-config:' "$repo_root/Makefile"; then
echo "ERROR: Makefile must render Ory config before starting Ory services." >&2
exit 1
fi
if ! grep -q 'scripts/render_ory_config.sh' "$repo_root/.gitea/workflows/staging_code_pull.yml"; then
echo "ERROR: staging code pull must render Ory config before docker compose up." >&2
exit 1
fi
"$repo_root/scripts/render_ory_config.sh" >/dev/null
for generated_config in \
"$repo_root/config/.generated/ory/kratos/kratos.yml" \
"$repo_root/config/.generated/ory/hydra/hydra.yml" \
"$repo_root/config/.generated/ory/keto/keto.yml" \
"$repo_root/config/.generated/ory/oathkeeper/oathkeeper.yml"
do
if [[ ! -f "$generated_config" ]]; then
echo "ERROR: Ory rendered config is missing: $generated_config" >&2
exit 1
fi
if grep -q '\${' "$generated_config"; then
echo "ERROR: Ory rendered config must not contain placeholders: $generated_config" >&2
exit 1
fi
done
for service in kratos-migrate kratos hydra-migrate hydra keto-migrate keto oathkeeper_logs_init oathkeeper; do
if ! grep -q "^ $service:" "$deploy_template"; then
echo "ERROR: deploy template Ory stack must include service: $service" >&2
exit 1
fi
done
for version_key in KRATOS_VERSION HYDRA_VERSION KETO_VERSION OATHKEEPER_VERSION; do
if ! grep -q "^$version_key=v26\\.2\\.0$" "$deploy_env_template"; then
echo "ERROR: deploy env template must define $version_key=v26.2.0." >&2
exit 1
fi
done
if ! grep -q 'entrypoint: \["/etc/config/oathkeeper/entrypoint.sh"\]' "$deploy_template"; then
echo "ERROR: deploy template Oathkeeper must use the env-aware entrypoint." >&2
exit 1
fi
if grep -q "rewrite \\^/oidc" "$deploy_gateway_template"; then
echo "ERROR: deploy template gateway must preserve the /oidc prefix." >&2
exit 1
fi
if ! grep -q '^version: v26.2.0$' "$deploy_kratos_template"; then
echo "ERROR: deploy Kratos template config version must match v26.2.0." >&2
exit 1
fi
for rule_id in hydra-well-known hydra-well-known-oidc hydra-oauth2 hydra-oauth2-oidc hydra-userinfo hydra-userinfo-oidc; do
if ! grep -q "\"id\": \"$rule_id\"" "$deploy_oathkeeper_rules_template"; then
echo "ERROR: deploy Oathkeeper rules must expose Hydra public route: $rule_id" >&2
exit 1
fi
done
if ! grep -q '"strip_path": "/oidc"' "$deploy_oathkeeper_rules_template"; then
echo "ERROR: deploy Oathkeeper prefixed routes must strip /oidc with strip_path." >&2
exit 1
fi

View File

@@ -15,6 +15,7 @@ saman = "Saman"
[domain.tenant_type]
company = "Company"
company_group = "Company Group"
organization = "Organization"
personal = "Personal"
user_group = "User Group"

View File

@@ -15,6 +15,7 @@ saman = "삼안"
[domain.tenant_type]
company = "COMPANY (일반 기업)"
company_group = "COMPANY_GROUP (그룹사/지주사)"
organization = "ORGANIZATION (정규 조직)"
personal = "PERSONAL (개인 워크스페이스)"
user_group = "USER_GROUP (내부 부서/팀)"

View File

@@ -15,6 +15,7 @@ saman = ""
[domain.tenant_type]
company = ""
company_group = ""
organization = ""
personal = ""
user_group = ""

View File

@@ -1,14 +1,16 @@
const Map<String, String> internalErrorWhitelistMessages = {
'settings_disabled': '현재 계정 설정 화면은 준비 중입니다.',
'invalid_session': '세션이 만료되었습니다. 다시 로그인해 주세요.',
'verification_required': '추가 인증이 필요합니다. 안내에 따라 진행해 주세요.',
'recovery_expired': '재설정 링크가 만료되었습니다. 다시 요청해 주세요.',
'recovery_invalid': '재설정 링크가 유효하지 않습니다.',
'rate_limited': '요청이 많습니다. 잠시 후 다시 시도해 주세요.',
'not_found': '요청한 페이지를 찾을 수 없습니다.',
'bad_request': '입력값을 확인해 주세요.',
'password_or_email_mismatch': '이메일 혹은 비밀번호가 일치하지 않습니다.',
'tenant_not_allowed': '허용되지 않은 테넌트입니다.',
const Map<String, String> internalErrorWhitelistMessageKeys = {
'settings_disabled': 'msg.userfront.error.whitelist.settings_disabled',
'invalid_session': 'msg.userfront.error.whitelist.invalid_session',
'verification_required':
'msg.userfront.error.whitelist.verification_required',
'recovery_expired': 'msg.userfront.error.whitelist.recovery_expired',
'recovery_invalid': 'msg.userfront.error.whitelist.recovery_invalid',
'rate_limited': 'msg.userfront.error.whitelist.rate_limited',
'not_found': 'msg.userfront.error.whitelist.not_found',
'bad_request': 'msg.userfront.error.whitelist.bad_request',
'password_or_email_mismatch':
'msg.userfront.error.whitelist.password_or_email_mismatch',
'tenant_not_allowed': 'msg.userfront.error.whitelist.tenant_not_allowed',
};
const Set<String> oryBypassErrorCodes = {

View File

@@ -65,7 +65,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.password_policy_fetch',
'비밀번호 정책을 불러오지 못했습니다.',
'Failed to load the password policy.',
);
}
}
@@ -84,7 +84,7 @@ class AuthProxyService {
}
throw _error(
'err.userfront.auth_proxy.profile_load',
'프로필을 불러오지 못했습니다: {{error}}',
'Failed to load the profile: {{error}}',
detail: response.body,
);
} finally {
@@ -110,7 +110,7 @@ class AuthProxyService {
}
throw _error(
'err.userfront.auth_proxy.profile_load',
'프로필을 불러오지 못했습니다: {{error}}',
'Failed to load the profile: {{error}}',
detail: response.body,
);
} finally {
@@ -144,7 +144,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.tenant_info_fetch',
'테넌트 정보를 불러오지 못했습니다.',
'Failed to load tenant information.',
);
}
}
@@ -180,7 +180,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.login_init',
'로그인 초기화에 실패했습니다: {{error}}',
'Failed to initialize login: {{error}}',
detail: response.body,
);
}
@@ -205,7 +205,7 @@ class AuthProxyService {
}
throw _error(
'err.userfront.auth_proxy.login_poll',
'로그인 상태 확인에 실패했습니다: {{error}}',
'Failed to check login status: {{error}}',
detail: response.body,
);
}
@@ -227,7 +227,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.verify_failed',
'검증에 실패했습니다: {{error}}',
'Verification failed: {{error}}',
detail: response.body,
);
}
@@ -261,7 +261,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.verify_failed',
'검증에 실패했습니다: {{error}}',
'Verification failed: {{error}}',
detail: response.body,
);
}
@@ -281,7 +281,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.revoke',
'세션 종료에 실패했습니다: {{error}}',
'Failed to revoke the session: {{error}}',
detail: response.body,
);
}
@@ -304,7 +304,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.load',
'활성 세션을 불러오지 못했습니다: {{error}}',
'Failed to load the active sessions: {{error}}',
detail: response.body,
);
}
@@ -342,7 +342,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.verify_failed',
'검증에 실패했습니다: {{error}}',
'Verification failed: {{error}}',
detail: response.body,
);
}
@@ -568,7 +568,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.sms_send',
'SMS 전송에 실패했습니다: {{error}}',
'Failed to send SMS: {{error}}',
detail: response.body,
);
}
@@ -591,7 +591,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.code_verify',
'인증 코드 확인에 실패했습니다: {{error}}',
'Failed to verify the code: {{error}}',
detail: response.body,
);
}
@@ -609,7 +609,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.qr_init',
'QR 로그인을 시작하지 못했습니다: {{error}}',
'Failed to start QR login: {{error}}',
detail: response.body,
);
}
@@ -631,7 +631,7 @@ class AuthProxyService {
}
throw _error(
'err.userfront.auth_proxy.qr_poll',
'QR 상태 확인에 실패했습니다: {{error}}',
'Failed to check QR status: {{error}}',
detail: response.body,
);
}
@@ -669,7 +669,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.qr_approve',
'QR 승인에 실패했습니다: {{error}}',
'Failed to approve QR login: {{error}}',
detail: response.body,
);
}
@@ -720,7 +720,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_create',
'사용자 생성에 실패했습니다: {{error}}',
'Failed to create the user: {{error}}',
detail: response.body,
);
}
@@ -749,7 +749,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.user_list',
'사용자 목록 조회에 실패했습니다: {{error}}',
'Failed to load the user list: {{error}}',
detail: response.body,
);
}
@@ -770,7 +770,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_delete',
'사용자 삭제에 실패했습니다: {{error}}',
'Failed to delete the user: {{error}}',
detail: response.body,
);
}
@@ -796,7 +796,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_status_update',
'상태 업데이트에 실패했습니다: {{error}}',
'Failed to update the user status: {{error}}',
detail: response.body,
);
}
@@ -829,7 +829,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_update',
'사용자 수정에 실패했습니다: {{error}}',
'Failed to update the user: {{error}}',
detail: response.body,
);
}
@@ -855,7 +855,7 @@ class AuthProxyService {
} else {
throw _error(
'err.userfront.auth_proxy.linked_apps_load',
'연동된 앱 목록을 불러오지 못했습니다.',
'Failed to load linked applications.',
);
}
} finally {
@@ -1043,7 +1043,7 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.phone_code_send',
'인증 코드 전송에 실패했습니다: {{error}}',
'Failed to send the verification code: {{error}}',
detail: response.body,
);
}

View File

@@ -282,9 +282,9 @@ class _ErrorScreenState extends State<ErrorScreen> {
final isProd = widget.isProdOverride ?? AuthProxyService.isProdEnv;
final normalizedCode = (widget.errorCode ?? '').trim();
final hasCode = normalizedCode.isNotEmpty;
final internalWhitelistFallback =
internalErrorWhitelistMessages[normalizedCode];
final isInternalWhitelisted = internalWhitelistFallback != null;
final internalWhitelistKey =
internalErrorWhitelistMessageKeys[normalizedCode];
final isInternalWhitelisted = internalWhitelistKey != null;
final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode);
final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass);
final isTenantAccessBlocked = normalizedCode == 'tenant_not_allowed';
@@ -294,7 +294,7 @@ class _ErrorScreenState extends State<ErrorScreen> {
final title = isTenantAccessBlocked
? tr(
'msg.userfront.error.tenant.page_title',
fallback: '애플리케이션 접근이 제한되었습니다',
fallback: 'Application access is restricted',
)
: isProd
? tr('msg.userfront.error.title')
@@ -332,17 +332,18 @@ class _ErrorScreenState extends State<ErrorScreen> {
final showTenantLookupFallback =
_tenantAccessDetails == null &&
(emailLabel.isEmpty || tenantLabel.isEmpty);
final internalWhitelistDetail = internalWhitelistKey == null
? null
: tr(internalWhitelistKey);
final detail = isTenantAccessBlocked
? tr(
'msg.userfront.error.tenant.detail',
fallback: '현재 로그인된 계정은 이 애플리케이션에 접근할 수 없습니다.',
fallback:
'The current signed-in account cannot access this application.',
)
: isProd
? (isInternalWhitelisted
? tr(
'msg.userfront.error.whitelist.$normalizedCode',
fallback: internalWhitelistFallback,
)
? internalWhitelistDetail!
: (isOryBypass
? tr(
'msg.userfront.error.ory.$normalizedCode',
@@ -422,7 +423,7 @@ class _ErrorScreenState extends State<ErrorScreen> {
Text(
tr(
'msg.userfront.error.tenant.title',
fallback: '접근 제한 정보',
fallback: 'Access restriction details',
),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
@@ -447,7 +448,8 @@ class _ErrorScreenState extends State<ErrorScreen> {
child: Text(
tr(
'msg.userfront.error.tenant.loading',
fallback: '현재 계정 정보를 불러오는 중입니다.',
fallback:
'Loading the current account details.',
),
style: theme.textTheme.bodySmall
?.copyWith(
@@ -462,39 +464,39 @@ class _ErrorScreenState extends State<ErrorScreen> {
_InfoRow(
label: tr(
'msg.userfront.error.tenant.account',
fallback: '계정',
fallback: 'Account',
),
value: emailLabel.isNotEmpty
? emailLabel
: tr(
'msg.userfront.error.tenant.account_unknown',
fallback: '알 수 없음',
fallback: 'Unknown',
),
),
const SizedBox(height: 8),
_InfoRow(
label: tr(
'msg.userfront.error.tenant.primary_tenant',
fallback: '대표 소속 테넌트',
fallback: 'Primary affiliated tenant',
),
value: tenantLabel.isNotEmpty
? tenantLabel
: tr(
'msg.userfront.error.tenant.tenant_unknown',
fallback: '알 수 없음',
fallback: 'Unknown',
),
),
const SizedBox(height: 8),
_InfoRow(
label: tr(
'msg.userfront.error.tenant.affiliated_tenants',
fallback: '전체 소속 테넌트',
fallback: 'All affiliated tenants',
),
value: affiliatedTenantLabels.isNotEmpty
? affiliatedTenantLabels.join(', ')
: tr(
'msg.userfront.error.tenant.tenant_unknown',
fallback: '알 수 없음',
fallback: 'Unknown',
),
),
if (showTenantLookupFallback) ...[
@@ -503,7 +505,7 @@ class _ErrorScreenState extends State<ErrorScreen> {
tr(
'msg.userfront.error.tenant.lookup_fallback',
fallback:
'표시 정보가 충분하지 않아 일부 항목은 확인되지 않을 수 있습니다.',
'Some fields may be unavailable because there is not enough profile information to display.',
),
style: theme.textTheme.bodySmall
?.copyWith(
@@ -518,7 +520,7 @@ class _ErrorScreenState extends State<ErrorScreen> {
tr(
'msg.userfront.error.tenant.load_failed',
fallback:
'계정 정보를 확인하지 못했습니다. 다시 시도해 주세요.',
'Failed to load account details. Please try again.',
),
style: theme.textTheme.bodySmall
?.copyWith(
@@ -548,7 +550,7 @@ class _ErrorScreenState extends State<ErrorScreen> {
Text(
tr(
'msg.userfront.error.tenant.allowed_box_title',
fallback: '접속 가능 테넌트',
fallback: 'Allowed tenants',
),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
@@ -559,7 +561,7 @@ class _ErrorScreenState extends State<ErrorScreen> {
_InfoRow(
label: tr(
'msg.userfront.error.tenant.allowed_tenants',
fallback: '접속 가능 테넌트',
fallback: 'Allowed tenants',
),
value: allowedTenantLabels.join(', '),
),
@@ -567,11 +569,11 @@ class _ErrorScreenState extends State<ErrorScreen> {
_InfoRow(
label: tr(
'msg.userfront.error.tenant.allowed_tenants',
fallback: '접속 가능 테넌트',
fallback: 'Allowed tenants',
),
value: tr(
'msg.userfront.error.tenant.tenant_unknown',
fallback: '알 수 없음',
fallback: 'Unknown',
),
),
],

Some files were not shown because too many files have changed in this diff Show More