diff --git a/.env.sample b/.env.sample index 6d647b2d..2cc41e84 100644 --- a/.env.sample +++ b/.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= \ No newline at end of file +ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback +VITE_ORGCHART_URL= diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index 2abd5158..4e1a6233 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -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 diff --git a/.gitignore b/.gitignore index 1a5c7519..a4f12e27 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .codex/ .serena/ .generated/ +config/.generated/ *.swp *.log *.out diff --git a/Makefile b/Makefile index 0c688341..78bf47fd 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index 776bb0bc..a9559c83 100644 --- a/README.md +++ b/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 접속 diff --git a/README_en.md b/README_en.md index 5ba1130b..79fba211 100644 --- a/README_en.md +++ b/README_en.md @@ -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) diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 4f71fe3d..78f97232 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -534,7 +534,7 @@ function AppLayout() { -
+
@@ -730,7 +730,7 @@ function AppLayout() {
-
+
diff --git a/adminfront/src/features/auth/AuthPage.tsx b/adminfront/src/features/auth/AuthPage.tsx index 015bbb67..002c69e6 100644 --- a/adminfront/src/features/auth/AuthPage.tsx +++ b/adminfront/src/features/auth/AuthPage.tsx @@ -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 ( -
-
-
-
-

- Admin auth -

-

Admin auth guardrails

-

- 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. -

-
-
- - IDP session placeholder - - -
-
-
- -
- {flows.map((flow) => ( -
-
- {flow.pill} - -
-

{flow.title}

-

- {flow.description} -

-
- ))} -
- -
-
-
- - - App-based approvals - -
-

- App session as MFA replacement -

-

- 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. +

+
+
+

+ Admin auth +

+

+ + 인증가드 +

+

+ 관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다.

-
-
- - - TTL discipline - -
-

- Keep admin sessions short -

-

- Default admin TTL is 15 minutes. Show countdown and nudge re-auth - with step-up MFA when critical actions (rotate secret, export logs) - happen. -

-
-
+
+ +
); } diff --git a/adminfront/src/features/overview/components/PermissionChecker.tsx b/adminfront/src/features/auth/components/PermissionChecker.tsx similarity index 90% rename from adminfront/src/features/overview/components/PermissionChecker.tsx rename to adminfront/src/features/auth/components/PermissionChecker.tsx index 8b2e090b..d2965ffc 100644 --- a/adminfront/src/features/overview/components/PermissionChecker.tsx +++ b/adminfront/src/features/auth/components/PermissionChecker.tsx @@ -44,7 +44,7 @@ function PermissionChecker() { const result = checkMutation.data; return ( - + @@ -100,7 +100,7 @@ function PermissionChecker() { @@ -108,17 +108,17 @@ function PermissionChecker() { {checkMutation.isSuccess && result && (
{result.allowed ? ( <>
Access ALLOWED
-

+

해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 포함)

@@ -127,7 +127,7 @@ function PermissionChecker() { <>
Access DENIED
-

+

해당 사용자는 요청한 리소스에 대해 권한이 없습니다.

diff --git a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx new file mode 100644 index 00000000..fbf9f83a --- /dev/null +++ b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx @@ -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( + + {ui} + , + ); +} + +describe("admin overview and auth guard pages", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders usage trend chart without quick navigation or permission checker", async () => { + renderWithProviders(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index e3d3a14e..6f1be04f 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -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(); + 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(); + 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 ( + + {icon} + {label} + {value} + + ); +} + +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 ( +
+
+
+ +

+ 회사별 앱별 로그인요청/기타 요청 현황 +

+
+ {filters} +
+ + {daily.length === 0 ? ( +
+ 표시할 RP 이용 집계가 없습니다. +
+ ) : ( +
+ + 일 단위 RP 요청 현황 + + + + 기타 요청 + + + + 로그인 요청 + + + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const gridY = padTop + innerHeight * ratio; + const label = Math.round(maxValue * (1 - ratio)); + return ( + + + + {label} + + + ); + })} + {daily.map((point, index) => { + const center = x(index); + const otherHeight = + (point.otherRequests / maxValue) * innerHeight; + return ( + + + + {formatPeriodLabel(point.date, period)} + + + ); + })} + + {daily.map((point, index) => ( + + ))} + +
+ )} + + {series.length > 0 && ( +
+ {series.map((item) => ( +
+ {item.clientLabel} + + {item.tenantLabel} + + + 로그인 {item.loginRequests.toLocaleString()} / 기타{" "} + {item.otherRequests.toLocaleString()} / 사용자{" "} + {item.uniqueSubjects.toLocaleString()} + +
+ ))} +
+ )} +
+ ); +} function GlobalOverviewPage() { + const [period, setPeriod] = useState("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 = ( +
+
+ {[ + ["day", "일"], + ["week", "주"], + ["month", "월"], + ].map(([value, label]) => ( + + ))} +
+ 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" + /> + +
+ ); + return ( -
+
-

+

{t("ui.admin.overview.title", "Dashboard")}

-

+

{t( "msg.admin.overview.description", "시스템 전반의 주요 현황을 확인하고 관리합니다.", @@ -36,166 +436,61 @@ function GlobalOverviewPage() {

-
+
- - - - {t("ui.admin.overview.summary.total_tenants", "총 테넌트")} - -
- -
-
- -
-
-

- 활성화된 테넌트 수 -

-
-
- - - - {t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")} - -
- -
-
- -
-
-

- 등록된 OIDC 앱 -

-
-
+ } + label={t( + "ui.admin.overview.summary.total_tenants", + "전체 테넌트 수", + )} + value={metric(stats?.totalTenants)} + /> + } + label={t( + "ui.admin.overview.summary.oidc_clients", + "OIDC 클라이언트", + )} + value={metric(stats?.oidcClients)} + />
- - - - - {t( - "ui.admin.overview.summary.audit_events_24h", - "최근 감사 로그 (24h)", - )} - -
- -
-
- -
-
-

- 발생한 이벤트 수 -

-
-
- - - - - {t("ui.admin.overview.summary.policy_gate", "정책 상태")} - -
- -
-
- -
- Active -
-

- 접근 제어 정상 동작 -

-
-
+ } + label={t( + "ui.admin.overview.summary.audit_events_24h", + "24시간 이벤트", + )} + value={metric(stats?.auditEvents24h)} + /> + } + label={t("ui.admin.overview.summary.policy_gate", "정책 상태")} + value="Active" + />
-
-

- {t("ui.admin.overview.quick_links.title", "빠른 작업")} -

-
- - -
- -
-
-

- 테넌트 추가 -

-

- 새로운 조직이나 그룹을 생성합니다. -

-
- -
- - -
- -
-
-

- 사용자 관리 -

-

- 전체 사용자를 조회하고 관리합니다. -

-
- - - - -
- -
-
-

- API 키 관리 -

-

- 시스템 연동을 위한 키를 발급합니다. -

-
- -
- - -
- -
-
-

- 감사 로그 -

-

- 보안 이벤트를 모니터링합니다. -

-
- -
-
- - -
- -
-
+ {usageQuery.isError ? ( +
+
+

+ 회사별 앱별 로그인요청/기타 요청 현황 +

+ {chartFilters} +
+
+ RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작 + 이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위 + 그래프가 표시됩니다. +
+
+ ) : ( + + )}
); } diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 06ab22b5..e3459b49 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -556,10 +556,10 @@ function TenantListPage() {
- +
- + 0 && @@ -634,7 +634,7 @@ function TenantListPage() { {getSortIcon("updatedAt")} - + {t("ui.admin.tenants.table.actions", "ACTIONS")} @@ -690,21 +690,18 @@ function TenantListPage() { )} - + - {t( - `domain.tenant_type.${tenant.type?.toLowerCase()}`, - tenant.type, - )} + {tenant.type} {tenant.slug} - + {tenant.recursiveMemberCount} - + {tenant.updatedAt ? new Date(tenant.updatedAt).toLocaleString("ko-KR") : "-"} - +
- ))} -
{ + 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 = @@ -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 + >; + 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 ( -
-
-

{title}

- +
+
+
+

{title}

+ {filters && onFiltersChange && ( +
+ {userFilterOptions.map((option) => ( + + ))} +
+ )} +
+
+ + + + + + + {title} 컬럼 설정 + + 이 테이블에 표시할 비교 컬럼을 선택하세요. + + +
+ {worksmobileComparisonColumnOptions.map((column) => ( + + ))} +
+ + + + + +
+
+ +
-
-
+
+
- 상태 - - Baron ID - - - Baron - - - Baron 조직 - - - WORKS ID - - - external_key - - - WORKS 도메인 - - - WORKS - - - WORKS 조직 - + {isColumnVisible("status") && ( + 상태 + )} + {isColumnVisible("baronId") && ( + + Baron ID + + )} + {isColumnVisible("baron") && ( + + Baron + + )} + {isColumnVisible("baronOrg") && ( + + Baron 조직 + + )} + {isColumnVisible("worksmobileId") && ( + + WORKS ID + + )} + {isColumnVisible("externalKey") && ( + + external_key + + )} + {isColumnVisible("worksmobileDomain") && ( + + WORKS 도메인 + + )} + {isColumnVisible("worksmobile") && ( + + WORKS + + )} + {isColumnVisible("worksmobileOrg") && ( + + WORKS 조직 + + )} + {isColumnVisible("manage") && ( + 관리 + )} {loading && ( - + 불러오는 중... )} {!loading && rows.length === 0 && ( - + 표시할 차이가 없습니다. @@ -720,87 +1018,126 @@ function ComparisonTable({ - toggleRow(row.baronId, checked) - } + disabled={!canSelectWorksmobileRow(row)} + onCheckedChange={(checked) => toggleRow(row, checked)} /> - - - {getWorksmobileComparisonStatusLabel(row.status)} - - - - {row.baronId ?? "-"} - - -
-
{row.baronName ?? "-"}
-
- {row.baronEmail ?? ""} + {isColumnVisible("status") && ( + + + {getWorksmobileComparisonStatusLabel(row.status)} + + + )} + {isColumnVisible("baronId") && ( + + {row.baronId ?? "-"} + + )} + {isColumnVisible("baron") && ( + +
+
{row.baronName ?? "-"}
+
+ {row.baronEmail ?? ""} +
-
- - - - - - {row.worksmobileId ?? "-"} - - - {row.externalKey ?? "-"} - - - - - -
-
{formatWorksmobilePersonName(row) || "-"}
-
- {row.worksmobileEmail ?? ""} + + )} + {isColumnVisible("baronOrg") && ( + + + + )} + {isColumnVisible("worksmobileId") && ( + + {row.worksmobileId ?? "-"} + + )} + {isColumnVisible("externalKey") && ( + + {row.externalKey ?? "-"} + + )} + {isColumnVisible("worksmobileDomain") && ( + + + + )} + {isColumnVisible("worksmobile") && ( + +
+
{formatWorksmobilePersonName(row) || "-"}
+
+ {row.worksmobileEmail ?? ""} +
-
- - - - + + )} + {isColumnVisible("worksmobileOrg") && ( + + + + )} + {isColumnVisible("manage") && ( + + {row.resourceType === "USER" && ( + + )} + + )} ))} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index c9ebf457..67709852 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -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("/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("/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( + "/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( "/v1/admin/tenants", @@ -510,7 +560,9 @@ export type WorksmobileOverview = { tenant: TenantSummary; config: { enabled: boolean; + domainMappings?: Record; tokenConfigured: boolean; + adminTenantId?: string; }; recentJobs: WorksmobileOutboxItem[]; }; diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 1847ce68..0a150c9b 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -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 }) => { diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index bd293614..f676ae66 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -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 ({ diff --git a/adminfront/tests/worksmobile.spec.ts b/adminfront/tests/worksmobile.spec.ts index 0068391a..294b5dc7 100644 --- a/adminfront/tests/worksmobile.spec.ts +++ b/adminfront/tests/worksmobile.spec.ts @@ -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(); + }); }); diff --git a/backend/Dockerfile b/backend/Dockerfile index 3c2fbf7e..e72ea159 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25-alpine +FROM golang:1.26.2-alpine WORKDIR /app diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 39a99db8..3fb5f052 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/go.mod b/backend/go.mod index fe7a265b..cb9b8736 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/internal/bootstrap/admin_account_test.go b/backend/internal/bootstrap/admin_account_test.go index b1b0b4dc..38983d9d 100644 --- a/backend/internal/bootstrap/admin_account_test.go +++ b/backend/internal/bootstrap/admin_account_test.go @@ -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) } diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index ddb4bfe5..2a38fb4c 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -45,6 +45,7 @@ func migrateSchemas(db *gorm.DB) error { &domain.ClientSecret{}, &domain.ClientConsent{}, &domain.KetoOutbox{}, + &domain.RPUsageEvent{}, &domain.WorksmobileOutbox{}, &domain.WorksmobileResourceMapping{}, &domain.SharedLink{}, diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 248618ea..1bacb35d 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -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 diff --git a/backend/internal/domain/rp_usage_event.go b/backend/internal/domain/rp_usage_event.go new file mode 100644 index 00000000..d551bf45 --- /dev/null +++ b/backend/internal/domain/rp_usage_event.go @@ -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) +} diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index 42ee1815..84d1ce0b 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -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 +} diff --git a/backend/internal/handler/admin_handler_test.go b/backend/internal/handler/admin_handler_test.go new file mode 100644 index 00000000..b392787e --- /dev/null +++ b/backend/internal/handler/admin_handler_test.go @@ -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()) +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 6f1d625b..8493d2ec 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -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) } diff --git a/backend/internal/handler/auth_handler_client_test.go b/backend/internal/handler/auth_handler_client_test.go index 6119340f..81b5fb89 100644 --- a/backend/internal/handler/auth_handler_client_test.go +++ b/backend/internal/handler/auth_handler_client_test.go @@ -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) { diff --git a/backend/internal/handler/auth_handler_consent_test.go b/backend/internal/handler/auth_handler_consent_test.go index 2d701319..f064b482 100644 --- a/backend/internal/handler/auth_handler_consent_test.go +++ b/backend/internal/handler/auth_handler_consent_test.go @@ -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) { diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index af462748..85020233 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -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 } diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 0b32a39a..e64e2e61 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -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 diff --git a/backend/internal/middleware/audit_middleware_test.go b/backend/internal/middleware/audit_middleware_test.go index 0244b64a..25bd1d30 100644 --- a/backend/internal/middleware/audit_middleware_test.go +++ b/backend/internal/middleware/audit_middleware_test.go @@ -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 } diff --git a/backend/internal/repository/clickhouse_repo.go b/backend/internal/repository/clickhouse_repo.go index 54ca02a7..29039411 100644 --- a/backend/internal/repository/clickhouse_repo.go +++ b/backend/internal/repository/clickhouse_repo.go @@ -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 := ` diff --git a/backend/internal/repository/main_test.go b/backend/internal/repository/main_test.go index c1c08d0c..e8de32c8 100644 --- a/backend/internal/repository/main_test.go +++ b/backend/internal/repository/main_test.go @@ -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) } diff --git a/backend/internal/repository/rp_usage_outbox_repository.go b/backend/internal/repository/rp_usage_outbox_repository.go new file mode 100644 index 00000000..58129a22 --- /dev/null +++ b/backend/internal/repository/rp_usage_outbox_repository.go @@ -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 +} diff --git a/backend/internal/service/rp_usage_event_emitter.go b/backend/internal/service/rp_usage_event_emitter.go new file mode 100644 index 00000000..73c0f8f3 --- /dev/null +++ b/backend/internal/service/rp_usage_event_emitter.go @@ -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[:]) +} diff --git a/backend/internal/service/rp_usage_event_emitter_test.go b/backend/internal/service/rp_usage_event_emitter_test.go new file mode 100644 index 00000000..976d1dc2 --- /dev/null +++ b/backend/internal/service/rp_usage_event_emitter_test.go @@ -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) +} diff --git a/backend/internal/service/rp_usage_projector_worker.go b/backend/internal/service/rp_usage_projector_worker.go new file mode 100644 index 00000000..286f4831 --- /dev/null +++ b/backend/internal/service/rp_usage_projector_worker.go @@ -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 +} diff --git a/backend/internal/service/worksmobile_client.go b/backend/internal/service/worksmobile_client.go index f467c49f..20e4248b 100644 --- a/backend/internal/service/worksmobile_client.go +++ b/backend/internal/service/worksmobile_client.go @@ -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 diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go index 6b77ec12..a2f495da 100644 --- a/backend/internal/service/worksmobile_sync_service.go +++ b/backend/internal/service/worksmobile_sync_service.go @@ -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 diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go index d2c9e1b5..06dde9ad 100644 --- a/backend/internal/service/worksmobile_sync_service_test.go +++ b/backend/internal/service/worksmobile_sync_service_test.go @@ -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 } diff --git a/compose.ory.yaml b/compose.ory.yaml index 5b6faede..2672ddde 100644 --- a/compose.ory.yaml +++ b/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 diff --git a/deploy/create-instance.sh b/deploy/create-instance.sh index 9ff61865..e386e774 100644 --- a/deploy/create-instance.sh +++ b/deploy/create-instance.sh @@ -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" diff --git a/deploy/templates/.env.template b/deploy/templates/.env.template index 130f5fc7..822f3095 100644 --- a/deploy/templates/.env.template +++ b/deploy/templates/.env.template @@ -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 diff --git a/deploy/templates/docker-compose.yaml b/deploy/templates/docker-compose.yaml index c6294da9..4c0e230d 100644 --- a/deploy/templates/docker-compose.yaml +++ b/deploy/templates/docker-compose.yaml @@ -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} diff --git a/deploy/templates/gateway/nginx.conf b/deploy/templates/gateway/nginx.conf index 5590645e..656c5e2e 100644 --- a/deploy/templates/gateway/nginx.conf +++ b/deploy/templates/gateway/nginx.conf @@ -29,7 +29,6 @@ http { } location /oidc { - rewrite ^/oidc/(.*)$ /$1 break; proxy_pass http://oathkeeper_srv; proxy_set_header Host $host; } diff --git a/deploy/templates/ory/kratos/kratos.yml b/deploy/templates/ory/kratos/kratos.yml.template similarity index 75% rename from deploy/templates/ory/kratos/kratos.yml rename to deploy/templates/ory/kratos/kratos.yml.template index b70ca3f2..de5b981b 100644 --- a/deploy/templates/ory/kratos/kratos.yml +++ b/deploy/templates/ory/kratos/kratos.yml.template @@ -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: diff --git a/deploy/templates/ory/oathkeeper/rules.json b/deploy/templates/ory/oathkeeper/rules.json index 00fe02e3..ea15e287 100644 --- a/deploy/templates/ory/oathkeeper/rules.json +++ b/deploy/templates/ory/oathkeeper/rules.json @@ -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" }] } ] diff --git a/devfront/src/components/common/ForbiddenMessage.tsx b/devfront/src/components/common/ForbiddenMessage.tsx index 97c2af01..43dee424 100644 --- a/devfront/src/components/common/ForbiddenMessage.tsx +++ b/devfront/src/components/common/ForbiddenMessage.tsx @@ -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, }); diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index be1da833..4ea438b9 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -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() {

- {t("ui.dev.brand", "Baron 로그인")} + {t("ui.dev.brand", "Baron Sign In")}

{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" ? : } {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", + )} >
{profileInitial} @@ -496,14 +503,14 @@ function AppLayout() {

- {t("ui.dev.session.auto_extend", "세션 만료 관리")} + {t("ui.dev.session.auto_extend", "Session expiry")}

{isSessionExpiryEnabled ? sessionText : t( "ui.dev.session.disabled", - "세션 만료 비활성화", + "Session expiry disabled", )}

@@ -539,7 +546,7 @@ function AppLayout() { }} > - {t("ui.dev.profile.title", "내 정보")} + {t("ui.dev.profile.title", "My Profile")}