forked from baron/baron-sso
Merge remote-tracking branch 'origin/dev' into fix/issue-637
This commit is contained in:
19
.env.sample
19
.env.sample
@@ -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=
|
||||
|
||||
@@ -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
1
.gitignore
vendored
@@ -8,6 +8,7 @@
|
||||
.codex/
|
||||
.serena/
|
||||
.generated/
|
||||
config/.generated/
|
||||
*.swp
|
||||
*.log
|
||||
*.out
|
||||
|
||||
58
Makefile
58
Makefile
@@ -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
|
||||
|
||||
14
README.md
14
README.md
@@ -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 접속
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
186
adminfront/src/features/overview/GlobalOverviewPage.test.tsx
Normal file
186
adminfront/src/features/overview/GlobalOverviewPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25-alpine
|
||||
FROM golang:1.26.2-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.ClientSecret{},
|
||||
&domain.ClientConsent{},
|
||||
&domain.KetoOutbox{},
|
||||
&domain.RPUsageEvent{},
|
||||
&domain.WorksmobileOutbox{},
|
||||
&domain.WorksmobileResourceMapping{},
|
||||
&domain.SharedLink{},
|
||||
|
||||
@@ -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
|
||||
|
||||
101
backend/internal/domain/rp_usage_event.go
Normal file
101
backend/internal/domain/rp_usage_event.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
156
backend/internal/handler/admin_handler_test.go
Normal file
156
backend/internal/handler/admin_handler_test.go
Normal 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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 := `
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
91
backend/internal/repository/rp_usage_outbox_repository.go
Normal file
91
backend/internal/repository/rp_usage_outbox_repository.go
Normal 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
|
||||
}
|
||||
67
backend/internal/service/rp_usage_event_emitter.go
Normal file
67
backend/internal/service/rp_usage_event_emitter.go
Normal 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[:])
|
||||
}
|
||||
132
backend/internal/service/rp_usage_event_emitter_test.go
Normal file
132
backend/internal/service/rp_usage_event_emitter_test.go
Normal 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)
|
||||
}
|
||||
82
backend/internal/service/rp_usage_projector_worker.go
Normal file
82
backend/internal/service/rp_usage_projector_worker.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
193
compose.ory.yaml
193
compose.ory.yaml
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -29,7 +29,6 @@ http {
|
||||
}
|
||||
|
||||
location /oidc {
|
||||
rewrite ^/oidc/(.*)$ /$1 break;
|
||||
proxy_pass http://oathkeeper_srv;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -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" }]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -1,5 +1,5 @@
|
||||
version: v0.11.0
|
||||
dsn: ${DSN}
|
||||
dsn: ${KETO_DSN}
|
||||
serve:
|
||||
read:
|
||||
host: 0.0.0.0
|
||||
@@ -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:
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" }]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -5,7 +5,7 @@
|
||||
## 1. 준비 사항
|
||||
테스트를 실행하기 위해 다음 도구들이 설치되어 있어야 합니다.
|
||||
- **Docker & Docker Compose** (백엔드 인프라 의존성용)
|
||||
- **Go 1.25+**
|
||||
- **Go 1.26.2+**
|
||||
- **Flutter SDK**
|
||||
- **Node.js 24 LTS+**
|
||||
|
||||
|
||||
@@ -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`으로의 리다이렉션이 안전하게 허용됩니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
321
docs/pkce-backchannel-logout-guide.md
Normal file
321
docs/pkce-backchannel-logout-guide.md
Normal 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 로컬 세션 종료까지 이어집니다.
|
||||
322
docs/server-side-app-backchannel-logout-guide.md
Normal file
322
docs/server-side-app-backchannel-logout-guide.md
Normal 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: 루트 리다이렉트 또는 비로그인 응답
|
||||
```
|
||||
@@ -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를 둡니다.
|
||||
|
||||
@@ -88,7 +88,7 @@ flowchart
|
||||
```
|
||||
|
||||
### 1. Backend (Go Fiber)
|
||||
- **Language**: Go 1.25+
|
||||
- **Language**: Go 1.26.2+
|
||||
- **Framework**: Fiber v2.25+
|
||||
- **Database**:
|
||||
- **ClickHouse**: 감사 로그 (고성능 데이터 수집)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "사용자 관리"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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": "*" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
100
scripts/render_ory_config.sh
Executable 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"
|
||||
78
test/backend_go_version_policy_test.sh
Normal file
78
test/backend_go_version_policy_test.sh
Normal 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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ saman = "Saman"
|
||||
[domain.tenant_type]
|
||||
company = "Company"
|
||||
company_group = "Company Group"
|
||||
organization = "Organization"
|
||||
personal = "Personal"
|
||||
user_group = "User Group"
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ saman = "삼안"
|
||||
[domain.tenant_type]
|
||||
company = "COMPANY (일반 기업)"
|
||||
company_group = "COMPANY_GROUP (그룹사/지주사)"
|
||||
organization = "ORGANIZATION (정규 조직)"
|
||||
personal = "PERSONAL (개인 워크스페이스)"
|
||||
user_group = "USER_GROUP (내부 부서/팀)"
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ saman = ""
|
||||
[domain.tenant_type]
|
||||
company = ""
|
||||
company_group = ""
|
||||
organization = ""
|
||||
personal = ""
|
||||
user_group = ""
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user