첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

66
baron-sso/docs/AGENTS.md Normal file
View File

@@ -0,0 +1,66 @@
# AGENTS 가이드 (Baron SSO)
## 버그 수정 절차 대원칙 (강제)
- 버그 대응 시 **재현 테스트를 먼저 작성**합니다.
- 재현 테스트가 실패하는 상태를 확인한 뒤에만 수정 작업을 시작합니다.
- 수정 후에는 테스트를 반복 실행하여 재현 테스트가 안정적으로 통과할 때까지 계속 보완합니다.
- 재현 테스트 없이 “감으로 수정”하거나, 실패 테스트를 남긴 채 성공으로 보고하지 않습니다.
- 이슈 종료 전에는 최소 1회 이상 실제 사용자 경로(예: 로그인/새로고침/리다이렉트)를 확인합니다.
- 테스트/원인/조치 내역은 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)에 반영합니다.
## 목적
- 인증/인가 허브로서 **Backend + Ory Stack** 중심 아키텍처를 유지
- 사용자 플로우(UserFront)와 관리 플로우(Admin/DevFront)를 명확히 분리
- 네트워크/보안 경계를 문서화해 회귀/설정 오류를 방지
## 시스템 요약
- **Backend**: Command 단일 진입점, 감사 로그를 ClickHouse에 적재
- **Ory Stack**: Kratos/Hydra/Keto/Oathkeeper (인증/토큰/정책)
- **Front**: UserFront(Flutter)-사용자 접점, AdminFront/DevFront(React)-내부 관리도구
- **원칙**: Front는 Backend API를 통해서만 IDP 기능을 호출
## 네트워크/보안 경계
- `ory-net`: Ory 내부 통신 전용 네트워크
- `baron_net`: App(backend/userfront/adminfront/devfront) 네트워크
- `public_net`: Oathkeeper, userfront 외부 공개. Gateway를 이용해 Proxy 분기
핵심 규칙:
- **Ory Admin 포트는 외부 노출 금지** (Backend만 `ory-net`을 통해 접근)
- **UserFront는 Oathkeeper 뒤에 있지 않음**
- **모든 Front(User/Admin/Dev)는 Ory Admin 엔드포인트에 직접 접근하지 않음**
## 인증/세션 핵심
- `IDP_PROVIDER` Ory 전용 저장 구조지만 향후 마이그레이션으로 추가 스택 지원할 수 있음
- `sessionJwt`**JWT가 아닐 수 있음** (Kratos session token은 opaque 가능)
- OIDC Consent 플로우는 UserFront의 `/consent` 경로에서 처리
- 토큰/쿠키 전달 방식 변경 시 `https://gitea.hmac.kr/baron/baron-sso/wiki/Authentication-and-Login-Flow.-`를 반드시 갱신
## 작업 체크리스트
- 인증/로그인 변경 시
- `https://gitea.hmac.kr/baron/baron-sso/wiki/Authentication-and-Login-Flow.-` 업데이트
- 세션/쿠키/Authorization 전달 방식 영향도 점검
- UserFront가 Ory/Oathkeeper 직접 호출하지 않도록 확인
- Ory 설정 변경 시
- `compose.ory.yaml`, `docker/ory/*` 변경 범위 명시
- `ory-net`/`public_net` 경계 유지 여부 확인
- 환경 변수 추가/변경 시
- `.env.sample` 반영
- 문서/가이드 갱신
- 클라이언트 로그 정책 영향 확인 (`CLIENT_LOG_DEBUG`, `USERFRONT_DEBUG_LOG`)
- 배포/운영 변경 시
- `Makefile`/compose 실행 절차 영향 확인
- 최소 Smoke 테스트 수행
- 로그 수집 레벨이 운영 기본 정책(`WARN/ERROR`)을 유지하는지 확인
## 클라이언트 로그 정책
- 상세 정책은 `docs/client-log-policy.md`를 기준으로 유지합니다.
- 원칙:
- 운영 기본값은 `WARN/ERROR`만 수집
- 운영 디버그는 `CLIENT_LOG_DEBUG=true`로만 일시 허용
- 민감정보 마스킹은 환경과 무관하게 항상 적용
## 테스트 참고
- 테스트 계획 및 수동 실행 기준은 `https://gitea.hmac.kr/baron/baron-sso/wiki/Test-Plan-and-Principles.-`를 따른다.

View File

@@ -0,0 +1,121 @@
# Baron SSO API Design Policy
## 1. 개요 (Overview)
본 문서는 Baron SSO 시스템의 백엔드 API 설계 원칙과 규약을 정의합니다. 모든 API 개발은 이 문서를 따름으로써 시스템의 일관성, 가독성, 유지보수성을 확보해야 합니다.
## 2. API 버전 관리 및 URL 구조 (Versioning & URI)
### 2.1 URL 구조
모든 API는 아래의 기본 구조를 따릅니다.
`[GET|POST|...] /api/{version}/{namespace}/{resource}[/{id}][/{action}]`
* **Version**: `v1`, `v2` 등 메이저 버전 단위로 관리합니다.
* **Namespace**: API의 사용 목적과 권한 범위를 구분합니다.
* `/auth`: 인증, 로그인, 비밀번호 찾기 등 (Public/User context)
* `/user`: 사용자 마이페이지, 프로필 수정 (Self-service, User context)
* `/admin`: 시스템 관리자 기능 (Admin context, Tenant aware)
* `/dev`: 개발자 포털 기능 (Developer context, RP management)
* **Resource**: 리소스명은 **복수형(Plural)** 명사를 사용합니다. (예: `clients`, `audit-logs`)
### 2.2 명명 규칙 (Naming Conventions)
* **URL Path**: **kebab-case** (소문자, 하이픈 사용)
* `GET /api/v1/audit-logs` (O)
* `GET /api/v1/auditLogs` (X)
* **Query Parameters**: **snake_case** 또는 **camelCase**를 허용하되, **camelCase**를 권장합니다.
* `GET /clients?clientId=...`
* **JSON Fields**: **camelCase**를 엄격히 준수합니다. (프론트엔드 JS/TS/Dart 표준 준수)
* `{ "clientId": "...", "createdAt": "..." }` (O)
* `{ "client_id": "...", "created_at": "..." }` (X)
* *예외:* DB 모델을 직접 반환해야 하는 불가피한 레거시(ClickHouse 로그 등)는 예외를 두되, 가급적 DTO 변환을 권장합니다.
## 3. HTTP 메서드 (HTTP Methods)
리소스에 대한 행위는 HTTP 메서드로 표현합니다.
| 메서드 | 용도 | 멱등성(Idempotent) | 예시 |
| :--- | :--- | :--- | :--- |
| **GET** | 리소스 조회 | O | `GET /clients` (목록), `GET /clients/:id` (상세) |
| **POST** | 리소스 생성, 또는 복잡한 액션 | X | `POST /clients` (생성), `POST /auth/login` (액션) |
| **PUT** | 리소스 전체 수정 (대체) | O | `PUT /users/:id` |
| **PATCH** | 리소스 일부 수정 | O (권장) | `PATCH /clients/:id/status` |
| **DELETE**| 리소스 삭제 | O | `DELETE /clients/:id` |
## 4. 요청 및 응답 형식 (Request & Response)
### 4.1 목록 조회 (List Response)
목록 조회 시 반드시 페이지네이션 메타데이터를 포함해야 합니다.
```json
{
"items": [
{ "id": "1", "name": "Resource A" },
{ "id": "2", "name": "Resource B" }
],
"limit": 50,
"offset": 0,
"total": 120 // 선택적 (성능 이슈 시 제외 가능)
}
```
### 4.2 단건 조회/생성/수정 (Single Resource Response)
데이터를 바로 반환하거나, 필요 시 래핑할 수 있습니다. 일관성을 위해 루트 객체로 반환하는 것을 권장합니다.
```json
{
"id": "1",
"name": "Resource A",
"status": "active"
}
```
### 4.3 에러 응답 (Error Response)
모든 에러는 일관된 포맷을 유지해야 합니다. 프로덕션 환경에서는 내부 스택 트레이스를 노출하지 않아야 합니다.
**HTTP Status Code 활용:**
* `400 Bad Request`: 입력값 검증 실패
* `401 Unauthorized`: 인증 토큰 없음/만료
* `403 Forbidden`: 권한 부족 (토큰은 있으나 접근 불가)
* `404 Not Found`: 리소스 없음
* `409 Conflict`: 데이터 충돌 (중복 생성 등)
* `429 Too Many Requests`: 레이트 리밋 초과
* `500 Internal Server Error`: 서버 내부 오류 (상세 내용 마스킹)
* `503 Service Unavailable`: 외부 의존성(Hydra, DB 등) 연결 실패
**JSON Body:**
```json
{
"error": "사람이 읽을 수 있는 에러 메시지",
"code": "MACHINE_READABLE_CODE", // 1차 표준 필드 (예: invalid_session, rate_limited)
"details": { ... } // 선택적 (Validation error 필드별 상세 등)
}
```
- `code`는 프론트 분기 기준이므로 신규/변경 API에서는 포함을 기본값으로 합니다.
## 5. 헤더 및 보안 (Headers & Security)
### 5.1 인증 (Authentication)
* **Authorization**: `Bearer {token}` 형식을 사용합니다.
* Backend는 Gateway 또는 Middleware에서 토큰을 검증하고 `User Context`를 생성해야 합니다.
### 5.2 테넌트 격리 (Multi-tenancy)
* **X-Tenant-ID**: 관리자 API(`admin`) 호출 시, 대상 테넌트를 식별하기 위해 필수적으로 사용합니다.
* 슈퍼 어드민이 아닐 경우, 요청자의 권한과 헤더의 테넌트가 일치하는지 검증해야 합니다.
### 5.3 요청 추적 (Tracing)
* **X-Request-ID**: 모든 요청/응답에 고유 ID를 포함하여 로그 추적성을 확보합니다. 클라이언트가 보내지 않으면 서버가 생성합니다.
## 6. 개발 가이드라인 (Implementation Guidelines)
### 6.1 DTO 사용
* DB 모델(Gorm, ClickHouse struct)을 그대로 API 응답으로 내보내지 마십시오.
* 반드시 **Response DTO** 구조체를 별도로 정의하여 `json` 태그를 통해 명명 규칙(camelCase)을 적용하고, 민감한 정보(비밀번호 해시, 내부 ID 등)를 필터링해야 합니다.
### 6.2 핸들러 구조
* 핸들러는 `Service` 레이어를 호출하고, HTTP 요청/응답 처리(파싱, 상태 코드 매핑)에만 집중해야 합니다.
* 비즈니스 로직은 `Handler`가 아닌 `Service` 또는 `Domain` 레이어에 위치해야 합니다.
### 6.3 로깅 정책
* 요청/응답 로그는 미들웨어 레벨에서 처리합니다.
* 에러 발생 시 `slog.Error`를 통해 스택 트레이스와 컨텍스트를 남기고, 클라이언트에게는 정제된 메시지만 전달합니다.

50
baron-sso/docs/Gemini.md Normal file
View File

@@ -0,0 +1,50 @@
# Gemini Project Context - Baron SSO
## Project Identity
- **Name**: Baron SSO
- **Organization**: `kr.co.baroncs`
- **Type**: User Authentication Hub & Unified Launcher
- **Core Philosophy**: Secure, Seamless, White-labeled.
## Technical Preferences
- **Language (Backend)**: Go (Golang) 1.25+
- **Framework (Backend)**: Fiber (v2.25+)
- **Database**:
- PostgreSQL (Primary/Meta)
- ClickHouse (Audit Logs - Local/Production)
- **Language (Frontend)**: Dart (Flutter 3.32+)
- **Platforms**: Web (PoC), iOS, Android.
- **Auth Provider**: Descope
- **Method**: Enchanted Link only (No Magic Link).
- **Requirement**: Invisible to end-users (White-labeling).
## Core Scenarios
1. **Same Browser SSO**: Access apps from Baron SSO launcher (logged in state).
2. **Cross-Device Auth**: Approve PC login via Mobile Baron SSO app (Enchanted Link required).
3. **Clean Login**: Email/SMS initial login. Future: OTP, MFA.
## Future Milestones
- **Passkey Support**: Expanded seamless auth for Scenario 2 & 3.
- **MFA Expansion**: OTP integration.
## Coding Standards
- **Go**: Follow standard Go project layout (`cmd`, `internal`, `pkg`). Use Clean Architecture principles where appropriate. Handle errors explicitly.
- **Flutter**: Use Riverpod for state management. Separate UI (Widgets) from Business Logic (Providers/Repositories).
- **General**: Comments in Korean or English (User is Korean speaker).
## Workspace Structure
Root: `/home/lectom/.gemini/antigravity/scratch/baron_sso`
- `/backend`: Go Fiber Application
- `/userfront`: Flutter Application
- `/docs`: Documentation (PRD, API Specs)
## Current Status
- **Planning Phase**: Completed PRD & Architecture.
- **Next**: Backend Setup (Go/Fiber).
## Reference Analysis (Descope Sample App)
- **Source**: `descope-sample-apps/flutter_sample_app_auth_func`
- **Findings**:
- **Auth Check**: Checks `Descope.sessionManager.session?.refreshToken.isExpired`.
- **Note**: Sample focuses on OAuth/OTP. Baron SSO requires **Enchanted Link**, which will use `Descope.auth.enchantedLink.signUpOrIn(...)` (inference based on SDK capability).
- **Architecture**: Simple Provider/State management recommended (Riverpod chosen for Baron SSO).

View File

@@ -0,0 +1,44 @@
# Baron SSO Data SoT (Source of Truth) Architecture Policy
## 1. Core Principle: "Ory Stack is the Single Source of Truth"
Baron SSO 시스템에서 인증(Identity), 인가(Authorization), OAuth2 위임(Delegation)의 데이터 원천은 **Ory Stack (Kratos, Keto, Hydra)** 입니다.
Backend의 로컬 데이터베이스(PostgreSQL)는 성능 최적화, 검색, 감사(Audit), 비즈니스 메타데이터 관리를 위한 **Read-Model** 및 **Cold Storage**의 역할만 수행합니다.
## 2. Component Policies
### 2.1 Identity & User Profile (Ory Kratos)
* **SoT:** Ory Kratos Identity (`traits`, `metadata_public`)
* **Local DB (`users` Table):** **Read-Model & Search Index**
* **목적:** 대규모 사용자 목록의 고속 검색(`LIKE`), 필터링, 정렬, 테넌트 조인(Join) 지원.
* **동기화 전략:** `Async Write-Behind`
* 사용자 생성/수정 API는 Kratos 처리가 성공하면 즉시 성공 응답을 반환합니다.
* 로컬 DB 동기화는 별도 고루틴(Goroutine)에서 비동기로 수행됩니다.
* **장애 격리:** 로컬 DB 장애가 사용자의 로그인/가입 프로세스를 차단하지 않습니다.
### 2.2 Permissions & Relationships (Ory Keto)
* **SoT:** Ory Keto (Relation Tuples)
* **Local DB:**
* 권한 판단 로직을 로컬 DB에 저장하지 않습니다.
* `Tenant`, `TenantGroup` 등 비즈니스 객체의 **생성/삭제 이벤트**를 Keto의 관계(Relation)로 비동기 동기화합니다.
* 모든 권한 검증(`CheckPermission`)은 반드시 Keto API를 통해 실시간으로 수행합니다.
### 2.3 OAuth2 Clients & Sessions (Ory Hydra)
* **SoT:** Ory Hydra (OAuth2 Clients, Access/Refresh Tokens, Consent Sessions)
* **Local DB (`client_secrets`, `client_consents`):** **Backup & Query-Model**
* `client_secrets`: Hydra는 해시된 시크릿만 저장하므로, 시크릿 재발급 및 관리를 위한 **원본 보관소(Cold Storage)**로 사용합니다.
* `client_consents`: Hydra API는 "특정 사용자의 동의 내역" 조회만 지원하므로, "특정 클라이언트의 전체 사용자 동의 목록"을 제공하기 위한 **조회용 모델(Query-Model)**로 사용합니다.
## 3. Data Flow & Synchronization Strategy
### 3.1 Write Path (Command)
1. **Request:** 클라이언트가 Backend API 요청.
2. **Ory Exec:** Backend가 Ory 서비스(Kratos/Hydra/Keto) API를 동기(Synchronous) 호출.
3. **Response:** Ory 성공 시 클라이언트에게 즉시 성공 응답 반환 (SoT 확정).
4. **Sync:** Backend가 비동기(Goroutine)로 로컬 DB 테이블을 갱신.
### 3.2 Read Path (Query)
* **Self Context (내 정보, 내 권한):** Ory Session/Token을 통해 직접 검증하거나 Kratos/Keto를 실시간 조회 (Always Fresh).
* **Admin Context (목록 조회, 검색):** 로컬 DB를 조회하여 빠른 응답 제공 (Eventually Consistent).
### 3.3 Conflict Resolution
* 데이터 불일치가 발견될 경우, 항상 **Ory Stack의 데이터를 기준(Authority)**으로 로컬 DB를 보정(Self-healing)합니다.

View File

@@ -0,0 +1,92 @@
# 린트 체크 및 테스트 실행 가이드
이 문서는 Baron SSO 프로젝트의 각 모듈별 정적 분석(Lint) 및 테스트 수행 방법을 설명합니다.
## 1. 준비 사항
테스트를 실행하기 위해 다음 도구들이 설치되어 있어야 합니다.
- **Docker & Docker Compose** (백엔드 인프라 의존성용)
- **Go 1.26.2+**
- **Flutter SDK**
- **Node.js 24 LTS+**
---
## 2. 모듈별 실행 명령어
### 2.1 Backend (Go)
백엔드 테스트는 Redis와 ClickHouse 컨테이너가 실행 중이어야 합니다.
```bash
# 인프라 서비스 실행
docker compose -f compose.infra.yaml up -d redis clickhouse
cd backend
# 린트 및 포맷 확인
go fmt ./...
go vet ./...
# 유닛 테스트 실행
export REDIS_ADDR=localhost:6379 CLICKHOUSE_HOST=localhost CLICKHOUSE_PORT_NATIVE=9000
go test ./...
```
### 2.2 Userfront (Flutter)
```bash
cd userfront
# 코드 포맷 확인
dart format --output=none --set-exit-if-changed lib test
# 정적 분석 (경고/정보 메시지는 무시)
flutter analyze --no-fatal-warnings --no-fatal-infos
# 유닛 및 위젯 테스트
flutter test
```
### 2.3 Adminfront & Devfront (React)
Biome을 사용하여 린트 및 포맷팅을 관리하며, Playwright로 E2E 테스트를 수행합니다.
```bash
cd adminfront # 또는 cd devfront
# 린트 및 포맷 자동 수정
npx biome check --write .
# 안전하지 않은 규칙까지 포함하여 자동 수정 (필요 시)
npx biome check --write --unsafe .
# E2E 테스트 실행
npm test
```
### 2.4 i18n 검증
코드 내에서 사용되는 다국어 키와 `locales/*.toml` 파일 간의 정합성을 검증합니다.
```bash
# 프로젝트 루트에서 실행
node tools/i18n-scanner/index.js
node tools/i18n-scanner/report.js
# 결과 확인
cat reports/i18n-report.txt
```
---
## 3. 통합 실행 (Batch)
프로젝트 루트의 스크립트를 통해 전체 과정을 한 번에 시도할 수 있습니다.
```bash
bash run_local_checks.sh
```
---
## 4. 정리 (Cleanup)
테스트 완료 후 실행 중인 테스트용 인프라 서비스를 종료합니다.
```bash
docker compose -f compose.infra.yaml down
```

View File

@@ -0,0 +1,118 @@
# UI 버튼 위치 및 정렬 정책 (UI Button Placement Policy)
본 문서는 Baron SSO 프로젝트 내 모든 프론트엔드 애플리케이션(`userfront`, `devfront`, `adminfront`)에서 일관된 사용자 경험(UX)을 제공하기 위한 UI 버튼 배치 및 정렬 가이드라인을 정의합니다. (관련 이슈: [#308](https://gitea.hmac.kr/baron/baron-sso/issues/308))
## 1. 버튼 종류별 위치 (Button Placement by Type)
버튼의 성격에 따라 다음과 같이 배치합니다.
* **Primary Action (주요 동작)**
* **예시**: 저장, 확인, 제출, 생성 등
* **위치**: 우측 하단 (Bottom Right) 또는 모달/다이얼로그의 우측 끝에 배치합니다. 사용자의 시선 흐름(좌에서 우, 위에서 아래)에 따라 최종 액션을 우측 하단에서 마무리하도록 유도합니다.
* **Secondary Action (보조 동작)**
* **예시**: 취소, 닫기, 이전으로 등
* **위치**: Primary 버튼의 바로 **좌측**에 배치합니다.
* **Destructive Action (파괴적 동작)**
* **예시**: 삭제, 초기화, 권한 해제 등
* **위치 및 스타일**: 붉은색(Red/Destructive) 스타일을 적용하여 시각적으로 명확히 구분합니다. Primary/Secondary 그룹과 물리적으로 분리하거나 (예: 좌측 끝 배치), Secondary 액션 위치에 두되 색상으로 강력한 경고를 줍니다.
## 2. 정렬 기준 (Alignment Rules)
* **폼(Form) 하단 버튼 그룹**
* **기본 정렬**: 우측 정렬 (Right-aligned). "취소"는 왼쪽, "저장"은 오른쪽에 위치합니다. `[ 취소 ] [ 저장 ]`
* **리스트 아이템 내부 액션 버튼**
* **기본 정렬**: 리스트/테이블의 각 행(Row) 우측 끝에 배치합니다.
* 버튼 개수가 많을 경우 (3개 이상), 툴팁이나 Dropdown 메뉴(예: 햄버거 버튼 또는 "더보기" 아이콘)로 숨겨 UI 복잡도를 낮춥니다.
## 3. 반응형 고려 (Responsive Design)
* **모바일 환경 (Mobile / Small Screens)**
* 화면 너비가 좁은 모바일 기기(예: `userfront` 앱 환경, `devfront`/`adminfront`의 모바일 뷰)에서는 버튼 그룹을 **Full Width (화면 가득 채움)**로 변경하여 터치 영역을 확보합니다.
* 여러 개의 버튼이 있는 경우 세로로 스택(Stack)하며, **Primary Action을 맨 위**에, Secondary Action을 그 아래에 배치합니다.
* *데스크탑*: `[ 취소 ] [ 확인 ]`
* *모바일*:
```
[ 확인 ]
[ 취소 ]
```
## 4. 로딩 및 피드백 (Loading & Feedback)
* **중복 제출 방지**: 폼 전송이나 API 호출을 발생시키는 버튼을 클릭하면 즉각적으로 버튼을 비활성화(Disabled) 상태로 변경하여 다중 클릭을 방지합니다.
* **로딩 스피너**: 버튼 내부에 로딩 스피너(Spinner)를 표시하여 사용자에게 진행 상황을 시각적으로 알립니다.
* **스켈레톤 로딩(Skeleton Loading)**: 화면 진입 시 전체 데이터를 로딩해야 하는 경우, 무의미한 빈 화면(빈 공간) 대신 스켈레톤 UI를 사용하여 로딩 중임을 직관적으로 알리고 체감 대기 시간을 줄입니다.
* **작업 결과 안내**: 성공, 실패 등의 결과는 Toast 메시지 (혹은 스낵바)를 통해 화면 하단/상단에 일시적으로 노출하여 사용자가 흐름을 끊지 않고도 인지할 수 있게 돕습니다.
## 5. 빈 상태 처리 (Empty State)
* **빈 목록 안내**: 테이블이나 리스트에 표시할 항목이 없는 경우 단순히 빈 화면으로 두지 않고 중앙 정렬된 아이콘이나 일러스트와 함께 "데이터가 없습니다." 등의 명확한 문구를 표시합니다.
* **콜 투 액션(Call to Action)**: 데이터가 비어 있는 경우 생성 버튼(Primary Action)을 빈 상태 안내 영역 아래에 배치하여 사용자가 즉시 데이터를 추가할 수 있도록 유도합니다.
## 6. 오류 표시 (Error Handling)
* **인라인(Inline) 오류**: 폼(Form)의 유효성 검사에서 실패한 경우, 각 입력 필드 바로 아래에 붉은색 텍스트로 실패 원인을 명확하게 표시합니다.
* **포커스 이동**: 제출 버튼 클릭 시 오류가 있는 첫 번째 입력 필드로 자동 스크롤 하거나 포커스(Focus)를 이동시켜 수정이 용이하게 합니다.
## 7. 접근성 (Accessibility - a11y)
* **포커스 링(Focus Ring)**: 키보드를 통해 탐색(Tab)하는 사용자를 위해 버튼, 텍스트 입력창 등에 포커스가 갈 경우 외곽선을 명확히 렌더링(예: 파란색 테두리 등)해야 합니다. `outline: none`을 무분별하게 사용하지 않습니다.
* **대체 텍스트**: 텍스트 없이 아이콘만 존재하는 버튼(예: X 형태의 닫기 버튼)의 경우 반드시 `aria-label` 속성(또는 Flutter의 `Semantics`)을 사용하여 스크린 리더 사용자가 해당 버튼의 역할을 알 수 있게 해야 합니다.
## 8. 프론트엔드 환경별 구현 가이드 (Implementation Guide)
현재 운영 중인 프론트엔드 환경에 맞춘 구현 가이드라인입니다.
### 8.1. React 환경 (`devfront`, `adminfront`)
Tailwind CSS 기반의 컴포넌트를 사용하여 아래와 같이 구현합니다.
* **버튼 그룹 우측 정렬 (데스크탑)**: `flex justify-end gap-2`
* **반응형 (모바일 세로 배치, 데스크탑 가로 배치)**: `flex flex-col-reverse sm:flex-row sm:justify-end gap-2`
*(참고: `flex-col-reverse`를 사용하면 코드 상 먼저 작성된 취소 버튼이 모바일에서는 아래로, 나중에 작성된 확인 버튼이 위로 올라가게 배치할 수 있습니다.)*
* **코드 예시**:
```tsx
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 mt-4">
<Button variant="outline" onClick={onCancel} disabled={isLoading}>취소</Button>
<Button variant="default" onClick={onSave} disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
저장
</Button>
</div>
```
### 8.2. Flutter 환경 (`userfront`)
Flutter 프레임워크를 사용하는 환경에서는 화면 너비에 따라 위젯 구성을 동적으로 처리해야 합니다.
* **폼 하단 정렬**: `Row` 위젯과 `MainAxisAlignment.end` 사용.
* **반응형 대응**: 화면 너비(MediaQuery)에 따라 `Row`를 전체 너비를 채우는 `Column`으로 스위칭하거나, `OverflowBar` 위젯 등을 활용할 수 있습니다.
* **코드 예시**:
```dart
// 데스크탑/태블릿용 (우측 정렬)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(onPressed: isLoading ? null : onCancel, child: const Text('취소')),
const SizedBox(width: 8),
ElevatedButton(
onPressed: isLoading ? null : onSave,
child: isLoading
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('확인')
),
],
)
// 모바일용 (전체 너비 세로 배치)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ElevatedButton(
onPressed: isLoading ? null : onSave,
child: isLoading
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('확인')
),
const SizedBox(height: 8),
TextButton(onPressed: isLoading ? null : onCancel, child: const Text('취소')),
],
)
```

View File

@@ -0,0 +1,79 @@
# Auto Login Policy (P0: Mobile Installed Browser App)
## 1. 목적
- 모바일 환경에서 브라우저 앱 설치(standalone/PWA, 홈 화면 실행)를 **최우선 경로(P0)** 로 정의합니다.
- 사용자 관점에서 앱 재실행/새로고침/짧은 백그라운드 복귀 시 재로그인을 최소화합니다.
- 기존 IDP abstraction 원칙을 유지하면서 자동 로그인 동작을 표준화합니다.
## 2. 범위
- 포함:
- UserFront 웹 실행 컨텍스트(설치형 앱, 일반 모바일 브라우저 탭, 데스크톱 브라우저)
- 백엔드 `/api/v1/auth/*`, `/api/v1/user/me` 기반 세션 확인/유지 정책
- 제외:
- 네이티브 Flutter 앱 바이너리 영속 저장소 상세 설계
- Ory 내부 스택 교체/확장 설계
## 3. 플랫폼 우선순위
1. P0: 모바일 브라우저 앱 설치(standalone/PWA)
2. P1: 모바일 브라우저 탭(Safari/Chrome 일반 탭)
3. P2: 데스크톱 브라우저
4. P3: 네이티브 Flutter 앱
## 4. 세션 정책
### 4.1 기본 원칙
- 자동 로그인 판단은 `cookie-first`로 수행합니다.
- token은 보조 수단이며, cookie 세션 확인 성공 시 cookie mode를 우선 적용합니다.
- 사용자 액션 기반 슬라이딩(30일)은 서버를 SoT로 유지합니다.
### 4.2 저장소 fallback 정책 (웹)
- 인증 보조 상태 저장소는 아래 순서를 따릅니다.
- `localStorage -> sessionStorage -> memory`
- 저장소 접근 실패(브라우저 정책/권한/프라이버시 모드) 시 다음 저장소로 자동 fallback 합니다.
### 4.3 금지사항
- Front에서 특정 IDP 벤더 SDK를 직접 연동해 세션 로직을 구현하지 않습니다.
- 자동 로그인 유지 로직을 클라이언트 로컬 타이머/임의 만료 계산만으로 확정하지 않습니다.
- 만료 판단은 반드시 서버 응답(예: `/api/v1/user/me`) 기준으로 확정합니다.
## 5. 수용기준 (Acceptance Criteria)
1. 설치형 모바일 브라우저 앱에서 로그인 후 앱 재실행 시 `signin`으로 튕기지 않고 보호 화면으로 진입합니다.
2. 최근 30일 내 활동이 있으면 세션 만료가 연장됩니다.
3. 30일 초과 무활동이면 세션 만료(401) 후 로그인 화면으로 이동합니다.
4. OIDC `login_challenge` 플로우에서 자동 승인/리다이렉트 회귀가 없어야 합니다.
5. cookie 세션과 token 세션이 충돌하지 않아야 합니다.
## 6. 테스트 정책 (Failing Test First)
### 6.1 단위 테스트
- 저장소 fallback 정책:
- local 실패 시 session에서 read/write 가능해야 합니다.
- clear 시 local/session/memory가 함께 정리되어야 합니다.
- cookie 승격 정책:
- 일반 로그인/`login_challenge` 유무에 따른 승격 조건 회귀를 방지합니다.
### 6.2 E2E 테스트 (WASM)
- 현재 한계:
- `userfront-e2e` 기본 설정은 `Desktop Chrome` + `serviceWorkers: block`입니다.
- 설치형 앱 시나리오를 직접 재현하기 어렵습니다.
- 보완 방향:
- 모바일 viewport 프로젝트 추가
- service worker 허용 시나리오 추가
- standalone 실행 가정 시나리오 추가
### 6.3 실기기 검증 (필수)
- Android A2HS, iOS 홈 화면 앱 각각에서 최소 1회 검증합니다.
- 이유:
- 브라우저 엔진/OS별 저장소/쿠키 정책 차이를 Playwright만으로 완전 대체할 수 없습니다.
## 7. 구현 체크리스트
- [ ] 웹 저장소 fallback 정책 구현/테스트
- [ ] cookie-first 자동 로그인 경로 회귀 테스트
- [ ] 슬라이딩 30일 서버 정책 테스트(단위 + E2E)
- [ ] 실기기 검증 결과를 `docs/test-plan/*`에 기록
## 8. 관련 문서
- `docs/AGENTS.md`
- `https://gitea.hmac.kr/baron/baron-sso/wiki/Authentication-and-Login-Flow.-`
- `docs/consent_loop_fix_report.md`
- `https://gitea.hmac.kr/baron/baron-sso/wiki/Test-Plan-and-Principles.-`

View File

@@ -0,0 +1,59 @@
# 가족사 테넌트 가입 및 관리 정책 (인증 기반 수정안)
이 문서는 보안 강화를 위해 **이메일 인증 성공 시에만 가족사 소속을 선택**할 수 있도록 변경된 가입 흐름을 설명합니다.
## 회원가입 및 권한 관리 흐름도
```mermaid
graph TD
%% 시작점
A([사용자 회원가입 시작]) --> B[이메일 입력 및 인증 코드 발송]
B --> C{이메일 인증 성공?}
C -- No --> B
C -- Yes --> D{인증된 이메일이<br>내부/가족사 도메인인가?}
%% 일반 도메인 (gmail, naver 등)
D -- No<br>(External) --> E[개인 테넌트 자동 할당<br>Type: PERSONAL]
E --> J
%% 내부 도메인 (hanmaceng.co.kr 등)
D -- Yes<br>(Internal) --> F[가족사 목록 노출 및 선택<br>Select Company Code]
F --> G{선택한 코드가<br>ACTIVE 상태인가?}
G -- No --> F
G -- Yes --> J[Ory Kratos 계정 생성]
%% 유저 생성 및 권한 할당
J --> K[(Local DB 유저 레코드 생성)]
K --> N[기본 권한 할당: user<br>Keto: members 부여]
N --> O([회원가입 완료])
%% 관리자 수동 개입 (별도 흐름)
P((최고 관리자<br>Super Admin)) -.-> Q[사용자 역할 변경<br>user -> tenant_admin]
Q -.-> R[(Keto 권한 수동 할당<br>owners, admins 부여)]
%% 스타일링
classDef process fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
classDef decision fill:#fff3e0,stroke:#e65100,stroke-width:2px;
classDef db fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px;
classDef startend fill:#f3e5f5,stroke:#4a148c,stroke-width:2px;
classDef admin fill:#f5f5f5,stroke:#616161,stroke-width:2px,stroke-dasharray: 5 5;
class A,O startend;
class B,F,J,N,Q process;
class C,D,G decision;
class E,K,R db;
class P admin;
```
## 핵심 정책 변경 사항
1. **선(先)인증 후(後)선택:** 사용자는 이메일 소유권 인증(OTP 또는 인증 링크)을 완료하기 전까지는 어떠한 가족사 소속도 선택할 수 없습니다.
2. **도메인 기반 노출 제어:**
- 인증된 이메일 도메인이 시스템에 등록된 가족사 도메인(`hanmaceng.co.kr` 등)일 경우에만 소속 선택 UI가 활성화됩니다.
- 일반 외부 도메인(gmail, naver 등)은 `PERSONAL` 테넌트로 강제 배정되어 가족사 리스트 자체가 노출되지 않습니다.
3. **이메일 도메인 중복 방지:** 같은 도메인을 쓰더라도 다른 소속일 수 있는 경우(예: 협력사 등)를 대비하여, 인증 성공 후에도 사용자가 직접 본인의 정확한 소속(`Company Code`)을 선택하게 하여 데이터 무결성을 확보합니다.
4. **수동 권한 위임 유지:** 모든 가입자는 기본적으로 `user` 권한을 부여받으며, 테넌트 관리자(`tenant_admin`)나 오너(`owner`) 권한은 지주사 관리자가 사용자의 신원을 최종 확인한 후 수동으로 부여합니다.
5. **실시간 상태 검증:** 가입 시점에 선택한 테넌트가 `ACTIVE` 상태가 아닐 경우 가입 진행을 차단합니다.

View File

@@ -0,0 +1,74 @@
# Backend Log Policy
## 1. 목적
- Backend 서버 로그의 기본 레벨과 운영 중 일시적 디버그 확장 규칙을 정의합니다.
- 운영 환경에서 과도한 로그 노출을 피하면서, 장애 분석이 필요한 경우에는 명시적으로 진단 로그를 확장할 수 있게 합니다.
## 2. 기준 변수
- `APP_ENV`
- `BACKEND_LOG_LEVEL` (선택 override)
## 3. 기본 동작
### 3.1 기본 레벨 결정
- `APP_ENV=dev|local|development`
- 기본 로그 레벨: `debug`
- 기본 출력 형식: text
- 그 외 환경(`stage`, `production`, `prod` 등)
- 기본 로그 레벨: `info`
- 기본 출력 형식: JSON
### 3.2 명시 override
- `BACKEND_LOG_LEVEL`이 설정되면 `APP_ENV` 기본값보다 우선합니다.
- 허용 값:
- `debug`
- `info`
- `warn`
- `error`
예시:
```env
APP_ENV=stage
BACKEND_LOG_LEVEL=debug
```
위 설정이면 stage 환경이더라도 backend `slog``debug`로 동작합니다.
## 4. 운영 가이드
- 운영/스테이징에서 장애 분석이 필요할 때만 `BACKEND_LOG_LEVEL=debug`를 일시적으로 설정합니다.
- 이슈 분석이 끝나면 즉시 기본값으로 되돌리는 것을 권장합니다.
- 기본 레벨(`info`)에서는 핵심 상태 변화와 경고/오류를 중심으로 남기고, 디버그 전용 진단 필드는 숨깁니다.
## 5. Headless Login 진단 로그
- `POST /api/v1/auth/headless/password/login` 같은 headless 로그인 경로는 기본적으로 `reason_code` 중심의 구조화 로그를 남깁니다.
- `BACKEND_LOG_LEVEL=debug` 또는 `APP_ENV=dev|local|development`일 때만 아래 진단 필드를 추가로 남깁니다.
- `expected_audiences`
- `received_audiences`
- `received_kid`
- `claim_issuer`
- `claim_subject`
- `claim_expires_at`
- `claim_not_before`
- `claim_issued_at`
- 민감 정보는 계속 로그에 남기지 않습니다.
- raw `client_assertion`
- password
- session token / cookie
## 6. 구현 위치
- logger 초기화: `backend/cmd/server/main.go`
- 레벨 결정 로직: `backend/internal/logger/logger.go`
- headless 로그인 debug 진단 필드: `backend/internal/handler/auth_handler.go`
## 7. 검증
```bash
cd backend
go test ./internal/logger -v
go test ./cmd/server -run 'TestNewErrorHandler_' -v
go test ./internal/handler -run 'TestHeadlessPasswordLogin_(DebugLogIncludesDiagnostics|InfoLogOmitsDebugDiagnostics)$' -v
```
## 8. 관련 문서
- `README.md`
- `docs/client-log-policy.md`
- wiki update draft: `docs/wiki-error-handling-policy-backend-log-update.md`

View File

@@ -0,0 +1,590 @@
# Baron SSO 전체 시스템 백업 및 복구 설계
## 목적
Baron SSO의 전체 시스템 백업/복구는 CSV export/import의 확장판이 아니라, 서비스 저장소 전체를 일관된 시점으로 보존하고 복원하는 재해 복구 기능이다.
핵심 목표는 다음과 같다.
- 사용자, 조직, 권한, RP, WORKS 연동 참조에 쓰이는 UUID를 그대로 보존한다.
- Kratos identity subject와 Baron local user ID가 어긋나지 않게 복구한다.
- Hydra/Keto/Oathkeeper 기반 인증/인가 상태를 서비스 가능한 수준으로 복원한다.
- 복구 후 WORKS externalKey 기반 비교/동기화가 기존 연동과 이어지도록 한다.
- 백업 산출물의 무결성, 보안, 복구 가능성을 검증 가능한 절차로 만든다.
## 배경과 결론
사용자 CSV는 `user_id`를 포함해 내보낼 수 있지만, 실제 사용자 계정의 주체 ID는 Kratos identity ID다. Kratos Admin API의 identity 생성 요청은 `id` 필드를 받지 않으므로, CSV import만으로 기존 사용자 UUID를 보장할 수 없다.
따라서 사용자 UUID 보존이 필요한 복구는 반드시 Kratos DB까지 포함한 full backup/restore로 처리해야 한다. CSV import는 운영 편의 기능으로 유지하되, 재해 복구나 WORKS 연동 보존 목적의 원장 복구 수단으로 간주하지 않는다.
## 백업 대상
### 필수 저장소
| 대상 | 저장소 | 포함 이유 | 복구 필수 여부 |
| --- | --- | --- | --- |
| Baron 애플리케이션 DB | `baron_postgres` | users, tenants, user_login_ids, user_groups, api_keys, outbox, WORKS mapping, RP metadata 등 | 필수 |
| Kratos DB | `ory_postgres``ory_kratos` | identity UUID, credentials, verifiable/recovery addresses, sessions | 필수 |
| Hydra DB | `ory_postgres``ory_hydra` | OAuth2 clients, consent, token/session 관련 상태 | 필수 |
| Keto DB | `ory_postgres``ory_keto` | ReBAC relation tuple 원장 | 필수 |
| Baron ClickHouse | `baron_clickhouse` | 감사 로그, 운영 추적 데이터 | 운영 정책상 필수 |
| Ory ClickHouse | `ory_clickhouse` | Ory/Oathkeeper/Vector 계열 로그 | 운영 정책상 필수 |
| 설정/비밀값 | `.env`, generated Ory config, WORKS private key, gateway/Oathkeeper config | 동일 환경 재기동과 외부 연동에 필요 | 필수 |
### 선택 저장소
| 대상 | 처리 원칙 |
| --- | --- |
| Redis | 로그인 pending state, cache, short code 등 휘발성 데이터다. full restore에서는 원칙적으로 제외하고 재시작 시 비운다. 무중단 이전 시나리오에서만 snapshot을 검토한다. |
| 프론트 빌드 산출물 | 소스/이미지 태그로 재생성한다. 별도 보관은 배포 재현성을 위한 선택 항목이다. |
| 로컬 개발 산출물 | reports, coverage, test-results 등은 백업 대상에서 제외한다. |
## 백업 산출물 형식
백업 단위는 압축된 디렉터리 또는 object storage prefix로 관리한다.
```text
baron-sso-backup-YYYYMMDD-HHMMSSZ/
manifest.json
checksums.sha256
postgres/
baron.dump
ory_kratos.dump
ory_hydra.dump
ory_keto.dump
globals.sql
clickhouse/
baron_clickhouse/
ory_clickhouse/
config/
env.redacted
env.encrypted
ory/
gateway/
reports/
row-counts.json
restore-readiness.json
```
`manifest.json`에는 최소한 다음 정보를 기록한다.
- backup format version
- 생성 시각, git commit, 이미지 태그
- 서비스별 DB schema/migration version
- 각 dump 파일의 checksum, 크기, 생성 명령 버전
- 백업 모드: `offline`, `maintenance`, `online-best-effort`
- 암호화 방식과 key id
- 복구 대상 환경 제한: `same-env-only`, `staging-rehearsal`, `cross-env`
## Dump 대상별 복구 flow 및 영향도
`make dump`의 서비스 필터는 저장소별 복구 단위를 명확히 나누기 위한 운영 인터페이스다. 전체 재해 복구는 `DUMP_SERVICES=all`을 기본으로 하되, staging rehearsal이나 부분 장애 분석에서는 개별 대상을 분리해서 검증할 수 있다.
### 대상별 요약
| 서비스 필터 | 주요 dump 산출물 | 포함 데이터 | 복구 중요도 | 복구 영향도 |
| --- | --- | --- | --- | --- |
| `postgres` | `postgres/baron.dump` | Baron users, tenants, membership, user_login_ids, user_groups, RP metadata, API keys, WORKS mapping/outbox, Keto outbox, consent projection 등 | 필수 | Baron control plane의 원장이다. 누락되면 사용자/테넌트/RP/WORKS 참조가 끊기고 Ory DB만 복구해도 서비스 의미가 깨진다. |
| `ory-postgres` | `postgres/globals.sql`, `postgres/ory_kratos.dump`, `postgres/ory_hydra.dump`, `postgres/ory_keto.dump` | Kratos identity/credential/session, Hydra client/consent/token state, Keto relation tuple | 필수 | 인증 주체, OAuth2/OIDC 상태, ReBAC 권한 원장이다. Baron DB와 시점이 다르면 로그인/인가/consent 불일치가 발생한다. |
| `clickhouse` | `clickhouse/baron_clickhouse/schema/*.sql`, `clickhouse/baron_clickhouse/data/*.native` | Baron audit_logs, RP usage event/aggregate 등 | 운영 정책상 필수 | 인증 자체를 막지는 않지만 감사 추적, 사용량 집계, 사고 분석 이력이 손실된다. |
| `ory-clickhouse` | `clickhouse/ory_clickhouse/schema/*.sql`, `clickhouse/ory_clickhouse/data/*.native` | Oathkeeper/Ory/Vector 접근 로그 | 운영 정책상 필수 | Ory edge 접근 로그와 장애 분석 근거가 손실된다. 인증 원장은 Postgres에 있으므로 직접 로그인 기능 영향은 제한적이다. |
| `config` | `config/env.redacted`, `config/generated-ory.*`, `config/gateway.*`, `config/compose/*` | 환경 변수 redacted snapshot, generated Ory config, gateway/Oathkeeper 설정, compose 파일 | 필수 | DB만 복구해도 secret/config가 맞지 않으면 Ory Stack, WORKS 연동, callback URL, gateway routing이 정상 기동하지 못한다. |
### `postgres`: Baron 애플리케이션 DB
Dump flow:
1. `baron_postgres` 컨테이너 실행 상태를 확인한다.
2. `${DB_NAME:-baron_sso}``pg_dump -Fc``postgres/baron.dump`에 저장한다.
3. `pg_stat_user_tables` 기준 row count를 `reports/baron-postgres-row-counts.txt`에 기록한다.
4. `checksums.sha256`에 dump와 report checksum을 기록한다.
Restore flow:
1. restore 전용 빈 Baron Postgres DB를 준비한다.
2. `make restore ... RESTORE_SERVICES=postgres CONFIRM_RESTORE=baron-sso``pg_restore --clean --if-exists`를 실행한다.
3. 복구 후 backend migration 자동 실행은 금지하고, dump 시점의 schema version과 현재 binary가 호환되는지 먼저 확인한다.
4. Kratos identity와 Baron `users.id`의 참조 검증을 수행한다.
5. WORKS relay, Keto outbox relay는 post-restore 검증 전까지 재개하지 않는다.
영향도:
- 사용자, 테넌트, 소속, RP metadata, WORKS mapping의 기준 원장이므로 full restore에서 가장 먼저 Baron/Ory 시점 정합성을 확인해야 한다.
- Baron DB만 과거 시점으로 되돌리면 Kratos identity, Hydra consent, Keto tuple과 불일치할 수 있다.
- WORKS mapping/outbox 상태가 과거로 돌아가면 외부 WORKS와 중복 upsert/delete 후보가 생길 수 있으므로 comparison dry-run이 필수다.
검증 포인트:
- `users.id`와 Kratos `identities.id` 일치
- `tenants.parent_id`, membership, user group 참조 무결성
- RP metadata와 Hydra client 연결성
- WORKS mapping의 BaronResourceID 참조 유효성
### `ory-postgres`: Kratos/Hydra/Keto DB
Dump flow:
1. `ory_postgres` 컨테이너 실행 상태를 확인한다.
2. `pg_dumpall --globals-only`로 role/권한 정보를 `postgres/globals.sql`에 저장한다.
3. `${KRATOS_DB:-ory_kratos}`, `${HYDRA_DB:-ory_hydra}`, `${KETO_DB:-ory_keto}`를 각각 `pg_dump -Fc`로 저장한다.
4. 각 DB의 row count report를 `reports/ory_*-row-counts.txt`에 기록한다.
Restore flow:
1. restore 전용 빈 Ory Postgres DB를 준비한다.
2. 필요 시 `globals.sql`을 먼저 적용한다.
3. Kratos, Hydra, Keto DB를 각각 복구한다.
4. migration은 자동 실행하지 않고, Ory binary version과 dump schema version을 확인한다.
5. Ory Stack을 backend보다 먼저 기동해 admin/public endpoint health를 확인한다.
영향도:
- Kratos DB는 사용자 subject와 credential 원장이므로 누락되면 비밀번호, recovery/verifiable address, identity UUID 보존이 불가능하다.
- Hydra DB는 client, consent, OAuth2 token/session 상태를 담으므로 누락되면 기존 RP 로그인/consent 상태가 재생성되거나 만료될 수 있다.
- Keto DB는 ReBAC tuple 원장이므로 누락되면 사용자/테넌트/RP 권한 판단이 실패한다.
- Ory DB만 복구하고 Baron DB가 맞지 않으면 identity는 있으나 Baron user가 없거나, Baron user는 있으나 identity가 없는 상태가 된다.
검증 포인트:
- Kratos identity 수와 Baron users 수 비교
- Hydra client와 Baron RP metadata 비교
- Keto tuple subject/object가 복구된 사용자/테넌트/RP를 참조하는지 확인
- 대표 사용자 password login, 대표 RP OIDC login/consent smoke
### `clickhouse`: Baron ClickHouse
Dump flow:
1. `baron_clickhouse` 컨테이너 실행 상태를 확인한다.
2. `system.tables`에서 Baron ClickHouse table 목록과 engine을 조회한다.
3. 일반 table은 `SHOW CREATE TABLE``FORMAT Native` 데이터를 함께 저장한다.
4. view 계열 engine은 restore insert 위험을 피하기 위해 schema만 저장한다.
5. table별 row count를 `reports/baron_clickhouse-row-counts.txt`에 기록한다.
Restore flow:
1. restore 전용 ClickHouse DB를 준비한다.
2. database를 생성한 뒤 table schema를 먼저 적용한다.
3. 일반 table의 `.native` 데이터를 insert한다.
4. materialized view나 view는 schema만 복구하고, 대상 table 데이터가 들어간 뒤 재계산/재생성 정책을 별도로 확인한다.
5. row count와 주요 기간의 min/max timestamp를 비교한다.
영향도:
- Baron audit log와 RP usage 집계가 손실되면 보안 감사, 운영 추적, 사용량 화면의 신뢰도가 떨어진다.
- 인증 기능 자체의 필수 원장은 아니지만, 사고 대응과 운영 증적 측면에서는 필수 백업 대상이다.
- aggregate/materialized view는 원본 event table과 복구 순서가 맞지 않으면 중복 집계나 누락 집계가 생길 수 있다.
검증 포인트:
- `audit_logs`, `rp_usage_events`, aggregate table row count 비교
- 주요 기간별 min/max timestamp 비교
- AdminFront/DevFront의 사용량 조회 smoke
### `ory-clickhouse`: Ory ClickHouse
Dump flow:
1. `ory_clickhouse` 컨테이너 실행 상태를 확인한다.
2. Ory/Oathkeeper/Vector 계열 table schema와 일반 table data를 저장한다.
3. table별 row count를 `reports/ory_clickhouse-row-counts.txt`에 기록한다.
Restore flow:
1. restore 전용 Ory ClickHouse DB를 준비한다.
2. schema를 적용한 뒤 일반 table data를 insert한다.
3. Vector/Oathkeeper 기동 후 신규 로그가 같은 table로 유입되는지 확인한다.
영향도:
- Ory edge 접근 로그와 gateway 관측 자료가 손실된다.
- 인증/인가 원장은 Postgres에 있으므로 서비스 기동 자체 영향은 제한적이다.
- 보안 사고 분석, OIDC redirect 장애 분석, rate/anomaly 분석에는 영향이 크다.
검증 포인트:
- Oathkeeper access log row count 비교
- Oathkeeper 경유 요청 후 신규 로그 유입 확인
- Vector pipeline 재기동 후 error log 확인
### `config`: 설정 snapshot
Dump flow:
1. `.env`가 있으면 secret 성격 key를 `REDACTED`로 치환해 `config/env.redacted`를 만든다.
2. `config/.generated/ory`, `gateway`, 주요 compose 파일을 tar snapshot 또는 file copy로 보존한다.
3. 실제 secret 원문은 1차 로컬 구현의 `env.redacted`에 포함하지 않는다. 운영 백업에서는 별도 암호화 산출물로 보관해야 한다.
Restore flow:
1. `make restore ... RESTORE_SERVICES=config` 실행 시 운영 파일을 직접 덮어쓰지 않는다.
2. snapshot은 `config-restored/`에 풀어 운영자가 diff로 검토한다.
3. secret 원문은 승인된 secret manager 또는 암호화 백업에서 별도 복원한다.
4. `make validate-auth-config`, `make verify-auth-config`로 callback/redirect/gateway mapping을 검증한다.
5. Ory Stack과 gateway를 기동한 뒤 대표 callback과 OIDC flow를 확인한다.
영향도:
- DB가 정상이어도 Hydra system secret, Kratos config, Oathkeeper rule, WORKS private key, callback URL이 맞지 않으면 서비스가 정상 동작하지 않는다.
- `env.redacted`만으로는 운영 복구가 완성되지 않는다. 운영 복구용 secret은 별도 암호화 저장소가 필요하다.
- config를 운영 파일에 바로 덮어쓰면 현재 환경의 도메인, callback, gateway rule을 깨뜨릴 수 있으므로 수동 검토가 기본이다.
검증 포인트:
- `make validate-auth-config`
- `make verify-auth-config`
- gateway/Oathkeeper route smoke
- WORKS credential dry-run 또는 외부 호출 차단 상태 검증
### 제외 대상의 복구 영향
| 제외 대상 | 제외 이유 | 복구 후 영향 | 운영 대응 |
| --- | --- | --- | --- |
| Redis | cache, pending login, short code 등 휘발성 상태 | 진행 중인 login/link/short code flow가 만료되거나 재시작된다. | 사용자에게 재시도 안내, 서비스 기동 후 cache 재수렴 확인 |
| 프론트 빌드 산출물 | 소스와 이미지 태그로 재생성 가능 | 기존 정적 파일을 그대로 보존하지 않는다. | 동일 commit/image tag로 재배포 |
| 로컬 reports/coverage/test-results | 운영 원장이 아님 | 운영 서비스 영향 없음 | CI artifact나 별도 관측 저장소 기준으로 확인 |
### 전체 복구 순서와 의존성
Full restore는 다음 순서를 기본으로 한다.
1. restore 전용 빈 환경과 volume을 준비한다.
2. `config` snapshot을 `config-restored/`에 풀고 운영자가 secret/config 차이를 검토한다.
3. `ory-postgres`를 복구한다.
4. `postgres`를 복구한다.
5. `ory-clickhouse``clickhouse`를 복구한다.
6. `make dump-verify``make restore-verify`로 산출물 무결성을 확인한다.
7. `make validate-auth-config`, `make verify-auth-config`로 Ory/gateway 설정을 확인한다.
8. Ory Stack을 먼저 기동하고 Kratos/Hydra/Keto health를 확인한다.
9. backend와 app을 기동한다.
10. super admin login, 일반 사용자 login, 대표 RP OIDC login/consent를 확인한다.
11. WORKS comparison dry-run을 수행한다.
12. 대량 delete/upsert 위험이 없을 때 relay worker와 외부 동기화를 재개한다.
부분 복구는 원칙적으로 장애 분석 또는 rehearsal에서만 사용한다. 운영에서 특정 저장소만 과거 시점으로 되돌리면 cross-store 정합성이 깨질 수 있으므로, 실제 운영 restore는 같은 backup directory에서 나온 동일 시점의 산출물을 함께 적용하는 것을 기본으로 한다.
Restore 입력과 report:
- `make dump`, `make restore`, `make upload-cloud`는 기본적으로 Debian Trixie slim 기반 `baron-sso-backup-tools:local` 컨테이너에서 실행한다.
- 호스트 요구사항은 Docker와 Docker socket 접근 권한으로 제한한다. `zstd`, `jq`, `curl`, `openssl`, `postgresql-client`, `docker-cli`는 backup-tools image에 포함한다.
- `make restore BACKUP=<backup-dir>`는 압축 해제된 백업 디렉터리를 입력으로 사용한다.
- `make restore DUMP_FILE=<backup>.tar.zst`는 archive를 임시 디렉터리에 풀어 복구한다.
- `BACKUP``DUMP_FILE`은 동시에 지정하지 않는다.
- restore report 기본 위치는 `BACKUP` 입력일 때 `<backup-dir>/reports/restore-report.json`, `DUMP_FILE` 입력일 때 `reports/restore/<archive-name>-restore-report.json`이다.
- restore report JSON이 생성되면 같은 경로에 `.md` 확장자의 Markdown 요약도 함께 생성한다.
- `RESTORE_REPORT=<path>`로 report 경로를 명시할 수 있다.
- restore report는 checksum 검증, 서비스별 복구 대상, 복구 후 row count 비교, config snapshot copy 위치를 기록한다.
- 외부 접속을 막은 상태에서 복구하려면 app/Ory serve/gateway를 먼저 중지하고 Postgres/ClickHouse/Redis만 기동해 restore를 수행한 뒤 검증 통과 후 Ory/backend/frontend를 순차 기동한다.
### 외부 분산 저장: WORKS Drive 업로드
로컬 dump가 끝난 뒤 같은 백업 디렉터리를 외부 저장소에 분산 보관하기 위해 `scripts/backup/upload_cloud.sh`를 사용한다. 현재 1차 대상은 WORKS Drive다.
Upload flow:
1. `make dump BACKUP=...` 또는 `make dump-upload-cloud BACKUP=...`로 백업 산출물을 만든다.
2. `dump.sh``reports/backup-report.md`를 생성한다. report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록된다.
3. `upload_cloud.sh`가 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 압축한다.
4. Drive API용 access token을 확인한다.
- 우선순위: `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`
- fallback 1: `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`으로 Drive 앱 access token 갱신
- fallback 2: `WORKS_DRIVE_OAUTH_*` 서비스 계정 JWT 토큰 발급
5. WORKS Drive upload URL 생성 API를 호출한다.
6. 발급받은 upload URL에 multipart `Filedata``.tar.zst` archive를 업로드한다.
7. `WORKS_DRIVE_UPLOAD_REPORTS=true`이면 대상 폴더 아래 `WORKS_DRIVE_REPORT_FOLDER_NAME` 하위 폴더를 찾거나 생성한 뒤 `reports/*.md`만 업로드한다.
- 업로드 파일명은 `backup-report-YYYYMMDD-HHMMSSZ.md`처럼 업로드 시각을 붙인다.
- `reports/cloud-upload.json`은 로컬 실행 기록이며 Drive 업로드 대상에서 제외한다.
8. 업로드 결과를 `reports/cloud-upload.json`에 기록한다.
대상 drive 선택:
| `WORKS_DRIVE_TARGET` | 필요 변수 | 업로드 URL 생성 API |
| --- | --- | --- |
| `sharedrive` | `WORKS_DRIVE_SHARED_DRIVE_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/sharedrives/{sharedriveId}/files[/<folderFileId>]` |
| `mydrive` | 선택 `WORKS_DRIVE_USER_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/users/{userId}/drive/files[/<folderFileId>]` |
| `group` | `WORKS_DRIVE_GROUP_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/groups/{groupId}/folder/files[/<folderFileId>]` |
| `sharedfolder` | `WORKS_DRIVE_USER_ID`, `WORKS_DRIVE_SHARED_FOLDER_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/users/{userId}/drive/sharedfolders/{sharedFolderId}/files[/<folderFileId>]` |
운영 주의:
- 업로드 archive는 `.tar.zst`로 고정한다. `zstd`가 없으면 실패해야 한다.
- Drive API는 `file` scope가 필요하다.
- `WORKS_DRIVE_PARENT_FILE_ID`는 폴더 이름이나 경로가 아니라 WORKS Drive API가 반환하는 폴더 `fileId`여야 한다.
- 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 백업 업로드용 `WORKS_DRIVE_OAUTH_*`는 용도가 다른 앱/키로 분리한다.
- 운영 기본 경로는 Drive용 access token을 명시 주입하거나 `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`으로 갱신하는 방식이다.
- 서비스 계정 JWT fallback은 Drive 업로드 앱 정책에서 Drive scope 위임이 허용된 경우에만 성공한다.
- 파일 크기가 WORKS Drive 단일 파일 제한에 걸릴 수 있으면 `WORKS_DRIVE_MAX_SINGLE_FILE_BYTES` 또는 `WORKS_DRIVE_FORCE_SPLIT=true`로 split part 업로드를 사용한다.
- Markdown report 업로드 기본 폴더명은 `reports`이며 `WORKS_DRIVE_REPORT_FOLDER_NAME`으로 바꿀 수 있다.
- 외부 업로드 성공은 복구 가능성을 보장하지 않는다. 업로드 후에도 `make dump-verify BACKUP=...`와 restore rehearsal을 별도로 수행해야 한다.
## 백업 모드
### 1. Offline backup
가장 안전한 모드다. 모든 writer를 중지한 뒤 dump한다.
순서:
1. gateway 또는 Oathkeeper에서 maintenance mode 활성화
2. backend, relay worker, vector 등 write producer 중지
3. Kratos/Hydra/Keto public/admin 요청 차단 또는 컨테이너 중지
4. Baron Postgres dump
5. Ory Postgres의 Kratos/Hydra/Keto DB dump
6. ClickHouse backup
7. 설정/비밀값 백업
8. checksum과 row count 생성
9. 서비스 재개
이 모드는 사용자 UUID, WORKS mapping, Keto relation, OAuth consent 상태의 일관성이 가장 좋다.
### 2. Maintenance backup
짧은 점검 모드에서 writer만 막고 read는 제한적으로 허용한다. 운영 기본 모드로 권장한다.
필수 조건:
- 사용자 생성/삭제/수정 차단
- 테넌트/조직 변경 차단
- WORKS outbox relay 중지
- Keto outbox relay 중지
- OAuth client 변경 차단
### 3. Online best-effort backup
무중단 스냅샷이다. 저장소가 여러 개라 cross-store 일관성을 보장할 수 없다. 감사 로그나 분석용 백업에는 가능하지만, 재해 복구용 원본으로는 사용하지 않는다.
## Postgres 백업 전략
Postgres는 논리 dump를 기본으로 한다.
- `pg_dump -Fc` 형식 사용
- DB별 dump 파일 분리
- `pg_dumpall --globals-only`로 role/extension/권한 정보 별도 보관
- 백업 전후 row count와 핵심 UUID sample 기록
대상 DB:
- Baron DB: `DB_NAME`
- Kratos DB: `ory_kratos`
- Hydra DB: `ory_hydra`
- Keto DB: `ory_keto`
복구는 빈 DB에 `pg_restore --clean --if-exists`로 수행한다. 기존 운영 DB에 덮어쓰는 in-place restore는 금지한다.
## ClickHouse 백업 전략
ClickHouse는 감사 로그 성격이 강하므로 정책을 분리한다.
- 재해 복구: ClickHouse native backup 또는 volume snapshot 사용
- 장기 보관: 파티션 단위 export 또는 object storage backup
- 복구 검증: 날짜 파티션별 row count와 min/max timestamp 비교
ClickHouse 백업 실패가 인증 기능 복구를 막지는 않지만, 감사 로그 보존 정책상 별도 실패로 취급한다.
## Redis 처리 원칙
Redis는 기본적으로 백업하지 않는다.
복구 후 영향:
- 로그인 pending flow 만료
- short code/link login flow 재시작 필요
- headless JWKS cache 재생성
- 세션 cache miss 발생 가능
Kratos/Hydra 자체 session/token 원장은 Postgres 쪽에 있으므로 Redis가 비어 있어도 서비스는 재수렴해야 한다.
## 설정과 비밀값
DB dump만으로는 복구가 불완전하다. 다음 항목은 암호화해서 함께 보관한다.
- `.env` 또는 배포 환경 변수
- Ory generated config
- Hydra system secret, cookie secret
- Kratos courier/config secret
- Keto config
- Oathkeeper rules/config
- WORKS Admin OAuth client private key
- API gateway 설정
- object storage backup key id
`env.redacted`는 검토용이고, 실제 복구에는 `env.encrypted`만 사용한다.
## 복구 절차
### Full restore 기본 절차
1. 대상 환경을 새로 준비한다.
2. 모든 애플리케이션 서비스를 중지한다.
3. 기존 DB가 있으면 별도 보관 후 빈 DB를 만든다.
4. Postgres globals를 복구한다.
5. Baron DB를 복구한다.
6. Kratos/Hydra/Keto DB를 복구한다.
7. ClickHouse를 복구한다.
8. 설정/비밀값을 복호화해 배치한다.
9. migration은 자동 실행하지 않고 현재 dump의 schema version을 확인한다.
10. Ory Stack을 기동한다.
11. backend를 기동한다.
12. relay worker는 아직 켜지 않는다.
13. post-restore verification을 수행한다.
14. WORKS comparison dry-run을 수행한다.
15. 문제가 없을 때 relay worker와 외부 동기화를 재개한다.
### Post-restore verification
필수 검증:
- Kratos identity 수와 Baron users 수 비교
- Baron `users.id`가 Kratos `identities.id`에 존재하는지 확인
- tenant parent tree 참조 무결성 확인
- `user_login_ids.user_id`, `user_login_ids.tenant_id` 참조 무결성 확인
- Keto relation subject/object가 복구된 사용자/테넌트를 참조하는지 확인
- Hydra client와 Baron RP metadata 참조 확인
- WORKS mapping의 BaronResourceID가 복구된 사용자/테넌트를 참조하는지 확인
- super admin 로그인 확인
- 일반 사용자 로그인 확인
- 대표 RP OIDC login/consent 확인
- WORKS comparison dry-run에서 externalKey 기준 대량 삭제/생성 후보가 없는지 확인
## WORKS 연동 복구 정책
WORKS 자체 데이터는 Baron 백업으로 복구하지 않는다. Baron이 보존해야 하는 것은 WORKS와 연결되는 참조 키다.
필수 보존:
- 사용자 UUID
- 테넌트 UUID
- WORKS resource mapping
- WORKS outbox 처리 상태
- WORKS domain mapping/config
복구 직후 정책:
- relay worker 자동 실행 금지
- comparison dry-run 먼저 실행
- externalKey 기준으로 Baron/WORKS가 매칭되는지 확인
- 대량 delete/upsert가 감지되면 동기화 중단
- 확인 후 필요한 사용자/조직만 재동기화
outbox는 복구 모드에 따라 처리한다.
| 복구 모드 | outbox 정책 |
| --- | --- |
| 같은 운영 환경 재해 복구 | outbox 상태 보존, 단 relay 재개 전 dry-run 필수 |
| staging rehearsal | outbox relay 비활성화, 외부 WORKS 호출 금지 |
| cross-env migration | outbox는 보존하되 실행하지 않고 별도 remap 정책 필요 |
## CSV export/import의 위치
CSV는 다음 목적으로만 사용한다.
- 운영자가 사용자/조직을 일괄 등록하거나 보정
- 일부 필드를 검토하기 위한 추출
- dry-run 입력 보조 자료
CSV는 다음 목적에 사용하지 않는다.
- Kratos identity UUID 보존 복구
- 비밀번호/credential 복구
- OAuth consent/token/session 복구
- Keto relation 원장 복구
- WORKS mapping 원장 복구
## 구현 계획
### Phase 1: 백업/복구 스크립트와 문서화
Estimate Time: 3~5d
- `scripts/backup/full-backup.sh`
- `scripts/backup/full-restore.sh`
- `scripts/backup/verify-backup.sh`
- `scripts/backup/verify-restore.sh`
- `manifest.json` 생성기
- checksum 생성
- row count report 생성
### Phase 2: staging restore rehearsal
Estimate Time: 3~5d
- 백업 파일로 격리된 staging stack 복구
- Ory/Baron/Postgres/ClickHouse 복구 검증
- 로그인/OIDC/관리자 화면 smoke test
- WORKS 외부 호출 차단 상태에서 comparison dry-run
### Phase 3: 운영 자동화
Estimate Time: 5~8d
- 정기 백업 스케줄링
- 암호화 및 object storage 업로드
- retention 정책
- 실패 알림
- restore rehearsal 주기화
### Phase 4: 관리 UI
Estimate Time: 5~8d
- backup 목록 조회
- backup 생성 요청
- restore readiness report 조회
- staging rehearsal 결과 조회
- 운영 restore는 UI에서 직접 실행하지 않고 승인 절차와 runbook으로 제한
## 테스트 전략
### 단위 테스트
- manifest 생성 검증
- checksum 검증
- row count report 비교
- restore readiness parser 검증
### 통합 테스트
- fixture DB 생성
- full backup 실행
- 빈 DB로 restore
- 핵심 테이블 row count 비교
- 사용자/테넌트 UUID 동일성 비교
- Kratos identity와 Baron user ID 일치 검증
### E2E 테스트
- super admin 로그인
- 일반 사용자 로그인
- AdminFront 사용자 목록 조회
- UserFront 로그인
- 대표 RP OIDC 로그인
- WORKS comparison dry-run
### 실패 테스트
- 누락된 dump 파일
- checksum 불일치
- schema version 불일치
- 일부 DB만 복구된 상태
- Kratos identity는 있는데 Baron user가 없는 상태
- Baron user는 있는데 Kratos identity가 없는 상태
- WORKS mapping이 복구되지 않은 상태
## 운영 정책
- 백업은 암호화하지 않은 상태로 저장하지 않는다.
- 운영 restore는 빈 환경 또는 새 볼륨에만 수행한다.
- restore 전 현재 운영 DB는 별도 snapshot으로 보존한다.
- restore 후 WORKS relay는 수동 승인 전까지 비활성화한다.
- 월 1회 이상 staging restore rehearsal을 수행한다.
- schema migration 직전 수동 backup을 강제한다.
## 남은 결정 사항
- RPO/RTO 목표값
- 백업 저장 위치와 암호화 key 관리 방식
- ClickHouse 장기 보관 기간
- WORKS outbox replay 정책의 운영 기본값
- 운영 restore 승인자와 절차
- restore rehearsal 자동 실행 주기

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="136" height="20" role="img" aria-label="adminfront: unknown">
<title>adminfront: unknown</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="136" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="78" height="20" fill="#555"/>
<rect x="78" width="58" height="20" fill="#6e7781"/>
<rect width="136" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="39" y="15" fill="#010101" fill-opacity=".3">adminfront</text>
<text x="39" y="14">adminfront</text>
<text x="107" y="15" fill="#010101" fill-opacity=".3">unknown</text>
<text x="107" y="14">unknown</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 964 B

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="116" height="20" role="img" aria-label="backend: unknown">
<title>backend: unknown</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="58" height="20" fill="#555"/>
<rect x="58" width="58" height="20" fill="#6e7781"/>
<rect width="116" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="29" y="15" fill="#010101" fill-opacity=".3">backend</text>
<text x="29" y="14">backend</text>
<text x="87" y="15" fill="#010101" fill-opacity=".3">unknown</text>
<text x="87" y="14">unknown</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 950 B

View File

@@ -0,0 +1,69 @@
{
"schemaVersion": 1,
"generatedBy": "scripts/update_code_check_badges.mjs",
"updatedAt": "2026-05-29T08:13:02.131Z",
"source": {
"branch": "dev",
"sha": null,
"shortSha": null,
"runId": null,
"runNumber": null
},
"badges": {
"dev-sha": {
"label": "dev",
"message": "unknown",
"color": "#0969da"
},
"code-check": {
"label": "code check",
"message": "failing",
"color": "#cf222e"
},
"biome": {
"label": "biome",
"message": "passing",
"color": "#2ea043"
},
"backend-tests": {
"label": "backend",
"message": "unknown",
"color": "#6e7781"
},
"userfront": {
"label": "userfront",
"message": "unknown",
"color": "#6e7781"
},
"adminfront": {
"label": "adminfront",
"message": "unknown",
"color": "#6e7781"
},
"devfront": {
"label": "devfront",
"message": "unknown",
"color": "#6e7781"
},
"orgfront": {
"label": "orgfront",
"message": "unknown",
"color": "#6e7781"
},
"userfront-chrome": {
"label": "chrome",
"message": "unknown",
"color": "#6e7781"
},
"userfront-firefox": {
"label": "firefox",
"message": "unknown",
"color": "#6e7781"
},
"userfront-safari": {
"label": "safari",
"message": "unknown",
"color": "#6e7781"
}
}
}

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="102" height="20" role="img" aria-label="biome: passing">
<title>biome: passing</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="102" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="44" height="20" fill="#555"/>
<rect x="44" width="58" height="20" fill="#2ea043"/>
<rect width="102" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="22" y="15" fill="#010101" fill-opacity=".3">biome</text>
<text x="22" y="14">biome</text>
<text x="73" y="15" fill="#010101" fill-opacity=".3">passing</text>
<text x="73" y="14">passing</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 942 B

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="136" height="20" role="img" aria-label="code check: failing">
<title>code check: failing</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="136" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="78" height="20" fill="#555"/>
<rect x="78" width="58" height="20" fill="#cf222e"/>
<rect width="136" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="39" y="15" fill="#010101" fill-opacity=".3">code check</text>
<text x="39" y="14">code check</text>
<text x="107" y="15" fill="#010101" fill-opacity=".3">failing</text>
<text x="107" y="14">failing</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 964 B

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="20" role="img" aria-label="dev: unknown">
<title>dev: unknown</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="96" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="38" height="20" fill="#555"/>
<rect x="38" width="58" height="20" fill="#0969da"/>
<rect width="96" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="19" y="15" fill="#010101" fill-opacity=".3">dev</text>
<text x="19" y="14">dev</text>
<text x="67" y="15" fill="#010101" fill-opacity=".3">unknown</text>
<text x="67" y="14">unknown</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 931 B

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="123" height="20" role="img" aria-label="devfront: unknown">
<title>devfront: unknown</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="123" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="65" height="20" fill="#555"/>
<rect x="65" width="58" height="20" fill="#6e7781"/>
<rect width="123" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="32.5" y="15" fill="#010101" fill-opacity=".3">devfront</text>
<text x="32.5" y="14">devfront</text>
<text x="94" y="15" fill="#010101" fill-opacity=".3">unknown</text>
<text x="94" y="14">unknown</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 958 B

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="123" height="20" role="img" aria-label="orgfront: unknown">
<title>orgfront: unknown</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="123" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="65" height="20" fill="#555"/>
<rect x="65" width="58" height="20" fill="#6e7781"/>
<rect width="123" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="32.5" y="15" fill="#010101" fill-opacity=".3">orgfront</text>
<text x="32.5" y="14">orgfront</text>
<text x="94" y="15" fill="#010101" fill-opacity=".3">unknown</text>
<text x="94" y="14">unknown</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 958 B

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="109" height="20" role="img" aria-label="chrome: unknown">
<title>chrome: unknown</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="109" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="51" height="20" fill="#555"/>
<rect x="51" width="58" height="20" fill="#6e7781"/>
<rect width="109" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="25.5" y="15" fill="#010101" fill-opacity=".3">chrome</text>
<text x="25.5" y="14">chrome</text>
<text x="80" y="15" fill="#010101" fill-opacity=".3">unknown</text>
<text x="80" y="14">unknown</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 950 B

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="116" height="20" role="img" aria-label="firefox: unknown">
<title>firefox: unknown</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="58" height="20" fill="#555"/>
<rect x="58" width="58" height="20" fill="#6e7781"/>
<rect width="116" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="29" y="15" fill="#010101" fill-opacity=".3">firefox</text>
<text x="29" y="14">firefox</text>
<text x="87" y="15" fill="#010101" fill-opacity=".3">unknown</text>
<text x="87" y="14">unknown</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 950 B

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="109" height="20" role="img" aria-label="safari: unknown">
<title>safari: unknown</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="109" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="51" height="20" fill="#555"/>
<rect x="51" width="58" height="20" fill="#6e7781"/>
<rect width="109" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="25.5" y="15" fill="#010101" fill-opacity=".3">safari</text>
<text x="25.5" y="14">safari</text>
<text x="80" y="15" fill="#010101" fill-opacity=".3">unknown</text>
<text x="80" y="14">unknown</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 950 B

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="130" height="20" role="img" aria-label="userfront: unknown">
<title>userfront: unknown</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="130" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="72" height="20" fill="#555"/>
<rect x="72" width="58" height="20" fill="#6e7781"/>
<rect width="130" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="36" y="15" fill="#010101" fill-opacity=".3">userfront</text>
<text x="36" y="14">userfront</text>
<text x="101" y="15" fill="#010101" fill-opacity=".3">unknown</text>
<text x="101" y="14">unknown</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 960 B

View File

@@ -0,0 +1,57 @@
# Baron-Orgchart 데이터 흐름 및 아키텍처
이 문서는 조직도 뷰어 프론트엔드(`baron-orgchart`, OrgFront)와 Baron SSO 통합 인증/백엔드 서버 간의 인증 및 데이터 통신 흐름을 설명합니다.
`baron-orgchart`는 자체적인 데이터베이스나 권한 관리 로직을 가지지 않으며, Baron SSO 백엔드에 전적으로 의존하는 순수 표현 계층(Presentation Layer)으로 동작합니다.
## 아키텍처 및 데이터 흐름도
```mermaid
sequenceDiagram
participant User as 사용자 (Browser)
box rgba(173, 216, 230, 0.2) 외부 클라이언트 (표현 계층)
participant OrgFront as OrgFront<br>(baron-orgchart)
end
box rgba(255, 228, 196, 0.2) Baron SSO (통합 인증 및 권한 중앙 서버)
participant UI as UserFront<br>(SSO 로그인 화면)
participant Hydra as Ory Hydra<br>(OAuth2/OIDC)
participant Kratos as Ory Kratos<br>(Identity/Session)
participant BE as Backend API<br>(baron_backend:3000)
participant Keto as Ory Keto<br>(권한/조직 관계)
participant DB as PostgreSQL<br>(메타데이터)
end
%% 1. 인증 흐름
User->>OrgFront: 1. 조직도 웹사이트 접속
OrgFront->>Hydra: 2. 로그인되지 않음. Authorization Code 요청 (OIDC)
Hydra-->>User: 3. UserFront(로그인 화면)로 리다이렉트
User->>UI: 4. ID/비밀번호 입력
UI->>Kratos: 5. 로그인 검증 및 세션 생성
UI-->>User: 6. 로그인 성공, OrgFront Callback으로 리다이렉트
User->>OrgFront: 7. Authorization Code 전달
OrgFront->>Hydra: 8. Code를 Access Token으로 교환
Hydra-->>OrgFront: 9. Access Token 발급 완료
%% 2. 조직도 데이터 요청 흐름
User->>OrgFront: 10. 조직도 페이지 접근
OrgFront->>BE: 11. API 호출: GET /user-groups<br>(헤더: Authorization Bearer [Token])
%% 백엔드 내부 데이터 조합
Note over BE,DB: 백엔드가 조직도 데이터를 취합하는 과정
BE->>Keto: 12. Token(사용자 ID) 기반 접근 권한 검증
BE->>DB: 13. 테넌트/부서 트리 메타데이터 조회
BE->>Keto: 14. 각 부서별 멤버십(Member) ID 조회
BE->>Kratos: 15. 멤버 ID로 프로필(이름/직급 등) 정보 조회
BE-->>OrgFront: 16. 조합된 최종 조직도 JSON 반환
Note over OrgFront: 17. 데이터를 바탕으로<br>트리/차트 UI 렌더링 (순수 표현)
OrgFront-->>User: 18. 조직도 화면 표시
```
## 주요 설명
1. **표현 계층 (파란색 영역):** `baron-orgchart` (OrgFront)는 자체 DB를 조회하지 않고, 브라우저의 요청을 받아 화면을 그리는 역할만 담당하는 React/Vite(또는 유사 프레임워크) 애플리케이션입니다.
2. **통합 관리 (주황색 영역):**
* 조직도 웹사이트(OrgFront)에 접근하려면 `Ory Hydra``UserFront`를 통해 먼저 SSO 로그인을 완료해야 합니다.
* 인증 토큰을 받아 API를 호출하면, **`baron-sso` 백엔드**가 DB, Keto(조직 관계망), Kratos(사용자 프로필) 등 흩어진 데이터를 하나로 뭉쳐(Aggregation) JSON 형태로 내려줍니다.
3. **API Proxy 통신:** `docker-compose.yaml` 설정의 `API_PROXY_TARGET=http://baron_backend:3000` 환경 변수를 통해, OrgFront로 들어온 데이터 API 요청은 모두 안전하게 SSO 백엔드로 프록시(Proxy) 처리됩니다.

View File

@@ -0,0 +1,71 @@
# Client Log Policy
## 1. 목적
- 운영 환경에서 클라이언트 로그를 최소권한으로 수집하고, 민감정보 유출을 방지합니다.
- 장애 분석이 필요한 경우에만 명시적 디버그 옵션으로 로그 레벨을 확장합니다.
## 2. 환경별 수집 정책
### 2.1 기준 변수
- `APP_ENV`
- `CLIENT_LOG_DEBUG`
- (UserFront fallback) `USERFRONT_DEBUG_LOG`
### 2.2 동작 매트릭스
- `APP_ENV != production|prod`
- 클라이언트 로그: `DEBUG/INFO/WARN/ERROR` 수집 허용
- `APP_ENV == production|prod` AND `CLIENT_LOG_DEBUG` 미설정
- 클라이언트 로그: `WARN/ERROR`만 수집
- `INFO` 네비게이션 노이즈 로그는 필터
- `APP_ENV == production|prod` AND `CLIENT_LOG_DEBUG=true|1|on|yes`
- 클라이언트 로그: `DEBUG/INFO/WARN/ERROR` 수집 허용
## 3. 민감정보 마스킹 규칙
### 3.1 Key 기반 마스킹
아래 키는 값 전체를 `*****`로 치환합니다.
- `password`, `currentPassword`, `newPassword`, `oldPassword`
- `token`, `accessToken`, `refreshToken`
- `secret`, `clientSecret`
- `authorization`, `cookie`, `setCookie`
- `verificationCode`, `code`
- `loginChallenge`, `loginVerifier`
- `sessionJwt`, `accessJwt`, `refreshJwt`
### 3.2 문자열 패턴 마스킹
메시지 본문에서도 아래 패턴을 마스킹합니다.
- `token=...`
- `authorization:...` 또는 `authorization=...`
- JSON 문자열 내 민감 key/value
## 4. 구현 위치
- Backend
- 정책/마스킹 로직: `backend/internal/logger/client_log_policy.go`
- 수집 엔드포인트 적용: `backend/cmd/server/main.go` (`POST /api/v1/client-log`)
- UserFront
- 정책/마스킹 로직: `userfront/lib/core/services/log_policy.dart`
- 로그 출력/전송 정책: `userfront/lib/core/services/logger_service.dart`
- 전송 직전 마스킹: `userfront/lib/core/services/auth_proxy_service.dart`
## 5. 검증
### 5.1 Backend
```bash
cd backend
go test ./internal/logger -count=1
go test ./cmd/server -count=1
```
### 5.2 UserFront
```bash
cd userfront
flutter test test/log_policy_test.dart
```
## 6. 운영 가이드
- 운영에서 디버그 로그가 필요하면 `CLIENT_LOG_DEBUG=true`를 명시적으로 설정하고, 이슈 해결 후 즉시 원복합니다.
- 운영에서도 민감정보 마스킹은 항상 강제되며 비활성화할 수 없습니다.
## 7. 참고
- Backend 서버 자체의 `slog` 레벨 정책은 별도로 관리합니다.
- 관련 문서: `docs/backend-log-policy.md`

View File

@@ -0,0 +1,91 @@
# 클라이언트 비활성화(Deactivation) 및 로그인 차단 흐름
이 문서는 관리자가 개발자 포털(`devfront`)에서 특정 클라이언트(RP)를 비활성화했을 때, 해당 앱을 통한 인증 시도가 어떻게 차단되는지 상세 기술 흐름을 설명합니다.
## 1. 개요
보안 사고나 점검 등의 사유로 특정 애플리케이션의 접근을 즉시 차단해야 할 경우, 관리자는 클라이언트 목록에서 '상태' 토글을 비활성화할 수 있습니다. 이 설정은 Ory Hydra의 클라이언트 메타데이터에 저장되며, Baron SSO 백엔드 인증 핸들러에서 이를 검증하여 차단을 수행합니다.
## 2. 전체 동작 흐름
```mermaid
sequenceDiagram
participant Admin as 관리자 (DevFront)
participant Backend as 백엔드 (API)
participant Hydra as Ory Hydra (OIDC 엔진)
participant User as 사용자
participant RP as 애플리케이션 (예: Gitea)
Note over Admin, Hydra: [1단계: 클라이언트 비활성화 설정]
Admin->>Admin: 상태 스위치 클릭 (활성 -> 비활성)
Admin->>Backend: PATCH /api/v1/dev/clients/{id}/status {status: "inactive"}
Backend->>Hydra: PATCH /admin/clients/{id} (JSON Patch 형식)
Hydra-->>Backend: 업데이트 완료 (metadata.status = "inactive")
Backend-->>Admin: 성공 알림
Note over User, Backend: [2단계: 로그인 시도 및 차단]
User->>RP: 로그인 시도 (Baron SSO 선택)
RP->>Hydra: 인증 요청 (/oauth2/auth)
Hydra->>User: 로그인 페이지로 리디렉션 (login_challenge 포함)
User->>Backend: 아이디/비밀번호 입력 및 로그인 요청
Backend->>Hydra: GetLoginRequest(challenge) 호출
Hydra-->>Backend: 클라이언트 정보 응답 (Metadata 포함)
Note right of Backend: 비활성 체크 로직 작동
Backend->>Backend: Metadata["status"] == "inactive" 확인
alt 비활성화 상태인 경우
Backend-->>User: 403 Forbidden (비활성화된 앱 안내)
Note over User: 로그인 프로세스 중단
else 활성화 상태인 경우
Backend->>Hydra: AcceptLoginRequest
Hydra-->>User: 동의 화면 또는 RP로 리디렉션
end
```
## 3. 상세 구현 내용
### 3.1. 관리자 UI 및 상태 변경
- **파일**: `devfront/src/features/clients/ClientsPage.tsx`
- **함수**: `updateStatusMutation`
- **로직**: 사용자가 스위치를 토글하면 `updateClientStatus` API를 호출합니다. 업데이트 중에는 중복 클릭을 방지하기 위해 스위치가 `disabled` 상태가 됩니다.
### 3.2. 백엔드 상태 업데이트 중계
- **파일**: `backend/internal/handler/dev_handler.go`
- **함수**: `UpdateClientStatus`
- **로직**: 클라이언트 ID와 변경할 상태값을 받아 `service.HydraAdminService``PatchClientStatus`를 호출합니다.
- **파일**: `backend/internal/service/hydra_admin_service.go`
- **함수**: `PatchClientStatus`
- **로직**: Ory Hydra Admin API 규격에 맞춰 **JSON Patch(RFC 6902)** 형식을 생성합니다.
```go
payload := []map[string]interface{}{
{"op": "replace", "path": "/metadata/status", "value": status},
}
```
### 3.3. 인증 단계별 차단 검증 (핵심 보안 로직)
백엔드는 OIDC 인증 흐름의 3가지 주요 진입점에서 클라이언트의 활성 상태를 매번 체크합니다.
#### 1) 수동 로그인 시 (`PasswordLogin`)
- **파일**: `backend/internal/handler/auth_handler.go`
- **로직**: 사용자가 직접 아이디/비밀번호를 입력했을 때, `login_challenge`가 있다면 Hydra에 해당 클라이언트 정보를 조회하여 `metadata.status`가 `inactive`인지 확인합니다.
#### 2) 자동 로그인 시 (`AcceptOidcLoginRequest`)
- **파일**: `backend/internal/handler/auth_handler.go`
- **로직**: 사용자가 이미 SSO 세션을 가지고 있어 자동으로 로그인이 진행될 때 호출됩니다. 승인(Accept)을 보내기 직전에 클라이언트 상태를 체크하여 차단합니다.
#### 3) 동의 화면 진입 시 (`GetConsentRequest`)
- **파일**: `backend/internal/handler/auth_handler.go`
- **로직**: 로그인 후 권한 동의 화면을 그리기 위해 정보를 가져오는 시점입니다. 비활성화된 앱이라면 정보를 반환하지 않고 에러를 발생시킵니다.
## 4. 예외 및 에러 처리
- 클라이언트가 비활성화된 경우 백엔드는 `403 Forbidden` 상태 코드와 함께 `"The client application is disabled."` 메시지를 반환합니다.
- 백엔드 로그에는 `slog.Warn`을 통해 `"Login rejected for inactive client"` 메시지가 기록되어 관리자가 시도 이력을 추적할 수 있습니다.
## 5. 확인 방법 (테스트 시나리오)
1. `devfront`에서 특정 앱의 스위치를 끕니다.
2. 해당 앱에서 로그인을 시도합니다.
3. 이미 로그인된 상태여도 리디렉션 과정에서 "비활성화된 애플리케이션입니다"라는 안내와 함께 차단되는지 확인합니다.
4. 백엔드 로그에서 차단 기록을 확인합니다:
```bash
docker compose logs -f backend | grep "inactive client"
```

View File

@@ -0,0 +1,48 @@
+# CompletePasswordReset 리팩터 전략 (IDP 추상화 전환)
2 +
3 +## 현 상황
4 +- `AuthHandler.CompletePasswordReset`가 Descope 전용 흐름을 포함:
5 + - Descope 비밀번호 정책 검사
6 + - Descope Management API `SetActivePassword` 호출
7 + - IDP Provider 추상화는 마지막에만 호출
8 +- 목표: IDP Provider 기반으로 통일하고, Descope 정책 검사는 Descope 선택 시에만 적용.
9 +
10 +## 단계별 패치 전략 (쿼터/앵커 분할)
11 +1) **전역 준비**: 함수 시작부에 `providerName := "unknown"; if h.IdpProvider != nil
{ providerName = h.I
dpProvider.Name() }` 이미 추가됨.
12 +2) **비밀번호 정책 블록 분리**
13 + - 현재 Descope 정책 블록을 그대로 두고, 먼저 작은 단위로 변환:
14 + - 블록 상단 주석을 `// Validate password complexity (Descope only)`로 교체.
15 + - 블록 전체를 `if providerName == "Descope" && h.DescopeClient != nil { ... }`로 감싸기.
16 + - 특수문자 `[\W_]` 등 이스케이프가 많으므로, 치환 시 작은 범위의 문장/줄 단위로 교체.
17 +3) **Descope SetActivePassword 제거 및 공통 UpdateUserPassword 호출**
18 + - `Attempting to update password via Descope Auth API` 주석부터 Descope 전용 호출을 삭제.
19 + - 그 위치에 공통 경로 추가:
20 + ```go
21 + ale.Log(..., slog.String("idp", providerName))
22 + if h.IdpProvider == nil { ... 500 ... }
23 + if err := h.IdpProvider.UpdateUserPassword(...); err != nil { ... 500 ... }
24 + ```
25 +4) **앵커 선택**
26 + - 삭제 앵커: `"Attempting to update password via Descope Auth API"` 줄과 그 아래 Descope 클
라이언트 nil 체
크 ~ SetActivePassword 오류 처리 블록.
27 + - 유지 앵커: 상단의 Redis 토큰 삭제/성공 응답 부분은 그대로.
28 +5) **검증**
29 + - `gofmt -w backend/internal/handler/auth_handler.go`
30 + - `rg "Descope Client is nil" backend/internal/handler/auth_handler.go` → 없어야 함.
31 + - `rg "UpdateUserPassword" backend/internal/handler/auth_handler.go` → 공통 경로만 남는지
확인.
32 +
33 +## 테스트 계획
34 +- 단위 테스트: IDP Provider를 모킹해 `CompletePasswordReset`를 직접 호출하는 테스트 추가 권장
(추후 작업). 현재는 수동 검증 예정.
35 +- 수동 확인: 빌드(go test ./... 불가하면 최소 gofmt 및 `go test ./internal/service -run
TestUpdateUserPassw
ord` 재확인).
36 +
37 +## 적용 순서 (패치 실행용)
38 +1) 정책 블록 if 래핑 + 주석 변경.
39 +2) Descope API 호출 블록 제거 후 공통 IDP 호출 삽입.
40 +3) gofmt 및 로그/앵커 검사.

View File

@@ -0,0 +1,147 @@
## 서비스 역할
### 1) `postgres_ory`
- Ory 스택(Kratos/Hydra/Keto)이 공용으로 쓰는 PostgreSQL DB
- `healthcheck`로 DB 준비 상태를 다른 서비스들이 기다릴 수 있게 함
### 2) `kratos-migrate`
- Kratos DB 스키마 마이그레이션을 수행하는 1회성 컨테이너
- Postgres가 healthy가 된 뒤에 실행되고, 성공해야 Kratos가 뜸
### 3) `kratos`
- **인증/회원(Identity) 담당**: 로그인/회원가입/리커버리/검증 등 Self-service flow 제공
- 포트
- `4433`(public): 브라우저/클라이언트가 접근하는 API
- `4434`(admin): 관리용 API(내부에서만 쓰는 게 일반적)
- `--watch-courier`는 이메일/메시지 발송 관련(개발 모드) 흐름을 돕는 옵션
### 4) `kratos-mcp-server` (현재는 `profiles: mcp`)
- Kratos Admin API를 대신 호출해주는 “자동화/툴링(LLM 연동 포함) 브리지”
- 사람/브라우저가 직접 쓰는 서비스라기보다, 내부 도구가 붙어서 identity 관리 작업을 자동화할 때 사용
### 5) `hydra-migrate`
- Hydra DB 스키마 마이그레이션을 수행하는 1회성 컨테이너
- Postgres가 healthy가 된 뒤 실행되고, 성공해야 Hydra가 뜸
### 6) `hydra`
- **OAuth2 / OIDC Provider**: authorization code 발급, access/refresh token 발급 등
- 포트
- `4444`(public): authorization/token/jwks 등 외부 클라이언트가 접근
- `4445`(admin): 클라이언트 등록/관리 등 관리자 API
- `URLS_SELF_ISSUER`, `URLS_LOGIN`, `URLS_CONSENT`로 “로그인/동의 화면을 어디서 처리할지”를 외부(backend)로 위임
### 7) `hydra-mcp-server` (지금은 profiles 제거되어 항상 뜸)
- Hydra Admin/Public API를 대신 호출해주는 “자동화/툴링(LLM 연동 포함) 브리지”
- 주 용도는 OAuth 클라이언트 생성/수정/조회 자동화, 테스트 환경 세팅, 운영 자동화 등
- 브라우저로 접속하는 포트 서비스가 아닐 가능성이 높고(ports 없음), 내부 도구가 붙어서 사용
---
### 8) `keto-migrate`
- Keto(권한/관계 기반 접근제어) DB 마이그레이션 수행 1회성 컨테이너
- Postgres가 healthy가 된 뒤 실행되고, 성공해야 Keto가 뜸
### 9) `keto`
- **권한/정책(관계 튜플) 기반 접근제어** 담당(Ory Keto)
- 포트
- `4466` read API
- `4467` write API
- “누가 어떤 리소스에 어떤 관계/권한이 있는지”를 저장/조회하는 역할
---
### 10) `oathkeeper`
- **Reverse proxy + Access rule enforcement**(인증/인가 게이트웨이)
- 일반적으로 앞단에서 요청을 받아서 “인증 여부 확인 후” 백엔드로 프록시
- 포트
- `4456` API(관리/디버그용으로 쓰는 경우 많음)
- `4455` Proxy(외부 트래픽이 통과하는 포트로 쓰는 경우가 많음)
---
### 11) `ory_stack_check`
- 알파인에서 curl로 Kratos/Hydra/Keto의 `/health/ready`를 폴링해서 “스택 준비 완료”를 확인하는 헬퍼
- 준비가 끝나야 다음 단계(init-rp)가 안전하게 실행됨
### 12) `init-rp`
- Hydra Admin API로 **OAuth 클라이언트(Relying Party)를 자동 등록**하는 1회성 컨테이너
- 여기서는 `adminfront`, `devfront` 클라이언트를 만들어 둠
- 실제 서비스 시작 시 “클라이언트가 없어서 로그인 플로우가 안 되는” 문제를 방지
---
## 네트워크 역할
### `ory-net` (external)
- Postgres/Kratos/Hydra/Keto/Oathkeeper 등 Ory 스택 내부 서비스들이 서로 통신하는 공용 네트워크
- `http://hydra:4445`, `http://kratos:4434` 같은 서비스 디스커버리가 여기서 성립
### `hydranet` (external)
- Hydra가 붙는 별도 네트워크
- `init-rp``hydranet`에 붙어서 Hydra Admin API로 클라이언트 등록을 수행
### `kratosnet` (external)
- Kratos가 붙는 별도 네트워크
- 다른 애플리케이션(예: backend)이 Kratos와 통신할 때 분리된 네트워크로 구성하는 패턴
---
## 볼륨 역할
### `ory_postgres_data`
- Postgres 데이터 영속화(컨테이너 재시작/재생성해도 DB 유지)
---
## 확인할 서비스
### Kratos:
```
curl -i http://localhost:4433/health/ready
```
### Hydra:
```
curl -i http://localhost:4441/health/ready
```
### Keto:
```
curl -i http://localhost:4466/health/ready
```
### Oathkeeper:
```
curl -i http://localhost:4456/health/ready
```
### 화면이 떠야 하는 것 (UI)
```
http://localhost:5000/... : Kratos UI(UserFront) (이미 OK)
http://localhost:5000, http://localhost:5174 : 프론트들 (이미 OK)
```

View File

@@ -0,0 +1,77 @@
# Baron SSO Consent(권한 동의) 흐름 설명
이 문서는 Baron SSO 시스템에서 `/consent` 페이지가 어떻게 구현되어 있으며, 사용자가 어떻게 이 페이지로 이동하게 되는지 설명합니다.
## 1. 개요
Consent(권한 동의) 흐름은 **Ory Hydra**가 처리하는 OAuth2/OpenID Connect 프로토콜의 핵심 절차입니다. 클라이언트 앱(Relying Party, RP)이 사용자의 정보에 접근하기 위해 특정 권한(Scope)을 요청할 때, 사용자가 아직 해당 권한을 승인하지 않았다면 Hydra는 사용자를 설정된 Consent URL로 리다이렉트하여 동의를 구합니다.
## 2. `/consent` 페이지로의 리다이렉트 과정
사용자가 `/consent` 페이지로 이동하는 것은 Ory Hydra의 환경 설정에 의해 제어됩니다.
- **설정 파일**: `compose.ory.yaml` (및 `docker-compose.yaml`)
- **환경 변수**: `URLS_CONSENT`
`compose.ory.yaml` 파일 내 `hydra` 서비스의 설정은 다음과 같습니다:
```yaml
hydra:
environment:
- URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent
```
Hydra는 권한 동의가 필요하다고 판단하면, 사용자의 브라우저를 다음 주소로 리다이렉트합니다:
`{USERFRONT_URL}/consent?consent_challenge={challenge_id}`
## 3. 프론트엔드 구현 (`userfront`)
`userfront` 애플리케이션(Flutter)은 권한 동의 화면의 UI와 사용자 상호작용을 처리합니다.
### 라우트 처리
- **파일**: `userfront/lib/main.dart`
- **로직**: 라우터 설정에서 `/consent` 경로를 처리합니다. URL 쿼리 파라미터에서 `consent_challenge`를 추출하여 `ConsentScreen` 위젯에 전달합니다.
### UI 및 비즈니스 로직
- **파일**: `userfront/lib/features/auth/presentation/consent_screen.dart`
- **로직**:
1. **정보 로드**: 페이지 로드 시 `AuthProxyService.getConsentInfo(widget.consentChallenge)`를 호출하여 동의 요청의 상세 정보를 가져옵니다.
2. **화면 표시**: 접근을 요청한 앱의 이름과 요청된 권한 목록(Scope)을 사용자에게 보여줍니다.
3. **동의 실행**: 사용자가 "동의" 버튼을 누르면 `AuthProxyService.acceptConsent(widget.consentChallenge)`를 호출합니다.
4. **최종 이동**: 백엔드로부터 성공 응답과 함께 `redirectTo` URL을 받으면, 해당 URL로 브라우저를 이동시켜 로그인/인증 과정을 완료합니다.
### API 서비스
- **파일**: `userfront/lib/core/services/auth_proxy_service.dart`
- **엔드포인트**:
- 정보 조회: `GET /api/v1/auth/consent`
- 동의 수락: `POST /api/v1/auth/consent/accept`
## 4. 백엔드 구현 (`backend`)
백엔드는 프론트엔드와 Ory Hydra Admin API 사이에서 보안 및 통신을 중계합니다.
### 핸들러
- **파일**: `backend/internal/handler/auth_handler.go`
- **주요 함수**:
- `GetConsentRequest`: 전달받은 `challenge`를 사용하여 Hydra로부터 권한 동의 요청의 상세 내용(클라이언트 정보, 스코프 등)을 가져옵니다.
- `AcceptConsentRequest`: Hydra에 권한 동의 수락을 통보합니다. Hydra가 생성한 최종 리다이렉트 URL(`redirect_to`)을 응답으로 받아 프론트엔드에 전달합니다.
### Hydra Admin 서비스 연동
- **파일**: `backend/internal/service/hydra_admin_service.go`
- **로직**: Hydra Admin API와 직접 통신합니다.
- 정보 조회: `GET /oauth2/auth/requests/consent` (Hydra Admin 인터페이스)
- 동의 수락: `PUT /oauth2/auth/requests/consent/accept` (Hydra Admin 인터페이스)
## 5. 전체 흐름 요약
1. **사용자**: 클라이언트 앱(예: adminfront, devfront 등)에서 로그인을 시도합니다.
2. **Hydra**: 사용자의 세션을 확인하고, 해당 앱에 필요한 권한 동의가 있는지 확인합니다. 동의가 필요하면 사용자를 `USERFRONT_URL/consent?consent_challenge=...`로 보냅니다.
3. **Userfront**: `/consent` 페이지가 로드되고 `consent_challenge`를 인식합니다.
4. **Userfront -> Backend**: `GET /api/v1/auth/consent`를 호출하여 어떤 권한을 요청 중인지 묻습니다.
5. **Backend -> Hydra**: Hydra Admin API에 해당 챌린지의 상세 정보를 조회하여 반환합니다.
6. **사용자**: 화면에서 권한 내용을 확인하고 "동의"를 클릭합니다.
7. **Userfront -> Backend**: `POST /api/v1/auth/consent/accept`를 호출합니다.
8. **Backend -> Hydra**: Hydra Admin API에 동의 수락을 요청합니다.
9. **Hydra -> Backend**: 인증을 완료할 수 있는 최종 리다이렉트 URL(클라이언트 앱의 callback 주소)을 반환합니다.
10. **Backend -> Userfront**: 해당 URL을 전달합니다.
11. **Userfront**: 사용자를 클라이언트 앱으로 이동시키며 프로세스가 종료됩니다.

View File

@@ -0,0 +1,74 @@
# [Flow] 사용자 동의 내역 조회 및 동기화 흐름
이 문서는 DevFront(개발자 포털)에서 클라이언트별 사용자 동의 내역을 조회하는 기능의 전체적인 데이터 흐름과 백엔드 로직을 설명합니다. 이 기능의 핵심은 Ory Hydra의 API 제약을 우회하고 멀티 테넌트 환경에서 데이터를 격리하기 위해 Baron SSO 자체 DB를 활용하는 것입니다.
## 1. 데이터 동기화 (자체 DB에 저장)
사용자의 동의 상태가 변경될 때마다 Baron SSO의 `client_consents` 테이블에 실시간으로 데이터가 동기화됩니다.
### 1.1. 사용자가 최초로 동의할 때
- **시작점**: 사용자가 로그인 후 동의 화면에서 "허용" 버튼을 클릭합니다.
- **파일**: `backend/internal/handler/auth_handler.go`
- **함수**: `AcceptConsentRequest`
- **로직**:
1. 프론트엔드로부터 `consent_challenge`와 사용자가 선택한 `scopes`를 전달받습니다.
2. `h.Hydra.AcceptConsentRequest`를 호출하여 Ory Hydra에 동의를 최종 승인합니다.
3. Hydra 요청이 성공하면, `domain.ClientConsent` 모델 객체를 생성합니다.
4. `h.ConsentRepo.Upsert` 함수를 호출하여 `client_consents` 테이블에 해당 동의 내역을 저장(INSERT 또는 UPDATE)합니다.
### 1.2. 사용자가 이미 동의하여 자동으로 승인될 때
- **시작점**: 이미 동의한 사용자가 다시 로그인을 시도하여 동의 화면이 생략(`skip: true`)될 때.
- **파일**: `backend/internal/handler/auth_handler.go`
- **함수**: `GetConsentRequest`
- **로직**:
1. Ory Hydra로부터 `skip: true` 응답을 받습니다.
2. 백엔드는 이 요청을 자동으로 수락하기 위해 `h.Hydra.AcceptConsentRequest`를 내부적으로 호출합니다.
3. 자동 승인이 성공하면, **마찬가지로 `h.ConsentRepo.Upsert`를 호출하여 `client_consents` 테이블의 데이터를 최신 상태로 동기화합니다.** 이는 동의 내역의 일관성을 보장합니다.
## 2. 데이터 조회 (DevFront 목록 표시)
관리자가 DevFront에서 동의 내역을 조회할 때의 흐름입니다.
- **시작점**: 관리자가 DevFront의 `Consent & Users` 페이지에 진입합니다.
- **파일**: `devfront/src/features/clients/ClientConsentsPage.tsx`
- **로직**:
1. React Query의 `useQuery` 훅이 실행되면서 백엔드 API `GET /api/v1/dev/consents?client_id=<ID>`를 호출합니다.
2. (보안) 이때 axios interceptor 등을 통해 현재 관리자의 **테넌트 ID가 담긴 `X-Tenant-ID` 헤더**를 함께 전송합니다.
- **파일**: `backend/internal/handler/dev_handler.go`
- **함수**: `ListConsents`
- **로직**:
1. `client_id``X-Tenant-ID` 헤더 값을 파라미터로 받습니다.
2. **테넌트 ID 유무에 따라 분기합니다**:
- `X-Tenant-ID`가 있으면, `h.ConsentRepo.ListByTenant`를 호출합니다.
- `X-Tenant-ID`가 없으면 (e.g., 슈퍼 관리자), `h.ConsentRepo.List`를 호출합니다.
- **파일**: `backend/internal/repository/client_consent_repository.go`
- **함수**: `ListByTenant`
- **로직**:
1. **가장 핵심적인 데이터 격리 로직이 실행됩니다.**
2. GORM을 사용하여 `client_consents` 테이블과 `users` 테이블을 `JOIN`합니다.
3. `WHERE` 절을 통해 `client_id`와 **관리자의 `tenant_id`**를 동시에 조건으로 사용하여, 해당 테넌트에 속한 사용자들의 동의 내역만 안전하게 필터링합니다.
4. 조회된 결과를 `DevHandler`로 반환합니다.
- **파일**: `backend/internal/handler/dev_handler.go`
- **함수**: `ListConsents` (계속)
- **로직**:
1. Repository로부터 필터링된 동의 목록을 전달받습니다.
2. 목록의 각 항목(사용자 `subject`)에 대해 `h.KratosAdmin.GetIdentity`를 호출하여 Kratos로부터 사용자 이름, 이메일 등 추가 정보를 가져와 응답 데이터를 보강합니다.
3. 최종적으로 보강된 데이터를 JSON 형태로 프론트엔드에 반환합니다.
- **파일**: `devfront/src/features/clients/ClientConsentsPage.tsx` (계속)
- **로직**:
1. `useQuery`가 성공적으로 데이터를 받아오면, 상태가 업데이트되고 화면에 테이블 형태로 동의 내역이 렌더링됩니다.
## 3. 데이터 철회 (동의 취소)
- **시작점**: 관리자가 DevFront의 동의 목록에서 "Revoke" 버튼을 클릭합니다.
- **파일**: `backend/internal/handler/dev_handler.go`
- **함수**: `RevokeConsents`
- **로직**:
1. `h.Hydra.RevokeConsentSessions`를 호출하여 Ory Hydra에서 실제 OIDC 세션을 무효화합니다.
2. 성공 시, `h.ConsentRepo.Delete`를 호출하여 `client_consents` 테이블에서도 해당 동의 내역을 삭제하여 상태를 일치시킵니다.

View File

@@ -0,0 +1,117 @@
# Consent 반복 노출 문제 해결 보고서
## 1. 개요
Gitea 등 RP(Relying Party) 로그인 시, 사용자가 이미 권한에 동의했음에도 불구하고 재로그인할 때마다 Consent(권한 동의) 화면이 반복적으로 노출되는 문제를 해결했습니다.
이 문서는 해당 문제를 해결하기 위해 수정된 파일, 함수, 그리고 변경된 동작 흐름을 기술합니다.
## 2. 수정된 파일 및 함수
### A. 백엔드 서비스 계층
* **파일**: `backend/internal/service/hydra_admin_service.go`
* **함수**: `AcceptConsentRequest`, `AcceptLoginRequest`
* **변경 내용**: Hydra에 동의 및 로그인 정보를 저장할 때 유효 기간(`remember_for`)을 연장.
### B. 백엔드 핸들러 계층
* **파일**: `backend/internal/handler/auth_handler.go`
* **함수**: `GetConsentRequest`
* **변경 내용**: Hydra로부터 받은 동의 요청 정보에 `skip: true` 플래그가 있는 경우, 화면 데이터를 반환하는 대신 **자동 승인 프로세스**를 수행하도록 로직 추가.
### C. 프론트엔드 (UserFront)
* **파일**: `userfront/lib/features/auth/presentation/consent_screen.dart`
* **함수**: `_fetchConsentInfo`
* **변경 내용**: 백엔드 API 응답에 `redirectTo` 필드가 포함된 경우, UI 렌더링을 건너뛰고 **즉시 해당 URL로 리다이렉트**하도록 수정.
---
## 3. 상세 수정 로직 및 동작 흐름
### 3.1. 동의 기억 기간 연장 (Backend Service)
기존에는 동의 정보가 1시간(`3600`) 동안만 유지되어, 1시간 후 재로그인 시 다시 동의 화면이 나타났습니다. 이를 30일(`2592000`)로 늘려 사용자의 편의성을 높였습니다.
```go
// backend/internal/service/hydra_admin_service.go
func (s *HydraAdminService) AcceptConsentRequest(...) {
// ...
payload := map[string]interface{}{
// ...
"remember": true,
"remember_for": 2592000, // 수정 전: 3600 (1시간) -> 수정 후: 30일
}
// ...
}
```
### 3.2. 자동 승인(Skip) 로직 구현 (Backend Handler)
Hydra는 사용자가 이전에 동의한 기록이 유효하다면 `skip: true` 플래그를 보냅니다. 백엔드는 이 신호를 감지하여 사용자 개입 없이 동의 절차를 완료해야 합니다.
**[수정된 흐름]**
1. `GetConsentRequest` 호출 시 Hydra로부터 `consentRequest` 정보를 받아옴.
2. `consentRequest.Skip``true`인지 확인.
3. **True인 경우 (자동 승인):**
* Kratos에서 사용자 신원(`Identity`) 조회.
* 사용자 특성(`Traits`)을 기반으로 OIDC 클레임(`sessionClaims`) 생성.
* `Hydra.AcceptConsentRequest`를 호출하여 승인 처리.
* Hydra가 반환한 리다이렉트 URL(`redirectTo`)을 프론트엔드에 JSON으로 응답.
4. **False인 경우 (일반 진행):**
* 기존 로직대로 Consent 화면에 필요한 정보(클라이언트 이름, 스코프 목록 등)를 반환.
```go
// backend/internal/handler/auth_handler.go
func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
// ... Hydra 조회 ...
// [추가된 로직] Skip 플래그 확인 및 자동 승인
if consentRequest.Skip {
// 1. 사용자 정보 조회
identity, _ := h.KratosAdmin.GetIdentity(...)
// 2. 클레임 생성
sessionClaims := buildOidcClaimsFromTraits(...)
// 3. Hydra 승인 요청
acceptResp, _ := h.Hydra.AcceptConsentRequest(..., sessionClaims)
// 4. 리다이렉트 URL 반환 (화면 생략)
return c.JSON(acceptResp)
}
// ... 기존 화면 정보 반환 로직 ...
}
```
### 3.3. 즉시 리다이렉트 처리 (Frontend)
프론트엔드는 백엔드의 응답을 확인하여, 화면을 그릴지 아니면 바로 다른 페이지로 이동할지 결정합니다.
**[수정된 흐름]**
1. `ConsentScreen` 진입 시 `_fetchConsentInfo` 실행.
2. 백엔드 API(`GET /consent`) 호출.
3. 응답 데이터(`info`)에 `redirectTo` 필드가 있는지 확인.
4. **존재하는 경우:** `webWindow.redirectTo`를 통해 즉시 이동. (UI 렌더링 중단)
5. **없는 경우:** 받은 정보를 바탕으로 권한 동의 UI(체크박스, 버튼 등) 렌더링.
```dart
// userfront/lib/features/auth/presentation/consent_screen.dart
Future<void> _fetchConsentInfo() async {
final info = await AuthProxyService.getConsentInfo(...);
// [추가된 로직] 리다이렉트 URL 존재 시 즉시 이동
if (info['redirectTo'] != null) {
webWindow.redirectTo(info['redirectTo']);
return;
}
// ... UI 렌더링 준비 ...
}
```
## 4. 최종 동작 시나리오
1. **최초 로그인**:
* Hydra `skip: false` -> 백엔드가 화면 정보 반환 -> 프론트엔드가 Consent UI 노출 -> 사용자 동의 -> 백엔드가 `remember_for: 30일`로 승인 처리.
2. **재로그인 (30일 이내)**:
* Hydra `skip: true` 반환.
* 백엔드 `GetConsentRequest`가 이를 감지하고 내부적으로 `AcceptConsentRequest` 수행.
* 백엔드가 프론트엔드에 `{ "redirectTo": "https://gitea..." }` 응답.
* 프론트엔드는 화면을 그리지 않고 즉시 Gitea로 이동.
* **결과**: 사용자는 동의 화면을 보지 않고 로그인 완료.

View File

@@ -0,0 +1,62 @@
# Consent 거부(Reject) 및 리다이렉트 흐름 상세
이 문서는 사용자가 권한 동의(Consent) 화면에서 '취소' 버튼을 클릭했을 때, 시스템이 어떻게 이를 처리하고 원래 로그인 시도를 했던 서비스(RP, 예: Gitea)로 되돌려보내는지에 대한 기술적 흐름을 설명합니다.
## 전체 시퀀스 다이어그램
1. **사용자**: Consent 화면에서 '취소' 클릭
2. **프론트엔드 (Flutter)**: 취소 확인 다이얼로그 표시 -> 확인 시 백엔드 API 호출
3. **백엔드 (Go)**: Hydra Admin API 'Reject' 호출
4. **Hydra**: 거부 처리 및 서비스(RP)로 돌아갈 리다이렉트 URL 생성
5. **백엔드 (Go)**: Hydra가 준 URL을 프론트엔드에 전달
6. **프론트엔드 (Flutter)**: 브라우저 주소를 해당 URL로 변경 (리다이렉트)
7. **서비스 (RP)**: 에러 파라미터를 수신하여 로그인 실패 처리
---
## 단계별 상세 흐름 및 관련 파일
### 1. 사용자 액션 및 확인 (프론트엔드 UI)
- **파일**: `userfront/lib/features/auth/presentation/consent_screen.dart`
- **로직**: `_onCancel()` 메서드가 실행됩니다.
- `showDialog`를 통해 사용자에게 정말 취소할 것인지 묻습니다.
- 사용자가 승인하면 `AuthProxyService.rejectConsent(widget.consentChallenge)`를 호출합니다.
### 2. 백엔드 통신 (프론트엔드 서비스)
- **파일**: `userfront/lib/core/services/auth_proxy_service.dart`
- **로직**: `rejectConsent` 메서드가 백엔드의 `/api/v1/auth/consent/reject` 엔드포인트로 `POST` 요청을 보냅니다. 이때 `consent_challenge` 값이 바디에 포함됩니다.
### 3. 거부 요청 접수 및 처리 (백엔드 핸들러)
- **파일**: `backend/internal/handler/auth_handler.go`
- **로직**: `RejectConsentRequest(c *fiber.Ctx)` 함수가 동작합니다.
- 프론트엔드에서 보낸 챌린지 코드를 추출합니다.
- `h.Hydra.RejectConsentRequest(ctx, challenge)`를 호출하여 실제 거부 로직을 서비스 레이어로 위임합니다.
### 4. Hydra Admin API 호출 (백엔드 서비스)
- **파일**: `backend/internal/service/hydra_admin_service.go`
- **로직**: `RejectConsentRequest` 메서드가 실행됩니다.
- Ory Hydra의 Admin API인 `PUT /oauth2/auth/requests/consent/reject`를 호출합니다.
- 요청 바디에 `error: "access_denied"`와 설명을 포함하여 Hydra에게 사용자가 거부했음을 알립니다.
- **핵심**: Hydra는 이 요청을 받으면 해당 OAuth2 플로우를 에러 상태로 종료시키고, 서비스(Gitea 등)의 `redirect_uri`에 에러 정보를 붙인 최종 URL(`redirect_to`)을 응답으로 줍니다.
- 예: `https://gitea.com/callback?error=access_denied&error_description=...`
### 5. 최종 리다이렉트 실행 (프론트엔드)
- **파일**: `userfront/lib/features/auth/presentation/consent_screen.dart`
- **로직**: 백엔드로부터 전달받은 `redirectTo` URL을 확인합니다.
- `webWindow.redirectTo(redirectTo)` (또는 `html.window.location.href`)를 호출하여 브라우저의 페이지를 이동시킵니다.
### 6. 서비스(RP)의 수신
- **결과**: 사용자는 Gitea 로그인 화면 또는 에러 페이지로 돌아가게 됩니다.
- URL에 포함된 `error=access_denied` 파라미터를 통해 Gitea는 "사용자가 동의를 거부하여 로그인이 취소됨"을 인지하고 적절한 안내 문구를 보여줍니다.
---
## 요약 가이드 (참조 파일 목록)
| 레이어 | 관련 파일 경로 | 주요 역할 |
| :--- | :--- | :--- |
| **UI** | `consent_screen.dart` | 취소 버튼 이벤트, 확인창 UI, 브라우저 주소 이동 |
| **API Client** | `auth_proxy_service.dart` | 백엔드 `/consent/reject` 호출 인터페이스 |
| **Handler** | `auth_handler.go` | HTTP 요청 수신, 서비스 레이어 호출 및 응답 반환 |
| **Service** | `hydra_admin_service.go` | Ory Hydra Admin API(`.../reject`)와 직접 통신 |
| **Router** | `backend/cmd/server/main.go` | `/api/v1/auth/consent/reject` 라우트 정의 |

View File

@@ -0,0 +1,138 @@
# RP 연동 해지(Consent Revoke) 기능 구현 가이드
## 1. 개요
사용자가 UserFront 대시보드의 '활동상황' 섹션에서 특정 서비스(RP)와의 연동(동의)을 직접 해지할 수 있는 기능입니다. 이 기능을 통해 사용자는 자신의 정보 제공 동의를 철회할 수 있으며, 이후 해당 서비스 재접속 시 다시 동의 화면을 거치게 됩니다.
## 2. 동작 흐름 (Workflow)
1. **사용자 요청 (Frontend)**:
* 대시보드 활동상황 카드에서 '연동 해지' 버튼을 클릭합니다.
* 확인 모달(Dialog)이 뜨고, 사용자가 '해지하기'를 확정합니다.
2. **API 호출 (Frontend -> Backend)**:
* UserFront는 백엔드 API `DELETE /api/v1/user/rp/linked/{client_id}`를 호출합니다.
* 이때, `AuthProxyService`는 현재 세션의 인증 토큰(Bearer Token)을 헤더에 포함하여 요청합니다.
3. **동의 철회 처리 (Backend -> Hydra)**:
* 백엔드는 요청을 수신하고, 요청자의 `subject`(사용자 ID)를 식별합니다.
* Ory Hydra Admin API를 호출하여 해당 `subject``client_id`에 대한 모든 동의 세션(Consent Sessions)을 삭제합니다.
4. **UI 갱신 (Frontend)**:
* API 호출이 성공하면, 프론트엔드는 목록을 새로고침하지 않고 해당 카드의 상태를 즉시 '해지됨'으로 변경합니다.
* 해지된 카드는 흐릿하게(Opacity) 처리되며, 버튼이 비활성화되어 중복 요청을 방지합니다.
---
## 3. 백엔드 구현 상세 (Go)
### 파일: `backend/internal/handler/auth_handler.go`
#### 3.1 핸들러 구현: `RevokeLinkedRp`
프론트엔드의 삭제 요청을 받아 처리하는 진입점입니다.
```go
func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
// 1. 파라미터 파싱
clientID := c.Params("id")
// 2. 사용자 식별 (Subject 조회)
subject, err := h.resolveConsentSubject(c)
if err != nil || subject == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
}
// 3. 서비스 호출 (Hydra 연동)
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke link")
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"status": "success",
"message": "Link revoked successfully",
})
}
```
### 파일: `backend/internal/service/hydra_admin_service.go`
#### 3.2 Hydra 연동: `RevokeConsentSessions`
실제 Hydra Admin API를 호출하여 동의 세션을 삭제하는 로직입니다.
* **API Endpoint**: `DELETE /admin/oauth2/auth/sessions/consent`
* **Query Params**: `subject={user_id}`, `client={client_id}`, `all=true`
```go
func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject, clientID string) error {
// ... (Hydra Client 초기화)
// Hydra API 호출
_, err := s.client.Admin.RevokeConsentSessions(ctx).
Subject(subject).
Client(clientID).
All(true). // 해당 클라이언트에 대한 모든 세션 삭제
Execute()
return err
}
```
---
## 4. 프론트엔드 구현 상세 (Flutter)
### 파일: `userfront/lib/core/services/auth_proxy_service.dart`
#### 4.1 API 호출: `revokeLinkedRp`
**중요**: 401 인증 오류를 방지하기 위해 `AuthTokenStore`에서 토큰을 가져와 명시적으로 헤더에 추가하는 로직이 적용되었습니다.
```dart
static Future<void> revokeLinkedRp(String clientId) async {
// ... (URL 설정)
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked/$clientId');
// 인증 헤더 구성 (401 오류 해결 핵심)
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
final response = await client.delete(url, headers: headers);
// ... (에러 핸들링)
}
```
### 파일: `userfront/lib/features/dashboard/presentation/dashboard_screen.dart`
#### 4.2 상태 관리 및 UI 로직
해지된 항목을 로컬 상태(`Set<String> _revokedClientIds`)로 관리하여, 불필요한 API 재조회 없이 즉각적인 UI 피드백을 제공합니다.
1. **상태 변수**:
```dart
final Set<String> _revokedClientIds = {}; // 이번 세션에서 해지한 ID 목록
```
2. **해지 핸들러 (`_onRevokeLink`)**:
* 사용자 확인 모달 표시.
* API 호출 성공 시 `setState`를 통해 `_revokedClientIds`에 ID 추가.
* `ScaffoldMessenger`로 "해지되었습니다" 알림 표시.
3. **UI 렌더링 (`_buildActivityCard`)**:
* `_revokedClientIds`에 포함된 ID인 경우:
* `Opacity(0.6)` 적용.
* 버튼 텍스트를 '해지됨'으로 변경하고 비활성화(`null`).
* 상태 라벨을 '비활성'으로 표시.
```dart
// UI 상태 결정
final isRevoked = _revokedClientIds.contains(rp.id);
// 카드 투명도 처리
final opaqueCard = Opacity(
opacity: item.isRevoked ? 0.6 : 1.0,
child: cardContent,
);
```
## 5. 요약
이 기능은 백엔드에서의 **정확한 사용자 식별 및 Hydra 세션 철회**와 프론트엔드에서의 **안전한 인증 처리 및 즉각적인 UI 피드백**이 결합되어 구현되었습니다. 특히 프론트엔드에서 연동 해지 시 목록에서 아예 사라지는 것이 아니라, '해지됨' 상태로 남겨두어 사용자가 자신의 행동(해지)을 명확히 인지할 수 있도록 UX를 고려했습니다.

View File

@@ -0,0 +1,110 @@
# Baron SSO 권한 선택 및 동적 스코프(Scope) 처리 흐름
이 문서는 사용자가 Consent(권한 동의) 화면에서 특정 권한(Scope)을 선택하고, 그 선택된 권한들이 어떻게 백엔드와 Ory Hydra로 전달되어 처리되는지에 대한 구현 상세를 설명합니다. 또한, 개발자 포털(`devfront`)에서 설정한 권한별 설명이 어떻게 화면에 동적으로 표시되는지 설명합니다.
## 1. 개요
사용자 중심의 개인정보 제어를 위해 다음과 같은 기능이 구현되었습니다.
1. **권한 선택**: 사용자는 필수(Mandatory)가 아닌 권한을 직접 선택하거나 해제할 수 있습니다.
2. **동적 설명**: 개발자 포털에서 설정한 각 권한에 대한 사용자 친화적인 설명(한글 등)이 Consent 화면에 표시됩니다.
3. **필수 권한 보장**: `openid`와 같이 서비스 동작에 필수적인 권한은 선택 해제가 불가능합니다.
## 2. 데이터 흐름 (Data Flow)
### Step 1: Consent 정보 조회 (`GET /consent`)
사용자가 `/consent` 페이지에 진입하면, 프론트엔드는 백엔드에 상세 정보를 요청합니다.
1. **Frontend (`userfront`)**: `AuthProxyService.getConsentInfo(challenge)` 호출.
2. **Backend (`backend`)**: `AuthHandler.GetConsentRequest` 핸들러 실행.
* Hydra Admin API를 호출하여 Consent Request 정보(요청된 스코프, 클라이언트 정보 등)를 가져옵니다.
* **핵심 로직**: Hydra Client의 `Metadata` 필드 내 `structured_scopes`를 파싱합니다.
* 파싱된 정보를 바탕으로 `scope_details` 객체(설명, 필수 여부 포함)를 생성하여 응답에 추가합니다.
```json
// 백엔드 응답 예시
{
"challenge": "...",
"requested_scope": ["openid", "profile", "email"],
"scope_details": {
"openid": { "description": "인증 필수 정보", "mandatory": true },
"profile": { "description": "프로필 정보", "mandatory": false }
},
...
}
```
### Step 2: UI 렌더링 및 사용자 선택
1. **Frontend (`userfront`)**: `ConsentScreen` 위젯 렌더링.
* 백엔드에서 받은 `requested_scope` 목록을 순회하며 체크박스를 생성합니다.
* `scope_details`에 있는 설명을 우선적으로 표시합니다.
* `mandatory: true`인 스코프(예: `openid`)는 체크박스를 비활성화(선택 고정)합니다.
* 사용자는 나머지 선택 가능한 스코프를 체크하거나 해제합니다.
### Step 3: 동의 처리 (`POST /consent/accept`)
사용자가 "동의하고 계속하기" 버튼을 클릭하면, **선택된 스코프 목록만** 백엔드로 전송됩니다.
1. **Frontend (`userfront`)**: `AuthProxyService.acceptConsent(challenge, grantScope: [...])` 호출.
* `grant_scope` 파라미터에 사용자가 최종적으로 선택한 스코프 배열을 담아 보냅니다.
2. **Backend (`backend`)**: `AuthHandler.AcceptConsentRequest` 핸들러 실행.
* 요청 바디에서 `grant_scope`를 추출합니다.
* 보안을 위해, 사용자가 보낸 `grant_scope`가 원래 요청된(requested) 스코프에 포함되는지 검증합니다.
* 검증된 스코프 목록을 Hydra의 `AcceptConsentRequest` 페이로드(`grant_scope`)에 담아 호출합니다.
3. **Hydra**:
* 전달받은 `grant_scope`만을 포함한 Access Token 및 ID Token을 생성할 준비를 하고, 최종 리다이렉트 URL을 반환합니다.
## 3. 파일별 구현 상세
### 1. Backend (`backend/internal/handler/auth_handler.go`)
* **`GetConsentRequest`**:
* Hydra Client의 Metadata(`structured_scopes`)를 읽어 `scope_details`를 응답에 주입하는 로직이 추가되었습니다.
* **`AcceptConsentRequest`**:
* 요청 바디 구조체에 `GrantScope []string` 필드를 추가했습니다.
* Hydra API 호출 시 `RequestedScope` 필드를 사용자가 선택한 스코프로 덮어씌워 전달합니다.
### 2. Frontend (`userfront/lib/features/auth/presentation/consent_screen.dart`)
* **`_fetchConsentInfo`**:
* API 응답의 `scope_details`를 파싱하여 `_scopeDescriptions``_mandatoryScopes` 상태를 동적으로 업데이트합니다.
* **`_acceptConsent`**:
* 사용자가 체크한 `_selectedScopes`를 리스트로 변환하여 API 호출 시 전달합니다.
* **UI**:
* `CheckboxListTile`을 사용하여 각 권한별 선택 UI를 구성했습니다.
### 3. Frontend Service (`userfront/lib/core/services/auth_proxy_service.dart`)
* **`acceptConsent`**:
* `List<String>? grantScope` 파라미터를 추가하고, API 요청 바디에 포함시키는 기능을 구현했습니다.
## 4. 메타데이터 구조 (참고)
개발자 포털(`devfront`)에서 설정된 스코프 메타데이터는 Hydra Client의 `metadata` 필드에 다음과 같이 저장됩니다.
```json
{
"metadata": {
"structured_scopes": [
{
"id": "1",
"name": "openid",
"description": "서비스 이용을 위한 필수 인증 정보입니다.",
"mandatory": true
},
{
"id": "2",
"name": "email",
"description": "이메일 알림을 받기 위해 필요합니다.",
"mandatory": false
}
]
}
}
```
백엔드는 이 정보를 읽어서 Consent 화면에 필요한 정보로 가공하여 전달합니다.

View File

@@ -0,0 +1,241 @@
# Custom Field JSONB 및 인덱스 정책
## 현재 구조
- Tenant custom schema는 `tenants.config.userSchema` JSONB에 저장한다.
- Tenant custom value는 backend DB의 `users.metadata` JSONB에 저장한다.
- `isLoginId=true`인 Tenant field 값은 로그인 식별자 처리를 위해 `user_login_ids`에도 동기화한다.
- Ory Kratos traits에는 인증/식별에 필요한 최소 값만 동기화하는 방향으로 정리한다.
- RP custom value는 backend DB의 `rp_user_metadata.metadata` JSONB에 별도 저장한다.
## Tenant Custom Field
Tenant schema field는 다음 속성을 기준으로 한다.
```json
{
"key": "employeeNo",
"label": "사번",
"type": "text",
"required": false,
"indexed": true,
"isLoginId": true,
"adminOnly": false,
"validation": "^[A-Z0-9]+$"
}
```
- `indexed=true`는 검색/필터 최적화 대상이라는 의미다.
- `isLoginId=true`이면 backend와 adminfront 모두 `indexed=true`를 강제한다.
- `isLoginId=true`는 값 필수를 의미하지 않는다. 값 필수 여부는 `required=true`로 별도 제어한다.
- `isLoginId=true`인 field는 `type=text`만 허용한다.
- JSONB 통합 정책에서는 `varchar` 크기 지정 의미를 두지 않는다.
## RP Custom Field
RP custom schema는 client metadata의 `customUserSchema`에 저장한다.
```json
{
"customUserSchema": [
{
"key": "approvalLevel",
"label": "승인 등급",
"type": "text",
"required": false,
"indexed": true,
"claimEnabled": true
}
]
}
```
RP custom value는 `rp_user_metadata` 테이블에 저장한다.
```text
client_id text
user_id uuid
metadata jsonb
created_at timestamptz
updated_at timestamptz
primary key (client_id, user_id)
foreign key (user_id) references users(id)
```
Backend API 초안:
```text
GET /api/v1/dev/clients/:id/users/:userId/metadata
PUT /api/v1/dev/clients/:id/users/:userId/metadata
```
PUT payload:
```json
{
"metadata": {
"approvalLevel": "A",
"preferences": {
"theme": "dark"
}
}
}
```
## 검색 및 인덱스
- `indexed=true` field만 검색 UI/API 후보로 노출한다.
- 기본 검색은 exact match, exists, JSON containment 중심으로 제한한다.
- RP custom field의 LIKE/fuzzy 검색은 기본 제공하지 않는다.
- GIN 인덱스는 backend index manager가 별도 상태로 관리하는 방향을 원칙으로 한다.
- API 요청 처리 중 `CREATE INDEX`를 동기 실행하지 않는다.
## Claim Projection
JWT 또는 userinfo 응답에서는 custom field를 top-level에 풀지 않는다.
Tenant/RP 단위로 묶어서 전달한다.
```json
{
"tenant_profiles": [
{
"tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"tenant_slug": "hanmac-family",
"fields": {
"employeeNo": "E1001"
}
}
],
"rp_profiles": [
{
"client_id": "sample-rp",
"fields": {
"approvalLevel": "A"
}
}
]
}
```
- `claimEnabled=true` field만 RP claim 후보로 포함한다.
- 긴 JSON 값은 기본적으로 token claim보다 userinfo/profile API 응답에 싣는 방향을 우선한다.
## 한맥가족 Tenant Claim Projection
한맥가족(`hanmac-family`) subtree의 tenant claim은 기본 claim과 상세 claim으로 나눈다. 기본 claim은 대표소속 tenant UUID인 `tenant_id`와 전체 소속 목록인 `joined_tenants`이며, RP가 `tenant` claim을 요청하면 tenant별 map 안에 조직 소속 정보를 묶어서 전달한다. 이 정보는 RP가 tenant context를 표시하거나 조직별 기본값을 선택하기 위한 projection이며, 관계형 데이터의 SoT는 PostgreSQL Business DB와 사용자 metadata이다.
기본 claim 예시는 다음과 같다.
```json
{
"tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"joined_tenants": [
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
]
}
```
Issue #775 구현 결과 기준으로 RP가 `tenant` claim을 요청했을 때 받는 대표 예시는 다음과 같다.
```json
{
"email": "hanmac-user@example.com",
"name": "한맥 사용자",
"tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"joined_tenants": [
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
],
"lead_tenants": [
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
],
"tenants": {
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210": {
"id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"slug": "tech-planning",
"name": "기술기획팀",
"type": "USER_GROUP",
"lead": true,
"representative": true,
"isPrimary": true,
"grade": "책임",
"jobTitle": "기술기획",
"position": "팀장",
"parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
"ancestors": [
{
"id": "01970f08-91da-7286-bd19-882fb98d1f2c",
"slug": "hanmac",
"name": "한맥기술",
"type": "COMPANY",
"parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345"
},
{
"id": "01970f07-4f01-7d9a-a71e-b53ad508f345",
"slug": "hanmac-family",
"name": "한맥가족",
"type": "COMPANY_GROUP",
"parentTenantId": null
}
]
},
"01970f0b-3448-7bb8-bdc7-16b6a1d2e661": {
"id": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661",
"slug": "quality",
"name": "품질관리팀",
"type": "USER_GROUP",
"lead": false,
"representative": false,
"isPrimary": false,
"grade": "선임",
"jobTitle": "품질관리",
"position": "파트원",
"parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
"ancestors": [
{
"id": "01970f08-91da-7286-bd19-882fb98d1f2c",
"slug": "hanmac",
"name": "한맥기술",
"type": "COMPANY",
"parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345"
},
{
"id": "01970f07-4f01-7d9a-a71e-b53ad508f345",
"slug": "hanmac-family",
"name": "한맥가족",
"type": "COMPANY_GROUP",
"parentTenantId": null
}
]
}
},
"profile": {
"emails": [
"hanmac-user@example.com"
],
"names": {
"name": "한맥 사용자"
}
}
}
```
- 예시의 `id` 값은 UUID 형식의 샘플이며, `slug`와 다르다.
- `tenant_id``joined_tenants`는 기본 claim이다.
- `tenant_id`는 사용자의 대표소속 tenant UUID이다. RP/client context tenant가 없더라도 공백으로 내려가지 않는다.
- `joined_tenants`는 사용자가 claim 상에서 소속된 모든 tenant UUID 목록이다.
- `lead_tenants``tenant` claim 요청 시 포함되며, `lead=true`인 tenant UUID 목록이다.
- `lead`는 tenant lead/조직장 역할을 나타낸다. 입력 metadata에서는 `lead`, `isLead`, `isOwner`, `isManager`를 허용한다.
- `representative``isPrimary`는 대표조직 여부를 나타낸다. 입력 metadata에서는 `representative`, `isPrimary`, `primary`를 허용한다.
- `grade`, `jobTitle`, `position`은 각각 직급, 직무, 직책이다.
- `parentTenantId`는 현재 tenant의 직속 parent tenant UUID이다. 최상위 root는 `null`이다.
- `ancestors`는 직속 상위 tenant부터 `hanmac-family` root까지의 parent chain이다.
- 기본 tenant와 각 ancestor 객체는 `parentTenantId`를 포함하므로, parent edge를 별도 추론 없이 그릴 수 있다.
- 대표소속 결정은 명시적 `tenant_id`, `additionalAppointments``representative/isPrimary/primary=true`, 가장 먼저 등록된 소속 순서로 적용한다.
- 생성 시 소속 tenant가 하나도 없으면 PERSONAL tenant를 자동 생성하고, 해당 tenant를 `tenant_id``joined_tenants`에 포함한다.
- RP/client tenant context는 대표소속 `tenant_id`를 덮어쓰지 않는다.
- tenant별 namespaced traits map이 없어도 `tenant_id` 또는 `additionalAppointments[].tenantId`를 기준으로 projection 항목을 만들 수 있다.
- 멀티 소속이면 기본 claim의 `joined_tenants`에 모든 소속 tenant를 넣는다. `tenant` claim 요청 시에는 `tenants`에도 모든 소속 tenant 상세를 넣고, `lead_tenants`에는 lead tenant만 넣는다.
- token 크기 보호를 위해 전체 조직도나 긴 custom JSON은 claim에 싣지 않고 profile/userinfo API 또는 backend API 응답으로 분리한다.
- RP는 `joined_tenants`로 전체 소속을 읽고, `lead_tenants`로 lead tenant를 빠르게 식별한다. 상세 표시는 `tenants[tenant_id]` 또는 `tenants[joined_tenants[n]]``ancestors`를 조합한다.

View File

@@ -0,0 +1,70 @@
# 데이터 정합성 검증 관리
## 개요
adminfront의 `데이터 정합성` 메뉴는 Baron SSO backend DB read model의 이상 징후를 `super_admin` 전용으로 확인하는 관리 화면입니다.
Baron SSO의 신원/권한 SoT는 Ory Stack(Kratos, Keto, Hydra)입니다. 이 기능은 SoT를 직접 수정하지 않고, backend PostgreSQL read model에서 운영자가 확인해야 할 불일치만 리포트합니다.
## API
- Method: `GET`
- Path: `/api/v1/admin/integrity`
- 권한: `super_admin`
응답은 전체 상태, 검사 시각, 요약, 섹션별 검사 결과를 포함합니다.
### 유령 로그인 ID 대상 조회
- Method: `GET`
- Path: `/api/v1/admin/integrity/orphan-user-login-ids`
- 권한: `super_admin`
`user_login_ids`가 존재하지 않거나 soft-deleted 된 `users`, `tenants`를 참조하는 행을 반환합니다. 각 행은 `loginId`, `fieldKey`, 사용자/테넌트 식별 정보, `missing_user`, `deleted_user`, `missing_tenant`, `deleted_tenant` 중 하나 이상의 사유를 포함합니다.
### 유령 로그인 ID 삭제
- Method: `DELETE`
- Path: `/api/v1/admin/integrity/orphan-user-login-ids`
- 권한: `super_admin`
요청 본문은 `{ "ids": ["..."] }` 형식입니다. 서버는 삭제 직전에 같은 트랜잭션 안에서 대상 행이 여전히 유령 로그인 ID인지 재검증하고, 재검증을 통과한 `user_login_ids` 행만 삭제합니다. 정상화되었거나 존재하지 않는 ID는 `skippedIds`로 반환합니다.
## 검사 항목
### 테넌트 정합성
- `duplicate_tenant_slugs`: 삭제되지 않은 tenant의 `LOWER(TRIM(slug))` 기준 중복을 검사합니다.
- `orphan_tenant_parents`: `tenants.parent_id`가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.
### 사용자 정합성
- `orphan_user_tenant_memberships`: `users.tenant_id`가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.
- `orphan_user_login_id_tenants`: `user_login_ids.tenant_id`가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.
- `orphan_user_login_id_users`: `user_login_ids.user_id`가 존재하지 않거나 soft-deleted user를 참조하는지 검사합니다.
## adminfront 동작
- `super_admin`은 사이드바의 `데이터 정합성` 메뉴에서 리포트를 볼 수 있습니다.
- `다시 검사` 실행 중에는 버튼이 비활성화되고 `검사 중` 상태가 표시됩니다. 요청이 끝나면 완료 또는 실패 상태 문구가 화면에 남습니다.
- `super_admin`은 같은 메뉴에서 유령 로그인 ID 대상을 확인하고, 체크박스로 선택한 뒤 확인 대화상자를 거쳐 삭제할 수 있습니다.
- `super_admin`은 adminfront 개요 화면 하단에서도 최종 검증 상태, 실패 건수, 검사 시각, 섹션별 상태 요약을 볼 수 있습니다.
- `tenant_admin` 등 non-super role은 화면 접근 시 권한 없음 메시지만 봅니다.
- 개요 화면의 전체 테넌트 수는 `fetchAllTenants()`로 실제 cursor pagination을 끝까지 수집한 리스트 수를 우선 사용합니다. 이로써 super가 보는 전체 테넌트 수와 리스트 기반 수치가 같은 소스에서 나오도록 맞춥니다.
- 개요 화면은 `super_admin`에게 전체 사용자 수(`totalUsers`)도 표시합니다. 이 값은 Kratos user projection 상태의 `projectedUsers` 기준입니다.
## 운영 주의
정합성 검증 리포트는 read-only입니다. 단, 유령 로그인 ID 삭제는 `super_admin`이 대상 행을 확인하고 선택한 경우에만 수행되는 명시적 maintenance action입니다.
삭제 작업의 기본 운영 순서는 다음과 같습니다.
1. `데이터 정합성` 메뉴에서 실패 항목과 유령 로그인 ID 대상 행을 확인합니다.
2. 사용자/테넌트가 실제로 복구 대상인지 먼저 판단합니다.
3. 복구 대상이 아니라 read model 잔여 데이터가 맞는 행만 선택합니다.
4. `선택 삭제` 실행 후 정합성 리포트가 다시 조회되어 실패 건수가 줄었는지 확인합니다.
이미 존재하는 orphan 사용자 소속 정리 경로는 다음과 같습니다.
- CLI: `backend/cmd/adminctl clear-orphan-user-tenant-memberships`
- SQL: `scripts/clear_orphan_user_tenant_memberships.sql`

View File

@@ -0,0 +1,173 @@
# DevFront RP 관계 설정 가이드
이 문서는 DevFront의 RP 상세 화면 > `관계` 탭에서 사용자에게 부여할 수 있는 관계 목록과 각 관계가 의미하는 기능 범위를 정리한다.
## 적용 범위
- 대상 namespace: `RelyingParty`
- 대상 화면: DevFront RP 상세 화면의 `Relationships`
- 대상 subject: 현재 1차 구현에서는 direct `User:<kratosIdentityId>` assignment
- 기준 구현:
- `docker/ory/keto/namespaces.ts`
- `devfront/src/features/clients/ClientRelationsPage.tsx`
- `backend/internal/handler/dev_handler.go`
## 기본 원칙
- 관계 탭에서 부여하는 관계는 **DevFront 운영 권한**이다.
- `RelyingParty#access`는 실제 서비스 로그인/접근 권한이며, DevFront 운영 권한과 별도이다.
- 아래 수동 부여 관계 중 하나라도 있으면 해당 RP에 대한 기본 조회 권한(`RelyingParty#view`)도 함께 생긴다.
- `RP 관리자(admins)`는 상위 관리 관계이며, 대부분의 세부 운영 권한을 포함한다.
- 세부 관계는 필요한 기능만 최소 권한으로 부여할 때 사용한다.
- `creator`는 생성 이력/자동 동기화용 내부 relation이며 관계 탭의 수동 부여 목록에는 노출하지 않는다.
## 관계 목록
| 화면 표시명 | Relation key | 의미 | 주요 허용 기능 |
|---|---|---|---|
| RP 관리자 | `admins` | RP 운영 전반을 관리할 수 있는 관리자 관계 | RP 조회, 설정 관리, secret 조회/재발급, JWKS 운영, consent 조회/회수, 관계 조회, 감사 로그 조회, 상태 변경 |
| RP 일반 설정 | `config_editor` | RP 이름, Redirect URI, 메타데이터 같은 일반 설정을 수정할 수 있는 관계 | RP 조회, 일반 설정 수정 |
| 시크릿 조회 | `secret_viewer` | Client secret을 조회할 수 있는 관계 | RP 조회, client secret 조회 |
| 시크릿 재발급 | `secret_rotator` | Client secret 재발급과 rotation을 수행할 수 있는 관계 | RP 조회, client secret 재발급 |
| JWKS 조회 | `jwks_viewer` | JWKS 상태, 캐시 정보, key summary를 조회할 수 있는 관계 | RP 조회, JWKS 상태/캐시/key summary 조회 |
| JWKS 운영 | `jwks_operator` | JWKS refresh/revoke 같은 운영 작업을 수행할 수 있는 관계 | RP 조회, JWKS 조회, JWKS refresh/revoke |
| 동의 조회 | `consent_viewer` | 이 RP의 consent 내역을 조회할 수 있는 관계 | RP 조회, 동의 및 사용자 목록 조회 |
| 동의 회수 | `consent_revoker` | 이 RP의 consent를 회수할 수 있는 관계 | RP 조회, 동의 조회, 동의 회수 |
| 관계 조회 | `relationship_viewer` | 이 RP에 부여된 direct relation을 조회할 수 있는 관계 | RP 조회, 관계 목록 조회 |
| 감사 로그 조회 | `audit_viewer` | 이 RP의 DevFront 감사 로그를 조회할 수 있는 관계 | RP 조회, 해당 RP 감사 로그 조회 |
| 상태 변경 | `status_operator` | RP 활성/비활성 상태를 변경할 수 있는 관계 | RP 조회, 활성/비활성 상태 변경 |
## Permit 매핑
Keto namespace 기준으로 relation은 다음 permit으로 계산된다.
| Permit | 허용 relation / 조건 | 기능 의미 |
|---|---|---|
| `view` | `admins`, `config_editor`, `secret_viewer`, `secret_rotator`, `jwks_viewer`, `jwks_operator`, `consent_viewer`, `consent_revoker`, `relationship_viewer`, `audit_viewer`, `status_operator`, 부모 tenant의 `view` 또는 `view_dev_console` | RP 기본 조회 및 목록 노출 |
| `manage` | `admins`, 부모 tenant의 `manage` | RP 관리 상위 권한 |
| `create` | `creator`, 부모 tenant의 `grant_dev_permissions`, `manage` | RP 생성. `creator`는 현재 수동 부여하지 않는 내부 relation이다. |
| `edit_config` | `config_editor`, `manage` | RP 일반 설정 수정 |
| `view_secret` | `secret_viewer`, `rotate_secret`, `manage` | client secret 조회 |
| `rotate_secret` | `secret_rotator`, `manage` | client secret 재발급/회전 |
| `view_jwks` | `jwks_viewer`, `operate_jwks`, `manage` | JWKS 상태/캐시/key summary 조회 |
| `operate_jwks` | `jwks_operator`, `manage` | JWKS refresh/revoke |
| `view_consents` | `consent_viewer`, `revoke_consents`, `manage` | consent 목록/상세 조회 |
| `revoke_consents` | `consent_revoker`, `manage` | consent 회수 |
| `view_relationships` | `relationship_viewer`, 부모 tenant의 `grant_dev_permissions`, `manage` | RP 관계 목록 조회 |
| `view_audit_logs` | `audit_viewer`, `manage` | 해당 RP의 DevFront 감사 로그 조회 |
| `change_status` | `status_operator`, `manage` | RP 활성/비활성 상태 변경 |
| `access` | `access`, `manage` | 실제 서비스 로그인/리소스 접근 |
## 권한별 운영 예시
### 특정 사용자가 RP만 조회해야 하는 경우
최소 관계:
- `audit_viewer`, `consent_viewer`, `jwks_viewer` 등 필요한 세부 조회 관계 중 하나
위 세부 관계는 모두 `RelyingParty#view`를 포함하므로, 사용자는 DevFront에서 해당 RP를 볼 수 있다.
### 특정 사용자가 동의 및 사용자 목록만 봐야 하는 경우
부여 관계:
- `consent_viewer`
허용 결과:
- RP 목록/상세 조회
- `동의 및 사용자 목록` 조회
허용하지 않는 기능:
- 동의 회수
- secret 재발급
- JWKS refresh/revoke
- 관계 부여/회수
- 상태 변경
### 특정 사용자가 동의를 조회하고 회수도 해야 하는 경우
부여 관계:
- `consent_revoker`
허용 결과:
- `revoke_consents` permit
- `view_consents` permit도 함께 허용
### 특정 사용자가 감사 로그만 봐야 하는 경우
부여 관계:
- `audit_viewer`
허용 결과:
- RP 목록/상세 조회
- 해당 RP와 연결된 DevFront 감사 로그 조회
주의:
- 감사 로그 필터링은 audit details의 `target_id` 또는 `client_id`가 RP client id와 일치하는지 기준으로 동작한다.
- 오래된 로그 또는 일부 경로에서 `target_id`/`client_id`가 누락된 로그는 RP별 권한 사용자에게 보이지 않을 수 있다.
### 특정 사용자를 RP 운영 담당자로 지정해야 하는 경우
부여 관계:
- `admins`
허용 결과:
- `manage` permit
- 대부분의 세부 운영 권한 허용
- consent 조회/회수, 감사 로그 조회, 관계 조회, 상태 변경 등 포함
주의:
- `admins`는 강한 권한이다.
- 단순 조회나 특정 작업만 필요하면 세부 relation을 우선 사용한다.
## 자동 부여 관계
RP 생성 시 `metadata.user_id`가 존재하면 생성자에게 기본 운영 relation 세트가 outbox로 적재된다.
현재 자동 부여 대상:
- `admins`
- `creator`
- `config_editor`
- `secret_viewer`
- `secret_rotator`
- `jwks_viewer`
- `jwks_operator`
- `consent_viewer`
- `consent_revoker`
- `relationship_viewer`
- `audit_viewer`
- `status_operator`
`creator`는 이 자동 부여 세트에는 포함되지만, 운영자가 관계 탭에서 수동으로 선택하는 관계는 아니다. 생성자 표시는 장기적으로 relation 부여 여부가 아니라 RP metadata 또는 audit read model 기반의 읽기 전용 정보로 제공하는 방향이 적절하다.
## 관련 tuple 예시
```text
RelyingParty:client-a#admins@User:user-1
RelyingParty:client-a#consent_viewer@User:user-2
RelyingParty:client-a#consent_revoker@User:user-3
RelyingParty:client-a#audit_viewer@User:user-4
RelyingParty:client-a#relationship_viewer@User:user-5
```
## 운영 주의사항
- 관계 부여/회수는 direct Keto write가 아니라 outbox 적재 방식으로 처리한다.
- 관계를 부여한 직후 실제 Keto 반영까지 worker 처리 지연이 있을 수 있다.
- 사용자가 DevFront에서 기대 권한을 얻지 못하면 다음을 우선 확인한다.
- relation tuple의 subject가 실제 로그인한 Kratos identity id와 같은지
- outbox worker가 tuple을 Keto에 반영했는지
- 대상 RP의 client id가 tuple object와 같은지
- audit/consent 로그에 `client_id` 또는 `target_id`가 정확히 기록되는지

View File

@@ -0,0 +1,91 @@
# DevFront OIDC 인증 흐름 및 무한 루프 해결 보고
이 문서는 `devfront` 개발자 포털의 OIDC 인증 구현 방식, 무한 루프 문제의 원인 및 해결 방법, 그리고 클라이언트 자동 등록 원리에 대해 설명합니다.
## 1. DevFront 로그인 동작 플로우 (OIDC Authorization Code Flow)
### 시퀀스 다이어그램
```mermaid
sequenceDiagram
actor User
participant DF as DevFront (RP)
participant HY as Ory Hydra (OP)
participant UF as UserFront (Login UI)
participant KR as Ory Kratos (Identity)
User->>DF: 로그인 버튼 클릭
DF->>HY: 인증 요청 (/oauth2/auth)
HY->>UF: 로그인 UI 리다이렉트
UF->>User: 로그인 페이지 표시
User->>UF: 자격 증명 입력 (Email/PW)
UF->>KR: 인증 수행
KR-->>UF: 인증 성공 (Session 생성)
UF->>HY: 로그인 승인 요청
HY->>User: 권한 동의(Consent) 화면 표시
User->>HY: '허용' 클릭
HY-->>DF: 인증 코드와 함께 리다이렉트 (/auth/callback?code=...)
DF->>HY: 토큰 교환 요청 (Code -> ID/Access Token)
HY-->>DF: 토큰 발급
Note over DF: [FIX] 백엔드 /api/me 호출 대신<br/>ID Token에서 프로필 정보 직접 추출
DF->>User: 대시보드 및 프로필 표시
```
### 단계별 설명
1. **인증 요청 (Login Request)**:
* 사용자가 `devfront` (localhost:5174)의 로그인 버튼을 클릭합니다.
* `react-oidc-context` 라이브러리가 Hydra의 `/oauth2/auth` 엔드포인트로 사용자를 리다이렉트합니다.
* 이때 클라이언트 ID(`devfront`), 리다이렉트 URI, Scope 등을 파라미터로 전달합니다.
2. **사용자 인증 (Authentication)**:
* Hydra는 현재 세션이 없음을 확인하고, 설정된 로그인 UI(`userfront`)로 사용자를 보냅니다.
* 사용자는 `userfront`에서 아이디/비밀번호를 입력하여 Kratos를 통해 인증을 마칩니다.
3. **권한 동의 (Consent)**:
* 인증이 완료되면 Hydra는 사용자에게 `devfront` 앱이 요청한 권한(openid, profile, email 등)을 허용할지 묻는 Consent 화면을 띄웁니다.
* 사용자가 '허용'을 누르면 Hydra는 `devfront`가 신뢰할 수 있는 앱임을 기록합니다.
4. **인증 코드 전달 및 토큰 교환 (Callback)**:
* Hydra는 사용자를 `devfront`의 콜백 페이지(`http://localhost:5174/auth/callback?code=...`)로 보냅니다.
* `devfront`는 이 코드를 Hydra의 토큰 엔드포인트로 보내 **ID Token**과 **Access Token**을 발급받습니다.
5. **사용자 정보 로드 (Profile Recovery)**:
* `devfront`는 발급받은 **ID Token**의 Payload를 디코딩하여 사용자 이름, 이메일 등의 프로필 정보를 즉시 화면에 렌더링합니다.
---
## 2. 클라이언트 자동 등록의 원리
사용자가 직접 `devfront`를 클라이언트로 등록하지 않았음에도 로그인이 가능한 이유는 인프라 설정 파일인 `compose.ory.yaml`에 정의된 **`init-rp` 컨테이너** 덕분입니다.
### `init-rp` 서비스의 역할
* Ory 스택(Kratos, Hydra, Keto)이 모두 정상 가동된 직후 실행됩니다.
* Hydra Admin API를 호출하여 서비스 운영에 필수적인 기본 클라이언트들을 자동으로 생성합니다.
### 자동 등록된 `devfront` 명세
```bash
hydra clients create
--endpoint http://hydra:4445
--id devfront
--grant-types authorization_code,refresh_token
--response-types code
--scope openid,offline_access,profile,email
--token-endpoint-auth-method none \ # Public Client (PKCE 사용)
--callbacks http://localhost:5174/auth/callback;
```
이 설정으로 인해 `devfront`라는 ID의 클라이언트가 미리 존재하게 되며, `localhost:5174/auth/callback`으로의 리다이렉션이 안전하게 허용됩니다.
---
## 3. 무한 루프 문제 해결 분석
### 발생 원인 (Problem)
* `devfront`가 로그인 성공 후 사용자 정보를 가져오기 위해 백엔드 API인 `/api/v1/user/me`를 호출했습니다.
* 이 API는 백엔드 세션 쿠키를 기반으로 동작하도록 설계되어 있었습니다.
* 하지만 브라우저 보안 정책(SameSite/Cross-Domain)으로 인해 `localhost`에서 보낸 요청에는 `sso.example.test` 도메인의 쿠키가 포함되지 않았습니다.
* **결과**: 백엔드는 401 Unauthorized를 반환 -> `devfront`는 401을 받으면 다시 로그인을 시도하도록 구현되어 있어 무한 루프가 발생했습니다.
### 해결 방법 (Solution)
* **쿠키 의존성 제거**: `AppLayout.tsx`에서 백엔드 호출(`fetchMe`)을 삭제했습니다.
* **ID Token 직접 활용**: 이미 OIDC 인증 성공 시점에 전달받은 **ID Token(`auth.user.profile`)**에 필요한 모든 사용자 정보가 들어있으므로, 이를 직접 사용하도록 수정했습니다.
* **표준 RP 구조 확립**: 이제 `devfront`는 백엔드의 세션 쿠키를 전혀 사용하지 않으며, API 요청 시에도 `Authorization: Bearer <token>` 헤더를 사용하여 정당한 권한을 증명합니다.

View File

@@ -0,0 +1,53 @@
# 커스텀 필드 기반 로그인 ID 연동 - DB 설계 문서
## 1. 개요
본 문서는 사용자(User) 정보에 범용 로그인 ID(`login_id`)를 추가하고, 이를 테넌트별 설정에 따라 커스텀 필드와 동기화하기 위한 **데이터베이스(DB) 관점의 설계 변경 사항**을 명세합니다.
## 2. DB 스키마 변경 사항
### 2.1. 대상 테이블: `users`
현재 백엔드(PostgreSQL)의 `users` 테이블에 `login_id` 컬럼을 추가합니다.
| 컬럼명 | 타입 | 제약 조건 | 설명 |
| :--- | :--- | :--- | :--- |
| `login_id` | `VARCHAR(255)` | `NULL` 허용 | 범용 로그인 식별자 (사번, 학번 등) |
#### 인덱스(Index) 설정
단순 `unique`가 아닌, **테넌트 내 고유성**을 보장하기 위해 `tenant_id`와 조합된 복합 유니크 인덱스를 생성합니다.
* **인덱스명**: `idx_tenant_login_id`
* **구성 컬럼**: `(tenant_id, login_id)`
* **효과**:
* 서로 다른 테넌트 간에는 동일한 `login_id`가 존재할 수 있습니다.
* 동일 테넌트 내에서는 중복된 `login_id`를 가질 수 없습니다.
### 2.2. 대상 테이블: `tenants`
`tenants` 테이블의 `config` (JSONB) 컬럼에 매핑 설정을 추가합니다.
* **설정 키**: `loginIdField`
* **설명**: 사용자의 `metadata` (커스텀 필드) 중 어떤 필드의 값을 `login_id`로 동기화할지 결정하는 필드 키 이름입니다.
* **예시**: `"loginIdField": "emp_no"`
## 3. 데이터 흐름 및 동기화
### 3.1. 사용자 생성/수정 (Sync Flow)
1. 사용자 생성/수정 시 전달된 `metadata`와 테넌트의 `config.loginIdField`를 확인합니다.
2. `metadata` 내에 해당 키의 값이 존재하면, 그 값을 `users.login_id` 컬럼에 저장합니다.
3. 동시에 Ory Kratos의 `traits.id` 필드에도 해당 값을 업데이트하여 Kratos를 통한 로그인을 가능하게 합니다.
### 3.2. 대량 업로드 (Bulk Import)
1. CSV/Excel 파일의 컬럼 중 테넌트에서 지정한 커스텀 필드 컬럼(예: 사번)을 파싱합니다.
2. 위의 동기화 로직과 동일하게 `login_id` 컬럼과 Kratos Traits를 업데이트합니다.
## 4. GORM 모델 반영 (Go)
```go
type User struct {
ID string `gorm:"primaryKey;type:uuid"`
Email string `gorm:"uniqueIndex;not null"`
LoginID string `gorm:"column:login_id;uniqueIndex:idx_tenant_login_id"`
TenantID *string `gorm:"column:tenant_id;type:uuid;uniqueIndex:idx_tenant_login_id"`
// ... 기타 필드
}
```

View File

@@ -0,0 +1,47 @@
# 커스텀 필드 기반 로그인 ID 연동 설계 문서 (구 사번 로그인)
## 1. 개요
기존에 고정된 사번(`employee_id`) 필드를 사용하려던 설계를 변경하여, 테넌트별로 지정한 **커스텀 필드**를 실제 로그인 식별자로 사용할 수 있도록 하는 범용적인 로그인 ID 체계를 구축합니다.
## 2. 시스템별 변경 사항
### 2.1. Ory Kratos (인증 서버)
* **Identity Schema**: `traits` 내에 `id` 필드를 추가합니다.
* **Identifier 설정**: `traits.id``credentials.password.identifier`로 설정하여 로그인 시 식별자로 사용할 수 있게 합니다.
* **특징**: 이 필드는 '사번', '학번', 'ID' 등 테넌트의 성격에 따라 다양한 용도로 활용되는 범용 식별자 역할을 합니다.
### 2.2. Backend (baron-sso-backend)
#### 데이터 모델 (`domain.User`)
* `login_id` (string) 컬럼 추가.
* `idx_tenant_login_id` 복합 유니크 인덱스 생성: `(tenant_id, login_id)`.
#### 테넌트 설정 (`domain.Tenant`)
* `Config` 내에 `loginIdField` 설정을 추가합니다.
* 이 설정은 해당 테넌트의 `userSchema` 중 어떤 필드(key)가 로그인 ID로 사용될지를 저장합니다.
#### 동기화 로직 (`UserHandler`)
* **사용자 생성/수정 시**:
1. 테넌트의 `loginIdField` 설정을 조회합니다.
2. 설정된 필드가 있다면 사용자의 `Metadata`에서 해당 값을 추출합니다.
3. 추출된 값을 Kratos의 `traits.id`와 로컬 DB의 `login_id` 컬럼에 동기화합니다.
* **대량 등록 (Bulk Import)**: CSV/JSON 업로드 시에도 동일한 동기화 로직이 적용됩니다.
### 2.3. Frontend (adminfront)
#### 테넌트 스키마 관리 (`TenantSchemaPage`)
* 커스텀 필드 정의 시 "로그인 ID로 사용" 체크박스를 추가합니다.
* 이 체크박스를 선택하면 해당 필드의 `key`가 테넌트 `Config.loginIdField`에 저장됩니다.
#### 사용자 관리 (`UserCreatePage`, `UserDetailPage`)
* 기본 정보 영역에 "로그인 ID" 필드를 노출하여 직접 관리할 수 있게 합니다.
### 2.4. Frontend (userfront)
#### 로그인 페이지 (`LoginScreen`)
* URL의 `companyCode` 또는 도메인을 통해 테넌트를 식별합니다.
* 해당 테넌트에 `loginIdField`가 설정되어 있다면, 로그인 입력란의 라벨을 해당 커스텀 필드의 라벨(예: "사번")로 동적으로 변경합니다.
## 3. 기대 효과
* 테넌트별로 상이한 로그인 식별자 요구사항(사번, 학생번호, 커스텀 ID 등)을 코드 수정 없이 유연하게 수용할 수 있습니다.
* 이메일, 전화번호 외의 추가적인 로그인 수단을 제공하여 사용자 편의성을 높입니다.

View File

@@ -0,0 +1,213 @@
# 외부 통합 모니터링 및 로그 수집 시스템 설계서 (Prometheus + Promtail + Loki)
## 1. 개요 (Overview)
본 문서는 Baron SSO 서비스가 배포될 **스테이징 서버**의 기존 도커(Docker) 기반 모니터링 및 로깅 인프라를 활용하여, **가용성 헬스체크(메트릭 수집)**와 **컨테이너 실시간 로그 통합 수집(로그 분석)**을 동시에 달성하고, 장애 상황 발생 시 담당자에게 즉시 SMS를 전송하는 엔드투엔드(End-to-End) 연동 설계를 정의합니다.
- **메트릭(상태) 모니터링**: Prometheus + Grafana를 활용하여 `/health` 및 프론트엔드 포트 가용성 수집
- **로그(텍스트) 모니터링**: Promtail + Loki를 활용하여 컨테이너 실시간 로그 수집 및 에러/패닉 로그 실시간 알림
- **장애 알림 전파**: 기존 사내 SMS 게이트웨이 서비스인 [grafana-sms-webhook](https://gitea.hmac.kr/ai-team/grafana-sms-webhook)를 연동하여 실시간 알림 수신
---
## 2. 네트워크 및 데이터 수집 아키텍처 (Architecture)
```
[ Staging Host Docker Environment ]
+-------------------------------------------------------------+
| baron_net (External Docker Network) |
| |
| +--------------------+ +--------------------+ |
| | baron_backend | | baron_adminfront | ... |
| | (Port 3000) | | (Port 5173) | |
| +----+---------+-----+ +----+---------+-----+ |
| | | | | |
| | | | | |
| | | (Docker Log | | |
| | | Stream) | | |
| | +-------+ | | |
| | v | v |
| | +----+-----+---------+-----+ |
| | | baron_promtail | (신규 수집기) |
| | | (Docker Socket 마운트) | |
| | +----------+---------------+ |
| | | (Push Logs) |
| | v |
| | +----------+---------------+ |
| | | Loki Container | (기존 분석기) |
| | +----------+---------------+ |
| | ^ |
| | (Scrape HTTP) | (Query Logs) |
| +----+-----------------------+-----------------------+ |
| | Prometheus / Grafana Container | |
| | (baron_net 네트워크 참여 / 수집 및 얼럿 룰 감시) | |
| +----------------------------+-----------------------+ |
| | (Alert Webhook) |
| v |
| +----------------------------+-----------------------+ |
| | grafana-sms-webhook | |
| | (사내 SMS API 게이트웨이 연동) | |
| +----------------------------+-----------------------+ |
+-------------------------------|-----------------------------+
|
| (NCP SENS Call)
v
[ Naver Cloud NCP ]
|
| (SMS/LMS)
v
[ Infra Administrator ]
```
---
## 3. 스테이징 배포 파일 반영 사양 (Staging Deployment Changes)
### 3.1 `docker-compose.staging.template.yaml` 변경 사항
`grafana-sms-webhook`과 로그 수집기인 `promtail` 컨테이너를 함께 기동하도록 추가합니다.
```yaml
# docker/docker-compose.staging.template.yaml 하단에 추가
services:
# ... 기존 backend, adminfront, userfront 등 서비스 정의 ...
grafana-sms-webhook:
# 저장소 주소: https://gitea.hmac.kr/ai-team/grafana-sms-webhook
image: ${SMS_WEBHOOK_IMAGE_NAME:-gitea.hmac.kr/ai-team/grafana-sms-webhook}:${IMAGE_TAG:-latest}
container_name: grafana_sms_webhook
restart: unless-stopped
env_file:
- .env
environment:
- NAVER_CLOUD_ACCESS_KEY=${NAVER_CLOUD_ACCESS_KEY}
- NAVER_CLOUD_SECRET_KEY=${NAVER_CLOUD_SECRET_KEY}
- NAVER_CLOUD_SERVICE_ID=${NAVER_CLOUD_SERVICE_ID}
- NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER}
- MONITOR_RECIPIENT_PHONES=${MONITOR_RECIPIENT_PHONES} # 콤마(,) 구분 수신처 번호
ports:
- "${SMS_WEBHOOK_PORT:-8080}:8080"
networks:
- baron_net
promtail:
image: grafana/promtail:2.9.0
container_name: baron_promtail
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- ./docker/promtail-config.template.yaml:/etc/promtail/promtail-config.yaml:ro
command: -config.file=/etc/promtail/promtail-config.yaml -config.expand-env=true
environment:
- LOKI_URL=${LOKI_URL:-http://loki:3100/loki/api/v1/push}
- APP_ENV=${APP_ENV:-development}
networks:
- baron_net
networks:
baron_net:
external: true
name: baron_net
```
### 3.2 `promtail-config.template.yaml` 설정 사양
수집기가 도커 소켓을 읽어 컨테이너명을 자동으로 식별하고, Baron SSO 관련 로그만 선별하여 라벨을 붙인 후 Loki로 전송합니다.
```yaml
# docker/promtail-config.template.yaml
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: ${LOKI_URL:-http://loki:3100/loki/api/v1/push}
scrape_configs:
- job_name: baron-sso-container-logs
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 10s
relabel_configs:
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: 'container_name'
# Baron SSO 및 핵심 Ory Stack 컨테이너만 필터링하여 로그 수집
- source_labels: ['container_name']
regex: '(baron_.*|oathkeeper|kratos|hydra|keto)'
action: keep
# 컨테이너 명에서 앞의 접두사를 떼어 서비스 및 잡 라벨 부여 (예: baron_backend -> backend)
- source_labels: ['container_name']
regex: 'baron_(.*)'
target_label: 'service'
- source_labels: ['container_name']
regex: 'baron_(.*)'
target_label: 'job'
# 동적 라벨 추가
- target_label: 'app_env'
replacement: '${APP_ENV:-development}'
```
---
## 4. 기존 Prometheus & Loki 연동 가이드
### 4.1 1단계: 기존 컨테이너를 `baron_net`에 합류
기존에 동작 중인 Prometheus, Loki, Grafana 컨테이너가 `baron_net` 내부 도커 DNS를 인식할 수 있도록 연결합니다.
```bash
docker network connect baron_net prometheus
docker network connect baron_net loki
docker network connect baron_net grafana
```
### 4.2 2단계: Prometheus 수집 설정 (`prometheus.yml`)
```yaml
scrape_configs:
- job_name: 'baron-sso-backend-staging'
metrics_path: '/health'
scrape_interval: 15s
static_configs:
- targets: ['baron_backend:3000']
```
---
## 5. Grafana 이중 알림 설정 (메트릭 알림 + 로그 알림)
기존 Grafana에서 다음 두 종류의 알림 규칙을 지정하고 수신처로 `grafana_sms_webhook`을 연동합니다.
### 5.1 메트릭 기반 가용성 얼럿 (Prometheus 데이터 소스)
* **목적**: 백엔드가 완전히 다운되거나 `/health` 가 503 에러를 리턴할 때 문자 발송
* **쿼리 예시**: `up{job="baron-sso-backend-staging"} == 0`
* **지속 기간(For)**: `3m`
* **장애 문자 템플릿**:
```text
[Baron SSO 서버 다운 얼럿]
대상: baron_backend
상태: DOWN (접속 불가)
내용: 백엔드 컨테이너가 정상적으로 동작하지 않거나 웹 서버가 중단되었습니다. 즉시 서버 상태를 점검해 주십시오.
```
### 5.2 로그 기반 실시간 에러/패닉 얼럿 (Loki 데이터 소스)
* **목적**: 서버는 돌고 있으나 내부 로직 상 치명적인 예외(Panic, Error)가 대량 발생하여 실사용자가 오작동을 겪을 때 문자 전송
* **쿼리 예시 (LogQL)**: `sum(count_over_time({app_env="stage", service="backend"} |= "panic" [5m])) > 0` 또는 `|= "ERROR"`
* **지속 기간(For)**: `0m` (발생 즉시 신속 문자 발송)
* **장애 문자 템플릿**:
```text
[Baron SSO 로그 에러 경보]
대상: baron_backend (Loki 수집 로그)
상태: 치명적인 에러/패닉 실시간 감지
내용: 백엔드 서비스 콘솔 로그에서 panic 또는 ERROR 키워드가 실시간으로 감지되었습니다. 로그 모니터링 대시보드를 확인하십시오.
```
---
## 6. 기대 효과 및 결론
1. **완벽한 가시성(Full Observability)**: 단순 서버 기동 여부 검사를 넘어, 서버 내부에서 도는 세부 에러나 버그 로그(Panic)까지 완전하게 모니터링 체계에 포착합니다.
2. **이중 알림으로 완벽 방어**: 네트워크 장비 고장에 의한 접속 실패는 **메트릭 얼럿**으로 잡고, 내부 로직 결함에 의한 기능 오작동은 **로그 얼럿**으로 이중 방어하여 인프라 가용성 99.99%를 보장합니다.
3. **효율적인 인프라 일원화**: 동일 그라파나 대시보드 내에서 메트릭 시각화와 로그 검색을 동시 처리하며, `grafana-sms-webhook` 통합 채널 하나만으로 모든 장애 문자를 송출합니다.

View File

@@ -0,0 +1,76 @@
# [Master Plan] 프론트엔드 모노레포 통합 및 공용 모듈 마이그레이션
## 1. 개요 (Overview)
현재 `adminfront`, `devfront`, `orgfront` 세 개의 프론트엔드 프로젝트는 동일한 기술 스택을 사용함에도 불구하고 코드가 각자 복제되어 관리되고 있습니다. 이를 **NPM Workspaces 기반의 모노레포** 구조로 개편하여 중복을 제거하고, 기술적 일관성을 확보하는 것이 본 설계의 목적입니다.
---
## 2. 아키텍처 설계 (Architecture)
### 2.1. 디렉토리 구조
프로젝트 루트를 Workspace로 설정하고, 모든 앱과 공용 모듈을 `packages/` 하위로 통합 관리합니다.
```text
baron-sso/
├── package.json # [New] 루트 레벨 Workspace 및 의존성 관리
├── packages/ # 재사용 가능한 내부 패키지
│ ├── shared-ui/ # Shadcn UI, 제네릭 AppLayout, 브랜드 자산(로고/아이콘)
│ ├── shared-utils/ # apiClient 팩토리, i18n 엔진, 공용 로케일(사전 병합)
│ ├── shared-auth/ # OIDC 설정, AuthGuard 본체, 세션 관리(Sliding Session)
│ ├── shared-types/ # 도메인 모델 및 API TypeScript 타입
│ └── shared-config/ # Tailwind, Biome, TSConfig 공통 설정
├── adminfront/ # Feature 중심 유지 + Shared 패키지 참조
├── devfront/ # App Shell 공유 + Shared 패키지 참조
└── orgfront/ # App Shell 공유 + Shared 패키지 참조
```
### 2.2. 모노레포 도입의 이유 (Why Workspaces?)
1. **깔끔한 임포트**: `../../common` 같은 상대 경로 대신 `@shared/ui`와 같은 패키지 이름으로 참조 가능.
2. **의존성 단일화**: 모든 앱이 동일한 라이브러리 버전을 사용하여 런타임 에러 방지.
3. **설정 자동화**: Vite와 TypeScript가 로컬 패키지를 자동으로 인식하여 HMR 및 타입 체킹 지원.
---
## 3. 핵심 기술 설계 (Technical Governance)
### 3.1. 의존성 관리 (Single Version Policy)
- `react`, `react-router-dom`, `tailwindcss`, `@tanstack/react-query` 등 핵심 라이브러리 버전을 루트 `package.json`에서 고정 관리합니다.
- 각 패키지는 필요한 경우에만 별도 의존성을 가지며, 가능한 루트 버전을 따릅니다.
### 3.2. i18n 및 에셋 전략
- **동적 병합(Dictionary Merge)**: 공용 문구(Shared)를 먼저 로드하고, 앱별 특화 문구를 그 위에 덮어쓰는(Merge) 로직을 `shared-utils`에서 제공합니다.
- **자산 중앙화**: 브랜드 로고, 파비콘 등 공통 정적 자산은 `shared-ui/assets`에서 관리합니다.
### 3.3. 제네릭 AppLayout 엔진
- `AppLayout.tsx`는 더 이상 하드코딩된 메뉴를 갖지 않습니다.
- 메뉴(`navItems`), 브랜드 로고, 역할 스위처 표시 여부 등을 **설정 객체(Config)**로 주입받아 렌더링하는 범용 엔진으로 리팩토링합니다.
---
## 4. 실행 로드맵 (Execution Roadmap)
### **[1단계] 인프라 구축 (Infrastructure)**
- 루트 `package.json` Workspace 설정.
- `shared-config` 패키지 생성 및 Tailwind/Biome 설정 중앙화.
- 각 앱에서 `@shared/config`를 참조하도록 설정 변경.
### **[2단계] 기초 모듈 추출 (Base Migration)**
- 중복도가 가장 높은 `components/ui/*``shared-ui`로 이동.
- `apiClient`, `i18n`, `utils` 등 순수 유틸리티 코드를 `shared-utils`로 이동.
- 각 앱의 임포트 경로를 `@shared/*`로 일괄 치환.
### **[3단계] 플랫폼 레이어 통합 (Platform)**
- `AppLayout.tsx`를 제네릭하게 리팩토링하여 `shared-ui`로 이동.
- `AuthGuard`, `LoginPage`, `sessionSliding` 등 인증 관련 핵심 로직을 `shared-auth`로 통합.
### **[4단계] 앱별 적용 및 최적화 (Optimization)**
- `devfront``orgfront`를 우선적으로 공용 레이아웃 기반으로 전환.
- `adminfront`는 비즈니스 로직(Feature)은 유지하되 기반 레이어만 교체.
- 빌드 테스트 및 트리쉐이킹(번들 최적화) 확인.
---
## 5. 기대 효과 (Expected Benefits)
- **관리 비용 절감**: 공통 UI 수정 시 3개 앱에 동시 반영.
- **개발 가속화**: 신규 프론트엔드 프로젝트 시작 시 인프라 세팅 시간 단축.
- **기술적 일관성**: 모든 프론트엔드가 동일한 기술 표준과 디자인 시스템을 공유.

View File

@@ -0,0 +1,45 @@
# Frontend 기능과 백엔드 테스트 매핑 가이드
이 문서는 `devfront``userfront`의 Hydra 관련 기능이 백엔드의 어떤 API를 호출하고, 해당 API가 어떤 테스트 코드로 검증되는지 설명합니다. 모든 기능은 백엔드에 이미 구현되어 있으며, '테스트' 열은 해당 기능을 검증하는 자동화 테스트의 존재 여부를 나타냅니다.
## 1. `devfront` (개발자/관리자 포털)
`devfront`는 OAuth2 클라이언트(RP)를 생성하고 관리하는 데 사용됩니다.
| `devfront` 기능 | 백엔드 API | 검증 테스트 파일 | 테스트 상태 |
| :--- | :--- | :--- | :--- |
| **클라이언트 목록 조회** | `GET /api/v1/dev/clients` | `dev_handler_test.go` | `TestListClients_Success` |
| **클라이언트 생성** | `POST /api/v1/dev/clients` | `dev_handler_test.go` | `TestCreateClient_Success` |
| **클라이언트 상세 조회** | `GET /api/v1/dev/clients/:id` | `dev_handler_test.go` | `TestGetClient_Success`, `TestGetClient_NotFound` |
| **클라이언트 정보 수정** | `PUT /api/v1/dev/clients/:id` | - | (테스트 미작성) |
| **클라이언트 상태 변경** | `PATCH /api/v1/dev/clients/:id/status`| - | (테스트 미작성) |
| **클라이언트 삭제** | `DELETE /api/v1/dev/clients/:id` | - | (테스트 미작성) |
| **시크릿 재발급** | `POST /api/v1/dev/clients/:id/rotate-secret`| - | (테스트 미작성) |
| **동의한 사용자 목록 조회**| `GET /api/v1/dev/consents` | - | (테스트 미작성) |
| **사용자 동의 철회** | `DELETE /api/v1/dev/consents` | - | (테스트 미작성) |
*참고: `dev_handler.go` 내의 기능들은 백엔드에 구현되어 있으나, 이번 커버리지 90% 달성 목표(핵심 인증 로직 중심)에서 관리자 기능으로 분류되어 우선순위가 조정되었습니다.*
---
## 2. `userfront` (사용자 포털)
`userfront`는 최종 사용자가 애플리케이션(RP)의 정보 접근 요청을 승인하거나 거부하는 OIDC 동의 화면 및 연동 관리를 처리합니다.
### 2.1. OIDC 동의 (Consent) 및 연동 관리
| `userfront` 기능 | 백엔드 API | 검증 테스트 파일 | 테스트 상태 |
| :--- | :--- | :--- | :--- |
| **동의 정보 조회** | `GET /api/v1/auth/consent` | `auth_handler_consent_test.go` | `TestGetConsentRequest_Normal` |
| **동의 승인** | `POST /api/v1/auth/consent/accept` | `auth_handler_consent_test.go` | `TestAcceptConsentRequest_Normal` |
| **동의 거부** | `POST /api/v1/auth/consent/reject` | - | (테스트 미작성) |
| **연동된 앱 목록 조회** | `GET /api/v1/user/rp/linked` | `auth_handler_linked_test.go` | `TestListLinkedRps_PriorityAndAggregation` |
| **연동 해제 (Revoke)** | `DELETE /api/v1/user/rp/linked/:id`| `auth_handler_client_test.go` | `TestRevokeLinkedRp_Success` |
| **연동 이력 조회** | `GET /api/v1/user/rp/history` | `auth_handler_client_test.go` | `TestListRpHistory_Aggregation` |
### 2.2. 인증 플로우 (Login Flows)
| `userfront` 기능 | 백엔드 API | 검증 테스트 파일 | 테스트 상태 |
| :--- | :--- | :--- | :--- |
| **QR 로그인 초기화** | `POST /api/v1/auth/qr/init` | `auth_handler_qr_test.go` | `TestQRLoginFlow_Success` |
| **QR 로그인 승인 (Scan)** | `POST /api/v1/auth/qr/approve` | `auth_handler_qr_test.go` | `TestScanQRLogin_Success` |
| **매직 링크 초기화** | `POST /api/v1/auth/enchanted-link/init`| `auth_handler_link_test.go` | `TestEnchantedLinkFlow_Email_Success` |
| **매직 링크 검증** | `POST /api/v1/auth/magic-link/verify` | `auth_handler_link_test.go` | `TestEnchantedLinkFlow_Email_Success` |

View File

@@ -0,0 +1,51 @@
# hydra-rp-dummy 사용 기록
## 목적
`devfront/hydra-rp-dummy.py`를 이용해 Hydra에 더미 RP consent를 생성하고, UserFront 활동상황 카드에 반영되는지 확인합니다.
## 사전 조건
- Hydra/크라토스 스택이 실행 중이어야 합니다.
- `ory_hydra` 컨테이너가 존재해야 합니다.
- Docker 이미지 `python:3.12-alpine`가 필요합니다.
## 입력 값
- `CLIENT_ID`: 더미 RP의 client_id
- `SUBJECT`: Kratos identity id (예: `22607c1b-bfbf-4a90-9505-36b348472e7a`)
- `REDIRECT_URI`: client에 등록된 redirect_uri
- `SCOPE`: `openid profile email`
- `STATE`, `NONCE`: 충분히 긴 랜덤 값
## 실행 방법
다음 명령으로 `hydra-rp-dummy.py`를 컨테이너에 마운트해 실행합니다.
```bash
docker run --rm --network container:ory_hydra \
-v /home/lectom/repos/baron-sso/devfront/hydra-rp-dummy.py:/tmp/hydra-rp-dummy.py:ro \
-e CLIENT_ID=52a597f0-5b06-4fcb-b804-93e88a56a75a \
-e SUBJECT=22607c1b-bfbf-4a90-9505-36b348472e7a \
-e REDIRECT_URI=https://example.com/callback \
-e SCOPE='openid profile email' \
-e STATE=state-$(date +%s%N) \
-e NONCE=nonce-$(date +%s%N) \
python:3.12-alpine python /tmp/hydra-rp-dummy.py
```
## 동작 방식 요약
- `hydra-rp-dummy.py`가 127.0.0.1:3000에 임시 consent app을 띄웁니다.
- `/oauth2/auth` 호출로 login_challenge를 받고 자동 수락합니다.
- 이어서 consent_challenge를 받아 자동 수락합니다.
- 마지막 redirect 단계에서 CSRF 에러가 발생할 수 있으나, **consent 세션 자체는 생성됩니다.**
## 생성 확인 방법
Hydra Admin API에서 consent 세션을 확인합니다.
```bash
docker run --rm --network container:ory_hydra curlimages/curl:8.11.1 \
sh -lc 'curl -s "http://127.0.0.1:4445/oauth2/auth/sessions/consent?subject=22607c1b-bfbf-4a90-9505-36b348472e7a&client=52a597f0-5b06-4fcb-b804-93e88a56a75a"'
```
예시 응답에 `grant_scope`, `handled_at`, `client_id` 등이 포함되면 성공입니다.
## 참고
- `URLS_LOGIN`, `URLS_CONSENT`는 현재 `http://127.0.0.1:3000`으로 설정되어 있습니다.
- 따라서 임시 consent app 없이는 consent 생성이 진행되지 않습니다.

View File

@@ -0,0 +1,147 @@
# Backend Hydra Test Guide
이 문서는 Baron SSO 백엔드 내에서 **Ory Hydra Admin API**와 연동되는 기능(`HydraAdminService`)을 테스트하는 방법과 커버리지 측정 방법을 설명합니다.
## 1. 테스트 개요
백엔드는 OAuth2 클라이언트 관리, 인증/동의(Consent) 요청 승인 등을 위해 Ory Hydra의 Admin API를 호출합니다.
본 테스트 가이드는 `httptest` 패키지와 Mocking을 활용하여 실제 Hydra 서버 없이 백엔드의 연동 로직을 빠르고 독립적으로 검증하는 방법을 다룹니다.
(주의: 본 가이드는 관리자용 UI인 `adminfront` 테스트가 아닌, 백엔드 내부의 API 연동 코드 테스트를 다룹니다.)
## 2. 테스트 환경 준비
테스트는 Go 언어의 표준 테스팅 프레임워크를 사용하므로 별도의 설치가 필요 없으나, 커버리지 확인을 위해 `backend/` 디렉토리에서 작업을 수행해야 합니다.
```bash
cd backend
```
## 3. 테스트 파일 목록 및 실행 방법
작성된 Hydra 관련 테스트 코드는 크게 3가지 파일로 나뉩니다.
### 3.1. HydraAdminService (백엔드 내부 서비스) 테스트
백엔드 내부에서 Ory Hydra Admin API와 통신하는 최하단 로직(클라이언트 관리, OIDC 흐름, 세션 관리)을 검증합니다.
* **위치:** `backend/internal/service/hydra_admin_service_test.go`
* **실행:**
```bash
go test -v ./internal/service -run TestHydraAdminService
```
### 3.2. Relying Party Service 테스트
`HydraAdminService`와 로컬 DB(RelyingParty) 간의 통합 및 롤백 로직을 검증합니다.
* **위치:** `backend/internal/service/relying_party_service_test.go`
* **실행:**
```bash
go test -v ./internal/service -run TestRelyingPartyService
```
### 3.3. Auth Handler 로그인 테스트
로그인 요청 시 백엔드 핸들러 레벨에서 발생하는 OIDC/Hydra 흐름(Login Challenge 처리 등)을 검증합니다.
* **위치:** `backend/internal/handler/auth_handler_login_test.go`
* **실행:**
```bash
go test -v ./internal/handler -run TestPasswordLogin
```
### 3.4. 전체 테스트 실행 (권장)
모든 Hydra 관련 연동 테스트를 한 번에 실행하려면 다음 명령어를 사용합니다.
```bash
go test -v ./internal/service ./internal/handler -run "TestHydraAdminService|TestRelyingPartyService|TestPasswordLogin"
```
## 4. 테스트 커버리지 측정
테스트가 코드의 어느 부분을 검증했는지 수치로 확인합니다.
### 4.1. 수치로 확인 (CLI)
```bash
# 1. HydraAdminService (백엔드 서비스) 커버리지
go test -v ./internal/service -run TestHydraAdminService -coverprofile=coverage_hydra.out
go tool cover -func=coverage_hydra.out | grep hydra_admin_service.go
# 2. RelyingPartyService (통합 서비스) 커버리지
go test -v ./internal/service -run TestRelyingPartyService -coverprofile=coverage_rp.out
go tool cover -func=coverage_rp.out | grep relying_party_service.go
```
**예시 출력:**
```text
baron-sso-backend/internal/service/hydra_admin_service.go:35: ListClients 100.0%
baron-sso-backend/internal/service/hydra_admin_service.go:70: GetClient 85.7%
...
total: (statements) 78.5%
```
### 4.2. 시각적으로 확인 (HTML)
어떤 코드가 테스트되지 않았는지(Missing Branch) 브라우저에서 색상으로 확인합니다.
```bash
go tool cover -html=coverage_hydra.out
```
* **초록색**: 테스트에 의해 실행된 코드
* **빨간색**: 테스트되지 않은 코드 (추가 테스트 케이스 필요)
* **회색**: 테스트 대상이 아닌 코드 (선언부 등)
## 5. 주요 테스트 항목 (Checklist)
| 분류 | 메서드 | 테스트 내용 | 파일 위치 |
| :--- | :--- | :--- | :--- |
| **클라이언트 관리** | `ListClients` | 클라이언트 목록 페이징 조회 | `hydra_admin_service_test.go` |
| | `GetClient` | 특정 클라이언트 상세 조회 (성공/실패) | `hydra_admin_service_test.go` |
| | `CreateClient` | 신규 클라이언트 생성 및 메타데이터 검증 | `hydra_admin_service_test.go` |
| | `UpdateClient` | 클라이언트 정보 수정 (PUT) | `hydra_admin_service_test.go` |
| | `PatchClientStatus` | 클라이언트 상태 변경 (JSON Patch) | `hydra_admin_service_test.go` |
| | `DeleteClient` | 클라이언트 삭제 | `hydra_admin_service_test.go` |
| **인증/동의** | `GetConsentRequest` | Consent Challenge 검증 및 요청 정보 조회 | `hydra_admin_service_test.go` |
| | `AcceptConsentRequest` | 동의 승인 및 리다이렉트 URL 반환 | `hydra_admin_service_test.go` |
| | `RejectConsentRequest` | 동의 거부 처리 | `hydra_admin_service_test.go` |
| | `GetLoginRequest` | Login Challenge 검증 | `hydra_admin_service_test.go` |
| | `AcceptLoginRequest` | 로그인 승인 및 리다이렉트 URL 반환 | `hydra_admin_service_test.go` |
| | `RejectLoginRequest` | 로그인 거부 처리 | `hydra_admin_service_test.go` |
| **세션 관리** | `ListConsentSessions` | 특정 사용자의 활성 세션 목록 조회 | `hydra_admin_service_test.go` |
| | `RevokeConsentSessions` | 특정 사용자/클라이언트의 세션 만료 처리 | `hydra_admin_service_test.go` |
| **서비스 통합** | `Create` (RP) | Hydra 생성 -> DB 생성 -> Keto 권한 부여 | `relying_party_service_test.go` |
| | `Create` (Rollback) | DB 실패 시 Hydra 롤백(삭제) 검증 | `relying_party_service_test.go` |
| **핸들러 연동** | `PasswordLogin` | OIDC 로그인 성공 및 Challenge 승인 | `auth_handler_login_test.go` |
| | `PasswordLogin` | 비활성(Inactive) 클라이언트 로그인 차단 | `auth_handler_login_test.go` |
## 6. 테스트 코드 작성 가이드
새로운 기능을 추가하거나 커버리지를 높일 때 다음 패턴을 참고하세요.
```go
func TestHydraAdminService_NewFeature(t *testing.T) {
// 1. Mock 핸들러 정의 (예상되는 요청 검증 및 가짜 응답 반환)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Assert: 요청 메서드, URL, 바디 검증
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
// Response: 가짜 응답 작성
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(expectedResponse)
})
// 2. 서비스 초기화 (Mock Client 주입)
svc := &HydraAdminService{
AdminURL: "http://hydra:4445",
HTTPClient: mockHydraClient(handler), // ory_service_test.go의 헬퍼 사용
}
// 3. 테스트 실행 및 검증
result, err := svc.NewFeature(context.Background(), args)
if err != nil {
t.Fatalf("failed: %v", err)
}
// Assert: 결과값 검증
}
```

View File

@@ -0,0 +1,144 @@
# Backend Hydra Test Guide
이 문서는 Baron SSO 백엔드 내에서 **Ory Hydra Admin API**와 연동되는 기능(`HydraAdminService`)을 테스트하는 방법과 커버리지 측정 방법을 설명합니다.
## 1. 테스트 개요
백엔드는 OAuth2 클라이언트 관리, 인증/동의(Consent) 요청 승인 등을 위해 Ory Hydra의 Admin API를 호출합니다.
본 테스트 가이드는 `httptest` 패키지와 Mocking을 활용하여 실제 Hydra 서버 없이 백엔드의 연동 로직을 빠르고 독립적으로 검증하는 방법을 다룹니다.
## 2. 테스트 환경 준비
테스트는 Go 언어의 표준 테스팅 프레임워크를 사용하므로 별도의 설치가 필요 없으나, 커버리지 확인을 위해 `backend/` 디렉토리에서 작업을 수행해야 합니다.
```bash
cd backend
```
## 3. 테스트 파일 목록 및 실행 방법
Hydra와 직접적으로 연관된 백엔드 로직 테스트는 `internal/handler``internal/service` 패키지에 집중되어 있습니다.
### 3.1. 핸들러 레벨 통합 테스트 (Handler Level)
사용자 요청(HTTP)부터 Hydra 연동까지의 전체 흐름을 검증합니다.
* **주요 파일:**
* `backend/internal/handler/auth_handler_consent_test.go`
* `backend/internal/handler/auth_handler_link_test.go`
* `backend/internal/handler/auth_handler_login_test.go`
* `backend/internal/handler/auth_handler_qr_test.go`
* `backend/internal/handler/auth_handler_client_test.go`
* **실행 (패키지 전체):**
```bash
cd backend
go test -v ./internal/handler/...
```
### 3.2. 서비스 레벨 단위 테스트 (Service Level)
백엔드 내부에서 Ory Hydra Admin API와 직접 통신하는 서비스의 단위 기능을 검증합니다.
* **주요 파일:** `backend/internal/service/hydra_admin_service_test.go`
* **실행:**
```bash
cd backend
go test -v ./internal/service -run TestHydraAdminService
```
### 3.3. Relying Party Service 테스트
`HydraAdminService`와 로컬 DB(RelyingParty) 간의 통합 및 롤백 로직을 검증합니다.
* **위치:** `backend/internal/service/relying_party_service_test.go`
* **실행:**
```bash
go test -v ./internal/service -run TestRelyingPartyService
```
### 3.4. 전체 테스트 실행 (권장)
모든 Hydra 관련 연동 테스트를 한 번에 실행하려면 다음 명령어를 사용합니다.
```bash
go test -v ./internal/service ./internal/handler
```
## 4. 테스트 커버리지 측정
`internal/handler` 패키지에 대한 커버리지를 측정하고 90% 임계값을 확인합니다.
```bash
# 1. 커버리지 측정 및 coverage.out 파일 생성
cd backend
go test -coverprofile=coverage.out ./internal/handler
# 2. 함수별 커버리지 확인 (CLI)
go tool cover -func=coverage.out
# 3. 상세 리포트 확인 (HTML)
go tool cover -html=coverage.out
```
## 5. 주요 테스트 항목 (Checklist)
| 분류 | 핸들러/메서드 | 테스트 내용 | 파일 위치 |
| :--- | :--- | :--- | :--- |
| **핸들러: 인증 흐름** | `GetConsentRequest` | Consent Challenge 검증 및 자동 승인(`skip=true`) 처리 | `auth_handler_consent_test.go` |
| | `AcceptConsentRequest` | 사용자가 동의한 Scope 기반으로 Consent 승인 | `auth_handler_consent_test.go` |
| | `PasswordLogin` | OIDC 로그인 성공 및 비활성 클라이언트 차단 검증 | `auth_handler_login_test.go` |
| | `AcceptOidcLoginRequest` | 쿠키/토큰 기반 OIDC 로그인 요청 승인 | `auth_handler_oidc_test.go` |
| | `Init/Poll/ScanQRLogin` | QR 코드 생성, 폴링, 승인으로 이어지는 전체 흐름 | `auth_handler_qr_test.go` |
| | `Init/Verify/PollEnchantedLink` | Magic Link 생성, 검증, 세션 발행으로 이어지는 전체 흐름 | `auth_handler_link_test.go`|
| | `HandleKratosCourierRelay` | Kratos의 OTP 발송 요청(Email/SMS) 수신 및 처리 | `auth_handler_otp_test.go` |
| **핸들러: 세션/RP 관리** | `ListLinkedRps` | Hydra 세션, 로컬 DB, Audit Log 3-way 병합 로직 | `auth_handler_linked_test.go` |
| | `RevokeLinkedRp` | 특정 RP(클라이언트) 연동 해제 및 세션 종료 | `auth_handler_client_test.go` |
| | `ListRpHistory` | Audit Log 기반의 RP 연동 이력 조회 | `auth_handler_client_test.go` |
| | `resolveConsentSubjects` | 토큰/쿠키에서 다중 사용자 식별자(Subject) 추출 | `auth_handler_qr_test.go` |
| **서비스: 클라이언트 관리** | `ListClients` | 클라이언트 목록 페이징 조회 | `hydra_admin_service_test.go` |
| | `GetClient` | 특정 클라이언트 상세 조회 (성공/실패) | `hydra_admin_service_test.go` |
| | `CreateClient` | 신규 클라이언트 생성 및 메타데이터 검증 | `hydra_admin_service_test.go` |
| | `UpdateClient` | 클라이언트 정보 수정 (PUT) | `hydra_admin_service_test.go` |
| | `PatchClientStatus` | 클라이언트 상태 변경 (JSON Patch) | `hydra_admin_service_test.go` |
| | `DeleteClient` | 클라이언트 삭제 | `hydra_admin_service_test.go` |
| **서비스: 인증/동의** | `GetConsentRequest` | Consent Challenge 검증 및 요청 정보 조회 | `hydra_admin_service_test.go` |
| | `AcceptConsentRequest` | 동의 승인 및 리다이렉트 URL 반환 | `hydra_admin_service_test.go` |
| | `RejectConsentRequest` | 동의 거부 처리 | `hydra_admin_service_test.go` |
| | `GetLoginRequest` | Login Challenge 검증 | `hydra_admin_service_test.go` |
| | `AcceptLoginRequest` | 로그인 승인 및 리다이렉트 URL 반환 | `hydra_admin_service_test.go` |
| | `RejectLoginRequest` | 로그인 거부 처리 | `hydra_admin_service_test.go` |
| **서비스: 세션 관리** | `ListConsentSessions` | 특정 사용자의 활성 세션 목록 조회 | `hydra_admin_service_test.go` |
| | `RevokeConsentSessions` | 특정 사용자/클라이언트의 세션 만료 처리 | `hydra_admin_service_test.go` |
| **서비스: 통합** | `Create` (RP) | Hydra 생성 -> DB 생성 -> Keto 권한 부여 | `relying_party_service_test.go` |
| | `Create` (Rollback) | DB 실패 시 Hydra 롤백(삭제) 검증 | `relying_party_service_test.go` |
## 6. 테스트 코드 작성 가이드
새로운 기능을 추가하거나 커버리지를 높일 때 다음 패턴을 참고하세요.
```go
func TestHydraAdminService_NewFeature(t *testing.T) {
// 1. Mock 핸들러 정의 (예상되는 요청 검증 및 가짜 응답 반환)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Assert: 요청 메서드, URL, 바디 검증
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
// Response: 가짜 응답 작성
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(expectedResponse)
})
// 2. 서비스 초기화 (Mock Client 주입)
svc := &HydraAdminService{
AdminURL: "http://hydra:4445",
HTTPClient: mockHydraClient(handler), // ory_service_test.go의 헬퍼 사용
}
// 3. 테스트 실행 및 검증
result, err := svc.NewFeature(context.Background(), args)
if err != nil {
t.Fatalf("failed: %v", err)
}
// Assert: 결과값 검증
}
```

269
baron-sso/docs/i18n.md Normal file
View File

@@ -0,0 +1,269 @@
# Baron SSO i18n(국제화) 처리 정책
## 1. 개요 (Overview)
본 문서는 Baron SSO 시스템(Backend, UserFront, AdminFront, DevFront)의 다국어 지원을 위한 표준 정책을 정의합니다.
모든 UI 텍스트와 서버 메시지는 **하드코딩을 지양하고, 약속된 코드값(Key)을 기반으로 렌더링**해야 합니다.
---
## 2. 기본 원칙 (Core Principles)
1. **Backend는 Key만 전달한다**: API 응답에는 사용자에게 보여질 텍스트(메시지)를 포함하지 않는다. 대신 기계가 해석 가능한 `code`를 반환한다.
2. **Frontend가 렌더링 주체다**: 모든 번역 데이터(Dictionary)는 프론트엔드 애플리케이션이 보유하며, 백엔드로부터 받은 `code`나 UI의 키값을 매핑하여 적절한 언어로 표시한다.
3. **URL 기반 로케일 격리**: 언어 설정은 URL 경로(`/{locale}/...`)에 명시적으로 드러나야 한다.
4. **포맷 및 관리**:
* 모든 리소스는 **TOML** 포맷을 사용한다. (주석 가능, 가독성 우수)
* **`template.toml`**을 기준(Master)으로 삼아, 각 언어 파일(`ko.toml`, `en.toml`)이 키를 빠짐없이 구현했는지 관리한다.
---
## 3. 키(Key) 명명 규칙 (Naming Convention)
메시지 키는 계층 구조를 가지며 `snake_case``dot(.)` 표기법을 혼용하여 체계적으로 관리합니다.
TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
### 3.1 카테고리 (Category)
가장 상위 레벨에서 메시지의 성격을 정의합니다.
| Prefix (Section) | 설명 | 예시 |
| :--- | :--- | :--- |
| **`[ui]`** | 버튼, 레이블, 메뉴 등 짧은 단어 | `ui.btn.save`, `ui.nav.dashboard` |
| **`[msg]`** | 문장형 알림, 설명 텍스트, 다이얼로그 내용 | `msg.info.saved_success` |
| **`[err]`** | 백엔드 에러 코드 또는 프론트 유효성 검사 에러 | `err.auth.user_not_found` |
| **`[domain]`** | 비즈니스 도메인 용어 (명사 위주) | `domain.user.role` |
| **`[unit]`** | 시간, 화폐, 데이터 단위 | `unit.time.sec` |
### 3.2 TOML 예시 (`template.toml`)
```toml
# UI Elements
[ui]
[ui.btn]
save = ""
cancel = ""
[ui.label]
password = ""
# Messages
[msg]
[msg.info]
saved_success = ""
# Errors (Backend Codes)
[err]
[err.auth]
user_not_found = ""
```
---
## 4. 로케일 및 라우팅 정책 (Locale & Routing)
### 4.1 지원 언어 및 Fallback
* **기본 언어(Default)**: `en` (영어)
* **지원 언어**: `ko` (한국어), `en` (영어)
* **매핑 로직**:
* 브라우저 설정이 `ko`, `ko-KR` 인 경우 → **한국어 (`ko`)** 노출
* 그 외 모든 언어(`ja`, `zh`, `en-US` 등) → **영어 (`en`)** 노출
### 4.2 URL 구조
모든 페이지는 URL의 첫 번째 경로(Path Segment)로 언어를 구분합니다.
* `https://sso.baron.io/ko/login` (한국어)
* `https://sso.baron.io/en/dashboard` (영어)
### 4.3 라우팅 및 리다이렉트 동작
1. **Root 접속 시 (`/`)**:
* 사용자의 브라우저 `navigator.language`를 감지합니다.
* `ko` 계열이면 `/ko/dashboard`로 리다이렉트합니다.
* 그 외에는 `/en/dashboard`로 리다이렉트합니다.
* *단, 이전에 언어를 선택한 쿠키나 로컬스토리지 값이 있다면 그 값을 우선합니다.*
* 로컬스토리지 키는 **`locale`**을 사용합니다.
2. **언어 변경 시**:
* 모든 화면에는 **언어 선택기(Language Selector)**가 노출되어야 합니다.
* 변경 시 해당 언어의 URL로 이동(`window.location` 변경 혹은 Router Push)하며, 선택된 언어를 로컬스토리지에 저장합니다.
---
## 5. 아키텍처 및 데이터 흐름
### 5.1 백엔드 (Go Fiber)
백엔드는 번역을 수행하지 않습니다. 오직 상황에 맞는 **Error Code**만 반환합니다.
**응답 예시 (401 Unauthorized):**
```json
{
"error": "Invalid password",
"code": "auth.invalid_credentials", // TOML의 [err.auth] invalid_credentials 와 매핑
"details": null
}
```
### 5.2 프론트엔드 (React / Flutter)
#### 5.2.1 리소스 공유 및 변환
* TOML은 개발 및 관리 편의를 위한 포맷입니다.
* **Build Time** 또는 **Runtime**에 TOML 파일을 로드하여 사용합니다.
* **React (Vite)**: `vite-plugin-toml` 등을 사용하거나, 빌드 스크립트로 JSON 변환 후 `i18next`에 주입.
* **Flutter**: `toml` 패키지를 사용하여 로드하거나, 전처리 스크립트로 ARB/JSON 변환 후 `easy_localization` 사용.
#### 5.2.2 관리 프로세스 (Template & CI)
1. **`template.toml`**: 개발자가 새로운 번역 키를 추가할 때 반드시 이 파일에 먼저 정의해야 합니다.
2. **`ko.toml`, `en.toml`**: 템플릿의 키를 바탕으로 실제 번역 값을 채워 넣습니다.
3. **UserFront 런타임 리소스 동기화**:
* UserFront는 런타임에 `userfront/assets/translations/*.toml`을 직접 읽습니다.
* 따라서 `locales/ko.toml`, `locales/en.toml`, `locales/template.toml`을 수정한 뒤에는 반드시 아래 동기화 스크립트를 실행해야 합니다.
* 실행 명령:
```bash
./scripts/sync_userfront_locales.sh
```
* 이 단계가 누락되면 루트 SoT와 UserFront 실제 표시 문구가 어긋날 수 있습니다.
4. **React 공통 locale 레이어**:
* React 계열 프런트(`adminfront`, `devfront`, `orgfront`)는 `common/locales/*.toml`을 공통 문구 레이어로 사용합니다.
* 공통 key는 `ui.common.*`, `msg.common.*` 범위에만 둡니다.
* 각 앱의 `src/locales/*.toml`은 앱 전용 문구를 유지하고, 로딩 시 `common locale -> app locale override` 순서로 merge 합니다.
3. **CI 검증 (Verification)**:
* **Level 1: 리소스 동기화 검사 (`template` vs `lang`)**
* `locales/*.toml`과 `common/locales/*.toml` 각각에 대해 `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다.
* 누락 시 빌드 실패.
* **Level 2: 코드 사용성 검사 (`code` vs `template`)**
* 전체 프론트엔드 소스코드(`src/**/*.{ts,tsx}`, `lib/**/*.dart`)를 스캔하여 번역 함수(`t('key')`, `'key'.tr()`)에 사용된 키를 추출합니다.
* **Missing Key**: 코드에는 있는데 해당 레이어의 `template.toml`에 없는 키를 검출하여 경고 또는 에러를 발생시킵니다.
* **Unused Key**: 각 `template.toml`에는 있는데 코드 어디에서도 쓰이지 않는 키를 리포트하여 정리할 수 있게 합니다.
#### 5.2.3 React (Admin/Dev) 구현 가이드
* **패키지 설치**:
```bash
npm install i18next react-i18next i18next-browser-languagedetector
npm install -D vite-plugin-toml
```
* **Vite 설정 (`vite.config.ts`)**:
```ts
import { defineConfig } from 'vite';
import { plugin as toml } from 'vite-plugin-toml';
export default defineConfig({
plugins: [toml], // .toml 파일을 모듈로 import 가능하게 함
});
```
* **i18n 초기화 (`src/i18n.ts`)**:
```ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// TOML 파일 직접 import (JSON 객체로 변환됨)
import ko from './locales/ko.toml';
import en from './locales/en.toml';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
ko: { translation: ko },
en: { translation: en },
},
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
});
export default i18n;
```
#### 5.2.4 Flutter (User) 구현 가이드
Flutter는 런타임에 TOML을 파싱하기 위해 `toml` 패키지와 `easy_localization`의 커스텀 로더를 사용합니다.
#### 5.2.5 UserFront 에러 표시 정책 (Production)
UserFront(`/error`)는 프로덕션에서 다음 규칙으로 에러를 표시합니다.
1. **Internal whitelist 코드**
- `msg.userfront.error.whitelist.{code}` 메시지를 노출합니다.
2. **ORY bypass 코드**
- `msg.userfront.error.ory.{code}` 메시지를 노출합니다.
3. **그 외 코드**
- `unknown_error`로 처리하고 일반 안내 문구(`msg.userfront.error.detail_contact`)를 노출합니다.
코드 집합은 `userfront/lib/core/constants/error_whitelist.dart`를 단일 기준으로 유지합니다.
* **패키지 추가 (`pubspec.yaml`)**:
```yaml
dependencies:
easy_localization: ^3.0.0
toml: ^0.14.0
flutter:
assets:
- assets/translations/
```
* **Custom AssetLoader 구현 (`lib/core/i18n/toml_asset_loader.dart`)**:
```dart
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:toml/toml.dart';
class TomlAssetLoader extends AssetLoader {
const TomlAssetLoader();
@override
Future<Map<String, dynamic>> load(String path, Locale locale) async {
final assetPath = '$path/${locale.languageCode}.toml';
try {
// 1. Asset 파일 읽기
final String content = await rootBundle.loadString(assetPath);
// 2. TOML 파싱
final TomlDocument document = TomlDocument.parse(content);
// 3. Map으로 변환하여 반환
return document.toMap();
} catch (e) {
// 로깅 또는 빈 맵 반환
print('Error loading TOML asset: $assetPath, error: $e');
return {};
}
}
}
```
* **초기화 (`main.dart`)**:
```dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await EasyLocalization.ensureInitialized();
runApp(
EasyLocalization(
supportedLocales: const [Locale('en'), Locale('ko')],
path: 'assets/translations',
fallbackLocale: const Locale('en'),
assetLoader: const TomlAssetLoader(), // 커스텀 로더 적용
child: const MyApp(),
),
);
}
```
---
## 6. 작업 체크리스트
1. [ ] **공통**: `locales/template.toml` 정의 및 초기 키셋 구성.
2. [ ] **CI**: `template.toml` vs `*.toml` 키 동기화 검증 스크립트 작성 (`scripts/verify-i18n.js` or `py`).
3. [ ] **Admin/DevFront**: Vite TOML 플러그인 설정 및 `react-i18next` 연동.
4. [ ] **UserFront**: TOML -> JSON 변환 스크립트 추가 및 `easy_localization` 연동.
### 6.1 UserFront 번역 수정 체크포인트
UserFront 번역을 수정할 때는 아래 순서를 기본 절차로 사용합니다.
1. `locales/*.toml` 수정
2. `./scripts/sync_userfront_locales.sh` 실행
3. UserFront 회귀 테스트 실행
- 예: `cd userfront && flutter test test/english_locale_placeholder_test.dart`
4. 전체 키 정합성 점검
- 예: `node tools/i18n-scanner/index.js`

View File

@@ -0,0 +1,245 @@
# Identity Redis Mirror 정책
관련 이슈: #1039
## 결정
사용자 identity에 대해 PostgreSQL DB projection을 SSOT 일치 보장 대상으로 취급하지 않습니다.
Baron SSO의 identity 원장은 Ory Kratos입니다. Redis는 Kratos identity를 빠르게 조회하기 위한 mirror/cache 계층이고, PostgreSQL `users`는 Baron 비즈니스 메타데이터, WORKS/Keto/RP 연동 참조, 감사 가능한 로컬 레코드로만 사용합니다.
## 역할 분리
| 구성요소 | 역할 | 보장 |
| --- | --- | --- |
| Kratos `identities` | identity SSOT | 인증 주체, credentials, recovery/verifiable address의 원장 |
| Redis identity mirror | cache/read mirror | 빠른 목록/검색/단건 조회. stale 가능 |
| PostgreSQL `users` | business local record | tenant, WORKS, RP, Keto 연동에 필요한 Baron 로컬 상태 |
PostgreSQL `users`의 visible count를 Kratos identity total로 표시하지 않습니다. 화면과 API에서는 identity mirror count와 local business user count를 분리해서 보여야 합니다.
## 현재 필드 대조
현재 코드 기준으로 Kratos traits와 backend DB `users`는 일부 필드를 중복 보관합니다. Redis mirror 전환 이후에는 Kratos traits를 인증/기본 identity 필드 중심으로 줄이고, Baron 업무/조직/연동 정보는 backend DB 전용으로 이동하는 방향을 기준으로 합니다.
### Kratos에 유지할 identity 필드
| 필드 | 현재 저장 위치 | 유지 이유 |
| --- | --- | --- |
| `id` | Kratos identity ID, backend `users.id` 참조 | 인증 subject. WORKS `externalKey` 기준 |
| `email` | Kratos traits, backend `users.email` | 로그인 ID, recovery, verification |
| `phone_number` | Kratos traits, backend `users.phone` | SMS 로그인/검증 식별자 |
| `custom_login_ids` | Kratos traits, backend `user_login_ids` | password identifier. backend는 중복/정책 검증용 index |
| `name` | Kratos traits, backend `users.name` | 기본 프로필 표시 |
| `role` | Kratos traits, backend `users.role` | 세션/profile claim 계산에 필요. 최종 권한은 Keto/ReBAC와 함께 검증 |
| `state` | Kratos identity state, backend `users.status`로 일부 투영 | identity 활성 상태의 원본 |
### 현재 Kratos에도 있지만 backend-only로 축소해야 하는 필드
| 필드 | 현재 코드 사용 | 전환 방향 |
| --- | --- | --- |
| `tenant_id` | 대표 테넌트, profile, local user sync | backend `users.tenant_id`와 membership/Keto 기준으로 이동. Kratos에는 최소 claim 캐시로만 허용 |
| `department` | 사용자 표시, 조직도, WORKS 비교 | backend `users.department` 또는 tenant membership metadata 기준 |
| `grade` | 직급 표시, role fallback legacy | backend `users.grade`. role fallback 용도 제거 |
| `position` | 직책 표시 | backend `users.position` |
| `jobTitle` | 직무 표시 | backend `users.job_title` |
| `affiliationType` | 내부/외부/게스트 구분 | backend `users.affiliation_type` |
| `relying_party_id` | RP admin profile 보조 | backend/RP relation 기준 |
| `additionalAppointments` | 다중 소속 표시/WORKS 연동 | backend membership metadata 또는 `users.metadata` 기준 |
| `sub_email`, `aliasEmails`, `secondary_emails`, `worksmobileAliasEmails` | WORKS alias 및 보조 이메일 | backend `users.metadata` 또는 명시 테이블 기준 |
| tenant UUID namespaced metadata | tenant별 custom schema 값 | backend `users.metadata` 또는 전용 custom-field storage 기준 |
### backend DB에만 저장되거나 DB가 원장이어야 하는 정보
| 데이터 | 저장 위치 | Kratos에 두지 않는 이유 |
| --- | --- | --- |
| soft-delete 상태 | `users.deleted_at` | Baron 운영/감사 로컬 상태. Kratos identity 삭제/비활성화와 의미가 다름 |
| Baron 사용자 상태 세부값 | `users.status` | WORKS provision/deprovision, org visible 정책과 결합된 업무 상태 |
| WORKS mapping/outbox/job 상태 | `worksmobile_*` 테이블 | 외부 SaaS 연동 상태이며 identity 인증 원장이 아님 |
| Keto outbox 및 relation sync 상태 | `keto_outboxes`, Keto | 권한/관계 원장과 처리 상태 |
| RP metadata/consent/usage | `rp_user_metadata`, `client_consents`, usage tables | RP별 업무 데이터와 위임 상태 |
| tenant tree, slug, visibility, owner/admin | `tenants`, relation/outbox | 조직/권한 원장. Kratos traits에 넣으면 stale claim이 됨 |
| custom field schema 및 tenant별 값 | tenant config, `users.metadata`, related tables | 조회/검색/검증 정책이 tenant별로 달라짐 |
| `user_login_ids` row metadata | `user_login_ids` | Kratos는 identifier 값만 필요. 발급 tenant/field key는 backend 업무 정보 |
| audit/session activity projection | audit/clickhouse/local tables | 감사/운영 분석 데이터 |
정리하면, Kratos에는 “로그인과 subject 확인에 필요한 최소 identity”만 남기고, 조직도/WORKS/RP/Keto/감사/tenant custom schema에 필요한 데이터는 backend DB가 맡습니다.
## 일관성 모델
Redis mirror는 strong consistency 원장이 아닙니다.
다만 Baron backend를 통해 발생한 Kratos write에 대해서는 다음 수준을 목표로 합니다.
1. Kratos create/update/delete 성공
2. 성공한 identity ID를 기준으로 Kratos `GetIdentity(id)` 재조회
3. Redis `identity:mirror:{id}` write-through
4. Redis index/state 갱신
5. Redis 갱신 실패 시 mirror state를 `failed` 또는 `stale`로 표시
Kratos Admin API를 backend 밖에서 직접 수정하는 경로는 운영 정책상 금지합니다. 금지할 수 없는 환경에서는 Redis mirror가 stale해질 수 있음을 인정하고, 주기 refresh와 drift report로만 복구합니다.
## Kratos write 경로 감사
2026-06-09 기준으로 `admin/identities`, `CreateUser`, `UpdateIdentity`, `DeleteIdentity`, `UpdateUserPassword`, Kratos DB `identities` 직접 변경 경로를 정적 검색했습니다.
### 중앙 Kratos client 구현
| 파일 | 역할 | 판정 |
| --- | --- | --- |
| `backend/internal/service/kratos_admin_service.go` | Kratos Admin API list/get/create/update/delete/password/session client | 허용. 이후 `IdentityWriteService`의 하위 client로만 사용 |
| `backend/internal/service/ory_service.go` | legacy IDP provider. create/password/verifiable address 변경 시 Kratos Admin API 호출 | 허용하되 write-through 책임은 상위 `IdentityWriteService`로 이동 |
### 정상 backend API 경로
아래 경로는 사용자 요청이 backend를 통과하지만, Redis mirror write-through 구현 시 모두 같은 중앙 write service를 지나야 합니다.
| 경로 | 파일 | Kratos 변경 | 판정 |
| --- | --- | --- | --- |
| admin 사용자 단건 생성 | `backend/internal/handler/user_handler.go` | `OryProvider.CreateUser` | 허용. 생성 성공 후 Kratos 재조회와 Redis mirror write-through 필요 |
| admin 사용자 bulk 생성 | `backend/internal/handler/user_handler.go` | `OryProvider.CreateUser` | 허용. 부분 성공/실패별 mirror 갱신 필요 |
| admin 사용자 수정 | `backend/internal/handler/user_handler.go` | `KratosAdmin.UpdateIdentity`, 선택적 `OryProvider.UpdateUserPassword` | 허용. password 변경도 identity write audit에 포함 |
| admin 사용자 삭제/bulk 삭제 | `backend/internal/handler/user_handler.go` | `KratosAdmin.DeleteIdentity` | 허용. Redis mirror delete 또는 tombstone 갱신 필요 |
| 일반 회원가입 | `backend/internal/handler/auth_handler.go` | `IdpProvider.CreateUser` | 허용이지만 local DB sync가 goroutine 기반이라 write-through 기준에서는 약함 |
| 내 프로필 수정 | `backend/internal/handler/auth_handler.go` | `KratosAdmin.UpdateIdentity` | 직접 `PUT /admin/identities/{id}` 호출 제거 완료. 향후 `IdentityWriteService` write-through 대상 |
| 비밀번호 재설정/내 비밀번호 변경 | `backend/internal/handler/auth_handler.go` | `IdpProvider.UpdateUserPassword` | 허용. traits mirror와 별도 audit event 필요 |
| 조직 그룹 멤버 추가 | `backend/internal/service/user_group_service.go` | Kratos write 없음 | Kratos `tenant_id`, `department` write 제거 완료. 조직/부서 정보는 backend DB/Keto/WORKS 기준 |
### backend 내부이지만 일반 API가 아닌 경로
| 경로 | 파일 | Kratos 변경 | 판정 |
| --- | --- | --- | --- |
| super-admin 보장 CLI | `backend/cmd/adminctl/main.go`, `backend/internal/bootstrap/admin_account.go` | `CreateUser`, `UpdateIdentityPassword` | 운영 bootstrap/정비 경로. 실행 후 Redis mirror 갱신 또는 refresh 필수 |
| 초기 admin seed | `backend/internal/bootstrap/kratos_seed.go` | `IdpProvider.CreateUser` | startup bootstrap 경로. 신규 환경에서만 허용하고 반복 실행 영향 점검 필요 |
| role 보정 CLI | `backend/cmd/fix_kratos_roles.go` | `ListIdentities``UpdateIdentity` | 기본 dry-run. 실제 변경은 `--dry-run=false --maintenance-window --mark-mirror-stale` 없이는 거부 |
### backend와 Kratos Admin API를 모두 우회하는 경로
| 경로 | 파일/문서 | 변경 대상 | 판정 |
| --- | --- | --- | --- |
| orphan tenant membership shell 정리 | `scripts/clear_orphan_tenant_memberships.sh` | `ory_kratos.identities.traits` 직접 `UPDATE` | `CONFIRM_KRATOS_DB_MAINTENANCE=baron-sso``MARK_IDENTITY_MIRROR_STALE=true` 없이는 거부 |
| tenant maintenance 문서의 직접 SQL 절차 | `docs/tenant-maintenance-procedures.md` | Baron DB 및 Kratos traits | 문서화된 우회 절차. Kratos DB 직접 UPDATE 절차는 폐기 또는 강한 경고 필요 |
| backup restore | `scripts/backup/restore.sh` | `ory_kratos` 전체 restore 가능 | DR 경로로만 허용. restore 이후 Redis mirror full rebuild와 Baron DB/Kratos drift report 필수 |
| Docker network 직접 접근 | `compose`, `docker`, `mcp` 설정 | `http://kratos:4434` 접근 가능 컨테이너 | public publish는 아니지만 같은 network의 정비 컨테이너가 admin API를 칠 수 있음. 접근 주체 제한 필요 |
### 조치 원칙
- Kratos identity write는 `IdentityWriteService` 하나로 모으고, 성공한 create/update/delete/password 변경이 audit와 Redis mirror write-through를 남기게 합니다.
- `auth_handler.updateKratosIdentity`처럼 `KRATOS_ADMIN_URL`을 직접 읽어 `admin/identities`를 호출하는 코드는 금지합니다.
- `backend/cmd/fix_kratos_roles.go`와 Kratos DB 직접 UPDATE 스크립트는 `--dry-run`, `--maintenance-window`, `--mark-mirror-stale` 같은 명시적 가드 없이는 실행하지 못하게 합니다.
- shell/SQL로 Kratos DB를 직접 수정한 경우에는 PostgreSQL projection이나 Redis mirror를 신뢰하지 않고, Kratos full refresh와 drift report를 먼저 실행합니다.
- CI에 정적 정책 테스트를 추가해 `admin/identities` write 호출과 `UPDATE identities` SQL이 허용 파일 밖에 생기면 실패시킵니다.
## Redis 키 설계
초기 구현 기준:
- `identity:mirror:{identityID}`
- Kratos identity summary JSON
- 단건 조회 cache
- `identity:mirror:state`
- `status`: `ready`, `refreshing`, `stale`, `failed`
- `lastRefreshedAt`
- `lastError`
- `observedCount`
- `identity:index:active`
- active identity ID 목록
- 구현은 Redis Set 또는 Sorted Set으로 둡니다.
기존 `domain.RedisRepository`가 문자열 KV만 제공하므로 목록 index를 제대로 구현하려면 Redis repository 인터페이스를 확장해야 합니다.
## API 원칙
사용자 목록 API는 다음 값을 구분해야 합니다.
- `identityTotal`: Redis mirror 기준 Kratos identity 수
- `localUserTotal`: PostgreSQL `users` 기준 Baron 로컬 사용자 수
- `mirrorStatus`: Redis mirror 상태
- `items`: identity mirror와 local business metadata를 조합한 응답
Redis cache miss 발생 시:
1. 단건 조회는 Kratos `GetIdentity(id)`로 fallback합니다.
2. fallback 성공 시 Redis mirror를 갱신합니다.
3. fallback 실패 시 SSOT 조회 실패로 응답합니다.
목록 조회는 Redis mirror가 `ready`가 아니면 경고 상태를 포함해야 합니다. DB projection을 대체 SSOT처럼 사용하지 않습니다.
## Front 전송과 cursor 보장
front로 전달되는 사용자 목록은 cursor 기반을 원칙으로 합니다. offset은 하위 호환 파라미터로만 유지하고, 신규 화면 또는 대량 조회 화면은 cursor 외 방식을 사용하지 않습니다.
### API 계약
`GET /api/v1/admin/users` 응답은 다음 형태를 유지해야 합니다.
```json
{
"items": [],
"limit": 50,
"cursor": "현재 요청 cursor 또는 빈 값",
"nextCursor": "다음 페이지 cursor 또는 빈 값",
"identityTotal": 2106,
"localUserTotal": 2106,
"mirrorStatus": "ready"
}
```
전환 기간에는 기존 `total`을 유지할 수 있지만 의미를 명확히 해야 합니다.
- `identityTotal`: Redis identity mirror 기준 Kratos identity 수
- `localUserTotal`: PostgreSQL `users` 기준 Baron business local record 수
- `total`: deprecated. 화면에서 신규 의미로 사용하지 않습니다.
### cursor 불변 조건
- `nextCursor`가 있으면 front는 반드시 다음 요청에 `cursor=nextCursor`를 사용합니다.
- cursor 요청에서는 `offset`을 의미 있게 사용하지 않습니다.
- backend는 cursor 요청에서 안정 정렬 키를 사용해야 합니다. 기본 정렬은 `created_at DESC, id DESC` 또는 mirror index score + `id` 같이 중복 없는 조합이어야 합니다.
- cursor는 필터 조건(`search`, `tenantSlug`, status filter 등)과 묶여야 합니다. 다른 필터에 재사용하면 400으로 거부할 수 있습니다.
- 대량 화면도 `limit=5000&offset=0` 단일 호출을 사용하지 않습니다.
### 현재 front 점검 결과
| 화면/모듈 | 현재 상태 | 조치 |
| --- | --- | --- |
| `adminfront` 사용자 목록 | `useInfiniteQuery``nextCursor` 사용 | 유지 |
| `adminfront` 일부 tenant/user group modal | `fetchUsers(20/100/1000, 0)` 단일 호출 | cursor helper로 전환 |
| `adminfront` bulk upload modal | `fetchUsers(10000, 0)` 단일 호출 | 금지. cursor 수집 helper 또는 서버 검증 API로 전환 |
| `orgfront` 조직도 | `fetchUsers(5000, 0)` 단일 호출 | cursor 기반 전체 수집 helper로 전환 |
| `orgfront` 조직 picker | `fetchUsers(5000, 0)` 단일 호출 | cursor 기반 전체 수집 helper로 전환 |
| `orgfront/src/lib/adminApi.ts` | `UserListResponse``nextCursor` 없음 | 타입 계약 보완 |
공통 helper 원칙:
- `fetchUsersPage(params)`는 단일 페이지 조회만 담당합니다.
- `fetchAllUsersByCursor(params)``nextCursor`가 없어질 때까지 반복합니다.
- front에서 arbitrary large limit로 전체 데이터를 가져오는 코드를 금지합니다.
- E2E 또는 unit test에서 `fetchAllUsersByCursor``nextCursor`를 끝까지 따라가는지 검증합니다.
## Refresh 정책
주기 refresh는 Redis mirror를 재구성하는 best-effort 작업입니다.
Kratos list pagination을 끝까지 순회하는 것은 refresh의 필요조건일 뿐입니다. 호출 도중 Kratos identity 변경이 끼어들 수 있으므로 snapshot isolation 보장으로 해석하지 않습니다.
refresh 중 불일치 또는 실패가 발생하면:
- Redis mirror state를 `failed` 또는 `stale`로 표시합니다.
- PostgreSQL `users`를 자동 삭제하지 않습니다.
- drift report를 남겨 운영자가 확인할 수 있게 합니다.
## 금지 사항
- Kratos partial list를 full snapshot으로 간주하지 않습니다.
- PostgreSQL `users`를 Kratos identity total의 원장으로 사용하지 않습니다.
- Redis mirror refresh 실패를 숨기고 `ready`로 표시하지 않습니다.
- 외부 도구가 Kratos Admin API를 직접 수정하도록 허용하지 않습니다.
## 전환 작업
1. `user_projection` 명칭과 API를 `identity_mirror` 성격으로 분리합니다.
2. Redis repository에 Set/Sorted Set 또는 scan 가능한 index 연산을 추가합니다.
3. Kratos create/update/delete 성공 직후 Redis write-through 테스트를 추가합니다.
4. admin 사용자 목록 응답에서 identity count와 local user count를 분리합니다.
5. 기존 DB projection 기반 count를 사용하는 화면과 WORKS 비교 경로를 점검합니다.

View File

@@ -0,0 +1,56 @@
# Product Requirements Document (PRD)
## 1. 개요 (Overview)
**Baron SSO**는 사용자 중심의 인증 허브이자 서비스 런처입니다. Ory-Stack 중 Kratos를 IDP(Identity Provider)로 사용하여 강력한 보안(MFA)을 제공하면서도, 최종 사용자에게는 **Baron SSO**라는 브랜드 경험을 일관되게 제공하는 것을 목표로 합니다.
## 2. 목표 (Goals)
- **Private IDP Hub**: 사용자가 자신의 계정 및 로그인 세션을 한곳에서 관리.
- **Seamless Auth**: 비밀번호 없는 간편 로그인 제공.
- **White-Labeling**: 자체 Flutter UI로 인증 흐름 완결.
- **Audit & Security**: 모든 중요 인증 이벤트 및 접근 기록을 자체 Backend(Go Fiber)를 통해 ClickHouse에 Audit Log로 저장.
- **Unified Launcher**: 인증 후 접근 가능한 서비스들을 한 화면에서 제공.
## 3. 기술 스택 (Tech Stack)
- **UserFront**: Flutter (Web PoC 우선, 추후 iOS/Android)
- **AdminFront**: Vite, React, Shadcn/ui, biome, playwright
- **DevFront**: Vite, React, Shadcn/ui, biome, playwright
- **Ory (IDP)**: Ory (Oathkeeper, Kratos, Hydra, Keto, Posgres)
- **Backend (Audit/API)**: Go (Fiber Framework)
- **Database**: Postgres (Meta), ClickHouse (Audit Logs)
- **Protocol**: OIDC/SAML (Service Integration), REST (Audit)
## 4. 주요 기능 (Key Features)
### 4.1 로그인 및 인증 (Authentication)
- **로그인 방식 1 (Primary)**: 이메일 + 비밀번호 (Email/Password).
- **로그인 방식 2 (Alternative)**: 전화번호 입력 -> Link with Code (SMS via Ncloud) -> 링크 클릭 -> 앱 로그인 완료 (Polling).
- **SMS Provider**: Ncloud (Naver Cloud Platform) 연동.
- **MFA (Multi-Factor Authentication)**: 필요 시 TOTP 또는 생체 인증 추가 (Descope Flow 설정). <- PoC Scope Out
### 4.2 대시보드 (Dashboard)
- **내 계정 정보**: 프로필 확인/수정.
- **활성 세션 리스트 (Active Sessions)**: 현재 로그인되어 있는 기기/브라우저 목록 확인 및 원격 로그아웃.
### 4.3 통합 런처 (UserFront)
- 로그인 승인 후 진입하는 메인 화면.
- 접근 권한이 있는 하위 서비스 아이콘 나열 및 SSO 연결.
### 4.4 관리자 사이트 (AdminFront)
### 4.5 개발자 사이트 (DevFront)
## 5. 사용자 시나리오 (User Flow)
1. 사용자가 Baron SSO 웹앱에 접속한다.
4.1. **이메일 로그인**: 이메일과 비밀번호를 입력하고 로그인한다.
4.2. **SMS 로그인**: 전화번호를 입력하고 '전송'을 누른다 -> 앱은 대기화면(Polling)으로 전환 -> 수신된 SMS 링크 클릭 -> 앱이 로그인 승인됨.
4. 사용자가 앱푸시 혹은 이메일 수신함에서 Enchanted Link를 클릭한다.
5. Baron SSO 웹앱이 로그인을 감지하고 메인 대시보드(런처)로 전환된다.
6. 런처에서 '메일 서비스' 아이콘을 클릭하여 해당 서비스로 이동한다.
## 6. 마일스톤 (Milestones)
1. **Phase 1 (Current)**: 기획 및 아키텍처 설계, 환경 구성.
2. **Phase 2**: Go Fiber Backend (Audit) 기본 구현.
3. **Phase 3**: Descope 설정 및 Flutter Web 기본 연동 (Enchanted Link).
4. **Phase 4**: 대시보드 및 세션 관리 UI 구현.
5. **Phase 5**: 관리자 모드 구현 및 통합 테스트 완료.

View File

@@ -0,0 +1,166 @@
# 조직 Context JSON API 계약
## 목적
외부 연동앱이 계정 세션 없이 M2M 방식으로 Baron SSO의 조직구성을 조회할 수 있게 한다. 조직구성은 Baron SSO backend의 tenant/user projection을 SSOT로 사용하며, iframe 또는 `postMessage` 계약은 사용하지 않는다.
## 인증
API Key 기반 headless 통신만 허용한다.
```http
X-Baron-Key-ID: <client id>
X-Baron-Key-Secret: <client secret>
```
필요 scope는 다음과 같다.
```text
org-context:read
```
API Key 발급/회수는 사람의 관리 행위이므로 super admin 권한으로만 수행한다. 반면 아래 조직 Context 조회 API는 사용자 세션 없이 API Key만으로 동작한다.
## Endpoint
```http
GET /api/v1/integrations/org-context
```
### Query
| 이름 | 기본값 | 설명 |
| --- | --- | --- |
| `tenantSlug` | `hanmac-family` | 조회할 subtree root tenant slug. 지정하지 않으면 `hanmac-family` 전체 subtree를 반환한다. |
| `includeUsers` | `true` | `false`이면 각 tenant의 `members`를 빈 배열로 반환한다. |
| `includeUserIds` | `false` | `true`이면 각 tenant의 `members[].id``members[].phone`만 추가한다. 사용자 UUID와 전화번호가 필요한 연동에서만 사용한다. |
상위 조직 지정은 slug만 사용한다. UUID 기반 지정은 계약에 포함하지 않는다.
## 예시 요청
```bash
curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&includeUsers=true' \
-H 'X-Baron-Key-ID: 01970f08-91da-7286-bd19-882fb98d1f2c' \
-H 'X-Baron-Key-Secret: <secret>'
```
## 예시 응답
```json
{
"schemaVersion": "baron.org-context.v1",
"issuedAt": "2026-05-13T12:00:00Z",
"scope": {
"tenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
"tenantSlug": "hanmac"
},
"tree": {
"id": "01970f08-91da-7286-bd19-882fb98d1f2c",
"type": "COMPANY",
"name": "한맥기술",
"slug": "hanmac",
"parentId": "01970f07-4f01-7d9a-a71e-b53ad508f345",
"status": "active",
"description": "",
"domains": [],
"memberCount": 0,
"visibility": "public",
"createdAt": "2026-05-13T00:00:00Z",
"updatedAt": "2026-05-13T00:00:00Z",
"members": [],
"children": [
{
"id": "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a",
"type": "USER_GROUP",
"name": "플랫폼실",
"slug": "platform",
"parentId": "01970f08-91da-7286-bd19-882fb98d1f2c",
"status": "active",
"description": "",
"domains": [],
"memberCount": 0,
"visibility": "internal",
"orgUnitType": "실",
"createdAt": "2026-05-13T00:00:00Z",
"updatedAt": "2026-05-13T00:00:00Z",
"members": [
{
"email": "user@example.com",
"name": "홍길동",
"grade": "책임",
"position": "실장",
"jobTitle": "Backend Engineer",
"isOwner": true,
"isLeader": true,
"isPrimary": true
}
],
"children": []
}
]
},
"tenants": [
{
"id": "01970f08-91da-7286-bd19-882fb98d1f2c",
"type": "COMPANY",
"name": "한맥기술",
"slug": "hanmac",
"parentId": "01970f07-4f01-7d9a-a71e-b53ad508f345",
"status": "active",
"description": "",
"domains": [],
"memberCount": 0,
"visibility": "public",
"createdAt": "2026-05-13T00:00:00Z",
"updatedAt": "2026-05-13T00:00:00Z",
"members": []
},
{
"id": "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a",
"type": "USER_GROUP",
"name": "플랫폼실",
"slug": "platform",
"parentId": "01970f08-91da-7286-bd19-882fb98d1f2c",
"status": "active",
"description": "",
"domains": [],
"memberCount": 0,
"visibility": "internal",
"orgUnitType": "실",
"createdAt": "2026-05-13T00:00:00Z",
"updatedAt": "2026-05-13T00:00:00Z",
"members": [
{
"email": "user@example.com",
"name": "홍길동",
"grade": "책임",
"position": "실장",
"jobTitle": "Backend Engineer",
"isOwner": true,
"isLeader": true,
"isPrimary": true
}
]
}
]
}
```
## 정책
- `tenantSlug`가 없으면 `hanmac-family` 전체 subtree를 반환한다.
- `tenantSlug`는 slug만 허용한다. UUID를 query 계약으로 쓰지 않는다.
- `visibility=private` tenant와 그 하위 tenant는 제외한다.
- `visibility=internal` tenant는 M2M 연동용 JSON API에는 포함한다.
- 외부 앱은 `schemaVersion`을 확인하고, 알 수 없는 version이면 별도 fallback을 적용한다.
- `tree`는 같은 tenant 집합을 계층 구조로 제공하고, `tenants`는 slug/id lookup용 flat array로 제공한다.
- 사용자 목록은 top-level `users`가 아니라 각 tenant의 `members`에 직접 소속 사용자 배열로 제공한다.
- `members`에는 `active`, `temporary_leave`, `suspended` 사용자만 포함한다. `preboarding`, `baron_guest`, `extended_leave`, `archived` 사용자는 email/local-part 선점 대상일 수 있지만 일반 조직도와 외부 연동 조직 조회에는 노출하지 않는다.
- tenant 세부 분류는 `type``orgUnitType`으로 구분한다. `orgUnitType`은 tenant `config.orgUnitType` 값이 있을 때만 포함한다.
- 기본 사용자 응답은 로그인 claim 수준의 표시 정보만 제공한다. UUID, role/status, metadata, 생성/수정 시각은 기본 응답에 포함하지 않는다.
- 사용자 UUID와 전화번호가 필요한 연동은 `includeUserIds=true`를 사용한다. 이때 각 tenant `members[].id``members[].phone`만 추가된다.
- `isOwner`는 appointment metadata의 `isOwner` 또는 `isManager` 기준이다.
- `isLeader`는 appointment metadata의 `lead` 또는 `isLead` 기준이며, `isOwner`/`isManager`가 true인 경우에도 true로 본다.
- `isPrimary`는 appointment metadata의 `representative`, `isPrimary`, `primary` 기준이다.
- appointment별 `grade`, `position`, `jobTitle`, `department`가 있으면 해당 tenant membership 값으로 우선 사용한다.

View File

@@ -0,0 +1,124 @@
# Ory Keto (ReBAC) 네임스페이스 및 권한 상속 다이어그램
이 문서는 `docker/ory/keto/namespaces.ts`에 정의된 Baron SSO 프로젝트의 Ory Keto(ReBAC) 네임스페이스와 각 네임스페이스 간의 권한 상속(Permits) 및 관계(Relations)를 나타내는 Mermaid 다이어그램입니다.
## 네임스페이스 설계 구조
Ory Keto는 다음과 같은 4개의 주요 네임스페이스로 구성되어 있습니다:
1. **`User`**: 권한의 주체가 되는 기본 사용자.
2. **`System`**: 시스템 전역 권한 (최고 관리자 및 인증된 사용자).
3. **`Tenant`**: 조직/회사/부서 등 모든 형태의 격리 공간. 상위-하위(`parents`) 계층 구조를 가짐.
4. **`RelyingParty`**: OIDC 클라이언트(앱/리소스). 특정 `Tenant`에 종속될 수 있음.
---
## Mermaid 다이어그램
```mermaid
classDiagram
class User {
<<Namespace>>
}
class System {
<<Namespace>>
-- Relations --
super_admins: User[]
authenticated_users: User[]
-- Permits --
manage_all: super_admins
}
class Tenant {
<<Namespace>>
-- Relations --
owners: User[]
admins: User[] | SubjectSet~System, super_admins~
members: User[]
parents: Tenant[]
developer_console_viewer: User[]
developer_console_grant_manager: User[]
-- Permits --
view: members OR admins OR parents.view
manage: admins OR owners OR parents.manage
manage_admins: owners OR parents.manage_admins
create_subtenant: manage
view_dev_console: developer_console_viewer OR grant_dev_permissions OR manage OR parents.view_dev_console
grant_dev_permissions: developer_console_grant_manager OR manage_admins OR parents.grant_dev_permissions
}
class RelyingParty {
<<Namespace>>
-- Relations --
admins: User[]
parents: Tenant[]
access: User[] | SubjectSet~Tenant, members~ | SubjectSet~System, authenticated_users~
creator: User[]
config_editor: User[]
secret_rotator: User[]
jwks_viewer: User[]
jwks_operator: User[]
consent_viewer: User[]
consent_revoker: User[]
relationship_viewer: User[]
audit_viewer: User[]
status_operator: User[]
-- Permits --
view: admins OR direct operator relations OR parents.view OR parents.view_dev_console
manage: admins OR parents.manage
create: creator OR parents.grant_dev_permissions OR manage
edit_config: config_editor OR manage
rotate_secret: secret_rotator OR manage
view_jwks: jwks_viewer OR operate_jwks OR manage
operate_jwks: jwks_operator OR manage
view_consents: consent_viewer OR revoke_consents OR manage
revoke_consents: consent_revoker OR manage
view_relationships: relationship_viewer OR parents.grant_dev_permissions OR manage
view_audit_logs: audit_viewer OR manage
change_status: status_operator OR manage
access: access OR manage
}
%% Relationship lines indicating references (SubjectSets or Direct inclusion)
User ..> System : super_admins, authenticated_users
User ..> Tenant : owners, admins, members, developer_console_*
User ..> RelyingParty : admins, access, operators
Tenant "1" --> "*" Tenant : parents (상위 조직 상속)
Tenant ..> RelyingParty : parents (소유권 상속)
Tenant ..> RelyingParty : view_dev_console / grant_dev_permissions (범위 권한)
Tenant ..> RelyingParty : access (members 접근 권한)
System ..> RelyingParty : access (authenticated_users)
%% Styling
style User fill:#e1f5fe,stroke:#333,stroke-width:2px
style System fill:#ffe0b2,stroke:#333,stroke-width:2px
style Tenant fill:#fff9c4,stroke:#333,stroke-width:2px
style RelyingParty fill:#e1bee7,stroke:#333,stroke-width:2px
```
### 권한 평가(Permit) 상세 로직 설명
- **Tenant (테넌트/조직):**
- `view` (조회): 테넌트의 일반 멤버(`members`), 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다.
- `manage` (관리): 테넌트의 관리자(`admins`), 조직장(`owners`), 그리고 **상위 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다.
- `manage_admins`: 조직장(`owners`)과 상위 테넌트의 `manage_admins` 상속 권한으로 판정합니다.
- `view_dev_console`: 직접 부여된 DevFront 조회 relation, `grant_dev_permissions`, `manage`, 상위 tenant 상속으로 판정합니다.
- `grant_dev_permissions`: 직접 부여된 DevFront 권한 부여 relation, `manage_admins`, 상위 tenant 상속으로 판정합니다.
- **RelyingParty (OIDC 앱):**
- `view` (조회): 앱의 직접 관리자(`admins`), 직접 운영 relation 보유자(`config_editor`, `jwks_viewer` 등), 또는 **이 앱을 소유한 테넌트(parents)에서 `view` 또는 `view_dev_console` 권한을 가진 자**가 조회할 수 있습니다.
- `manage` (관리): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다.
- `edit_config`, `rotate_secret`, `operate_jwks`, `revoke_consents`, `change_status`: 각 직접 relation 또는 `manage`로 판정합니다.
- `view_relationships`: 직접 `relationship_viewer`, 상위 tenant의 `grant_dev_permissions`, 또는 `manage`로 판정합니다.
- `view_audit_logs`: 직접 `audit_viewer` 또는 `manage`로 판정합니다.
- `access` (접근/로그인 가능 여부): 이 앱에 직접 접근 권한을 부여받은 유저/그룹(`access`), 또는 앱을 관리할 수 있는 권한(`manage`)을 가진 사람이 접근할 수 있습니다.
- _접근 대상(access)은 특정 유저, 특정 테넌트의 전 멤버, 또는 전역 인증된 유저(System:authenticated_users)가 될 수 있습니다._
### 설계 원칙 메모
- `view_dev_console`는 RP 목록/기본 정보 조회 범위를 주는 tenant 범위 권한입니다.
- `view_dev_console`만으로 RP의 개별 운영 액션 permit이 자동 부여되지는 않습니다.
- `manage`는 1차 하위호환 permit으로 유지하며, 세부 permit이 완전히 backend/API에 반영되기 전까지 상위 호환 의미를 가집니다.

View File

@@ -0,0 +1,87 @@
# Ory Keto ReBAC 정책 및 관계 튜플 가이드
본 문서는 Baron SSO의 다형성 테넌트 구조를 지원하기 위해, Google Zanzibar 모델을 차용한 Ory Keto의 네임스페이스 및 관계 튜플(Relation Tuples) 설계 가이드를 정의합니다.
## 1. 중앙집중형 인가 백본 (Authorization Backbone)
복잡한 다단계 B2B2B 조직도 내에서의 상속 권한은 외부 백엔드의 RDBMS 논리만으로 실시간 검증하기에 과부하가 심합니다.
따라서 조직도나 권한이 변경될 때마다 이를 Keto의 관계 튜플로 실시간 변환/전송하고, 권한 검증(Check)은 초고속 병렬 처리가 가능한 Keto 엔진으로 오프로딩(Offloading)합니다.
## 2. 네임스페이스 (Namespaces)
과거 혼용되던 `UserGroup` 네임스페이스는 폐기되며, 현재 Baron SSO는 아래 4개의 네임스페이스를 기준으로 ReBAC를 구성합니다.
1. **`User`**: 권한의 subject가 되는 사용자
2. **`Tenant`**: 모든 격리 공간 (회사, 지주사, 사내 부서, 개인 워크스페이스, 유저 그룹)
3. **`RelyingParty`**: 테넌트가 소유하는 자원/앱 (OIDC 클라이언트)
4. **`System`**: 테넌트에 종속되지 않는 전역 권한 (Super Admin 등)
## 3. 관계 튜플 규칙 (Relationship Tuples)
### 3.1 테넌트 계층 및 리더십
- **조직장 임명**: `Tenant:<조직ID>#owners@User:<유저ID>`
- **어드민 자동 상속**: `Tenant:<조직ID>#admins@Tenant:<조직ID>#owners`
- **테넌트 계층(부모-자식)**: `Tenant:<하위ID>#parents@Tenant:<상위ID>`
*(상위 테넌트의 `admins`는 하위 테넌트의 모든 권한을 상속받습니다.)*
- **DevFront 조회 범위 부여**: `Tenant:<조직ID>#developer_console_viewer@User:<유저ID>`
- **DevFront 권한 부여 범위 부여**: `Tenant:<조직ID>#developer_console_grant_manager@User:<유저ID>`
### 3.1.1 Tenant permit 원칙
- `view`: 멤버/관리자/상위 tenant 상속 기준의 tenant 조회 권한
- `manage`: 관리자/오너/상위 tenant 상속 기준의 tenant 관리 권한
- `manage_admins`: 오너 및 상위 tenant 상속 기준의 관리자 관계 관리 권한
- `create_subtenant`: `manage`를 가진 주체가 하위 tenant를 생성하는 권한
- `view_dev_console`: DevFront 진입 및 tenant 범위 RP 목록/기본 정보 조회 권한
- `grant_dev_permissions`: tenant 범위 RP 운영 관계를 부여/회수할 수 있는 상위 권한
`view_dev_console``grant_dev_permissions``Tenant#manage`와 별개 축으로 분리합니다. 다만 1차 구현에서는 하위호환을 위해 `manage` 또는 `manage_admins`를 가진 주체가 각각 `view_dev_console`, `grant_dev_permissions`도 함께 가지는 모델로 둡니다.
### 3.2 Relying Party (앱 자원) 제어 및 RP Admin
RP에 별도의 가상 테넌트를 만들지 않고, 자원 객체 자체의 다중 상속을 사용합니다.
- **앱 소유권 지정**: `RelyingParty:<앱ID>#parents@Tenant:<소유테넌트ID>` (소유 테넌트의 최고 관리자가 앱 관리 가능)
- **전담 관리자(RP Admin) 직접 할당**: `RelyingParty:<앱ID>#admins@User:<유저ID>`
- **Private 앱 접근 허용**: `RelyingParty:<앱ID>#access@Tenant:<소유테넌트ID>#members`
- **Public 앱 접근 허용**: `RelyingParty:<앱ID>#access@System:authenticated_users#members`
- **RP 생성 권한 부여**: `RelyingParty:<앱ID>#creator@User:<유저ID>`
- **RP 설정 수정 권한 부여**: `RelyingParty:<앱ID>#config_editor@User:<유저ID>`
- **Client Secret rotate 권한 부여**: `RelyingParty:<앱ID>#secret_rotator@User:<유저ID>`
- **JWKS 조회 권한 부여**: `RelyingParty:<앱ID>#jwks_viewer@User:<유저ID>`
- **JWKS 운영 권한 부여**: `RelyingParty:<앱ID>#jwks_operator@User:<유저ID>`
- **Consent 조회 권한 부여**: `RelyingParty:<앱ID>#consent_viewer@User:<유저ID>`
- **Consent 회수 권한 부여**: `RelyingParty:<앱ID>#consent_revoker@User:<유저ID>`
- **Relationship 조회 권한 부여**: `RelyingParty:<앱ID>#relationship_viewer@User:<유저ID>`
- **감사 로그 조회 권한 부여**: `RelyingParty:<앱ID>#audit_viewer@User:<유저ID>`
- **상태 변경 권한 부여**: `RelyingParty:<앱ID>#status_operator@User:<유저ID>`
### 3.2.1 RelyingParty permit 원칙
- `view`: RP 상세 및 기본 메타데이터 조회 권한
- `manage`: 기존 호환용 상위 관리 권한
- `create`: RP 생성 권한
- `edit_config`: RP 일반 설정 수정 권한
- `rotate_secret`: client secret 재발급/rotate 권한
- `view_jwks`: JWKS 상태/캐시/key summary 조회 권한
- `operate_jwks`: JWKS refresh/revoke 수행 권한
- `view_consents`: consent 목록/상세 조회 권한
- `revoke_consents`: consent 회수 권한
- `view_relationships`: direct / inherited relationship 조회 권한
- `view_audit_logs`: 해당 RP의 DevFront 감사 로그 조회 권한
- `change_status`: 활성/비활성 상태 변경 권한
- `access`: 실제 서비스 로그인 및 리소스 접근 권한
1차 구현 원칙은 다음과 같습니다.
- `RelyingParty#manage`는 제거하지 않고 유지합니다.
- `manage`는 신규 세부 permit의 상위 호환 permit으로 동작합니다.
- `access`는 서비스 접근 권한이며 DevFront 운영 권한과 동일시하지 않습니다.
- `Tenant#view_dev_console`는 RP 목록/기본 정보 조회 범위를 주지만, `edit_config`, `operate_jwks`, `revoke_consents` 같은 개별 운영 액션 permit을 자동 부여하지 않습니다.
- RP 개별 운영 액션은 `RelyingParty` 세부 permit으로 직접 판정합니다.
### 3.3 유저 그룹 subject set 규칙
현재 구현에서 유저 그룹은 별도 `UserGroup` namespace를 사용하지 않고, `Tenant` namespace 내부의 유저 그룹 tenant와 subject set으로 표현합니다.
- **유저 그룹 멤버십**: `Tenant:<GroupTenantID>#members@User:<UserID>`
- **유저 그룹 전체에 tenant role 부여**: `Tenant:<TenantID>#<Relation>@Tenant:<GroupTenantID>#members`
즉, 문서에서 말하는 “유저 그룹 멤버 전체”는 실제 Keto tuple에서 `Tenant:<groupId>#members` subject set으로 표현됩니다.
## 4. 트랜잭셔널 아웃박스를 통한 정합성 확보
Keto와의 데이터 일관성 문제는 시스템의 치명적인 아킬레스건입니다.
백엔드 DB에서 테넌트나 멤버십을 변경할 때, Keto API 직접 호출에 따른 부분 실패(Partial Failure)를 방지하기 위해 **트랜잭셔널 아웃박스(Transactional Outbox) 패턴**을 반드시 사용해야 합니다.
DB 커밋과 함께 아웃박스 테이블에 튜플 이벤트를 기록하고, 비동기 릴레이 워커가 Keto로 동기화를 보장합니다. Soft delete 발생 시 즉각적으로 Keto의 튜플을 Hard delete 하여 권한을 회수합니다.

View File

@@ -0,0 +1,72 @@
# Ory Keto 관계 튜플 샘플 가이드 (Relationship Tuple Samples)
이 문서는 Baron SSO의 **유저 그룹 중심 통합 권한 정책**을 구현하기 위해 Ory Keto에 저장되는 실제 데이터 샘플을 제공합니다.
## 1. 기본 명명 규칙 (Naming Convention)
- **Namespace:** `Tenant`, `UserGroup`, `RelyingParty`, `User`
- **Format:** `namespace:object_id#relation@subject` (subject는 `user_id`이거나 `namespace:object_id#relation` 형태의 Subject Set임)
---
## 2. 유저 그룹 및 멤버십 샘플 (User Group & Membership)
### 2.1 그룹 멤버 추가
"한맥 IT 개발팀(`hanmac-it-dev`) 그룹에 사용자 `alice`를 멤버로 추가"
- **Tuple:** `UserGroup:hanmac-it-dev#members@User:alice-uuid`
- **설명:** alice는 이제 해당 그룹의 권한을 상속받을 준비가 됨.
### 2.2 그룹장(Leader) 임명
"사용자 `bob``hanmac-it-dev` 그룹의 그룹장으로 임명"
- **Tuple:** `UserGroup:hanmac-it-dev#owners@User:bob-uuid`
- **설명:** bob은 그룹의 소유권을 가지며, 정책에 따라 해당 테넌트의 어드민이 됨.
---
## 3. 정책 기반 자동 권한 상속 (Policy Bridges)
### 3.1 그룹장 -> 테넌트 어드민 승격
"그룹장은 해당 그룹(테넌트)의 어드민 권한을 가진다" (**핵심 정책**)
- **Tuple:** `Tenant:hanmac-it-dev#admins@UserGroup:hanmac-it-dev#owners`
- **설명:** 이 튜플 하나로 인해 `UserGroup:hanmac-it-dev#owners`에 속한 `bob``Tenant:hanmac-it-dev``admins` 자격을 얻음.
### 3.2 그룹 멤버 -> 테넌트 일반 권한
"그룹 멤버들은 해당 테넌트의 조회(view) 권한을 가진다"
- **Tuple:** `Tenant:hanmac-it-dev#members@UserGroup:hanmac-it-dev#members`
- **설명:** 그룹에 속한 모든 사용자가 테넌트 자원을 볼 수 있게 함.
---
## 4. 테넌트 간 권한 상속 및 자원 소유 (Hierarchy)
### 4.1 타 테넌트 관리 권한 부여
"개발팀 그룹(`hanmac-it-dev`)에게 `alpha-project` 테넌트의 관리 권한을 부여"
- **Tuple:** `Tenant:alpha-project#manage@UserGroup:hanmac-it-dev#members`
- **설명:** 이제 개발팀 멤버 전원이 `alpha-project`를 요리할 수 있음.
### 4.2 테넌트-자원(RP) 연결
"인증 앱(`messenger-app`)은 `hanmac-it-dev` 테넌트 소속이다"
- **Tuple:** `RelyingParty:messenger-app#parents@Tenant:hanmac-it-dev`
- **설명:** `hanmac-it-dev`를 관리하는 사람은 부모 관계를 통해 `messenger-app`도 관리할 수 있게 됨.
---
## 5. 종합 시나리오 예제: Hanmac Family
| 대상 리소스 | 관계 | 주체 (Subject) | 비고 |
| :--- | :--- | :--- | :--- |
| `UserGroup:hanmac-it` | `members` | `User:alice` | Alice는 IT팀 멤버 |
| `UserGroup:hanmac-it` | `owners` | `User:bob` | Bob은 IT팀 그룹장 |
| `Tenant:hanmac-it` | `admins` | `UserGroup:hanmac-it#owners` | **정책:** Bob은 IT 테넌트 어드민 |
| `Tenant:project-x` | `manage` | `UserGroup:hanmac-it#members` | IT팀 전체가 Project-X 관리 |
| `RelyingParty:rp-01` | `parents` | `Tenant:project-x` | rp-01은 Project-X 소속 |
### 🔍 권한 확인 (Check API) 결과:
1. **Bob은 `rp-01`을 관리할 수 있는가?**
- `bob` -> `UserGroup:hanmac-it#owners` -> `Tenant:hanmac-it#admins` -> (상속 시) -> `rp-01`
- **결과: YES**
2. **Alice는 `rp-01`을 관리할 수 있는가?**
- `alice` -> `UserGroup:hanmac-it#members` -> `Tenant:project-x#manage` -> (부모 관계) -> `rp-01`
- **결과: YES**
3. **Alice는 `Tenant:hanmac-it`의 설정을 바꿀 수 있는가?**
- Alice는 `members`일 뿐 `admins`가 아님.
- **결과: NO**

View File

@@ -0,0 +1,82 @@
# Kratos 사용자 traits 필드 인벤토리
작성일: 2026-05-13
## 확인 대상
- 설정 파일: `docker/ory/kratos/identity.schema.json`
- 로컬 Kratos DB: `ory_postgres` / `ory_kratos.identities.traits`
- 전역 Personal 테넌트: `9607eb7b-04d2-42ab-80fe-780fe21c7e8f` / `personal`
## Kratos schema에 설정된 traits 필드
| 필드 | 타입 | 용도 |
| --- | --- | --- |
| `custom_login_ids` | string array | password identifier |
| `email` | string | password/code identifier, recovery, verification, required |
| `name` | string | 사용자 이름 |
| `phone_number` | string | password/code SMS identifier |
| `department` | string | 부서 |
| `affiliationType` | string | 소속 유형 |
| `companyCode` | string | 대표 테넌트 slug |
| `role` | string | 권한 역할 |
| `tenant_id` | string | 대표 테넌트 UUID |
| `displayname` | string | 레거시 표시 이름 후보 |
| `completeForm` | boolean | 레거시 가입 폼 완료 여부 후보 |
| `team` | string | 레거시 팀 후보 |
| `taxCode` | string | 레거시 세무 코드 후보 |
| `familyCompany` | string | 레거시 가족사 후보 |
| `familyUniqueKey` | string | 레거시 가족사 고유키 후보 |
| `personal` | boolean | 레거시 Personal 여부 후보 |
| `grade` | string | 직급 |
현재 schema는 `additionalProperties: true`라서 위 목록에 없는 traits도 저장 가능합니다.
## 로컬 Kratos DB에 실제 저장된 traits 필드
| 필드 | identity 수 |
| --- | ---: |
| `affiliationType` | 3 |
| `companyCode` | 3 |
| `companyCodes` | 1 |
| `department` | 3 |
| `email` | 3 |
| `grade` | 3 |
| `name` | 3 |
| `phone_number` | 1 |
| `role` | 3 |
| `tenant_id` | 1 |
## 정리 후보
유지 후보:
- 인증 식별자: `email`, `phone_number`, `custom_login_ids`
- 사용자 기본 프로필: `name`
- 권한/대표 소속: `role`, `tenant_id`, `companyCode`
- 조직 표시/연동: `department`, `grade`
- 다중 소속이 필요한 동안 유지: `companyCodes`
schema 추가 검토 후보:
- backend projection에서 읽는 `position`, `jobTitle`
- 한맥가족 다중 소속을 metadata로 유지할 경우 `additionalAppointments`
- 대표 테넌트 표시값을 traits로 계속 줄 경우 `primaryTenantId`, `primaryTenantSlug`, `primaryTenantName`, `primaryTenantIsOwner`
제거 후보:
- `displayname`
- `completeForm`
- `team`
- `taxCode`
- `familyCompany`
- `familyUniqueKey`
- `personal`
- `hanmacFamily`는 이미 `test/kratos_identity_schema_policy_test.sh`에서 금지 필드로 검사 중입니다.
## 제안 정책
1. Personal 사용자는 사용자별 Personal 테넌트를 생성하지 않고 전역 `personal` 테넌트만 사용합니다.
2. Kratos traits는 인증/클레임에 필요한 최소 필드만 유지합니다.
3. 조직도나 연동 전용 확장 데이터는 traits 최상위에 흩뿌리지 않고 Baron DB의 user projection 또는 명시된 metadata 구조로 모읍니다.
4. `additionalProperties: true`를 바로 `false`로 바꾸면 기존 identity 갱신이 실패할 수 있으므로, 먼저 backend sanitizer와 마이그레이션으로 제거 후보를 정리한 뒤 schema를 닫습니다.

View File

@@ -0,0 +1,100 @@
# OIDC Redirect 매핑 검증 정책
## 목적
- OIDC 로그인/리다이렉트 검증 시, URL 문자열의 기계적 동일성만으로 정상/비정상을 판단하지 않습니다.
- Gateway(Oathkeeper/Nginx) 경유 구조에서 발생하는 Public URL과 Internal URL의 의도된 차이를 정책적으로 허용하되, 매핑의 유효성은 엄격히 검증합니다.
## 적용 범위
- UserFront, AdminFront, DevFront, OrgFront의 로그인/콜백 경로
- Ory Stack(Hydra/Kratos/Oathkeeper) 설정
- `compose.ory.yaml`, `docker/compose.ory.yaml`, `docker/staging_pull_compose.template.yaml`
- `gateway/nginx.conf`, `deploy/templates/gateway/nginx.conf`, `docker/ory/oathkeeper/rules*.json`
- `Makefile` 기반 사전 검증/스모크 검증 단계
## 핵심 원칙
1. URL 직접 일치(`direct_match`)와 게이트웨이 매핑 일치(`mapped_match`)를 구분합니다.
2. Public URL과 Internal URL이 다르더라도, `Public -> Gateway/Oathkeeper -> Internal` 경로가 검증되면 정상으로 간주합니다.
3. 매핑 체인이 없거나 규칙이 누락된 경우는 실패(`unmapped_fail`)로 간주합니다.
## 용어 정의
- Public URL: 브라우저에서 접근하는 URL. 예: `https://sso.example.test/oidc/oauth2/auth`
- Internal URL: 컨테이너 내부 통신 URL. 예: `http://hydra:4444/oauth2/auth`
- Mapping Chain: Public 요청이 Gateway/Oathkeeper 규칙을 통해 Internal URL로 전달되는 경로
## 판정 규칙
1. `direct_match`
- 프론트가 생성한 `redirect_uri`/`authority`와 실제 등록/설정 URL이 동일 레이어에서 직접 일치
- 예: 로컬 개발에서 모두 `http://localhost:*` 기준
2. `mapped_match`
- Public URL과 Internal URL이 다르지만, 아래가 모두 성립
- Gateway 라우팅 규칙 존재: `/oidc` prefix를 제거하지 않고 Oathkeeper로 전달
- Oathkeeper `match``upstream` 규칙 존재: `/oidc/*` rule이 `strip_path=/oidc`로 Hydra에 전달
- 최종 업스트림이 기대 서비스(Hydra/Kratos)로 연결
3. `unmapped_fail`
- Public/Internal 불일치가 있는데 매핑 규칙이 없거나 누락
- callback/return URL이 등록되지 않았거나 경로 규약 불일치
- 환경 변수는 존재하나 실제 compose/rules 반영이 누락
## 검증 항목
1. 정적 검증 (`make validate-auth-config`)
- `USERFRONT_URL`, `OATHKEEPER_PUBLIC_URL`, `HYDRA_PUBLIC_URL`, `KRATOS_BROWSER_URL` 정합성
- `ADMINFRONT_CALLBACK_URLS`, `DEVFRONT_CALLBACK_URLS`, `ORGFRONT_CALLBACK_URLS` URL 유효성/중복/경로 규약
- Gateway `/oidc`, `/auth` 라우팅 규칙 존재 여부
- Oathkeeper `rules*.json`의 Hydra/Kratos 매핑 규칙 존재 여부
- staging pull/deploy template의 Oathkeeper entrypoint 사용 여부
- `KRATOS_ALLOWED_RETURN_URLS_JSON`에 공개 도메인, locale path, callback/return path가 포함되는지 여부
2. 런타임 검증 (`make verify-auth-config`)
- OIDC Discovery endpoint 조회 가능 여부
- Hydra 등록 client(`adminfront`, `devfront`, `orgfront`)의 `redirect_uris` 확인
- 필요 시 Gateway 경유 endpoint probe로 매핑 체인 확인
## 경로 규약
- DevFront callback: `/auth/callback`
- AdminFront callback: `/auth/callback`
- OrgFront callback: `/auth/callback`
- UserFront OIDC 진입점: `/oidc/*` (Gateway 경유)
- locale return path: `/ko`, `/en`, `/ko/auth/callback`, `/en/auth/callback`
## `/oidc` 책임 경계
- Gateway는 `/oidc` prefix를 보존합니다.
- Oathkeeper는 `/oidc/.well-known/*`, `/oidc/oauth2/*`, `/oidc/userinfo` rule에서 `strip_path=/oidc`를 적용합니다.
- Hydra는 prefix가 제거된 내부 경로(`/.well-known/*`, `/oauth2/*`, `/userinfo`)를 받습니다.
- 따라서 gateway template이나 staging pull compose에서 `rewrite ^/oidc`가 다시 들어가면 dev/stage/prod 간 책임 경계가 달라지므로 실패로 간주합니다.
## Oathkeeper rules 선택 정책
- Oathkeeper는 직접 `command: serve proxy ...`로 시작하지 않고 `/etc/config/oathkeeper/entrypoint.sh`를 통해 시작합니다.
- entrypoint는 `APP_ENV`에 따라 다음 파일을 선택하고 `/tmp/oathkeeper/rules.active.json`으로 복사합니다.
- `stage|staging`: `rules.stage.json`
- `production|prod`: `rules.prod.json`
- 그 외: `rules.json`
- `oathkeeper.yml``file:///tmp/oathkeeper/rules.active.json`만 읽습니다.
## Kratos allowed return URL 정책
- stage/prod에서는 `KRATOS_ALLOWED_RETURN_URLS_JSON`을 명시하는 것을 우선합니다.
- 최소 포함 대상:
- `KRATOS_UI_URL`, `KRATOS_UI_URL/`
- `USERFRONT_URL`, `USERFRONT_URL/`
- `USERFRONT_URL/ko`, `USERFRONT_URL/ko/`
- `USERFRONT_URL/en`, `USERFRONT_URL/en/`
- `USERFRONT_URL/auth/callback`
- `USERFRONT_URL/ko/auth/callback`
- `USERFRONT_URL/en/auth/callback`
- `ADMINFRONT_CALLBACK_URLS`, `DEVFRONT_CALLBACK_URLS`, `ORGFRONT_CALLBACK_URLS`
- private IP, legacy domain, comma-space가 포함된 URI 항목은 stage/prod 기본값으로 두지 않습니다.
## 운영 지침
1. 환경별 URL은 동일할 필요가 없고, 매핑 체인이 검증 가능해야 합니다.
2. `localhost` 하드코딩은 로컬 전용 예외로만 허용하며, 스테이징/운영은 env 기반으로 주입합니다.
3. 신규 도메인/경로 추가 시, 프론트 설정과 Ory/Gateway 규칙을 반드시 동시에 변경하고 검증 결과를 이슈/PR에 첨부합니다.
## 관련 이슈
- #262
- #269
- #271
- #272
- #274
- #276
- #710

View File

@@ -0,0 +1,111 @@
# 조직도 기반 테넌트 및 권한 매핑 정책 (Organization Chart Policy)
이 문서는 실무 부서 및 직급 체계가 포함된 인사(HR) 데이터를 기반으로, Baron SSO의 다형성 테넌트(Polymorphic Tenant)와 Ory Keto 기반의 ReBAC 권한 모델을 어떻게 구축하고 동기화할 것인지에 대한 아키텍처 설계와 가이드라인을 정의합니다.
## 1. 개요 및 요구사항 분석
제공된 인사 데이터(샘플)는 다음과 같은 특징을 가집니다.
| 연번 | 그룹 | 디비젼 | 팀 | 셀 | 직급 | 이름 | 직무 | 구분 | 소속 |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 사장단 | - | - | - | 사장 | 정태원 | 사장단 | 센터장 | 한맥 |
| 4 | 엔지니어링 기획 | - | - | - | 부사장 | 양병홍 | 엔지니어 | 그룹장 | 삼안 |
| 5 | 엔지니어링 기획 | 일반구조물 | - | - | 수석 | 이동원 | 엔지니어 | 디비젼장 | 삼안 |
| 6 | 엔지니어링 기획 | 일반구조물 | 구조물계획 | - | 수석 | 김일태 | 엔지니어 | 팀장 | 삼안 |
| 7 | 엔지니어링 기획 | 일반구조물 | 구조물계획 | - | 수석 | 곽현석 | 엔지니어 | 팀원 | 한맥 |
### 🔍 주요 분석 포인트 (Matrix Organization)
가장 중요한 점은 6번(삼안 소속)과 7번(한맥 소속)이 **서로 다른 법인 소속임에도 불구하고 "엔지니어링 기획그룹 > 일반구조물 디비젼 > 구조물계획 팀" 이라는 동일한 논리적 부서에 속해 있다**는 것입니다.
이는 개별 법인(COMPANY) 산하에 부서가 종속되는 Tree 구조가 아니라, 지주사(COMPANY_GROUP) 차원에서 부서를 관리하고 법인 소속은 개인의 속성(Attribute)으로 분리해야 함을 의미합니다.
---
## 2. 아키텍처 매핑 전략 (Data to DB)
엑셀의 각 컬럼은 데이터베이스 모델과 다음과 같이 1:1로 매핑됩니다.
### 2.1 조직 (Tenant) 매핑
모든 조직 단위는 `Tenant` 테이블에 저장되며 `type``parent_id`로 계층을 구성합니다.
* **소속 (한맥, 삼안):** `Tenant` (Type: `COMPANY`) - 법인 격리 공간
* **그룹 / 디비젼 / 팀 / 셀:** `Tenant` (Type: `USER_GROUP`) - 논리적 사내 조직
* **계층 연결:** 하위 조직(팀)의 `parent_id`는 상위 조직(디비젼)의 `id`를 참조합니다.
### 2.2 사용자 (User) 속성 매핑
* **이름:** `User.Name`
* **직급 (사장, 부사장, 수석 등):** `User.Position`
* **직무 (엔지니어, 기획자 등):** `User.JobTitle`
* **소속 (법인 코드):** `User.CompanyCode` (`hanmac`, `saman` 등)
* **식별자:** (엑셀에 누락됨) 시스템 로그인을 위해 반드시 **이메일(Email) 또는 사번(LoginID)** 컬럼이 추가되어야 합니다.
### 2.3 권한 및 역할 (Keto ReBAC) 매핑
엑셀의 **구분(센터장, 그룹장, 디비젼장, 팀장, 팀원)** 컬럼은 해당 사용자가 조직 내에서 어떤 권한을 가지는지(Ory Keto의 Relation)를 결정합니다.
* **리더 (장급):** 해당 조직 테넌트의 `owners` 또는 `admins` 튜플 부여.
* *예:* 양병홍 부사장은 `엔지니어링 기획그룹``owners`가 됩니다.
* **팀원:** 가장 말단 조직 테넌트의 `members` 튜플 부여.
* *예:* 곽현석 수석은 `구조물계획 팀``members`가 됩니다.
---
## 3. 다이어그램: 통합 조직도 계층 설계
아래는 위 전략을 바탕으로 구성된 지주사 통합 조직도와 권한 상속(Keto OPL) 다이어그램입니다.
```mermaid
graph TD
%% 지주사 및 법인
G[지주사<br>Type: COMPANY_GROUP] --> C1[한맥<br>Type: COMPANY]
G --> C2[삼안<br>Type: COMPANY]
%% 통합 조직도 (지주사 직속 논리적 연결)
G -.-> T1[전략기획그룹<br>Type: USER_GROUP]
G -.-> T2[엔지니어링 기획그룹<br>Type: USER_GROUP]
T2 --> T2_1[일반구조물 디비젼<br>Type: USER_GROUP]
T2_1 --> T2_1_1[구조물계획 팀<br>Type: USER_GROUP]
%% 유저 권한 매핑 (Keto Tuples)
U2([양병홍 / 삼안]) -. owners (그룹장) .-> T2
U3([이동원 / 삼안]) -. owners (디비젼장) .-> T2_1
U4([김일태 / 삼안]) -. owners (팀장) .-> T2_1_1
U5([곽현석 / 한맥]) -. members (팀원) .-> T2_1_1
%% Keto OPL 상속 (부모의 권한이 자식으로 흐름)
T2 -. 부모/자식 상속 .-> T2_1
T2_1 -. 부모/자식 상속 .-> T2_1_1
%% 결과적인 권한 도달
U2 -. 자동 상속 (Read/Write) .-> T2_1_1
%% 스타일
classDef company fill:#e3f2fd,stroke:#0277bd,stroke-width:2px;
classDef group fill:#fff3e0,stroke:#e65100,stroke-width:2px;
classDef user fill:#f3e5f5,stroke:#4a148c,stroke-width:1px;
class G,C1,C2 company;
class T1,T2,T2_1,T2_1_1 group;
class U2,U3,U4,U5 user;
```
### 💡 ReBAC 상속의 이점 (OPL)
위 다이어그램에서 **양병홍 부사장(그룹장)**은 최상위 조직인 `엔지니어링 기획그룹``owners`로 한 번만 매핑됩니다.
하지만 Keto의 `parents` 상속 설계 덕분에, 하위의 `일반구조물 디비젼``구조물계획 팀`, 그리고 향후 생겨날 모든 하위 '셀' 단위까지 **자동으로 관리 권한(Read/Write)을 상속**받게 됩니다. 권한 부여 작업을 1회로 최소화할 수 있습니다.
---
## 4. 구축 파이프라인 (Bulk Import) 가이드
수백, 수천 명의 조직도를 수동으로 입력하는 것은 불가능하므로, 시스템은 **CSV 일괄 등록(Bulk Import)** API를 제공해야 합니다.
1. **데이터 준비:** 엑셀 데이터를 CSV로 변환합니다. (반드시 이메일 또는 사번 컬럼 포함)
2. **조직(Tenant) 순차 생성 (Upsert):**
* 스크립트는 CSV의 그룹 ➔ 디비젼 ➔ 팀 ➔ 셀 순서로 읽으며, 없는 조직은 생성하고 상위 조직의 ID를 `parent_id`로 연결합니다.
* 생성 시 백엔드 `TenantService`는 자동으로 Keto에 `parents` 튜플을 동기화합니다.
3. **사용자(User) 계정 생성:**
* Ory Kratos에 계정을 생성하고(`POST /identities`), 로컬 DB `users` 테이블에 직급, 직무 등의 메타데이터를 저장합니다.
4. **멤버십(Keto 튜플) 매핑:**
* 사용자가 속한 **가장 깊은(Deepest) 말단 조직 단위 하나**를 찾습니다.
* 직책(장급/일반)에 따라 `owners` 또는 `members` 권한을 Keto에 부여합니다.
이 정책을 통해 복잡한 매트릭스 조직과 권한 체계를 단일 아키텍처로 우아하게 통합할 수 있습니다.

View File

@@ -0,0 +1,84 @@
# Ory Stack 상세 가이드 (Baron SSO)
이 문서는 Baron SSO의 핵심 엔진인 Ory Stack의 구성 요소와 전체적인 인증/인가 플로우를 설명합니다.
## 1. 구성 요소별 상세 역할
| 구성 요소 | 별칭 | 주요 역할 | 핵심 기능 |
| :------------- | :------------ | :--------------- | :-------------------------------------------- |
| **Kratos** | Identity | **사용자 관리** | 회원가입, 로그인, MFA, 프로필 수정, 계정 복구 |
| | | | |
| **Hydra** | OAuth2/OIDC | **연동 및 토큰** | Access/ID 토큰 발급, 외부 서비스 SSO 연동 |
| **Keto** | Authorization | **권한 제어** | RBAC, ACL, "누가 무엇을 할 수 있는가" 판별 |
| **Oathkeeper** | Proxy/Gateway | **접근 통제** | 요청 검증, 세션 확인, 헤더 변환, API 보호 |
---
## 2. 시스템 플로우 (System Flow)
사용자가 보호된 백엔드 리소스에 접근할 때의 일반적인 흐름입니다.
### [인증 및 접근 흐름]
1. **Request**: 사용자가 API 요청을 보냄 (예: `GET /api/data`).
2. **Intercept (Oathkeeper)**: Oathkeeper가 요청을 가로챔.
3. **Authenticate (Kratos)**: Oathkeeper가 Kratos에게 사용자의 세션 쿠키가 유효한지 확인.
4. **Authorize (Keto)**: Oathkeeper가 Keto에게 해당 사용자가 `/api/data`를 볼 권한이 있는지 확인.
5. **Transform**: 모든 검증이 끝나면 Oathkeeper가 사용자 정보를 헤더(예: `X-User-ID`)에 담아 백엔드로 전달.
6. **Response**: 백엔드가 로직을 수행하고 결과를 반환.
### [SSO 연동 흐름 (OIDC)]
1. **Discovery**: 외부 서비스(App A)가 로그인 필요 시 Hydra로 인증 요청을 보냄.
2. **Login Challenge**: Hydra가 로그인 UI(`userfront`)로 리다이렉트하며 챌린지를 보냄.
3. **Auth (Kratos)**: 사용자가 `userfront`에서 로그인(Kratos 사용).
4. **Accept**: `userfront`가 로그인 성공 시 Hydra에게 챌린지 수락을 알림.
5. **Token Issuance**: Hydra가 App A에게 Auth Code를 주고, App A는 이를 Access/ID Token으로 교환.
---
## 3. 아키텍처 다이어그램
```mermaid
graph TD
User((사용자))
subgraph "Edge / Gateway"
OK[Ory Oathkeeper]
end
subgraph "Identity & Access Layer"
KR[Ory Kratos]
HY[Ory Hydra]
KE[Ory Keto]
end
subgraph "Application Layer"
BE[Backend API]
AF[Admin Front]
UF[User Front]
end
User -->|API Request| OK
User -->|Login/Register| UF
UF --> KR
OK -->|1. 세션 확인| KR
OK -->|2. 권한 확인| KE
OK -->|3. 요청 전달| BE
AF -->|관리 작업| BE
BE -->|Admin API 호출| KR & HY & KE
HY -->|SSO 토큰 발급| User
```
---
## 4. 요약
- **Kratos**는 사용자의 정보를 알고 있습니다.
- **Keto**는 사용자의 권한을 알고 있습니다.
- **Hydra**는 사용자를 외부 서비스에 증명합니다.
- **Oathkeeper**는 위 서비스들을 이용해 입구를 지킵니다.

140
baron-sso/docs/ory-usage.md Normal file
View File

@@ -0,0 +1,140 @@
# Ory Stack 사용 가이드
이 문서는 Baron SSO 로컬/도메인 환경에서 Ory Stack(Kratos/Hydra/Keto/Oathkeeper) 사용법과 내부/외부 엔드포인트 구성을 정리합니다.
## 1) 구성 요약
- **Kratos**: Identity/Session 관리(SoT)
- **Hydra**: OAuth2/OIDC 토큰 엔진
- **Keto**: 권한/정책
- **Kratos UI**: UserFront가 self-service UI 역할 (login/registration 등)
## 2) 실행 방법
```bash
# 인증 리다이렉트 설정 생성/검증
make validate-auth-config
# 인프라 + Ory Stack
docker compose -f compose.infra.yaml -f compose.ory.yaml up -d
# 앱 스택(backend/userfront/adminfront/devfront)
docker compose -f docker-compose.yaml up -d
```
Make 기반 실행을 사용할 경우:
```bash
make up-ory
make up-app
```
`up-*` 타깃은 내부적으로 `validate-auth-config`를 선행 수행하여 callback/allowed_return_urls 정합성을 먼저 검증합니다.
## 3) 내부 통신 vs 브라우저 접근용 URL 분리
Ory 구성은 **컨테이너 내부 통신 URL**과 **브라우저 접근 URL**을 분리해야 합니다.
### 내부 통신용 URL(컨테이너 네트워크)
- `KRATOS_PUBLIC_URL=http://kratos:4433`
- `KRATOS_ADMIN_URL=http://kratos:4434`
- `HYDRA_ADMIN_URL=http://hydra:4445`
- Hydra public upstream은 Oathkeeper rule 내부에서 `http://hydra:4444`로 전달합니다.
### 브라우저 접근용 URL(외부 도메인/프록시)
- `KRATOS_BROWSER_URL` : Kratos Public의 외부 URL. 보통 `${OATHKEEPER_PUBLIC_URL}/auth`
- `KRATOS_UI_URL` : UserFront의 외부 URL (Kratos UI 역할)
- `HYDRA_PUBLIC_URL` : Hydra issuer/OIDC discovery의 외부 URL. 보통 `${OATHKEEPER_PUBLIC_URL}/oidc`
- `VITE_OIDC_AUTHORITY` : 프론트엔드 OIDC authority. `HYDRA_PUBLIC_URL`과 같아야 합니다.
예시(로컬):
```env
KRATOS_BROWSER_URL=http://localhost:4433
KRATOS_UI_URL=http://localhost:5000
```
예시(리버스 프록시/도메인):
```env
OATHKEEPER_PUBLIC_URL=https://sso.example.com
KRATOS_BROWSER_URL=https://sso.example.com/auth
KRATOS_UI_URL=https://sso.example.com
HYDRA_PUBLIC_URL=https://sso.example.com/oidc
VITE_OIDC_AUTHORITY=https://sso.example.com/oidc
```
### 포트 노출 정책
- **Kratos/Hydra Admin 포트는 호스트에 노출하지 않음** (내부 네트워크 전용)
- MCP 서버는 동일 네트워크에서 `http://kratos:4434`, `http://hydra:4445`로 접근
- Backend는 `ory-net`에 연결되어 있어 Admin 포트 접근 가능
- 브라우저/Frontend는 Backend API를 통해서만 IDP 기능을 호출
## 4) Kratos Self-service UI 리다이렉트 설정
Kratos는 self-service UI URL을 설정값으로 사용합니다. **UserFront의 브라우저 접근 URL**이어야 정상 동작합니다.
- `KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL`
- `KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS`
- `KRATOS_SELFSERVICE_FLOWS_*_UI_URL`
- `KRATOS_ALLOWED_RETURN_URLS_JSON`
compose에서 기본적으로 다음과 같이 오버라이드합니다:
- `KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login`
- `KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration`
- `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`
stage/prod에서는 `KRATOS_ALLOWED_RETURN_URLS_JSON`에 공개 도메인과 callback/locale 경로를 명시합니다.
필수 후보:
- `${KRATOS_UI_URL}`, `${KRATOS_UI_URL}/`
- `${USERFRONT_URL}`, `${USERFRONT_URL}/`
- `${USERFRONT_URL}/ko`, `${USERFRONT_URL}/ko/`
- `${USERFRONT_URL}/en`, `${USERFRONT_URL}/en/`
- `${USERFRONT_URL}/auth/callback`
- `${USERFRONT_URL}/ko/auth/callback`
- `${USERFRONT_URL}/en/auth/callback`
- `${ADMINFRONT_CALLBACK_URLS}`, `${DEVFRONT_CALLBACK_URLS}`, `${ORGFRONT_CALLBACK_URLS}`
## 5) `/oidc` Gateway/Oathkeeper 책임 경계
Gateway는 `/oidc` prefix를 rewrite하지 않습니다. `/oidc/*` 요청은 prefix를 보존한 채 Oathkeeper로 전달하고, Oathkeeper rule이 `strip_path=/oidc`로 Hydra 내부 upstream(`http://hydra:4444`)에 전달합니다.
이 정책은 `gateway/nginx.conf`, `deploy/templates/gateway/nginx.conf`, `docker/ory/oathkeeper/rules*.json`, `docker/staging_pull_compose.template.yaml`에서 동일해야 합니다.
## 6) Oathkeeper active rules
Oathkeeper는 `/etc/config/oathkeeper/entrypoint.sh`를 통해 시작해야 합니다. entrypoint는 `APP_ENV`에 따라 env별 rules 파일을 고르고 `/tmp/oathkeeper/rules.active.json`을 생성합니다.
- `APP_ENV=stage|staging`: `rules.stage.json`
- `APP_ENV=production|prod`: `rules.prod.json`
- 그 외: `rules.json`
`docker/ory/oathkeeper/oathkeeper.yml``file:///tmp/oathkeeper/rules.active.json`을 읽습니다. compose나 배포 템플릿이 entrypoint를 우회해 `oathkeeper serve proxy`를 직접 실행하면 active rules 생성이 누락될 수 있습니다.
## 7) 트러블슈팅
### 7.1 로그인 클릭 시 동작 없음
- 원인: Kratos 기동 실패(설정 파싱 실패 등) 또는 브라우저용 URL이 내부 도메인(`kratos:...`)으로 설정됨
- 확인:
- `docker logs ory_kratos`에서 config 오류 여부 확인
- 브라우저 네트워크 탭에서 `/self-service/login/browser` 응답 확인(302 Location 헤더)
### 7.2 kratos.yml에 ${...} 환경 변수 치환 실패
- Kratos 설정 파일은 `${ENV}` 치환을 지원하지 않음
- 해결: compose 환경 변수로 `KRATOS_SELFSERVICE_*`, `KRATOS_SERVE_*` 오버라이드 사용
## 8) 네트워크 접근 테스트
아래 스크립트는 **ory-net에서 Admin 포트 접근 가능** / **baron_net(Frontend 영역)에서 접근 불가**를 검증합니다.
```bash
bash test/ory-network-check.sh
```
직접 확인하려면:
```bash
# ory-net에서는 성공해야 함
docker run --rm --network ory-net curlimages/curl:8.10.1 -fsS http://hydra:4445/health/ready
docker run --rm --network ory-net curlimages/curl:8.10.1 -fsS http://kratos:4434/health/ready
# baron_net에서는 실패해야 함
docker run --rm --network baron_net curlimages/curl:8.10.1 -fsS http://hydra:4445/health/ready
docker run --rm --network baron_net curlimages/curl:8.10.1 -fsS http://kratos:4434/health/ready
```
## 9) 참고 파일
- `compose.ory.yaml`
- `docker/ory/kratos/kratos.yml`
- `.env.sample`
- `https://gitea.hmac.kr/baron/baron-sso/wiki/Authentication-and-Login-Flow.-`

View File

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

View File

@@ -0,0 +1,104 @@
# RBAC / ReBAC 미들웨어 정책 정리
## 1. 목적
- `backend/internal/middleware/rbac.go`는 **역할 기반(RBAC)**과 **관계 기반(ReBAC, Keto)**을 조합해 접근 제어를 일관되게 적용합니다.
- 핵심 목표는 **운영 단순성 + 권한 정밀도**의 균형입니다.
## 2. 구성 요소와 역할
### 2.1 RequireRole
- 역할(Role) 기반 접근 제어를 담당합니다.
- Super Admin은 즉시 통과합니다.
- 허용된 역할 목록에 포함되지 않으면 차단합니다.
- API Key 인증은 우회합니다(시스템/운영 경로).
### 2.2 RequireKetoPermission
- Ory Keto(ReBAC) 권한 체크를 수행합니다.
- Super Admin은 즉시 통과합니다.
- Keto의 관계 튜플에 기반해 `CheckPermission`을 수행합니다.
### 2.3 RequireTenantMatch
- 사용자가 요청한 테넌트에 대한 관리 자격이 있는지 검증합니다.
- **상속 권한 인정:** 사용자의 기본 테넌트뿐만 아니라, 유저 그룹 멤버십이나 그룹장 직책을 통해 **상속받은 모든 테넌트**를 대상으로 합니다.
- Super Admin 및 유효한 API Key 요청은 통과합니다.
## 3. ReBAC 기반인데도 RBAC가 필요한 이유
1) **정책 단순화**
- Super Admin 같은 전역 정책은 ReBAC로 표현할 수도 있지만, RBAC가 더 빠르고 명확합니다.
2) **운영 경로 단축**
- API Key, 배치성 요청 등은 일반 사용자 흐름과 분리해 처리합니다.
- 불필요한 ReBAC 호출을 줄여 장애 전파를 줄입니다.
3) **테넌트 범위 제어의 명확성**
- "Tenant Admin은 자기 테넌트만"은 자주 쓰는 규칙으로, 미들웨어 단에서 즉시 판단이 효율적입니다. 유저 그룹 도입 이후에는 "상속받은 모든 관리 대상 테넌트"로 범위가 확장됩니다.
4) **성능 및 안정성**
- Keto는 외부 서비스 호출이므로 지연/실패 가능성이 있습니다.
- RBAC로 1차 필터링을 하여 호출 수를 줄입니다.
## 4. SoT(단일 진실 공급원) 충돌 시 우선순위 정책
### 4.1 사용자/인증 SoT
- **1순위: Kratos Identity / Session**
- 사용자 식별과 세션 유효성의 최종 판단 기준
- **2순위: Backend 프로필 DB / 캐시**
- Kratos와 동기화가 보장되는 범위에서만 보조 사용
### 4.2 권한/정책 SoT
- **1순위: Keto(ReBAC) 관계 튜플**
- 리소스 접근 권한의 최종 판단 기준.
- **유저 그룹 상속:** 사용자가 속한 유저 그룹에 부여된 권한은 Keto를 통해 실시간으로 상속됩니다.
- **그룹장-어드민 연동:** 유저 그룹의 장(Leader)은 해당 그룹(테넌트)의 어드민 권한을 자동으로 가집니다.
- **2순위: RBAC(Role)**
- 전역/상위 정책의 단축 규칙.
- ReBAC와 충돌 시, ReBAC 결과가 항상 우선.
### 4.3 테넌트 컨텍스트 SoT
- **1순위: 서버 측 프로필 및 상속된 권한 (ManageableTenants)**
- 사용자의 기본 `tenantId`뿐만 아니라, 유저 그룹을 통해 **상속받은 관리 가능 테넌트 목록** 전체를 기준으로 판단합니다.
- **2순위: 요청 헤더(X-Tenant-ID)**
- 헤더는 "요청 의도"를 나타내며, `ManageableTenants` 목록에 포함된 ID여야 합니다.
- 불일치 시 차단.
### 4.4 OIDC/RP 정보 SoT
- **1순위: Hydra Client/Consent 데이터**
- **2순위: Backend audit details**
- 과거 데이터 재현을 위해 audit details에 client_id/client_name을 기록
## 5. 충돌 시 처리 원칙 (확정)
1) **RBAC는 필터이고, 허용의 최종 판단은 ReBAC**
- RBAC 통과는 ReBAC 호출의 전제일 뿐, 허용 조건이 아니다.
- ReBAC 결과가 "허용"이어야만 최종 통과한다.
2) **RBAC 통과 + ReBAC 실패 → 차단**
- ReBAC가 최종 권한을 가진다.
3) **RBAC 실패 + ReBAC 통과 → 차단**
- 역할 기반 정책 위반은 즉시 차단한다.
4) **Super Admin 예외**
- Super Admin이라도 기본 흐름에서는 ReBAC 판단을 거친다.
- 예외가 필요한 API는 별도로 명시하고, 감사 로그에 명확히 남긴다.
5) **API Key 우회 범위**
- API Key 우회는 최소 범위로 제한한다.
- 우회 대상 API와 사유를 별도 문서로 관리한다.
## 6. 정책 보완 필요 지점 (결정 필요)
1) **Tenant 헤더 불일치 정책**
- `X-Tenant-ID`가 프로필 테넌트와 불일치할 때 차단은 확정
- 테넌트 전환 UI/흐름에 따라 정책 확정 필요
2) **API Key 우회 범위 문서화**
- 현재는 `RequireRole`/`RequireTenantMatch`에서 우회 처리
- 우회 허용 API 목록과 사유를 문서로 고정 필요
## 7. 관련 코드
- `backend/internal/middleware/rbac.go`
- `backend/internal/handler/auth_handler.go`
- `backend/internal/service/keto_service.go`

View File

@@ -0,0 +1,244 @@
# RP 자동 로그인 지원 가이드
이 문서는 Baron SSO의 userfront 연동 앱에서 RP를 클릭했을 때 별도 로그인 버튼 클릭 없이 OIDC 인증을 시작하려는 RP 등록자와 RP 개발자를 위한 기준입니다.
## 목적
자동 로그인은 userfront가 RP의 자체 로그인 시작 URL로 사용자를 보내고, RP가 그 진입점에서 OIDC Authorization Code + PKCE 흐름을 직접 시작하는 방식입니다.
Baron backend가 `/oauth2/auth?...` URL을 대신 만들어 넘기지 않는 이유는 RP가 직접 `state`, `nonce`, PKCE verifier/challenge, callback 검증 상태를 관리해야 하기 때문입니다. SPA나 모바일 웹앱은 이 상태가 RP 저장소에 있어야 callback을 안전하게 처리할 수 있습니다.
## 등록 메타데이터
RP는 Hydra client metadata에 다음 값을 저장합니다.
| 키 | 타입 | 설명 |
| --- | --- | --- |
| `auto_login_supported` | boolean | 자동 로그인 지원 여부입니다. `true`일 때만 userfront가 자동 로그인 URL을 진입 URL로 사용합니다. |
| `auto_login_url` | string | RP가 OIDC 로그인을 시작하는 URL입니다. `http` 또는 `https` URL이어야 합니다. |
devfront의 RP 일반 설정에서 다음 항목을 입력합니다.
1. `자동 로그인 지원`을 켭니다.
2. `자동 로그인 시작 URL`에 RP 로그인 시작 URL을 입력합니다.
3. Redirect URI 목록에는 RP callback URL을 등록합니다.
4. 저장 후 userfront 연동 앱 카드에서 “연동앱 클릭 시 별도 로그인 없이 로그인할 수 있습니다.” 안내가 보이는지 확인합니다.
### 설정 규칙
- `자동 로그인 시작 URL`은 반드시 `http://` 또는 `https://`로 시작하는 완전한 절대 URL이어야 합니다.
- Baron은 이 값을 브라우저 진입 URL로만 사용합니다. Baron이 `/oauth2/auth?...`를 대신 생성하지 않습니다.
- 따라서 이 URL은 RP 내부의 "로그인 시작 엔드포인트"여야 합니다.
- 루트(`/`)가 아니라 실제로 OIDC 시작을 트리거하는 경로를 넣어야 합니다.
허용 예시:
```text
http://localhost:3333/login
http://localhost:3333/login?auto=1
https://rp.example.com/login?auto=1&returnTo=%2Fdashboard
```
비권장 예시:
```text
http:localhost:3333/login
localhost:3333/login
/login?auto=1
```
예시:
```text
auto_login_supported: true
auto_login_url: https://org.example.com/login?auto=1
redirect_uri: https://org.example.com/auth/callback
```
### 로컬 RP 예시
로컬에서 `client_id=f5cdd938-a3ae-4e47-ab83-4c13e59949f5` RP가 `http://localhost:3333`에서 실행 중이라면, 메인 페이지가 `/login` 링크를 노출하고 `/login` 호출 시 Baron OIDC authorize endpoint로 `302`를 반환하는지 먼저 확인합니다.
이 경우 devfront 설정 탭의 권장 입력값은 다음과 같습니다.
```text
자동 로그인 지원: ON
자동 로그인 시작 URL: http://localhost:3333/login
Redirect URI: http://localhost:3333/callback
```
주의:
- `http://localhost:3333/`는 홈 URL일 뿐, 로그인 시작 URL이 아닐 수 있습니다.
- `http://localhost:3333/login?auto=1`도 저장은 가능하지만, RP가 `/login`만으로 이미 즉시 OIDC를 시작한다면 `?auto=1`은 필수가 아닙니다.
- 현재 확인된 로컬 데모 RP는 `/login`만 호출해도 Baron OIDC로 바로 리다이렉트합니다.
## RP 구현 요구사항
RP는 `auto_login_url`에서 다음 동작을 구현해야 합니다.
1. `auto=1` 쿼리를 읽습니다.
2. 이미 RP 세션이 있으면 기본 화면 또는 `returnTo` 경로로 이동합니다.
3. RP 세션이 없고 `auto=1`이면 로그인 버튼을 기다리지 않고 OIDC authorization 요청을 시작합니다.
4. OIDC 요청 전에 `state`, `nonce`, PKCE `code_verifier`, `code_challenge`를 생성합니다.
5. `state`, `nonce`, `code_verifier`, `returnTo`는 RP origin의 안전한 저장소에 보관합니다.
6. callback에서 `state`를 검증하고, token 교환 시 `code_verifier`를 사용합니다.
7. 인증 완료 후 저장된 `returnTo`가 있으면 해당 경로로 이동합니다.
권장 URL 형식:
```text
https://rp.example.com/login?auto=1
https://rp.example.com/login?auto=1&returnTo=%2Fdashboard
```
## Baron 내장 RP 기준
Baron 계열 RP는 다음 fallback을 사용합니다. env URL이 설정되어 있을 때만 자동 로그인 지원으로 간주합니다.
| Client ID | Env | 자동 로그인 URL |
| --- | --- | --- |
| `adminfront` | `ADMINFRONT_URL` | `${ADMINFRONT_URL}/login?auto=1` |
| `devfront` | `DEVFRONT_URL` | `${DEVFRONT_URL}/login?auto=1&returnTo=%2Fclients` |
| `orgfront` | `ORGFRONT_URL` | `${ORGFRONT_URL}/login` |
orgfront는 `/login` 진입부터 OIDC authorize 요청을 즉시 시작하며, 기본 callback 이후 이동 경로는 `/chart`입니다. 수동 로그인 화면 검증이 필요하면 `/login?auto=0`으로 자동 시작을 끌 수 있습니다.
## userfront 동작
userfront는 backend의 linked RP 응답을 기준으로 진입 URL을 선택합니다.
1. `status`가 active가 아니면 진입 URL을 만들지 않습니다.
2. `auto_login_supported=true`이면 `auto_login_url`을 우선 사용합니다.
3. `auto_login_url`이 없으면 `init_url`을 사용합니다.
4. 자동 로그인 미지원이면 `init_url`이 있어도 기존 `url`로 이동합니다.
이 기준 때문에 `auto_login_supported=false`인 RP는 accidental auto-login을 수행하지 않습니다.
## 시퀀스 다이어그램
### 1. 설정 저장 흐름
```mermaid
sequenceDiagram
participant Admin as 운영자
participant DevFront as DevFront 설정 화면
participant Backend as Baron Backend
participant Hydra as Hydra Client Metadata
Admin->>DevFront: RP 설정 화면 진입
Admin->>DevFront: 자동 로그인 지원 ON
Admin->>DevFront: 자동 로그인 시작 URL 입력
Admin->>DevFront: 저장
DevFront->>Backend: RP 일반 설정 수정 요청
Backend->>Backend: auto_login_url 검증
Note over Backend: scheme=http/https<br/>host 존재 필요
Backend->>Hydra: client metadata update
Hydra-->>Backend: 저장 완료
Backend-->>DevFront: 저장 성공
DevFront-->>Admin: 저장 완료 표시
```
### 2. userfront에서 자동 로그인 시작
```mermaid
sequenceDiagram
participant User as 사용자
participant UserFront as UserFront
participant Backend as Baron Backend
participant RP as RP 로그인 시작 URL
participant Baron as Baron OIDC/Hydra
User->>UserFront: 연동 앱 카드 클릭
UserFront->>Backend: linked RP 목록/상태 조회
Backend-->>UserFront: auto_login_supported, auto_login_url 반환
alt auto_login_supported = true
UserFront->>RP: GET auto_login_url
Note over RP: RP가 state, nonce,<br/>PKCE verifier/challenge 생성
RP-->>UserFront: 302 Baron authorize endpoint
UserFront->>Baron: GET /oidc/oauth2/auth
else auto_login_supported = false
UserFront->>UserFront: 일반 url 또는 init_url 사용
end
```
### 3. 로컬 3333 RP 예시 흐름
```mermaid
sequenceDiagram
participant User as 사용자 브라우저
participant UserFront as UserFront
participant RP as localhost:3333 RP
participant Baron as Baron OIDC
User->>UserFront: RP 카드 클릭
UserFront->>RP: GET http://localhost:3333/login
RP-->>User: 302 /oidc/oauth2/auth?...&client_id=f5cdd938-a3ae-4e47-ab83-4c13e59949f5
User->>Baron: GET authorize endpoint
Baron-->>User: 로그인/동의 또는 기존 세션 진행
Baron-->>RP: callback redirect
RP-->>User: RP 세션 생성 후 앱 화면 진입
```
## 로직 요약
1. DevFront는 `auto_login_supported`, `auto_login_url`을 RP metadata로 저장합니다.
2. Backend는 `auto_login_supported=true`일 때만 `auto_login_url`을 linked RP 응답에 포함시켜 userfront가 사용할 수 있게 합니다.
3. UserFront는 카드 클릭 시 이 URL로 직접 이동합니다.
4. RP는 그 진입점에서 OIDC 요청을 "직접" 생성해야 합니다.
5. Callback 검증에 필요한 `state`, `nonce`, PKCE 상태는 RP 저장소가 소유해야 합니다.
6. 그래서 Baron이 RP 대신 authorize URL을 만들어 주는 구조로 바꾸면 안 됩니다.
## 검증 체크리스트
RP 등록자는 다음을 확인해야 합니다.
1. devfront에서 `자동 로그인 지원`이 켜져 있습니다.
2. `자동 로그인 시작 URL`이 실제 RP 로그인 진입점입니다.
3. `auto_login_url`에 직접 접속하면 로그인 버튼 클릭 없이 Baron OIDC authorize 요청이 시작됩니다.
4. callback URL이 Redirect URI에 등록되어 있습니다.
5. callback 이후 RP가 `state`, `nonce`, PKCE 검증을 통과합니다.
6. userfront 연동 앱 카드에 자동 로그인 안내 문구가 표시됩니다.
7. userfront에서 RP 카드를 클릭하면 RP 로그인 화면에 머물지 않고 OIDC 흐름으로 진입합니다.
orgfront 기준 검증 명령:
```bash
npm run test -- tests/orgfront-auto-login.spec.ts --project=chromium
```
## 실패 시 확인할 항목
| 증상 | 확인 항목 |
| --- | --- |
| userfront에서 일반 URL로만 이동함 | RP metadata의 `auto_login_supported``true`인지 확인합니다. |
| userfront 카드에 안내 문구가 없음 | backend `/api/v1/user/rp/linked` 응답에 `auto_login_supported=true`가 내려오는지 확인합니다. |
| RP 로그인 화면에 머무름 | RP가 `auto=1` 쿼리를 읽어 자동으로 `signinRedirect` 또는 동일한 OIDC 시작 함수를 호출하는지 확인합니다. |
| callback에서 state 오류 발생 | userfront나 backend가 만든 `/oauth2/auth?...` URL을 직접 쓰지 말고 RP 자체 로그인 시작 URL에서 OIDC 요청을 생성해야 합니다. |
| 등록 저장이 실패함 | `auto_login_supported=true`일 때 `auto_login_url`이 비어 있거나 `http/https` URL이 아닌지 확인합니다. |
| `http:localhost:3333/login` 같은 값이 브라우저에서는 열림 | 브라우저 보정에 기대지 말고 `http://localhost:3333/login`처럼 완전한 절대 URL로 저장합니다. |
## 구현 예시
React RP 예시입니다. 실제 프로젝트에서는 사용하는 OIDC client 라이브러리의 API에 맞춰 적용합니다.
```tsx
const returnTo = searchParams.get("returnTo") || "/";
const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => {
if (auth.isAuthenticated) {
navigate(returnTo, { replace: true });
return;
}
if (!shouldAutoLogin || auth.isLoading || auth.activeNavigator) {
return;
}
void auth.signinRedirect({
state: { returnTo },
});
}, [auth, navigate, returnTo, shouldAutoLogin]);
```
중요한 점은 `signinRedirect`가 RP에서 실행되어야 한다는 것입니다. 그래야 RP가 callback 검증에 필요한 상태를 보유할 수 있습니다.

View File

@@ -0,0 +1,305 @@
# 외부 RP Ory IAM 연동 가이드 초안
본 문서는 외부 RP가 자체 IAM을 만들지 않고 Baron SSO/Ory Stack/Keto 기반 공용 IAM을 연동하기 위한 초안입니다.
## 공개 Manifest
- HTML: `/.well-known/baron-rp-manifest`
- JSON: `/.well-known/baron-rp-manifest.json`
- JSON Schema: `/.well-known/baron-rp-manifest.schema.json`
RP는 JSON manifest를 우선 기준으로 삼고, HTML 페이지는 사람이 확인하는 규격 문서로 사용합니다.
## Identity Contract
RP는 raw `kratos_identity_id`를 비즈니스 키로 저장하거나 파싱하지 않습니다. `X-Baron-External-Key`는 RP가 생성하거나 제출하는 값이 아니라, Baron이 인증된 subject를 기준으로 발급 또는 조회해서 RP 요청 직전에 주입하는 Baron-issued alias입니다.
- `X-Baron-Subject`: Keto 권한 판정 subject입니다. 예: `User:<baron_identity_id>`
- `X-Baron-External-Key`: RP의 local user insert/upsert에 쓰는 opaque external key입니다. RP는 이 값을 해석하지 않고 전체 문자열 그대로 저장합니다.
- `X-Baron-Client-ID`: 현재 요청이 속한 RP client id입니다.
RP의 local user key는 `provider + external_key` 조합으로 저장합니다. 이메일은 변경될 수 있으므로 stable primary key로 사용하지 않습니다.
정리하면 “RP가 알고 저장할 수 있는 값”은 Baron이 주입한 canonical external alias뿐입니다. RP가 alias를 직접 만들거나 raw `kratos_identity_id`에서 alias를 계산하면 안 됩니다. 최초 로그인 또는 최초 접근 시 RP가 사용자를 생성해야 한다면, Baron이 이미 주입한 `X-Baron-External-Key`를 사용해 insert/upsert합니다.
```mermaid
flowchart TD
A[User authenticates through Baron SSO] --> B[Baron resolves internal identity]
B --> C[Baron derives or loads Baron-issued alias]
C --> D[Baron injects X-Baron-External-Key]
D --> E[Baron injects X-Baron-Subject]
E --> I[RP receives trusted headers from Baron gateway]
I --> F[RP upserts local user with provider + X-Baron-External-Key]
F --> G[RP stores the full external key as opaque value]
G --> H[RP never parses or stores raw kratos_identity_id]
```
## OIDC Tenant Claim Contract
Baron은 기본적으로 대표소속 tenant와 전체 소속 tenant 목록을 식별할 수 있도록 `tenant_id`, `joined_tenants`를 ID token claim에 포함할 수 있습니다. RP가 OIDC scope 또는 client metadata 정책을 통해 `tenant` claim을 요청하면 Baron은 여기에 더해 tenant별 상세 정보를 포함합니다. 이 claim은 RP가 UI 표시, 조직 맥락 선택, RP 내부 권한 매핑을 시작하기 위한 입력이며, 최종 권한 판정은 Baron gateway/Keto check 또는 Baron이 발급한 trusted header를 기준으로 해야 합니다.
기본 claim 구조는 다음과 같습니다.
```json
{
"tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"joined_tenants": [
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
]
}
```
`tenant` claim을 요청하면 상세 claim 구조는 다음과 같습니다.
```json
{
"tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"joined_tenants": [
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
],
"lead_tenants": ["01970f0a-5c28-74d8-a73a-f6e9e9a7b210"],
"tenants": {
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210": {
"id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"slug": "tech-planning",
"name": "기술기획팀",
"type": "USER_GROUP",
"lead": true,
"representative": true,
"isPrimary": true,
"grade": "책임",
"jobTitle": "기술기획",
"position": "팀장",
"parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
"ancestors": [
{
"id": "01970f08-91da-7286-bd19-882fb98d1f2c",
"slug": "hanmac",
"name": "한맥기술",
"type": "COMPANY",
"parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345"
},
{
"id": "01970f07-4f01-7d9a-a71e-b53ad508f345",
"slug": "hanmac-family",
"name": "한맥가족",
"type": "COMPANY_GROUP",
"parentTenantId": null
}
]
},
"01970f0b-3448-7bb8-bdc7-16b6a1d2e661": {
"id": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661",
"slug": "quality",
"name": "품질관리팀",
"type": "USER_GROUP",
"lead": false,
"representative": false,
"isPrimary": false,
"grade": "선임",
"jobTitle": "품질관리",
"position": "파트원",
"parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
"ancestors": [
{
"id": "01970f08-91da-7286-bd19-882fb98d1f2c",
"slug": "hanmac",
"name": "한맥기술",
"type": "COMPANY",
"parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345"
},
{
"id": "01970f07-4f01-7d9a-a71e-b53ad508f345",
"slug": "hanmac-family",
"name": "한맥가족",
"type": "COMPANY_GROUP",
"parentTenantId": null
}
]
}
}
}
```
필드 의미는 다음과 같습니다.
- `tenant_id`: 기본 claim입니다. 사용자의 대표소속 tenant UUID입니다. 현재 RP/client context tenant가 없더라도 공백으로 내려가지 않습니다.
- `joined_tenants`: 기본 claim입니다. 사용자가 claim 상에서 소속된 모든 tenant UUID 목록입니다. `additionalAppointments`의 모든 한맥가족 subtree tenant를 포함합니다.
- `lead_tenants`: `tenant` claim 요청 시 포함됩니다. `lead=true`로 판정된 tenant id 목록입니다.
- `tenants`: `tenant` claim 요청 시 포함됩니다. tenant UUID를 key로 하는 tenant별 claim map입니다. 멀티 소속이면 소속 tenant마다 하나씩 포함되며, `slug`는 별도 필드로 내려갑니다.
- `tenants.*.lead`: 해당 tenant에서 lead 권한 또는 조직장 역할이 있으면 `true`입니다. Baron 입력에서는 `lead`, `isLead`, `isOwner`, `isManager`를 수용할 수 있습니다.
- `tenants.*.representative`: 대표조직이면 `true`입니다. Baron 입력에서는 `representative`, `isPrimary`, `primary`를 수용할 수 있습니다.
- `tenants.*.grade`: 직급입니다.
- `tenants.*.jobTitle`: 직무입니다.
- `tenants.*.position`: 직책입니다.
- `tenants.*.parentTenantId`: 현재 tenant의 직속 parent tenant UUID입니다. 최상위 root면 `null`입니다.
- `tenants.*.ancestors`: 직속 상위 tenant부터 `hanmac-family` root까지의 parent chain입니다.
대표소속 결정 정책은 다음과 같습니다.
- 명시적인 `tenant_id`가 있으면 이를 대표소속으로 사용합니다.
- 명시적인 대표소속이 없으면 `additionalAppointments`에서 `representative=true`, `isPrimary=true`, `primary=true`인 소속을 사용합니다.
- 대표 표시가 없으면 가장 먼저 등록된 소속 tenant를 대표소속으로 사용합니다.
- 생성 시 소속 tenant가 하나도 없으면 Baron이 PERSONAL tenant를 자동 생성하고, 해당 PERSONAL tenant UUID를 `tenant_id``joined_tenants`에 포함합니다.
- RP/client의 tenant context는 대표소속을 덮어쓰지 않습니다. RP context tenant가 필요한 경우 별도 필드나 RP route context로 다뤄야 합니다.
한맥가족(`hanmac-family`) subtree에 속한 tenant claim은 다음 규칙을 따릅니다.
- `TenantService.GetTenant` 기준 parent chain이 `hanmac-family` root에 도달한 경우에만 한맥가족 확장 필드를 보강합니다.
- `additionalAppointments`만 존재하고 tenant별 namespaced traits map이 없어도 `tenant_id` 또는 `additionalAppointments[].tenantId`를 기준으로 `tenants` 항목을 생성할 수 있습니다.
- 사용자가 여러 tenant에 소속되면 기본 claim인 `joined_tenants`에는 모든 소속 tenant가 포함됩니다.
- `tenant` claim 요청 시 `tenants`에도 모든 소속 tenant의 상세가 포함되고, `lead_tenants`에는 그중 `lead=true`인 tenant만 포함됩니다.
- 직급/직무/직책과 대표조직/lead 여부는 사용자 소속 metadata(`additionalAppointments`)를 우선합니다.
- `ancestors`는 직속 상위 tenant부터 root 방향으로 정렬되며, root가 `hanmac-family`일 때까지만 포함합니다.
- 기본 tenant와 각 ancestor 객체는 `parentTenantId`를 포함합니다. 이 필드로 parent edge를 바로 그릴 수 있습니다.
주의사항:
- Tenant tree, 직급, 직무, 직책은 PostgreSQL Business SoT와 tenant/user metadata를 기준으로 합니다. Kratos traits는 인증 식별 정보 중심으로 유지해야 하며, 관계형 데이터의 영구 SoT로 취급하지 않습니다.
- Token 크기가 커질 수 있으므로 RP가 긴 조직 전체 정보를 필요로 하면 ID token claim보다 userinfo/profile API 또는 Baron backend API 연동을 우선 검토합니다.
- RP는 `lead_tenants` 또는 `tenants.*.lead`만으로 보안상 중요한 권한을 단독 판정하지 않습니다. 권한 변경/민감 리소스 접근은 Keto 기반 Baron authorization contract를 함께 사용해야 합니다.
### Issue #775 구현 결과 예시
아래 예시는 #775 구현 후 `tenant_id=01970f0a-5c28-74d8-a73a-f6e9e9a7b210`, 사용자 `additionalAppointments`에 대표 소속 `tech-planning`과 겸직 소속 `quality`가 함께 있고, 두 tenant의 parent chain이 `hanmac -> hanmac-family`로 이어지는 경우 ID token에 내려가는 데이터 형태입니다. 예시의 `id` 값은 UUID 형식의 샘플이며, `slug`와 다릅니다.
```json
{
"email": "hanmac-user@example.com",
"name": "한맥 사용자",
"tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"joined_tenants": [
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
],
"lead_tenants": [
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
],
"tenants": {
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210": {
"id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"slug": "tech-planning",
"name": "기술기획팀",
"type": "USER_GROUP",
"lead": true,
"representative": true,
"isPrimary": true,
"grade": "책임",
"jobTitle": "기술기획",
"position": "팀장",
"parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
"ancestors": [
{
"id": "01970f08-91da-7286-bd19-882fb98d1f2c",
"slug": "hanmac",
"name": "한맥기술",
"type": "COMPANY",
"parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345"
},
{
"id": "01970f07-4f01-7d9a-a71e-b53ad508f345",
"slug": "hanmac-family",
"name": "한맥가족",
"type": "COMPANY_GROUP",
"parentTenantId": null
}
]
},
"01970f0b-3448-7bb8-bdc7-16b6a1d2e661": {
"id": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661",
"slug": "quality",
"name": "품질관리팀",
"type": "USER_GROUP",
"lead": false,
"representative": false,
"isPrimary": false,
"grade": "선임",
"jobTitle": "품질관리",
"position": "파트원",
"parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c",
"ancestors": [
{
"id": "01970f08-91da-7286-bd19-882fb98d1f2c",
"slug": "hanmac",
"name": "한맥기술",
"type": "COMPANY",
"parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345"
},
{
"id": "01970f07-4f01-7d9a-a71e-b53ad508f345",
"slug": "hanmac-family",
"name": "한맥가족",
"type": "COMPANY_GROUP",
"parentTenantId": null
}
]
}
},
"profile": {
"emails": [
"hanmac-user@example.com"
],
"names": {
"name": "한맥 사용자"
}
}
}
```
RP 소비 기준:
- lead tenant를 빠르게 찾을 때는 `lead_tenants`를 우선 사용합니다.
- 전체 소속 tenant 목록은 `joined_tenants`로 읽고, 각 소속의 상세 조직 맥락은 `tenants[joined_tenants[n]]`에서 읽습니다.
- 대표소속의 상세 조직 맥락은 `tenants[tenant_id]`에서 읽습니다.
- 상위 조직 breadcrumb은 `tenants[tenant_id].ancestors`를 직속 상위부터 root 방향으로 표시합니다.
- 조직 트리 edge는 기본 tenant와 각 ancestor의 `parentTenantId`를 사용해 그립니다.
- 대표조직 여부는 `representative`를 우선 사용하고, 기존 primary 표현 호환이 필요하면 `isPrimary`를 함께 읽습니다.
## obj_id 조회 흐름
`obj_id`는 Keto check의 target object입니다. 명시적으로 전달된 `obj_id`가 있으면 정규화 후 사용하고, 없으면 route context에서 `client_id`, `tenant_id` 순서로 추론합니다. 둘 다 없으면 RP가 명확한 target object를 제공하지 않은 것이므로 요청을 거부해야 합니다.
```mermaid
flowchart TD
A[RP request] --> B{obj_id supplied?}
B -->|yes| C[Normalize object type and obj_id]
B -->|no| D{Route has client_id?}
D -->|yes| E[obj_id = RelyingParty:<client_id>]
D -->|no| F{Route has tenant_id?}
F -->|yes| G[obj_id = Tenant:<tenant_id>]
F -->|no| H[Reject: explicit obj_id required]
C --> I[Check Keto relation]
E --> I
G --> I
I --> J{allowed?}
J -->|yes| K[Inject trusted Baron headers]
J -->|no| L[Reject request]
K --> M[Write audit with obj_id, relation, client_id, X-Request-Id]
```
대표 object 패턴은 다음과 같습니다.
- RP 단위: `RelyingParty:<client_id>`
- Tenant 단위: `Tenant:<tenant_id>`
- RP 내부 리소스 단위: `Resource:<resource_type>:<resource_id>`
## Audit Contract
audit 누락 방지는 범위를 나눠서 보장합니다.
- Baron이 중개하는 IAM mutation은 `fail_closed_sync`입니다. audit write가 실패하면 원 요청도 실패해야 합니다.
- audit sink가 없거나 사용할 수 없으면 mutation은 `reject_mutation`으로 처리합니다.
- allowlist된 read audit은 부하 보호를 위해 best effort로 둘 수 있으나, 권한/설정 변경 command에는 적용하지 않습니다.
- RP 자체 비즈니스 이벤트는 RP가 동일한 `X-Request-Id`를 correlation key로 사용해 audit을 남겨야 합니다.
필수 audit detail 필드는 다음과 같습니다.
- `obj_id`
- `relation`
- `client_id`
- `subject`
- `decision`
따라서 “audit 누락 없음”은 Baron-mediated IAM command에 대해 보장합니다. RP 내부에서 직접 발생하는 비즈니스 이벤트까지 포함하려면 RP가 이 audit contract를 구현하고, audit 저장 실패 시 동일하게 fail closed 처리해야 합니다.

View File

@@ -0,0 +1,105 @@
# RP 활동상황 UX 확장 기능 동작 흐름
이 문서는 UserFront의 '활동상황' UX 확장 기능(Scope 표시, Consent 이력, 과거 연동 앱 보기)이 어떤 파일과 로직을 통해 구현되었는지, 그리고 전체 데이터 흐름이 어떻게 동작하는지 설명합니다.
## 1. 개요
이 기능의 목표는 사용자에게 자신이 동의한 권한(Scope) 내역을 명확히 보여주고, 과거의 동의 및 해지 이력을 제공하며, 더 이상 사용하지 않는 앱의 목록도 확인할 수 있도록 하는 것입니다. 이를 위해 백엔드에 동의 이력을 기록하는 로직이 추가되었고, 프런트엔드는 이 데이터를 활용하여 확장된 UX를 제공합니다.
## 2. 전체 데이터 흐름
```mermaid
sequenceDiagram
participant User as 사용자
participant UserFront
participant Backend
participant AuditDB as 감사 로그 (ClickHouse)
participant Hydra
User->>UserFront: 대시보드 접속
UserFront->>Backend: GET /api/v1/user/rp/linked (활성 앱 목록 요청)
Backend->>Hydra: ListConsentSessions (활성 세션 조회)
Hydra-->>Backend: 현재 유효한 동의 세션 목록
Backend-->>UserFront: 활성 앱 목록 응답 (scopes 포함)
UserFront->>Backend: GET /api/v1/user/rp/history (전체 이력 요청)
Backend->>AuditDB: FindByUserAndEvents("consent.granted", "consent.revoked")
AuditDB-->>Backend: 동의/해지 로그 목록
Backend-->>UserFront: 가공된 앱별 최종 상태 목록
UserFront-->>User: 활성 앱과 과거 연동 앱 목록 표시
User->>UserFront: 특정 앱의 '상세정보' 클릭
UserFront-->>UserFront: 다이얼로그 표시 (Scope 목록 + 필터링된 이력)
```
## 3. 백엔드 구현 상세 (`backend`)
### 3.1. 동의/해지 이벤트 기록
**파일**: `internal/handler/auth_handler.go`
- **동의 시 (`AcceptConsentRequest`)**: 사용자가 권한 동의를 수락하면, `consent.granted` 타입의 감사 로그를 생성합니다.
- **로직**: `h.AuditRepo.Create()`를 호출합니다.
- **저장 정보**: 로그의 `Details` 필드에 Client ID, Client 이름, 사용자가 동의한 **Scope 목록**을 JSON 형태로 저장합니다.
- **해지 시 (`RevokeLinkedRp`)**: 사용자가 연동 해지를 하면, `consent.revoked` 타입의 감사 로그를 생성합니다.
- **로직**: `h.AuditRepo.Create()`를 호출합니다.
- **저장 정보**: `Details` 필드에 Client ID를 저장합니다.
### 3.2. 이력 조회 API 구현
**파일**: `internal/handler/auth_handler.go`
- **신규 핸들러 (`ListRpHistory`)**: `GET /api/v1/user/rp/history` 요청을 처리합니다.
- **로직**:
1. `h.resolveConsentSubject(c)`를 통해 요청한 사용자의 ID를 식별합니다.
2. `h.AuditRepo.FindByUserAndEvents`를 호출하여 해당 사용자의 `consent.granted`, `consent.revoked` 로그를 모두 조회합니다.
3. 조회된 로그를 시간순으로 처리하여 앱(Client ID)별로 그룹화하고, 각 앱의 최종 상태(`status`), 마지막 승인일(`last_approved_at`), 마지막 해지일(`last_revoked_at`) 등을 계산하여 응답을 구성합니다.
### 3.3. 데이터베이스 인터페이스 확장
**파일**: `internal/domain/models.go`, `internal/repository/clickhouse_repo.go`
- **인터페이스 변경**: `AuditRepository` 인터페이스에 `FindByUserAndEvents` 메서드를 추가하여 특정 사용자의 특정 이벤트 유형 로그를 조회할 수 있는 규약을 정의했습니다.
- **구현**: `ClickHouseRepository``FindByUserAndEvents` 메서드를 실제 SQL 쿼리로 구현했습니다. `WHERE user_id = ? AND event_type IN (?)` 절을 사용하여 성능을 확보합니다.
### 3.4. 라우팅 등록
**파일**: `cmd/server/main.go`
- `user` API 그룹에 `user.Get("/rp/history", authHandler.ListRpHistory)` 라우팅 규칙을 추가하여 API를 외부로 노출시켰습니다.
---
## 4. 프런트엔드 구현 상세 (`userfront`)
**주요 파일**: `lib/features/dashboard/presentation/dashboard_screen.dart`
### 4.1. 데이터 모델 추가
- **`RpHistoryItem`**: 백엔드의 `rp/history` API 응답을 파싱하기 위한 새로운 데이터 모델 클래스를 정의했습니다.
- **`_ActivityItem` 수정**: 기존 UI 모델에 `List<String> scopes` 필드를 추가하여, 활성 앱의 Scope 정보를 위젯 내부에서 사용할 수 있도록 했습니다.
### 4.2. API 호출 로직
- **`_fetchRpHistory()`**: `initState`에서 호출되며, 백엔드의 `GET /api/v1/user/rp/history` API를 호출하여 모든 연동 이력(활성/해지 포함)을 가져와 `_rpHistoryFuture` 상태에 저장합니다.
- **`_fetchLinkedRps()`**: 기존 로직을 유지하며, 현재 활성화된 앱 목록을 `GET /api/v1/user/rp/linked`를 통해 가져옵니다.
### 4.3. UI 렌더링 로직
#### 4.3.1. 활성 앱 카드 (`_buildActivityCard`)
- **"상세정보" 버튼 추가**: 기존의 '연동 해지' 버튼 옆에 '상세정보' 버튼을 추가했습니다.
- **`onPressed` 이벤트**: 이 버튼을 누르면 `_showRpDetails(item)` 함수가 호출됩니다.
#### 4.3.2. 상세 정보 다이얼로그 (`_showRpDetails`)
- **Scope 목록 표시**: `_ActivityItem`에 저장된 `scopes` 리스트를 `Wrap``Chip` 위젯을 사용해 보기 좋게 표시합니다.
- **이력 표시**: `_rpHistoryFuture`에서 가져온 전체 이력 중, 현재 보고 있는 앱의 `clientId`와 일치하는 항목을 찾아 마지막 승인/해지 일시와 현재 상태를 표시합니다.
#### 4.3.3. 과거 연동 앱 목록 (`_buildPastRps`)
- **위치**: '활동상황' 섹션 아래, '접속이력' 섹션 위에 새로운 섹션으로 추가되었습니다.
- **데이터 소스**: `_rpHistoryFuture`를 사용합니다.
- **필터링 로직**: `status``'active'`가 아닌 항목들(주로 'revoked')만 필터링하여 목록을 만듭니다.
- **UI 재사용**: 필터링된 데이터를 `_ActivityItem` 모델로 변환한 뒤, 기존의 `_buildActivityGrid``_buildActivityCard` 위젯을 재사용하여 일관된 UI를 보여줍니다. '연동 해지' 버튼은 비활성화 처리됩니다.

View File

@@ -0,0 +1,138 @@
# 연동된 RP 홈페이지 이동 기능 구현 가이드
## 1. 개요
UserFront 대시보드의 '활동상황' 섹션에서 연동된 RP(Relying Party) 카드를 클릭했을 때, 해당 서비스의 홈페이지로 이동하는 기능의 구현 상세입니다.
특히, RP 설정에 홈페이지 주소(`client_uri`)가 명시되지 않은 경우에도 `Redirect URI`를 기반으로 주소를 추론하여 이동할 수 있도록 **Fallback 로직**이 적용되었습니다.
## 2. 동작 흐름 (Data Flow)
1. **초기 로딩**: 사용자가 대시보드에 접속하면 프론트엔드는 백엔드에 연동된 RP 목록을 요청합니다.
2. **데이터 가공 (Backend)**:
* 백엔드는 Ory Hydra에서 사용자의 동의(Consent) 세션을 조회합니다.
* 각 RP(Client) 정보에서 `client_uri`를 확인합니다.
* **Fallback**: 만약 `client_uri`가 비어있다면, `redirect_uris`의 첫 번째 주소를 파싱하여 `Scheme``Host` (예: `https://gitea.hmac.kr`)를 추출해 홈페이지 주소로 사용합니다.
3. **렌더링 (Frontend)**:
* 응답받은 목록을 기반으로 카드를 생성합니다.
* RP 상태가 '활성(active)'인 경우에만 클릭 이벤트를 활성화합니다.
4. **사용자 인터랙션**:
* 사용자가 카드를 클릭하면 `url_launcher`를 통해 새 브라우저 탭에서 해당 주소를 엽니다.
* 주소가 없는 경우 사용자에게 안내 메시지(SnackBar)를 표시합니다.
---
## 3. 백엔드 구현 상세 (Go)
### 파일: `backend/internal/handler/auth_handler.go`
#### 3.1 구조체 변경
API 응답 모델인 `linkedRpSummary``URL` 필드를 추가하여 프론트엔드로 전달할 수 있게 했습니다.
```go
type linkedRpSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Logo string `json:"logo,omitempty"`
URL string `json:"url,omitempty"` // 추가된 필드
LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"`
Status string `json:"status"`
Scopes []string `json:"scopes,omitempty"`
}
```
#### 3.2 URL 할당 및 Fallback 로직 (`ListLinkedRps`)
Hydra Client 정보 매핑 시, `ClientURI` 부재 시 `RedirectURIs`를 활용하는 로직이 핵심입니다.
```go
// ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback)
clientURL := strings.TrimSpace(client.ClientURI)
if clientURL == "" && len(client.RedirectURIs) > 0 {
// 예: https://gitea.hmac.kr/callback -> https://gitea.hmac.kr
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
}
// ...
records[clientID] = &linkedRpRecord{
linkedRpSummary: linkedRpSummary{
// ...
URL: clientURL, // 가공된 URL 할당
},
}
```
---
## 4. 프론트엔드 구현 상세 (Flutter)
### 파일: `userfront/lib/features/dashboard/presentation/dashboard_screen.dart`
#### 4.1 모델 업데이트
백엔드 응답을 처리하기 위해 `LinkedRp` 모델에 `url` 필드를 추가했습니다.
```dart
class LinkedRp {
final String id;
// ...
final String url; // 추가된 필드
factory LinkedRp.fromJson(Map<String, dynamic> json) {
return LinkedRp(
// ...
url: json['url']?.toString() ?? '',
);
}
}
```
#### 4.2 UI 인터랙션 구현 (`_buildActivityCard`)
카드의 클릭 가능 여부를 판단하고, 클릭 시 이동 로직을 처리합니다.
1. **클릭 조건**: RP 상태가 '활성'(`isActive`)이면 클릭 가능하도록 설정합니다.
2. **시각적 피드백**:
* 활성 상태인 경우 테두리 색상(Green)과 그림자 효과(BoxShadow)를 적용하여 클릭 가능함을 암시합니다.
* `MouseRegion`을 사용하여 마우스 오버 시 포인터 커서(`SystemMouseCursors.click`)를 표시합니다.
3. **이동 로직**:
* `url_launcher` 패키지의 `launchUrl`을 사용합니다.
* URL이 비어있거나 유효하지 않은 경우 `ScaffoldMessenger`를 통해 안내 메시지를 띄웁니다.
```dart
// 활성 상태면 클릭 가능
final isClickable = isActive;
// ... (UI 스타일링 코드 생략)
if (isClickable) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () async {
if (item.url != null && item.url!.isNotEmpty) {
final uri = Uri.parse(item.url!);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
// 브라우저 실행 실패 시
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('해당 링크를 열 수 없습니다.')),
);
}
}
} else {
// URL 정보가 없는 경우 (백엔드 Fallback 실패 등)
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('이동할 페이지 주소(Client URI)가 설정되지 않았습니다.')),
);
}
}
},
child: opaqueCard,
),
);
}
```
## 5. 요약
이 기능은 사용자가 별도의 설정 없이도 연동된 서비스로 쉽게 이동할 수 있도록 편의성을 제공합니다. 백엔드에서의 **지능적인 주소 추론**과 프론트엔드에서의 **직관적인 UI 피드백**이 결합되어 완성되었습니다.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@@ -0,0 +1,80 @@
# Staging 502 원인 분석 및 재발방지 조치
## 개요
- 대상: `https://sso.hmac.kr`
- 관련 Actions run: `https://gitea.hmac.kr/baron/baron-sso/actions/runs/962`
- 관련 이슈: `https://gitea.hmac.kr/baron/baron-sso/issues/1025`
- 확인일: 2026-06-08
Gitea Actions run 962는 성공으로 종료됐지만, `https://sso.hmac.kr``502 Bad Gateway`를 반환했습니다. 장애는 애플리케이션 코드 레벨의 단일 오류가 아니라 스테이징 Docker Compose의 restart policy 누락으로 인해 호스트 또는 Docker 데몬 재시작 후 장기 실행 컨테이너가 자동 복구되지 않은 문제였습니다.
## 근본 원인
`docker/staging_pull_compose.template.yaml`에서 일부 장기 실행 서비스에 `restart: unless-stopped` 또는 `restart: always`가 없었습니다.
호스트 상태 확인 결과, 재시작 정책이 있던 `baron_gateway`, Postgres, Redis, ClickHouse, Oathkeeper는 자동 복구됐지만, 정책이 없던 다음 컨테이너는 종료 상태로 남았습니다.
- `baron_backend`
- `baron_userfront`
- `baron_adminfront`
- `baron_devfront`
- `baron_orgfront`
- `ory_kratos`
- `ory_hydra`
- `ory_keto`
- `ory_postgres`
- `ory_clickhouse`
결과적으로 외부 reverse proxy와 `baron_gateway`는 살아 있었지만, gateway의 upstream인 UserFront/Backend/AdminFront/DevFront/OrgFront가 없어 외부 도메인이 502를 반환했습니다. `/auth`, `/oidc` 계열 요청은 Oathkeeper가 살아 있어 JSON 404까지 도달했습니다.
## 조치
- `docker/staging_pull_compose.template.yaml`의 장기 실행 서비스에 `restart: unless-stopped`를 추가했습니다.
- 마이그레이션/유틸 컨테이너에는 restart policy를 추가하지 않았습니다.
- `kratos-migrate`
- `hydra-migrate`
- `keto-migrate`
- `ory_stack_check`
- `init-rp`
- `oathkeeper_logs_init`
- `infra_check`
- `.gitea/workflows/staging_code_pull.yml`의 배포 후 검증 범위를 확장했습니다.
- `baron_backend` health 확인
- `baron_userfront` 확인
- `baron_gateway` 확인
- 이 검증은 배포 직후 smoke test이며, 배포 이후 장애를 감지하는 운영 알람 대책은 아닙니다.
## 재발방지 테스트
추가된 정책 테스트:
```sh
sh test/staging_pull_restart_policy_test.sh
```
기존 배포 정책 테스트 강화:
```sh
sh test/staging_frontend_deploy_policy_test.sh
```
두 테스트는 다음을 보장합니다.
- 스테이징 장기 실행 서비스에는 restart policy가 있어야 합니다.
- 마이그레이션/유틸 컨테이너는 restart 대상에 포함하지 않습니다.
- 배포 성공 판정은 내부 프론트엔드뿐 아니라 backend, userfront, gateway까지 확인해야 합니다.
## 남은 과제
배포 이후 발생하는 장애를 감지하고 알림을 보내려면 별도 상시 모니터링 구성이 필요합니다. 배포 워크플로 내부 health check는 post-deploy smoke test일 뿐이므로, 머신 재시작 이후 장애 감지나 알림의 대체 수단으로 사용하지 않습니다.
## 검증 결과
복구 후 다음 외부 URL이 정상 응답을 반환했습니다.
- `https://sso.hmac.kr/` -> 200
- `https://sso.hmac.kr/ko/signin` -> 200
- `https://sadmin.hmac.kr/` -> 200
- `https://sdev.hmac.kr/` -> 200
- `https://sorg.hmac.kr/` -> 200

View File

@@ -0,0 +1,85 @@
# 스테이징 배포 및 DB 초기화 프로세스 분석
현재 Gitea Actions(`staging_release.yml`)를 통해 스테이징 서버에 배포가 진행될 때 발생하는 **데이터 초기화(Wipe)****초기 관리자 계정 생성(Seed)** 과정을 설명하는 다이어그램입니다.
---
## 1. 스테이징 배포 파이프라인 (데이터가 날아가는 이유)
배포 스크립트에 포함된 `docker compose down -v` 명령어의 `-v` 옵션으로 인해, 컨테이너가 내려갈 때 영구 저장소(Volumes)까지 통째로 삭제되는 흐름입니다.
```mermaid
graph TD
Start[Gitea Action 수동 실행<br/>Release Baron SSO to Staging] --> SSH(Staging 서버 SSH 접속)
SSH --> Env[최신 환경변수 및 .env 파일 생성]
Env --> Pull[docker compose pull<br/>최신 이미지 다운로드]
Pull --> DownV{docker compose down -v}
style DownV fill:#ffebee,stroke:#ff0000,stroke-width:2px
DownV -->|1. 컨테이너 종료| StopC[Backend, Frontend, Kratos 등<br/>모든 컨테이너 중지]
DownV -->|2. 볼륨 완전 삭제| WipeDB[(데이터베이스 볼륨 파괴<br/>postgres_data<br/>ory_postgres_data<br/>clickhouse_data)]
StopC --> Up[docker compose up -d]
WipeDB --> Up
Up --> CleanState[새로운 컨테이너 시작<br/>완전히 텅 빈 Clean DB 상태]
```
**📌 분석 포인트:**
* 배포할 때마다 붉은색으로 칠해진 `down -v` 단계가 실행됩니다.
* 이 단계에서 기존에 생성해두었던 **테넌트, 일반 유저, 조직도, 권한 등 모든 데이터가 공장 초기화**됩니다. (Dev 서버와 DB를 공유하지 않습니다)
---
## 2. 백엔드 Bootstrap (어드민 계정이 새로 생성되는 이유)
데이터베이스가 완전히 텅 빈 상태로 컨테이너가 새로 시작된 직후, 백엔드 서버가 부팅되면서 `.env`에 정의된 시스템 관리자 계정을 자동으로 생성(Seed)하는 흐름입니다.
```mermaid
sequenceDiagram
autonumber
participant Docker as Staging Server
participant BE as Backend (kratos_seed.go)
participant Kratos as Ory Kratos (DB)
participant Keto as Ory Keto (RBAC)
Docker->>BE: 1. 컨테이너 시작 (Bootstrapping)
activate BE
Note over BE: 백엔드 서버 구동 전 초기화 스크립트 실행
BE->>BE: 2. .env 읽기<br/>(ADMIN_EMAIL, ADMIN_PASSWORD)
BE->>Kratos: 3. CreateUser API 호출<br/>(email, password, role: "super_admin")
activate Kratos
Note over Kratos: 텅 빈 DB에<br/>최초의 계정 1개 생성
Kratos-->>BE: 4. Identity ID 반환
deactivate Kratos
BE->>Keto: 5. 권한 동기화 API 호출<br/>(System 네임스페이스에 super_admin 할당)
activate Keto
Keto-->>BE: 6. 권한 부여 완료
deactivate Keto
Note over BE: 백엔드 서버 HTTP 요청 수신 준비 완료
deactivate BE
```
**📌 분석 포인트:**
* 이전 단계에서 DB가 모두 날아갔기 때문에 기존 계정은 하나도 없습니다.
* 하지만 백엔드가 구동되면서 **(3)번 과정**을 통해 Gitea Secrets에 저장된 `STG_ADMIN_PASSWORD` 정보로 **가장 권한이 높은 슈퍼 어드민 계정 단 1개**를 Kratos DB에 밀어 넣습니다.
* 그래서 방금 전 배포가 끝난 스테이징 서버에 들어가면, 예전에 만든 데이터는 없지만 **이 스크립트가 방금 만들어준 어드민 계정으로는 로그인이 성공**하게 되는 것입니다.
---
### 💡 (참고) 데이터를 유지하고 싶다면?
스테이징 배포 시마다 데이터가 날아가는 것을 방지하려면, `.gitea/workflows/staging_release.yml` 파일 내부의 배포 스크립트에서 `-v` 옵션을 제거해야 합니다.
```bash
# 수정 전 (데이터 완전 삭제)
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml down -v || true
# 수정 후 (컨테이너만 재시작, DB 볼륨 유지)
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml down || true
```

View File

@@ -0,0 +1,77 @@
# Headless Password Login Backend API Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Trusted RP에 한해 `login_challenge + loginId + password`를 받아 OIDC 로그인 흐름을 계속할 수 있는 백엔드 API를 추가합니다.
**Architecture:** 기존 `AuthHandler.PasswordLogin`의 IDP sign-in, identity resolve, Hydra login accept 흐름을 재사용 가능한 helper로 정리하고, 신규 endpoint에서는 Hydra client의 trusted/headless 상태를 추가 검증합니다. 성공 응답은 `sessionJwt`를 숨기고 `redirectTo`만 반환해 RP 브라우저가 기존 OIDC 흐름을 이어가도록 합니다.
**Tech Stack:** Go, Fiber, Ory Hydra Admin API, Kratos/Ory provider, testify
---
### Task 1: Failing Test 추가
**Files:**
- Modify: `backend/internal/handler/auth_handler_login_test.go`
- [ ] **Step 1: headless trusted RP 성공 케이스 테스트 추가**
`POST /api/v1/auth/headless/password/login` 호출 시 trusted + headless enabled client면 `redirectTo`만 반환하는 테스트를 추가합니다.
- [ ] **Step 2: headless 미허용/불일치 케이스 테스트 추가**
아래 케이스를 각각 추가합니다.
- headless flag 없음
- trusted RP 조건 미충족
- request body `client_id`와 Hydra login request client 불일치
- inactive client
- [ ] **Step 3: 테스트 단독 실행으로 실패 확인**
Run: `go test ./backend/internal/handler -run 'TestHeadlessPasswordLogin'`
Expected: 신규 route 또는 handler 부재로 FAIL
### Task 2: 신규 API 최소 구현
**Files:**
- Modify: `backend/cmd/server/main.go`
- Modify: `backend/internal/handler/auth_handler.go`
- [ ] **Step 1: route 추가**
`/api/v1/auth/headless/password/login` route를 등록합니다.
- [ ] **Step 2: request body 및 검증 helper 추가**
`client_id`, `login_challenge`, `loginId`, `password`를 받는 handler를 추가하고, Hydra login request의 client 일치 여부와 trusted/headless/inactive 상태를 검증합니다.
- [ ] **Step 3: 공통 로그인 처리 helper로 기존 로직 재사용**
기존 `PasswordLogin`의 sign-in, identity resolve, Hydra accept 흐름을 helper로 분리하거나 최소 중복으로 재사용합니다.
- [ ] **Step 4: 성공 응답을 headless 정책에 맞게 고정**
성공 시 `redirectTo`, `status`, `provider`만 응답하고 `sessionJwt`는 반환하지 않도록 합니다.
### Task 3: 검증 및 회귀 확인
**Files:**
- Modify: `backend/internal/handler/auth_handler_login_test.go`
- [ ] **Step 1: 신규 테스트 재실행**
Run: `go test ./backend/internal/handler -run 'TestHeadlessPasswordLogin'`
Expected: PASS
- [ ] **Step 2: 기존 password login 관련 회귀 테스트 실행**
Run: `go test ./backend/internal/handler -run 'TestPasswordLogin'`
Expected: PASS
- [ ] **Step 3: 이슈 업데이트**
`#480`에 테스트 결과와 남은 후속 범위(전화번호 링크 승인형 분리)를 코멘트로 남깁니다.

View File

@@ -0,0 +1,150 @@
# Headless Login Debug Improvement Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make headless password login failures return safe reason-specific response codes and produce debug-only diagnostic logs.
**Architecture:** Keep the change local to `AuthHandler` by introducing a headless-password-specific error classification helper and reusing it across response mapping and structured logging. Preserve current success-path behavior while adding explicit tests for reason codes and debug-field gating.
**Tech Stack:** Go, Fiber, `log/slog`, `stretchr/testify`, `go-jose`
---
### Task 1: Add failing response-classification tests
**Files:**
- Modify: `backend/internal/handler/auth_handler_login_test.go`
- Reference: `backend/internal/handler/auth_handler.go`
- [ ] **Step 1: Write failing tests for classified 401 responses**
Add tests for:
- audience mismatch -> `code=invalid_client_assertion_audience`
- signature mismatch -> `code=invalid_client_assertion_signature`
- iss/sub mismatch -> `code=invalid_client_assertion_iss_sub`
- expired assertion -> `code=invalid_client_assertion_expired`
- invalid credentials -> `code=password_or_email_mismatch`
- [ ] **Step 2: Run only the new response tests and verify they fail for the expected reason**
Run:
```bash
cd backend && go test ./internal/handler -run 'TestHeadlessPasswordLogin_(AudienceMismatchReturnsDetailedCode|SignatureMismatchReturnsDetailedCode|IssSubMismatchReturnsDetailedCode|ExpiredAssertionReturnsDetailedCode)|TestPasswordLogin_InvalidCredentials_ReturnsCode' -v
```
Expected:
- new headless response tests fail because the handler still returns generic codes/messages
- existing invalid-credentials test remains green unless intentionally updated
### Task 2: Add failing log-gating tests
**Files:**
- Modify: `backend/internal/handler/auth_handler_login_test.go`
- Reference: `backend/internal/logger/logger.go`
- [ ] **Step 1: Write tests for debug-only diagnostic logging**
Add tests that install a temporary `slog` handler backed by a buffer and assert:
- debug logger emits `expected_audiences` and `received_audiences`
- info logger does not emit those debug-only fields
- [ ] **Step 2: Run only the new log tests and verify they fail**
Run:
```bash
cd backend && go test ./internal/handler -run 'TestHeadlessPasswordLogin_(DebugLogIncludesDiagnostics|InfoLogOmitsDebugDiagnostics)' -v
```
Expected:
- tests fail because the current handler does not emit reason-specific structured logs yet
### Task 3: Implement classified headless assertion failures
**Files:**
- Modify: `backend/internal/handler/auth_handler.go`
- [ ] **Step 1: Add a local classified error type and helpers**
Implement a small helper shape for:
- `status`
- `code`
- `safeMessage`
- `logMessage`
- `debugAttrs`
- [ ] **Step 2: Make claim validation return classified reasons**
Replace generic string errors from `validateHeadlessClientAssertionClaims` with typed/classified failures for:
- iss/sub mismatch
- expired
- not active yet
- iat in future
- audience mismatch
- [ ] **Step 3: Make assertion verification return signature/jwks-specific failures**
Ensure parse, jwks load, claims, and signature-verification paths map to distinct safe codes/messages.
### Task 4: Implement structured logging and handler integration
**Files:**
- Modify: `backend/internal/handler/auth_handler.go`
- [ ] **Step 1: Add a helper to detect whether debug logging is enabled**
Use the default `slog` logger handler to gate diagnostic fields without changing global logger initialization.
- [ ] **Step 2: Log classified assertion failures in `HeadlessPasswordLogin`**
Log:
- reason code
- client id
- path
- debug-only attrs when debug logging is enabled
- [ ] **Step 3: Log classified credential failures**
Keep response behavior safe while adding a structured log line for credential mismatch.
### Task 5: Verify and document
**Files:**
- Modify: `docs/test-plan/backend-test-inventory.md` if headless password login inventory needs expansion
- Modify: issue `#502` comment with failing-test evidence and implementation summary
- [ ] **Step 1: Run focused handler tests**
Run:
```bash
cd backend && go test ./internal/handler -run 'TestHeadlessPasswordLogin_|TestPasswordLogin_InvalidCredentials_ReturnsCode' -v
```
Expected:
- all targeted tests pass
- [ ] **Step 2: Run broader backend handler regression tests if practical**
Run:
```bash
cd backend && go test ./internal/handler -v
```
Expected:
- no regressions in auth handler tests
- [ ] **Step 3: Update issue `#502` with what failed first, what changed, and what passed**
- [ ] **Step 4: Review whether `docs/test-plan/backend-test-inventory.md` or troubleshooting docs should be updated**

View File

@@ -0,0 +1,146 @@
# Headless Login Debug and Error Classification Design
## Scope
This design covers issue `#502` only.
- Target endpoint: `POST /api/v1/auth/headless/password/login`
- Goal: classify 401 failure reasons for headless password login, return a safe response to RP clients, and emit richer diagnostics only when debug logging is enabled
- Non-goal: introduce a shared auth-wide error/observability layer across all handlers. That follow-up work is tracked separately in issue `#503`.
## Problem
Current behavior makes production debugging slow.
- Multiple assertion failures collapse into `invalid_client_assertion`
- The handler often returns early without emitting a reason-specific log line
- RP clients cannot distinguish safe failure categories such as audience mismatch vs signature mismatch
- Server operators cannot quickly tell whether the failure came from client assertion validation or user credential validation
## Constraints
- Response policy must follow the agreed level `2`: return `code + short safe message`
- Debug logs may include level `3`-style diagnostics, but only when the logger is configured for debug level
- Sensitive values must never be logged: raw `client_assertion`, password, session token, cookies
- Existing successful headless password login flow must remain unchanged
- Existing policy requires failing tests first
## Recommended Approach
Introduce a small headless-password-specific failure classification layer inside `auth_handler.go`.
The classification layer should map each failure into:
- `status`
- `code`
- `safeMessage`
- `logMessage`
- `debugFields`
This stays local to the current handler so the change remains small, but the structure should be reusable enough to extract later in issue `#503`.
## Failure Categories
### Client assertion failures
- `invalid_client_assertion_parse`
- `invalid_client_assertion_signature`
- `invalid_client_assertion_iss_sub`
- `invalid_client_assertion_expired`
- `invalid_client_assertion_not_before`
- `invalid_client_assertion_iat_future`
- `invalid_client_assertion_audience`
- `invalid_client_assertion_jwks_load`
### Credential failure
- `password_or_email_mismatch`
## Response Contract
Responses should expose only safe details.
Examples:
- `{"code":"invalid_client_assertion_audience","error":"Client assertion audience mismatch"}`
- `{"code":"invalid_client_assertion_signature","error":"Client assertion signature verification failed"}`
- `{"code":"password_or_email_mismatch","error":"Invalid credentials"}`
The response must not echo:
- expected vs received audience values
- full claim payload
- kid mismatch internals beyond the safe message
- raw token values
## Logging Strategy
### Normal log level
Emit one structured warning/error log per classified failure with:
- `reason_code`
- `client_id`
- `path`
- `req_id` if present in context/log pipeline
Message examples:
- `headless password login client assertion failed`
- `headless password login credential authentication failed`
### Debug log level
Add diagnostic fields that are useful for root cause analysis:
- `expected_audiences`
- `received_audiences`
- `received_kid`
- `jwks_refreshed`
- `claim_issuer`
- `claim_subject`
- `claim_expires_at`
- `claim_not_before`
- `claim_issued_at`
- `login_challenge_prefix`
The `login_challenge` should be truncated before logging.
## Implementation Shape
Inside `backend/internal/handler/auth_handler.go`:
1. Add a small internal type for classified headless assertion errors.
2. Make `validateHeadlessClientAssertionClaims` return structured reasons instead of generic strings.
3. Make `verifyHeadlessClientAssertion` return a classified failure that the handler can both:
- convert to a safe HTTP response
- log with optional debug fields
4. Add a small helper that checks whether debug logging is enabled for the current default `slog` logger.
5. Log credential authentication failures with their reason code as well.
## Testing
Add failing tests first in `backend/internal/handler/auth_handler_login_test.go`.
Required tests:
- audience mismatch returns `401 + invalid_client_assertion_audience`
- signature mismatch returns `401 + invalid_client_assertion_signature`
- iss/sub mismatch returns `401 + invalid_client_assertion_iss_sub`
- expired assertion returns `401 + invalid_client_assertion_expired`
- invalid credentials still returns `401 + password_or_email_mismatch`
- debug logger captures diagnostic fields for assertion audience mismatch
- non-debug logger does not emit debug-only fields
## Risks
- Over-classifying too much detail into RP responses could create unnecessary information disclosure
- Logging helper implementation must not assume a specific handler type beyond standard `slog`
- Existing tests may assume the older generic `invalid_client_assertion` code and will need intentional updates
## Success Criteria
- Operators can distinguish signature, claims, and credential failures from logs
- RP clients get safe but actionable reason codes
- Debug mode includes additional diagnostics without leaking secrets
- Existing success path and previously covered headless login behavior remain green

View File

@@ -0,0 +1,158 @@
# Tenant 유지보수 절차
이 문서는 관리자가 비정기적으로 수행할 수 있는 tenant 데이터 정합성 점검 및 정리 절차를 정리한다.
## Orphan 사용자 tenant 소속정보 정리
### 대상
다음 중 하나에 해당하는 활성 Baron 사용자는 orphan 소속정보 정리 대상이다.
- `users.tenant_id`가 존재하지 않거나 soft-deleted 된 `tenants.id`를 가리킨다.
- `users.company_code`가 활성 `tenants.slug`와 매칭되지 않는다.
- `users.company_codes` 배열 중 하나 이상이 활성 `tenants.slug`와 매칭되지 않는다.
정리 시 다음 필드를 비운다.
- `tenant_id`
- `company_code`
- `company_codes`
### 사전 확인
```sql
SELECT
u.email,
u.tenant_id,
u.company_code,
u.company_codes
FROM users AS u
WHERE u.deleted_at IS NULL
AND (
(
u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE t.id = u.tenant_id
AND t.deleted_at IS NULL
)
)
OR (
NULLIF(BTRIM(u.company_code), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(u.company_code))
AND t.deleted_at IS NULL
)
)
OR EXISTS (
SELECT 1
FROM UNNEST(COALESCE(u.company_codes, ARRAY[]::text[])) AS code(value)
WHERE NULLIF(BTRIM(code.value), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(code.value))
AND t.deleted_at IS NULL
)
)
);
```
Kratos identity traits도 같은 기준으로 정리한다.
- `traits.tenant_id`
- `traits.companyCode`
- `traits.companyCodes`
### Baron users만 실행
Go 구현 경로를 사용하는 권장 명령은 다음과 같다.
```bash
go run ./cmd/adminctl clear-orphan-user-tenant-memberships --dry-run
go run ./cmd/adminctl clear-orphan-user-tenant-memberships
```
컨테이너나 배포 환경에서는 같은 명령을 adminctl 바이너리로 실행한다.
```bash
adminctl clear-orphan-user-tenant-memberships --dry-run
adminctl clear-orphan-user-tenant-memberships
```
SQL만 직접 실행해야 하는 경우에는 다음 스크립트를 사용할 수 있다.
```bash
docker exec -i baron_postgres psql -U baron -d baron_sso < scripts/clear_orphan_user_tenant_memberships.sql
```
실행 결과에는 정리된 사용자와 기존 소속정보가 출력된다.
### Baron users와 Kratos identity traits 함께 실행
로컬 Docker Compose 기준 기본 컨테이너명은 다음과 같다.
- Baron DB: `baron_postgres`
- Kratos DB: `ory_postgres`
```bash
scripts/clear_orphan_tenant_memberships.sh
```
컨테이너명이나 DB 접속 정보가 다르면 환경변수로 override 한다.
```bash
BARON_CONTAINER=baron_postgres \
BARON_DB_USER=baron \
BARON_DB_NAME=baron_sso \
KRATOS_CONTAINER=ory_postgres \
KRATOS_DB_USER=ory \
KRATOS_DB_NAME=ory_kratos \
scripts/clear_orphan_tenant_memberships.sh
```
### 사후 확인
사전 확인 쿼리를 다시 실행했을 때 결과가 0건이어야 한다.
Kratos identity traits는 다음 쿼리로 확인한다.
```sql
SELECT
id,
traits->>'email' AS email,
traits->>'tenant_id' AS tenant_id,
traits->>'companyCode' AS company_code,
traits->'companyCodes' AS company_codes
FROM identities
WHERE COALESCE(traits->>'tenant_id', '') <> ''
OR COALESCE(traits->>'companyCode', '') <> ''
OR traits ? 'companyCodes';
```
## Soft-deleted tenant slug 점검
`tenants.slug`는 DB unique index이므로 soft-deleted row도 slug를 점유한다. 현재 삭제 로직은 삭제 전에 slug에 `-deleted-...` suffix를 붙여 재사용 가능하게 만들지만, 과거 데이터나 수동 변경으로 삭제된 row가 원래 slug를 계속 점유하면 AdminFront 검색에는 보이지 않으면서 생성은 unique violation으로 실패할 수 있다.
### legacy 점유 row 확인
```sql
SELECT
id,
slug,
name,
deleted_at
FROM tenants
WHERE deleted_at IS NOT NULL
AND slug NOT LIKE '%-deleted-%'
ORDER BY deleted_at DESC;
```
### 정책
- 활성 tenant가 같은 slug를 가지고 있으면 생성 실패가 정상이다.
- soft-deleted tenant만 같은 slug를 점유하고 있으면 생성 직전에 해당 deleted row의 slug를 release 한다.
- `FindBySlug`와 검색 API는 활성 tenant만 반환하므로, 생성 제약도 활성 tenant 기준으로 체감되도록 맞춘다.

View File

@@ -0,0 +1,32 @@
# 테넌트 기본 아키텍처 및 데이터 정책 (Tenant Architecture Policy)
본 문서는 Baron SSO의 테넌트 다형성(Polymorphism) 모델과 데이터베이스 스키마, 그리고 외부 시스템(Kratos)과의 역할 분리에 대한 핵심 아키텍처 정책을 정의합니다.
## 1. 테넌트 다형성 모델 (Polymorphic Tenant)
모든 형태의 격리 공간은 `Tenant`라는 단일 개념(Base Unit)으로 취급되며, 데이터베이스의 `type` 속성을 통해 그 역할이 구분됩니다.
- **`PERSONAL`**: B2C 개인 워크스페이스. 개인 유저 가입 시 1:1로 자동 생성됩니다.
- **`COMPANY`**: B2B 법인/기업. 독립적인 비즈니스와 사내 조직도를 가집니다.
- **`COMPANY_GROUP`**: B2B2B 지주사/그룹사. 여러 `COMPANY`를 하위로 거느리며 권한을 통합합니다.
- **`USER_GROUP`**: 사내 조직 (본부/팀 등). 과거에는 분리된 개념이었으나, 현재는 완벽한 통합을 위해 테넌트의 한 종류로 1:1 매핑됩니다.
## 2. 외부 백엔드 데이터베이스 의무 채택 (Separation of SoT)
Kratos 내부 트레이트(Traits)에 테넌트, 직급 등 관계형 데이터를 저장하는 것은 토큰 비대화 및 쿼리 성능 저하를 일으키는 안티 패턴입니다. 따라서 데이터의 진실 공급원(SoT)을 철저히 분리합니다.
- **Ory Kratos (Identity SoT)**: 이메일, 패스워드 등 순수 식별 정보만 저장합니다.
- **PostgreSQL (Business SoT)**: 반드시 커스텀 외부 백엔드 DB를 구축하여, 테넌트의 트리 구조, 사용자 직급, 애플리케이션 설정 등을 전담하여 관리합니다.
## 3. 데이터베이스 스키마 분리 전략
테넌트 테이블의 비대화를 막기 위해, Identity(신분증) 역할과 무거운 Business 데이터를 분리 조인(Join)합니다.
- **`tenants` 테이블**: 최소한의 식별 정보(`id`, `name`, `type`)만 저장하는 초경량 베이스 테이블.
- **`company_settings` 테이블**: `COMPANY``COMPANY_GROUP` 타입 전용 무거운 비즈니스 설정 (결제 정보, 커스텀 도메인 등).
- **`user_groups` 테이블**: `USER_GROUP` 타입 전용 사내 조직도 메타데이터 (`parent_id`, 조직장 정보 등).
## 4. 논리적 다중 테넌트 OIDC 관리 (Logical Pooling)
인프라 비용의 팽창을 막기 위해 테넌트별로 Hydra(OAuth2) 데이터베이스를 물리적으로 복제하는 방식은 금지합니다.
대신 공유되는 소수의 Hydra 클러스터 앞단에 도메인 및 헤더를 재작성하는 지능형 프록시를 배치하고, 백엔드의 동의(Consent) 로직을 통해 요청된 클라이언트의 테넌트 맥락에 맞는 **동적 클레임(Dynamic Claim)**을 ID Token에 주입합니다.

View File

@@ -0,0 +1,163 @@
# 통합 테넌트 및 권한 아키텍처 정책 (Integrated Tenant & ReBAC Policy)
이 문서는 Baron SSO 시스템 내 B2C(개인)부터 B2B(단일기업), B2B2B(그룹사) 및 사내 조직도(`UserGroup`)에 이르는 모든 격리 공간과 권한 상속을 다루는 **다형성 테넌트(Polymorphic Tenant)**의 공식 정책을 정의합니다.
연구 결과에 기반하여, 단순한 B2C 수준을 넘어 복잡한 테넌트 생태계를 안정적이고 고성능으로 지원하기 위해 **외부 DB 의무 채택, 트랜잭셔널 아웃박스 동기화, Keto 기반 인가 백본, 논리적 다중 테넌트 OIDC 관리**라는 4대 핵심 아키텍처 원칙을 준수합니다.
---
## 1. 기본 원칙 (Core Axioms)
### 1.1 "모든 격리 공간은 테넌트이다" (Everything is a Tenant)
- 개인 워크스페이스, 기업 고객, 지주사, 그리고 사내의 특정 팀이나 본부(`UserGroup`) 등 **모든 종류의 격리 공간은 최상위 범용 단위인 `Tenant`로 취급**됩니다.
- 과거에 혼용되던 `UserGroup` 네임스페이스는 폐기되며, 격리 공간을 나타내는 Keto(ReBAC) 네임스페이스는 `Tenant` 하나로 단일화됩니다. 권한 상속 로직은 테넌트의 성격과 무관하게 동일하게 동작합니다.
### 1.2 "권한 주체와 자원의 분리" (Namespaces)
시스템의 완벽한 권한 통제를 위해, Keto에는 `Tenant` 외에도 다음과 같은 필수 네임스페이스가 존재합니다.
- **`Tenant`:** 격리된 공간 (회사, 부서, 개인)
- **`RelyingParty`:** 테넌트가 소유하는 자원/앱 (OIDC 클라이언트)
- **`System`:** 테넌트에 종속되지 않는 전역 권한 (Super Admin 등)
### 1.3 소유(Ownership)와 가시성(Visibility)의 분리
- 시스템의 모든 자원(예: RelyingParty, 앱)은 반드시 특정 `Tenant`가 소유(`manage`)합니다.
- 그러나 자원의 소유권과 **누가 접근할 수 있는가(가시성, `access`)는 별개**입니다. 내부망용 앱(Private)과 대국민 서비스(Public)를 동일한 기업(Tenant)이 동시에 소유하고 제어할 수 있습니다.
### 1.4 외부 백엔드 데이터베이스 아키텍처 의무 채택 (Separation of SoT)
사용자 데이터를 Kratos의 내부 트레이트(Traits)에 무분별하게 저장하는 것은 안티 패턴입니다. 이는 토큰 비대화와 쿼리 성능 저하를 초래합니다.
- **Kratos (Identity):** "누구인가?" (인증, 이메일, 패스워드 등 순수 식별 정보). 테넌트, 직급 등 관계형 데이터는 절대 보관하지 않습니다.
- **PostgreSQL (Business):** "어디에 속하며 조직 구조는 어떠한가?" (직급, 조직도, 테넌트 설정 등).
- **Keto (ReBAC Authorization Backbone):** "무엇을 할 수 있는가?" (권한 및 상속).
---
## 2. 하이브리드(다형성) 테넌트 아키텍처
데이터베이스의 `tenants` 테이블은 가장 가벼운 신분증(Identity) 역할을 수행하며, 세부 비즈니스 설정은 타입별로 별도의 테이블에 1:1 조인(Join)하여 관리합니다.
### 2.1 테넌트 유형 (Tenant Types)
| 타입 (Enum) | 설명 | 특징 및 1:1 조인 테이블 |
| :--- | :--- | :--- |
| `PERSONAL` | B2C 개인 워크스페이스 | 일반 사용자 가입 시 1:1로 생성. 조직도(UserGroup) 기능 비활성화. |
| `COMPANY` | B2B 일반 기업/법인 | 독립된 비즈니스. 사내 조직도를 가짐. 무거운 설정은 `company_settings`에 저장. |
| `COMPANY_GROUP`| B2B2B 지주사/그룹사 | 여러 `COMPANY`를 하위로 거느리며 최고 관리자 권한을 통합. `company_settings` 조인. |
| `USER_GROUP` | 사내 조직 (본부/팀 등) | `COMPANY` 내부에 속하는 조직. 사내 조직도 메타데이터는 `user_groups` 테이블에 저장. |
---
## 3. 중앙집중형 인가 백본 (Keto ReBAC Tuples)
복잡한 다단계 B2B2B 조직도 내에서의 상속 권한을 외부 RDBMS 논리만으로 실시간 검증하는 것은 과부하를 초래합니다. 따라서 조직도 변경 시 해당 데이터를 즉시 Keto의 관계 튜플로 변환하여 전송하고, **인가 검증(Check)은 초고속 병렬 처리가 가능한 Keto 엔진으로 오프로딩**합니다.
### 3.1 조직장(Leader)과 어드민(Admin) 상속
특정 부서(테넌트)의 그룹장(`owners`)으로 임명되면, 해당 부서 및 그 하위 부서의 최고 관리자(`admins`) 권한을 자동으로 상속받습니다.
- **조직장 임명:** `Tenant:<조직ID>#owners@User:<유저ID>`
- **자동 상속 룰:** `Tenant:<조직ID>#admins@Tenant:<조직ID>#owners`
### 3.2 계층 간 권한 상속 (Hierarchy)
지주사 $\rightarrow$ 법인 $\rightarrow$ 사내조직 간의 상속은 모두 `parents` 튜플을 사용합니다.
- **계층 설정:** `Tenant:<하위ID>#parents@Tenant:<상위ID>`
- **자동 상속 룰:** 상위 테넌트의 `admins`는 하위 테넌트의 `manage``view` 권한을 모두 가집니다.
### 3.3 리소스 제어 및 RP Admin (Relying Party)
RP(앱)는 별도의 가상 테넌트를 만들지 않고 자원(Object) 자체의 다중 상속을 통해 권한을 제어합니다.
- **앱 관리 (Manage):**
- `RelyingParty:<앱ID>#parents@Tenant:<소유테넌트ID>` (앱 소유권 지정. 해당 테넌트의 최고 관리자가 앱을 관리할 수 있음)
- `RelyingParty:<앱ID>#admins@User:<유저ID>` (특정 유저를 **RP Admin**으로 직접 지정)
- **Private 앱 접근 (Access):** `RelyingParty:<앱ID>#access@Tenant:<소유테넌트ID>#members` (소유한 회사의 멤버만 접근)
- **Public 앱 접근 (Access):** `RelyingParty:<앱ID>#access@System:authenticated_users#members` (전역 인증 유저 누구나 접근)
---
## 4. 권한 흐름도 (Mermaid)
```mermaid
graph TD
%% Types
subgraph COMPANY_GROUP [지주사 테넌트]
CG[Tenant: 한맥 그룹]
end
subgraph COMPANY [법인 테넌트]
C1[Tenant: 한맥 IT]
end
subgraph USER_GROUP [사내조직 테넌트]
UG1[Tenant: 개발본부]
UG2[Tenant: 클라우드팀]
end
subgraph USERS [사용자]
CEO[User: 그룹 최고경영자]
DEV_L[User: 팀장]
DEV[User: 팀원]
RP_ADM[User: RP 전담 관리자]
end
subgraph RESOURCES [자원]
RP1[RelyingParty: 사내 인트라넷]
end
%% Hierarchy Tuples
C1 -- "parents" --> CG
UG1 -- "parents" --> C1
UG2 -- "parents" --> UG1
RP1 -- "parents" --> C1
%% Memberships
CEO -- "owners" --> CG
DEV_L -- "owners" --> UG2
DEV -- "members" --> UG2
%% RP Admin
RP_ADM -- "admins" --> RP1
%% Effective Admin Control (Inherited)
CEO -. "Inherits Admin Control" .-> C1
CEO -. "Inherits Admin Control" .-> UG1
CEO -. "Inherits Admin Control" .-> UG2
CEO -. "Inherits Manage" .-> RP1
DEV_L -. "Inherits Admin Control" .-> UG2
%% Styles
style CG fill:#dfd,stroke:#333
style C1 fill:#dfd,stroke:#333
style UG1 fill:#f9f,stroke:#333
style UG2 fill:#f9f,stroke:#333
style CEO fill:#ff9,stroke:#333
style DEV_L fill:#ff9,stroke:#333
style RP_ADM fill:#ff9,stroke:#333
style RP1 fill:#bbf,stroke:#333
```
---
## 5. 지능형 프록시를 통한 논리적 다중 테넌트 OIDC 관리
인프라 비용 팽창을 막기 위해 테넌트별로 Hydra(OAuth2/OIDC) 클러스터를 물리적으로 복제하는 것은 지양합니다.
### 5.1 Logical Pooling for OAuth2
- 모든 테넌트(`COMPANY`, `PERSONAL`)는 소수의 공유된 Hydra 클러스터를 사용합니다.
- Hydra 클러스터 앞단에 도메인 및 헤더를 재작성하는 지능형 프록시(API Gateway)를 배치하여, 테넌트별로 물리적으로 분리된 것과 같은 라우팅 효과를 제공합니다.
### 5.2 동적 클레임 주입 (Dynamic Claim Injection)
- 로그인 및 동의(Consent) 흐름은 전적으로 외부 백엔드 데이터베이스(Business SoT)가 주도합니다.
- 백엔드는 요청된 클라이언트(RP)의 테넌트 맥락(Context)을 파악하고, 유저가 속한 현재 조직 정보 및 권한(Role)을 Hydra에 전달하여 **ID Token의 Custom Claim으로 동적 주입**합니다.
---
## 6. 분산 시스템 동기화 무결성 확보 (Data Consistency)
Kratos 웹훅 통신 지연이나 이중 쓰기(Dual-Write) 오류로 인한 '고아 레코드(Ghost Identity)'를 원천 차단해야 합니다.
### 6.1 빠른 제어권 반환 및 비동기 처리 혼합 (Kratos $\rightarrow$ DB)
- **가입 웹훅(after_registration):** Kratos에서 웹훅 요청이 오면, 백엔드는 국소 DB 트랜잭션을 열어 최소한의 필수 식별자 데이터만 `users` 테이블에 커밋하고 **즉각적으로 200 OK를 응답하여 Kratos에 제어권을 반환**합니다. (가입 중단 방지)
- 웰컴 이메일 발송, 초기 조직도 프로비저닝 등 무거운 작업은 즉시 처리하지 않고 메시지 큐(Message Queue)나 비동기 워커로 넘겨 후속 처리합니다.
### 6.2 트랜잭셔널 아웃박스 패턴 (DB $\rightarrow$ Keto)
- 백엔드 로직에 의해 테넌트나 권한이 변경될 때, Keto API를 직접 호출하면 네트워크 오류 시 부분 실패(Partial Failure)가 발생할 수 있습니다.
- 이를 방지하기 위해 DB 커밋 시 동일 트랜잭션 내에서 `keto_outbox` 테이블에 튜플 변경 이벤트를 기록합니다. 이후 CDC(Change Data Capture) 로직이나 릴레이 워커를 통해 비동기로 시스템 간 동기화를 진행하여 응답 속도와 정합성을 동시에 달성합니다.
### 6.3 삭제 정책 (Cascade) 및 정기 대사
- **즉시 회수:** 백엔드 DB에서 Soft Delete(`deleted_at`)가 발생하면, Outbox 워커는 지연 없이 즉각적으로 Keto의 튜플을 Hard Delete 합니다.
- **정기 대사 (Reconciliation):** Kratos(Identity), PostgreSQL(DB), Keto(ReBAC) 3자 간의 불일치(고아 튜플, 누락된 멤버십 등)를 매일 1회 이상 배치 크론 잡을 통해 능동적으로 색출하고 자동 복구/삭제합니다.

View File

@@ -0,0 +1,59 @@
# Tenant visibility exposure policy
## 목적
`hanmac-family` 하위 조직의 `config.visibility` 값이 호출 위치별로 어디까지 노출되는지 정리한다. 현재 정책에서 완전 공개 조직도는 없다고 본다. 따라서 `public`은 인터넷 전체 공개가 아니라 인증/공유 경계 안에서의 기본 노출값이다.
## Visibility 값
| 값 | 의미 | 기본값 여부 |
| --- | --- | --- |
| `public` | 인증된 사용자 화면과 통제된 공유 경계에서 기본 노출 가능한 조직. 현재 정책상 인터넷 완전 공개를 의미하지 않음 | `visibility`가 없거나 빈 값이면 `public` |
| `internal` | Baron 로그인 세션 또는 M2M API Key를 가진 내부/연동 경계에는 노출하지만, 외부 공유 경계에는 노출하지 않는 조직 | 명시 설정 필요 |
| `private` | 기본 조직도 노출 대상에서 제외하는 조직. 해당 조직의 하위 조직도 함께 제외 | 명시 설정 필요 |
`visibility``orgUnitType` 설정은 현재 `hanmac-family` descendant tenant에만 허용된다. `hanmac-family` root 자체에는 org config를 두지 않는 정책이다.
## 호출 위치별 노출 기준
| 호출 위치 | 인증/권한 경계 | `public` | `internal` | `private` |
| --- | --- | --- | --- | --- |
| OrgFront 일반 조직도 `/chart` | 로그인 세션, backend `/api/v1/admin/tenants` 기반 | 노출 | 노출 | 권한 없는 사용자는 미노출 |
| OrgFront embed picker `/embed/picker` | 로그인 세션 또는 picker 자동 로그인 경로 | 노출 | 노출 | 권한 없는 사용자는 미노출 |
| 공유 링크 `/api/v1/public/orgchart?token=...` | share token. 완전 공개가 아니라 통제된 공유 경계 | 노출 | 미노출 | 미노출 |
| M2M 조직 Context JSON API `/api/v1/integrations/org-context` | API Key + `org-context:read` | 노출 | 노출 | 미노출 |
| AdminFront 테넌트 관리 `/api/v1/admin/tenants` | 사용자 role/Keto 관리 경계 | 노출 | 노출 | `super_admin`, 해당 private subtree owner/admin/manage 가능 사용자, 또는 명시적 private 조회 권한자만 노출 |
| AdminFront CSV export | 사용자 role/Keto 관리 경계 | 노출 | 노출 | `/api/v1/admin/tenants`와 동일 |
| AdminFront CSV import | 권한 있는 관리자 작업 | 입력값 검증 대상 | 입력값 검증 대상 | 입력값 검증 대상 |
## 핵심 판단
`internal`은 현재 “특정 조직 권한자에게만 보이는 조직”이 아니다. 로그인된 OrgFront 사용자가 backend에서 해당 tenant family를 받을 수 있는 경우, OrgFront 기본 조직도와 picker에는 `internal` 조직이 기본 노출된다.
다만 `internal`은 공개 공유 링크에서는 제외된다. 외부 M2M JSON API에서는 API Key 자체가 신뢰 경계이므로 `internal`을 포함한다.
`private`은 기본적으로 일반 조직도, picker, 공유 링크, M2M JSON API에서 제외된다. 또한 `private` 조직 아래의 descendant도 함께 제외된다. AdminFront 테넌트 관리와 CSV export에서도 권한 없는 사용자에게는 제외된다.
`private` 노출이 허용되는 사용자는 다음 중 하나다.
- `super_admin`
- 해당 private 조직 또는 ancestor를 `ManageableTenants`로 가진 owner/admin/manage 가능 사용자
- Keto/ReBAC에서 해당 private 조직에 `view_private`, `view_private_descendants`, `view`, `manage` 중 하나를 가진 사용자
- ancestor tenant에 `view_private_descendants`를 가진 사용자
## 구현 근거
- backend `tenantVisibility``internal`, `private`만 별도 값으로 인정하고 나머지는 `public`으로 처리한다.
- backend `filterPublicTenants``internal`, `private`, 그리고 그 descendant를 공개 공유 링크 노출에서 제외한다.
- backend `filterOrgContextSubtree``private`과 그 descendant만 M2M 조직 Context에서 제외한다. 따라서 `internal`은 포함된다.
- backend `/api/v1/admin/tenants`와 CSV export는 사용자 profile과 Keto 권한을 기준으로 private root 및 descendant를 필터링한다.
- OrgFront `filterTenantsByVisibility(..., "internal")``private`만 제외한다. 일반 `/chart``/embed/picker`는 backend에서 이미 권한 필터링된 tenant 목록 위에서 이 기준을 사용한다.
- OrgFront `filterTenantsByVisibility(..., "public")``internal`, `private`를 제외한다. share token 기반 공개 조직도가 이 기준을 사용한다.
## 회색지대
현재 이름만 보면 `public`이 인터넷 완전 공개라는 의미로 해석될 수 있지만, 정책상 완전 공개 조직도는 없고 `public`은 기본 노출값이다.
현재 이름만 보면 `internal`이 “조직 내부 구성원 또는 특정 권한자만”이라는 의미로 해석될 수 있지만, 구현은 “공유 링크에는 숨기고, 로그인/M2M 경계에는 노출”이다.
특정 권한자에게만 보이는 조직은 `private`과 Keto/ReBAC 권한으로 표현한다. `internal`을 제한 공개 권한 모델로 확장하려면 별도 정책 변경이 필요하다.

View File

@@ -0,0 +1,132 @@
# Backend 테스트 전수 목록
- 범위: `backend/cmd/server`, `backend/internal/**`
- 기준: `func Test...` 패턴으로 수집한 단위/통합 테스트
| 파일 | 테스트 | 역할 |
|---|---|---|
| `backend/cmd/server/error_handler_test.go:23` | `TestNewErrorHandler_ProductionMasksServerError` | 오류/예외/거부 경로 검증 |
| `backend/cmd/server/error_handler_test.go:48` | `TestNewErrorHandler_ProductionPassesClientError` | 오류/예외/거부 경로 검증 |
| `backend/cmd/server/error_handler_test.go:73` | `TestNewErrorHandler_DevelopmentReturnsOriginalServerError` | 오류/예외/거부 경로 검증 |
| `backend/cmd/server/error_handler_test.go:98` | `TestNewErrorHandler_MapsUnauthorizedCode` | 오류/예외/거부 경로 검증 |
| `backend/cmd/server/headless_login_e2e_test.go:363` | `TestHeadlessPasswordLogin_E2E_ResponseIncludesDetailedCodeAndLogs` | 실제 app 경로에서 headless login 401 응답 본문과 구조화 로그를 함께 검증 |
| `backend/cmd/server/headless_login_e2e_test.go:407` | `TestHeadlessPasswordLogin_E2E_DebugLogsIncludeDiagnostics` | 실제 app 경로에서 debug 레벨일 때만 headless 진단 필드가 로그에 포함되는지 검증 |
| `backend/cmd/server/headless_login_e2e_test.go:446` | `TestHeadlessPasswordLogin_E2E_AcceptsForwardedHTTPSAudience` | forwarded proto/host가 있는 경우 `https` absolute audience가 실제 app 경로에서 성공하는지 검증 |
| `backend/cmd/server/headless_login_e2e_test.go:491` | `TestHeadlessPasswordLogin_E2E_AcceptsConfiguredPublicHTTPSAudience` | `BACKEND_PUBLIC_URL` 기준으로 internal `http` 요청에서도 `https` absolute audience가 성공하는지 검증 |
| `backend/internal/handler/api_key_handler_test.go:19` | `TestApiKeyHandler_CreateApiKey` | 핵심 CRUD/서비스 동작 검증 |
| `backend/internal/handler/api_key_handler_test.go:41` | `TestApiKeyHandler_Validation` | 유효성/정책/유틸 검증 |
| `backend/internal/handler/auth_handler_async_test.go:198` | `TestSignup_AsyncDB_Isolation` | 복구/격리/회복 탄력성 검증 |
| `backend/internal/handler/auth_handler_client_test.go:16` | `TestRevokeLinkedRp_Success` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_client_test.go:56` | `TestListRpHistory_Aggregation` | Hydra/RP 연동 검증 |
| `backend/internal/handler/auth_handler_consent_test.go:134` | `TestAcceptConsentRequest_Normal` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_consent_test.go:26` | `TestGetConsentRequest_Normal` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_consent_test.go:71` | `TestGetConsentRequest_Skip_AutoAccept` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_link_test.go:24` | `TestEnchantedLinkFlow_Email_Success` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_link_test.go:94` | `TestEnchantedLinkFlow_Sms_Success` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_linked_test.go:26` | `TestListLinkedRps_PriorityAndAggregation` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_login_test.go:114` | `TestPasswordLogin_OIDC_Success` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_login_test.go:201` | `TestPasswordLogin_OIDC_InactiveClient` | 오류/예외/거부 경로 검증 |
| `backend/internal/handler/auth_handler_login_test.go:255` | `TestPasswordLogin_NoOIDC_Success` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_login_test.go:940` | `TestHeadlessPasswordLogin_InvalidClientAssertionRejected` | headless 로그인 서명 검증 실패 응답 코드 검증 |
| `backend/internal/handler/auth_handler_login_test.go:1033` | `TestHeadlessPasswordLogin_AudienceMismatchReturnsDetailedCode` | headless 로그인 audience mismatch 분류 검증 |
| `backend/internal/handler/auth_handler_login_test.go:1062` | `TestHeadlessPasswordLogin_IssSubMismatchReturnsDetailedCode` | headless 로그인 iss/sub mismatch 분류 검증 |
| `backend/internal/handler/auth_handler_login_test.go:1102` | `TestHeadlessPasswordLogin_ExpiredAssertionReturnsDetailedCode` | headless 로그인 만료 assertion 분류 검증 |
| `backend/internal/handler/auth_handler_login_test.go:1097` | `TestHeadlessPasswordLogin_AcceptsForwardedHTTPSAudience` | forwarded proto/host가 있는 경우 `https` absolute audience가 성공하는지 검증 |
| `backend/internal/handler/auth_handler_login_test.go:1132` | `TestHeadlessPasswordLogin_AcceptsConfiguredPublicHTTPSAudience` | `BACKEND_PUBLIC_URL` 기준으로 internal `http` 요청에서도 `https` absolute audience가 성공하는지 검증 |
| `backend/internal/handler/auth_handler_login_test.go:1166` | `TestHeadlessPasswordLogin_RejectsHTTPAudienceWhenConfiguredPublicURLIsHTTPS` | `BACKEND_PUBLIC_URL=https`일 때 잘못된 `http` absolute audience는 계속 거부되는지 검증 |
| `backend/internal/handler/auth_handler_login_test.go:1280` | `TestHeadlessPasswordLogin_DebugLogIncludesDiagnostics` | debug 로그에서만 진단 필드가 노출되는지 검증 |
| `backend/internal/handler/auth_handler_login_test.go:1312` | `TestHeadlessPasswordLogin_InfoLogOmitsDebugDiagnostics` | 일반 로그에서 debug 진단 필드가 숨겨지는지 검증 |
| `backend/internal/handler/auth_handler_login_test.go:1442` | `TestPasswordLogin_InvalidCredentials_ReturnsCode` | 비밀번호 불일치 응답 코드 검증 |
| `backend/internal/handler/auth_handler_oidc_test.go:106` | `TestAcceptOidcLoginRequest_TokenFallbackToCookie` | 복구/격리/회복 탄력성 검증 |
| `backend/internal/handler/auth_handler_oidc_test.go:21` | `TestAcceptOidcLoginRequest_CookieOnly` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_otp_test.go:14` | `TestHandleKratosCourierRelay_Email` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_otp_test.go:43` | `TestVerifySignupCode_Success` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_otp_test.go:85` | `TestVerifySignupCode_Invalid` | 오류/예외/거부 경로 검증 |
| `backend/internal/handler/auth_handler_qr_test.go:107` | `TestScanQRLogin_Success` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_qr_test.go:150` | `TestResolveConsentSubjects_TokenAndCookie` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_qr_test.go:57` | `TestQRLoginFlow_Success` | 인증/OIDC 플로우 검증 |
| `backend/internal/handler/auth_handler_test.go:67` | `TestCompletePasswordReset_MissingLoginID` | 오류/예외/거부 경로 검증 |
| `backend/internal/handler/auth_handler_test.go:97` | `TestCompletePasswordReset_InvalidPasswordPolicy` | 오류/예외/거부 경로 검증 |
| `backend/internal/handler/auth_handler_test.go:127` | `TestCompletePasswordReset_NilIDPProvider` | 오류/예외/거부 경로 검증 |
| `backend/internal/handler/auth_handler_test.go:157` | `TestCompletePasswordReset_TokenValueOverridesLoginIDQuery` | 비밀번호 재설정 토큰 우선 규칙 검증 |
| `backend/internal/handler/auth_handler_test.go:209` | `TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists` | 오류/예외/거부 경로 검증 |
| `backend/internal/handler/auth_handler_test.go:249` | `TestProcessPasswordResetToken_EncodesLoginIDInRedirect` | 리다이렉트/쿼리 보존 규칙 검증 |
| `backend/internal/handler/dev_handler_test.go:103` | `TestCreateClient_Success` | Hydra/RP 연동 검증 |
| `backend/internal/handler/dev_handler_test.go:1248` | `TestCreateClient_DefaultsSkipConsentToTrue` | Hydra RP 생성 시 `skip_consent` 기본값 검증 |
| `backend/internal/handler/dev_handler_test.go:1303` | `TestCreateClient_AllowsExplicitSkipConsentFalse` | Hydra RP 생성 시 명시적 consent 요구 설정 보존 검증 |
| `backend/internal/handler/dev_handler_test.go:1508` | `TestUpdateClient_AllowsExplicitSkipConsentFalse` | Hydra RP 수정 시 명시적 consent 요구 설정 보존 검증 |
| `backend/internal/handler/dev_handler_test.go:15` | `TestListClients_Success` | Hydra/RP 연동 검증 |
| `backend/internal/handler/dev_handler_test.go:49` | `TestGetClient_Success` | Hydra/RP 연동 검증 |
| `backend/internal/handler/dev_handler_test.go:83` | `TestGetClient_NotFound` | 오류/예외/거부 경로 검증 |
| `backend/internal/handler/password_policy_test.go:57` | `TestGeneratePasswordUsesNonAlphanumericRequirement` | 유효성/정책/유틸 검증 |
| `backend/internal/handler/tenant_handler_test.go:101` | `TestTenantHandler_ApproveTenant` | 핵심 CRUD/서비스 동작 검증 |
| `backend/internal/handler/tenant_handler_test.go:73` | `TestTenantHandler_CreateTenant` | 핵심 CRUD/서비스 동작 검증 |
| `backend/internal/handler/user_group_handler_test.go:109` | `TestUserGroupHandler_AddMember` | 권한/관계 모델 검증 |
| `backend/internal/handler/user_group_handler_test.go:128` | `TestUserGroupHandler_AssignRole` | 권한/관계 모델 검증 |
| `backend/internal/handler/user_group_handler_test.go:71` | `TestUserGroupHandler_List` | 권한/관계 모델 검증 |
| `backend/internal/handler/user_group_handler_test.go:91` | `TestUserGroupHandler_Create` | 권한/관계 모델 검증 |
| `backend/internal/idp/factory_test.go:123` | `TestChainedProviderMetadataUnion` | 회귀 방지 기본 동작 검증 |
| `backend/internal/idp/factory_test.go:139` | `TestChainedProviderUpdateUserPasswordFallback` | 복구/격리/회복 탄력성 검증 |
| `backend/internal/idp/factory_test.go:152` | `TestChainedProviderUpdateUserPasswordAllFail` | 인증/OIDC 플로우 검증 |
| `backend/internal/logger/audit_logger_test.go:14` | `TestAuditLogEntry_RedactsSensitiveFields` | 감사 로그 민감정보 마스킹/비노출 검증 |
| `backend/internal/middleware/audit_middleware_test.go:42` | `TestAuditMiddleware` | 회귀 방지 기본 동작 검증 |
| `backend/internal/middleware/error_code_enricher_test.go:22` | `TestErrorCodeEnricher_AddsCodeToLegacyErrorResponse` | 오류/예외/거부 경로 검증 |
| `backend/internal/middleware/error_code_enricher_test.go:50` | `TestErrorCodeEnricher_DoesNotOverrideExistingCode` | 오류/예외/거부 경로 검증 |
| `backend/internal/middleware/error_code_enricher_test.go:72` | `TestErrorCodeEnricher_IgnoreSuccessPayload` | 오류/예외/거부 경로 검증 |
| `backend/internal/middleware/rbac_test.go:115` | `TestRequireKetoPermission_Success` | 권한/관계 모델 검증 |
| `backend/internal/middleware/rbac_test.go:138` | `TestRequireTenantMatch_SuperAdmin` | 권한/관계 모델 검증 |
| `backend/internal/middleware/rbac_test.go:160` | `TestRequireTenantMatch_Forbidden` | 오류/예외/거부 경로 검증 |
| `backend/internal/middleware/rbac_test.go:184` | `TestRequireRole_Unauthorized` | 오류/예외/거부 경로 검증 |
| `backend/internal/middleware/rbac_test.go:69` | `TestRequireRole_Success` | 권한/관계 모델 검증 |
| `backend/internal/middleware/rbac_test.go:92` | `TestRequireRole_Forbidden` | 오류/예외/거부 경로 검증 |
| `backend/internal/response/error_response_test.go:22` | `TestErrorWithDetailsResponseShape` | 오류/예외/거부 경로 검증 |
| `backend/internal/response/error_response_test.go:61` | `TestStatusCodeMapping` | 회귀 방지 기본 동작 검증 |
| `backend/internal/service/hydra_admin_service_test.go:102` | `TestHydraAdminService_GetConsentRequest` | 인증/OIDC 플로우 검증 |
| `backend/internal/service/hydra_admin_service_test.go:124` | `TestHydraAdminService_PatchClientStatus` | Hydra/RP 연동 검증 |
| `backend/internal/service/hydra_admin_service_test.go:13` | `TestHydraAdminService_ListClients` | Hydra/RP 연동 검증 |
| `backend/internal/service/hydra_admin_service_test.go:148` | `TestHydraAdminService_UpdateClient` | Hydra/RP 연동 검증 |
| `backend/internal/service/hydra_admin_service_test.go:165` | `TestHydraAdminService_ListConsentSessions` | 인증/OIDC 플로우 검증 |
| `backend/internal/service/hydra_admin_service_test.go:183` | `TestHydraAdminService_RevokeConsentSessions` | 인증/OIDC 플로우 검증 |
| `backend/internal/service/hydra_admin_service_test.go:198` | `TestHydraAdminService_RejectConsentRequest` | 오류/예외/거부 경로 검증 |
| `backend/internal/service/hydra_admin_service_test.go:215` | `TestHydraAdminService_RejectLoginRequest` | 오류/예외/거부 경로 검증 |
| `backend/internal/service/hydra_admin_service_test.go:232` | `TestHydraAdminService_GetLoginRequest` | 인증/OIDC 플로우 검증 |
| `backend/internal/service/hydra_admin_service_test.go:249` | `TestHydraAdminService_AcceptConsentRequest` | 인증/OIDC 플로우 검증 |
| `backend/internal/service/hydra_admin_service_test.go:267` | `TestHydraAdminService_AcceptLoginRequest` | 인증/OIDC 플로우 검증 |
| `backend/internal/service/hydra_admin_service_test.go:294` | `TestHydraAdminService_ErrorHandling` | 오류/예외/거부 경로 검증 |
| `backend/internal/service/hydra_admin_service_test.go:319` | `TestHydraAdminService_NotFound` | 오류/예외/거부 경로 검증 |
| `backend/internal/service/hydra_admin_service_test.go:39` | `TestHydraAdminService_GetClient` | Hydra/RP 연동 검증 |
| `backend/internal/service/hydra_admin_service_test.go:60` | `TestHydraAdminService_CreateClient` | Hydra/RP 연동 검증 |
| `backend/internal/service/hydra_admin_service_test.go:86` | `TestHydraAdminService_DeleteClient` | Hydra/RP 연동 검증 |
| `backend/internal/service/keto_service_test.go:101` | `TestKetoService_ErrorHandling` | 오류/예외/거부 경로 검증 |
| `backend/internal/service/keto_service_test.go:123` | `TestKetoService_CheckPermission_Forbidden` | 오류/예외/거부 경로 검증 |
| `backend/internal/service/keto_service_test.go:12` | `TestKetoService_CheckPermission` | 권한/관계 모델 검증 |
| `backend/internal/service/keto_service_test.go:137` | `TestKetoService_CreateRelation_Retry` | 복구/격리/회복 탄력성 검증 |
| `backend/internal/service/keto_service_test.go:34` | `TestKetoService_CreateRelation` | 권한/관계 모델 검증 |
| `backend/internal/service/keto_service_test.go:58` | `TestKetoService_DeleteRelation` | 권한/관계 모델 검증 |
| `backend/internal/service/keto_service_test.go:79` | `TestKetoService_ListRelations` | 권한/관계 모델 검증 |
| `backend/internal/service/ory_service_test.go:125` | `TestFindIdentityID_QueryEncoding` | 회귀 방지 기본 동작 검증 |
| `backend/internal/service/ory_service_test.go:38` | `TestUpdateUserPassword_Success` | 인증/OIDC 플로우 검증 |
| `backend/internal/service/ory_service_test.go:78` | `TestUpdateUserPassword_NotFound` | 오류/예외/거부 경로 검증 |
| `backend/internal/service/ory_service_test.go:98` | `TestUpdateUserPassword_ServerError` | 오류/예외/거부 경로 검증 |
| `backend/internal/service/relying_party_service_test.go:133` | `TestRelyingPartyService_Create_HydraFail` | Hydra/RP 연동 검증 |
| `backend/internal/service/relying_party_service_test.go:152` | `TestRelyingPartyService_Create_KetoFail_Rollback` | 복구/격리/회복 탄력성 검증 |
| `backend/internal/service/relying_party_service_test.go:190` | `TestRelyingPartyService_Get_Success` | Hydra/RP 연동 검증 |
| `backend/internal/service/relying_party_service_test.go:221` | `TestRelyingPartyService_Update_Success` | Hydra/RP 연동 검증 |
| `backend/internal/service/relying_party_service_test.go:250` | `TestRelyingPartyService_Delete_Success` | Hydra/RP 연동 검증 |
| `backend/internal/service/relying_party_service_test.go:85` | `TestRelyingPartyService_Create_Success` | Hydra/RP 연동 검증 |
| `backend/internal/service/tenant_service_test.go:126` | `TestTenantService_RegisterTenant_AddsDomainsAsVerified` | 핵심 CRUD/서비스 동작 검증 |
| `backend/internal/service/tenant_service_test.go:156` | `TestTenantService_RequestRegistration_AddsDomainAsUnverified` | 핵심 CRUD/서비스 동작 검증 |
| `backend/internal/service/tenant_service_test.go:187` | `TestTenantService_RequestRegistration_RejectsDomainMismatch` | 오류/예외/거부 경로 검증 |
| `backend/internal/service/tenant_service_test.go:208` | `TestTenantService_ApproveTenant_AssignsAdminRelationWhenUserExists` | 권한/관계 모델 검증 |
| `backend/internal/service/tenant_service_test.go:240` | `TestTenantService_ApproveTenant_DoesNotAssignWhenUserMissing` | 오류/예외/거부 경로 검증 |
| `backend/internal/service/user_group_service_test.go:103` | `TestUserGroupService_Create` | 권한/관계 모델 검증 |
| `backend/internal/service/user_group_service_test.go:124` | `TestUserGroupService_AddMember` | 권한/관계 모델 검증 |
| `backend/internal/service/user_group_service_test.go:138` | `TestUserGroupService_AssignRoleToTenant` | 권한/관계 모델 검증 |
| `backend/internal/service/user_group_service_test.go:154` | `TestUserGroupService_ListRoles` | 권한/관계 모델 검증 |
| `backend/internal/service/user_group_service_test.go:188` | `TestUserGroupService_Get_WithKratosFallback` | 복구/격리/회복 탄력성 검증 |
| `backend/internal/utils/masking_test.go:9` | `TestMaskSensitiveJSON` | 유효성/정책/유틸 검증 |
| `backend/internal/utils/slug_test.go:15` | `TestValidateSlug_ReservedKeywords` | 유효성/정책/유틸 검증 |
| `backend/internal/utils/slug_test.go:38` | `TestValidateSlug_LengthRules` | 유효성/정책/유틸 검증 |
| `backend/internal/utils/slug_test.go:60` | `TestValidateSlug_FormatRules` | 유효성/정책/유틸 검증 |
| `backend/internal/utils/slug_test.go:8` | `TestValidateSlug_Valid` | 유효성/정책/유틸 검증 |
| `backend/internal/validator/schema_validator_test.go:65` | `TestValidateIDPCompatibility` | 유효성/정책/유틸 검증 |

View File

@@ -0,0 +1,103 @@
# UserFront WASM Playwright E2E 확장 계획
- 작성일: 2026-02-23
- 대상: `userfront` (Flutter Web WASM 산출물)
- 목적: 로그인/리다이렉트/QR 흐름의 브라우저 실동작 회귀를 CI에서 자동 검증
## 1) 전제
- `flutter build web --wasm --release` 산출물(`userfront/build/web`)을 정적 서버로 서빙합니다.
- Playwright는 해당 URL로 접속해 E2E를 수행합니다.
- 카메라/QR은 실장비 의존도를 제거하기 위해 브라우저 API mock 기반 케이스를 기본으로 구성합니다.
## 2) 확장 범위 (우선순위)
1. Locale 진입/리다이렉트
- `/` 진입 시 `/{locale}`로 이동
- 비로그인 상태 `/{locale}` 진입 시 `/{locale}/signin` 이동
- 로그인 상태 `/{locale}` 진입 시 `/{locale}/dashboard` 이동
2. 로그인 성공/실패 및 새로고침 회귀
- 정상 로그인 후 `/{locale}/dashboard` 진입
- 대시보드 진입 후 새로고침 시 `signin`으로 튕기지 않음
- 비밀번호 오류 시 코드 기반 에러 표시 동작 확인
3. 비밀번호 재설정 플로우
- reset 링크 진입 후 비밀번호 변경
- 변경된 비밀번호로 즉시 로그인 가능
4. QR 로그인 (웹 로그인 페이지)
- QR init/poll 기본 플로우
- 만료/재발급 동작
5. QR 스캔/승인 (WASM)
- `/scan`에서 스캔 결과가 `/{locale}/approve?ref=...`로 전달됨
- BarcodeDetector 미지원/카메라 실패 시 수동 입력 fallback 동작
- approve 성공 시 dashboard 이동
6. 널체크 회복 경로 회귀
- `/ko` 경로에서 null-check 예외 발생 시 recovery target(`/{locale}/signin`) 이동 보장
## 3) 구현 단계
### Phase 0. E2E 실행 기반
- `userfront-e2e/` (Playwright) 추가
- `BASE_URL`/`LOCALE`/`MOCK_AUTH` 환경변수 표준화
- CI job: WASM build 산출물 서빙 + Playwright 실행
### Phase 1. 인증/리다이렉트 핵심 회귀
- 범위 1~2 구현
- 실패 재현 케이스를 먼저 작성(Failing test first)
### Phase 2. 비밀번호 재설정 회귀
- 범위 3 구현
- 성공/실패 케이스 분리
### Phase 3. QR 흐름 회귀
- 범위 4~5 구현
- BarcodeDetector/getUserMedia mock fixture 도입
### Phase 4. 에러/회복 회귀
- 범위 6 구현
- null-check 복구 라우팅 검증
## 4) 현재 구현 상태 (2026-02-24)
- Phase 0: 완료
- `userfront-e2e/` 워크스페이스 추가
- 로컬 SPA fallback 서버(`scripts/serve-userfront-build.mjs`) 추가
- 실행 커맨드: `cd userfront-e2e && npm run test:wasm`
- CI 잡 연결: `.gitea/workflows/code_check.yml``userfront-e2e-tests`
- Phase 1: 완료
- `tests/auth-routing.spec.ts` 추가
- 구현 시나리오:
- 비로그인 `/ko``/ko/signin` 리다이렉트
- 로그인 상태 `/ko` 진입 + 새로고침 후 `/ko/dashboard` 유지
- 비로그인 `/ko/approve?ref=...` 진입 시 `notice=qr_login_required`와 함께 signin 이동
- 로그인 상태 `/ko/approve?ref=...`에서 approve API 호출 후 dashboard 이동
- Phase 2: 완료
- `tests/password-and-reset.spec.ts` 추가
- 구현 시나리오:
- 비밀번호 로그인 성공 시 dashboard 이동 + 토큰 저장 확인
- 비밀번호 로그인 실패 시 코드 기반 에러(`password_or_email_mismatch`)가 client-log로 기록되는지 확인
- reset-password 성공 시 signin 이동 확인
- 참고:
- WASM 렌더링에서는 접근성/DOM selector가 제한되어 로그인/리셋 폼은 `flt-glass-pane` 좌표 기반 입력으로 검증
- 전수 인벤토리:
- `https://gitea.hmac.kr/baron/baron-sso/wiki/UserFront-WASM-E2E-Inventory.-`
- 라우트 22개 + 기능 회귀 12개(총 42 테스트) 코드화 완료
- 프로필 소속 회귀 강화:
- `tests/profile-department.spec.ts` 추가
- 구현 시나리오:
- 소속 수정 후 blur 저장 요청 전송
- 입력 후 즉시 새로고침 시 저장 요청 미전송 재현
- 동일값/빈값 입력 시 저장 요청 미전송
- 수정 후 새로고침 뒤 재수정 저장 요청 누락 방지
## 5) 완료 기준
- 핵심 인증 플로우(로그인/새로고침/리다이렉트/QR)가 Playwright 회귀군으로 자동화됩니다.
- 프로덕션 이슈 재발 건은 재현 테스트가 먼저 추가됩니다.
- PR에서 E2E 결과 링크(성공/실패 로그) 확인이 가능합니다.
## 6) 운영 원칙
- 버그는 반드시 재현 테스트를 먼저 추가합니다.
- 재현 테스트가 실패하는 상태를 확인한 뒤 수정합니다.
- 수정 후 동일 테스트를 반복 실행해 안정 통과까지 완료합니다.
- 테스트 하네스는 단계별로 초기화/정리합니다.
- 예: `beforeEach`에서 auth/mock state 재시드, `afterEach`에서 route mock 해제(`page.unroute`) 및 누수 상태 정리

View File

@@ -0,0 +1,12 @@
# AdminFront/DevFront E2E 테스트 전수 목록
- 범위: `adminfront/tests/*.spec.ts`, `devfront/tests/*.spec.ts`
- 기준: Playwright `test(...)` 케이스 전수
- 참고: UserFront WASM E2E 확장 계획은 `docs/test-plan/userfront-wasm-e2e-expansion-plan.md`에서 별도 관리
| 파일 | 테스트 | 역할 |
|---|---|---|
| `adminfront/tests/example.spec.ts:3` | `has title` | 초기 페이지 렌더링 스모크 검증 |
| `adminfront/tests/user-management.spec.ts:26` | `user create and delete flow` | 관리자 사용자 생성/삭제 E2E 검증 |
| `devfront/tests/clients.spec.ts:3` | `clients page loads correctly` | DevFront 클라이언트 페이지 접근 스모크 검증 |
| `devfront/tests/example.spec.ts:3` | `has title` | 초기 페이지 렌더링 스모크 검증 |

View File

@@ -0,0 +1,53 @@
# dev 브랜치 충돌 대응 정책
## 현재 상태 점검 기준
- `git status -sb` 기준으로 `unmerged paths`가 없으면 파일 단위 충돌은 없는 상태입니다.
- `non-fast-forward` push 거절은 로컬/원격 히스토리 분기(diverged) 상태로 간주합니다.
- 원격 확인 불가(DNS/네트워크 장애) 시, 로컬 기준 상태를 우선 공유하고 원격 fetch 가능 시점에 재확인합니다.
## 기본 원칙
1. `dev` 반영 전 최신 원격 기준선 확보
2. 충돌 해결은 기능 회귀 방지 우선
3. 해결 후 CI 강제 검사 통과 확인
## CI 강제 검사 정책
- `.gitea/workflows/code_check.yml`는 아래 이벤트에서 항상 실행됩니다.
- `push` to `dev`
- `pull_request` targeting `dev`
- `workflow_dispatch` (수동 실행)
- 수동 실행 입력으로 검사 항목을 끄는 방식은 사용하지 않습니다.
- `backend-tests`, `userfront-tests``lint` 결과와 무관하게 실행 시도하여 전체 실패 지점을 한 번에 확인합니다.
## 표준 절차
1. 원격 최신화
```bash
git fetch origin dev
git status -sb
git rev-list --left-right --count origin/dev...dev
```
2. 분기 상태별 처리
- 로컬만 앞섬 (`0 N`): `git push origin dev`
- 원격만 앞섬 (`N 0`): `git rebase origin/dev` 후 push
- 상호 분기 (`N M`): `git rebase origin/dev`로 정렬 후 충돌 해결
3. 충돌 해결 후 검증
```bash
make validate-auth-config
make verify-auth-config
```
## 우선순위 정책 (이번 범위 #274 / #276)
1. OIDC 리다이렉트/쿼리 전달 회귀 방지 로직 유지
2. `Makefile` 기반 인증 설정 생성/검증 경로 유지
3. `compose.ory.yaml`의 callback/allowed_return_urls env 연동 유지
4. `.env` 값 형식 안정성 유지 (same-line 주석 금지)
## 주의 사항
- `dev` 공유 브랜치에서는 `force push`를 사용하지 않습니다.
- `.env`에서 `KEY=value #comment` 형태는 금지합니다. (URL 끝 공백으로 Hydra/Kratos 기동 실패 가능)
- callback URL 끝 `/``make validate-auth-config`에서 실패 처리됩니다.
## 관련 문서
- `docs/oidc_redirect_mapping_validation_policy.md`
- `README.md`

View File

@@ -0,0 +1,77 @@
# Hydra RP Consent 시도 기록
## 목표
- 샘플 RP(`52a597f0-5b06-4fcb-b804-93e88a56a75a`)와 사용자(`b24051@hanmaceng.co.kr`, Kratos ID: `22607c1b-bfbf-4a90-9505-36b348472e7a`) 사이에 Hydra consent 세션을 생성.
## 시도한 방법과 실패 원인
### 1) hydra 컨테이너 내부에서 `sh` 실행 후 스크립트 수행
- 시도: `docker exec -i ory_hydra sh -lc '...'`
- 실패: `sh`가 존재하지 않음 (Hydra 컨테이너가 distroless 이미지).
- 원인: 쉘 바이너리 미포함.
### 2) `curlimages/curl` 컨테이너를 hydra 네트워크에 붙여서 consent 생성 흐름 수행
- 시도 흐름:
1. `/oauth2/auth` 호출로 `login_challenge` 획득
2. Admin API로 `login_challenge` 수락
3. `login_accept.redirect_to`(보통 `http://127.0.0.1:3000/...`)로 이동해 `consent_challenge` 추출
- 실패: `login_accept.redirect_to`가 **consent app(127.0.0.1:3000)**로 향하는데, 해당 서비스가 떠 있지 않아 접근 불가.
- 원인: consent app가 실행 중이 아니라 127.0.0.1:3000 접속 실패.
### 3) `login_accept.redirect_to`를 그대로 호출해 Location 헤더에서 consent_challenge 추출 시도
- 실패: 위와 동일하게 consent app 경로를 직접 호출하게 되어 연결 실패.
- 원인: consent app 미기동.
### 4) DCR(동적 클라이언트 등록) 시 metadata 포함 시도
- 실패: `invalid_client_metadata` (DCR에서는 `metadata`를 설정할 수 없음)
- 원인: Hydra DCR 정책 제한.
## 요약
- **핵심 실패 원인**은 consent app(로그인/동의 UI)이 실행 중이 아니라서 redirect_to를 따라가면 접속이 불가능한 점.
- distroless 이미지로 인해 `docker exec`로 쉘 스크립트를 바로 실행할 수 없음.
## 다음 시도(새 방식)
- consent app로 직접 이동하지 않고, **Hydra public endpoint**에서 `login_verifier`를 이용해 `consent_challenge`를 추출한 뒤 Admin API로 수락.
- 흐름:
1. `/oauth2/auth``login_challenge`
2. `/oauth2/auth/requests/login/accept``login_verifier`
3. `/oauth2/auth?client_id=...&login_verifier=...` 호출 → Location 헤더에서 `consent_challenge` 추출
4. `/oauth2/auth/requests/consent/accept` 호출
## 추가 시도 및 결과
### 5) login_verifier를 이용한 consent_challenge 생성 (public /oauth2/auth 호출)
- 시도: `login_verifier``/oauth2/auth?login_verifier=...` 호출 → Location에서 `consent_challenge` 추출 시도
- 실패: `login_verifier has already been used` 또는 `invalid_client` 등으로 예제 redirect(`https://example.com/callback?...`) 에러 반환
- 원인 추정:
- `login_verifier`는 1회성으로, 기존 흐름(redirect_to 또는 consent app)에 의해 이미 사용됨
- `login_verifier`만으로는 client 맥락이 부족하여 invalid_client 발생 가능
### 6) 임시 consent app(127.0.0.1:3000) 없이 redirect_to 직접 호출
- 실패: consent app 미기동으로 연결 불가
- 원인: login/consent UI URL이 기본값(127.0.0.1:3000)이라 실제 서비스 필요
### 7) Python 컨테이너에서 임시 consent app + 클라이언트 플로우 실행
- 방법: python:3.12-alpine 컨테이너에서
- 127.0.0.1:3000에 최소 consent 앱 실행
- `/oauth2/auth` 호출을 따라가며 login/consent 자동 수락
- 결과:
- 첫 시도에서 `state` 길이 부족으로 `invalid_state` 발생
- 이후 `state/nonce` 길이 충분히 늘려 재시도 중
## 현재 결론
- 실제 consent 앱이 없으면 Hydra는 consent_challenge를 만들 수 없고 흐름이 중단됨.
- 임시 consent 앱을 컨테이너 내부에서 띄우는 방식이 가장 현실적이며, 이 흐름으로 계속 진행 중.
### 8) devfront/hydra-rp-dummy.py + docker mount 실행
- 방식: `hydra-rp-dummy.py`를 컨테이너에 마운트하여 임시 consent app(127.0.0.1:3000)으로 로그인/동의 자동 수락
- 결과: 최종 리다이렉트가 `request_forbidden` (CSRF 쿠키 없음) 에러로 종료됨
- 하지만 Hydra Admin 조회 결과 consent 세션은 생성됨
- `handled_at`: 2026-01-30T05:01:46.770699Z
- subject: `22607c1b-bfbf-4a90-9505-36b348472e7a`
- client_id: `52a597f0-5b06-4fcb-b804-93e88a56a75a`
- grant_scope: `openid profile email`
### 상태
- consent 세션 생성 완료(확인됨)
- 최종 리다이렉트 단계에서 CSRF 오류는 남아 있으나, 목적(연동/동의 저장)은 달성됨

View File

@@ -0,0 +1,35 @@
# #146 원격 링크 로그인 세션/이력 불일치 대응
## 요약
- Ory 링크 로그인은 실제로 `/api/v1/auth/login/code/verify` 또는 `/api/v1/auth/login/code/verify-short` 경로를 사용합니다.
- 기존에는 `verifyOnly``/api/v1/auth/magic-link/verify`에만 적용되어, 링크를 클릭한 기기에서 세션이 발급되는 문제가 있었습니다.
- 인증수단 표기는 loginId 기반 추론에 의존해 SMS 요청이 Email로 표시되는 문제가 있었습니다.
## 원인
- verify-only 적용 범위가 magic link에 한정되어 있었고, Ory 코드 기반 경로는 세션을 즉시 발급했습니다.
- audit 로그의 인증수단 표기는 request_body/loginId 기반 추론만 사용했습니다.
## 변경 사항
### 1) verify-only 범위 확장
- `/api/v1/auth/login/code/verify`, `/api/v1/auth/login/code/verify-short``verifyOnly` 지원 추가
- verify-only일 때는 승인 상태만 저장하고 세션 발급은 Polling(Desktop)에서 수행
### 2) Polling 시 세션 발급 주체 정리
- 승인 상태(`status=approved`)는 **요청한 기기(A)**에서만 세션 발급
- Ory 코드 플로우는 Polling 시점에 `VerifyLoginCode`를 수행해 세션 생성
### 3) 인증수단 표기 개선
- `pendingRef` 기준으로 `login_method`(sms/email), `login_flow`(code/link) 저장
- audit 로그에 해당 메타를 기록하여 SMS/Email, 코드/링크 구분을 명확히 표시
- verify-only 요청 로그는 타임라인에서 제외
## 영향 범위
- Backend: 링크 로그인 승인/세션 발급 경로 변경
- Front: verify-only 플래그 전달 확장
- 문서: auth-flow/test-plan 업데이트
## 테스트 계획 (요약)
- Desktop에서 링크 요청 → Mobile에서 링크 클릭(verifyOnly) → Desktop Polling으로 세션 발급
- Mobile 단말에서 세션/로그인 이력 미생성 확인
- 인증수단 표기(SMS/Email) 정확성 확인
- 코드/링크 만료/재사용 시나리오 점검

View File

@@ -0,0 +1,51 @@
# Issue #269 해결 기록: `/{locale}/` 도입 후 query parameter 유실
## 개요
- 대상 이슈: `#269`
- 증상: locale 보정 또는 비로그인 리다이렉트 과정에서 GET query parameter가 유실되거나 형태가 변형됨
- 영향: OIDC 로그인 연계 파라미터(`login_challenge`, `redirect_uri`, `notice` 등) 전달 실패 가능
## 원인
1. 비로그인 리다이렉트 시 `login_challenge`만 선택 보존하고 나머지 query를 폐기
2. locale 경로 재작성 시 `uri.queryParameters` 기반 재직렬화로 원본 query 문자열(중복 key, 순서, 인코딩) 보존 실패
3. `head.length == 2` 휴리스틱으로 locale이 아닌 2글자 경로 prefix까지 locale로 오인 가능
## 수정 사항
### 1) 비로그인 리다이렉트에서 raw query 전체 보존
- 파일: `userfront/lib/main.dart`
- 변경: `state.uri.query`를 그대로 `/[locale]/signin`에 연결
```dart
final rawQuery = state.uri.query;
if (rawQuery.isNotEmpty) {
return '/$locale/signin?$rawQuery';
}
return '/$locale/signin';
```
### 2) locale 경로 재작성 시 raw query/fragment 보존
- 파일: `userfront/lib/core/i18n/locale_utils.dart`
- 변경: `queryParameters` 재직렬화 제거, `uri.query`/`uri.fragment` 원문 유지
```dart
final queryPart = uri.hasQuery ? '?${uri.query}' : '';
final fragmentPart = uri.fragment.isNotEmpty ? '#${uri.fragment}' : '';
return '$path$queryPart$fragmentPart';
```
### 3) locale 판별 조건 엄격화
- 파일: `userfront/lib/core/i18n/locale_utils.dart`
- 변경: `head.length == 2` 휴리스틱 제거, `supportedLocaleCodes.contains(head)`만 허용
## 테스트 보강
- 파일: `userfront/test/locale_utils_test.dart`
- 추가/변경:
- raw query 순서 및 중복 key(`a=1&a=2`) 보존
- fragment 보존
- unknown 2-letter prefix(`zz`)를 locale로 제거하지 않음
## 기대 결과
- `/signin?redirect_uri=...&notice=...` -> locale 보정 후 query 100% 유지
- 비로그인 보호 경로 -> `/[locale]/signin` 이동 시 기존 query 유지
- 인코딩된 nested `redirect_uri`, 중복 query key, fragment 보존

View File

@@ -0,0 +1,83 @@
# Issue #269 테스트 시나리오
## 목적
`/{locale}/` 라우팅 도입 이후 query parameter 유실 회귀를 방지합니다.
## 범위
- UserFront locale 경로 보정 (`buildLocalizedPath`)
- 비로그인 redirect 경로 생성 (`buildSigninRedirectPath`)
- locale 지원 목록 동기화 (`assets/translations/*.toml` -> `LocaleRegistry`)
## 테스트 시나리오
### S1. locale 보정 시 기본 query 보존
- 입력: `/signin?redirect_uri=https://example.com`
- 기대: `/ko/signin?redirect_uri=https://example.com`
### S2. locale 보정 시 raw query 순서/중복 key 보존
- 입력: `/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2`
- 기대: `/ko/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2`
### S3. locale 보정 시 fragment 보존
- 입력: `/signin?notice=qr_login_required#auth`
- 기대: `/ko/signin?notice=qr_login_required#auth`
### S4. unknown 2-letter prefix 오인 제거
- 입력: `/zz/signin`
- 기대: `/ko/zz/signin`
### S5. 비로그인 redirect에서 query 없음
- 입력: locale=`ko`, uri=`/ko/profile`
- 기대: `/ko/signin`
### S6. 비로그인 redirect에서 query 전체 보존
- 입력: locale=`ko`, uri=`/ko/profile?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2&notice=qr_login_required`
- 기대: `/ko/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2&notice=qr_login_required`
### S7. locale 목록 하드코딩 제거 검증
- 입력: asset 목록 (`assets/translations/en.toml`, `assets/translations/ko.toml`, `assets/translations/template.toml`, 기타 invalid 파일)
- 기대:
- `template.toml` 제외
- 유효 locale 파일(`en.toml`, `ko.toml`)만 지원 목록에 반영
### S8. 실계정 비밀번호 변경 스모크(E2E)
- 목적: 로그인 상태 플로우가 기존 동작을 깨지 않았는지 확인
- 절차:
- Kratos Admin API로 임시 계정 생성(초기 비밀번호 포함)
- 구 비밀번호 로그인 성공 확인
- Settings API로 비밀번호 변경
- 구 비밀번호 로그인 실패 확인
- 신 비밀번호 로그인 성공 확인
- 테스트 계정 삭제(정리)
- 기대:
- 비밀번호 변경 전/후 인증 결과가 정확히 반전
- 테스트 종료 후 identity 삭제 완료(잔존 계정 없음)
## 실행 방법
```bash
cd userfront
flutter test test/locale_utils_test.dart
flutter test test/locale_registry_test.dart
flutter test test/router_redirect_widget_test.dart
flutter test --platform chrome test/locale_utils_test.dart test/locale_registry_test.dart test/router_redirect_widget_test.dart
```
## 자동화 매핑
- `userfront/test/locale_utils_test.dart`
- S1~S6 전부 커버
- `userfront/test/locale_registry_test.dart`
- S7 커버
- `userfront/test/router_redirect_widget_test.dart`
- 로그인/비로그인 redirect 동작 검증(`redirect_uri`, `redirect_url`)
## 최근 실행 결과
- 실행일: 2026-02-19
- 결과:
- Flutter 테스트(VM): 통과
- Flutter 테스트(Chrome): 통과
- S8 실계정 E2E: 통과
- `login_old_password=200`
- `change_password=200`
- `login_old_after_change=400`
- `login_new_after_change=200`
- `cleanup(delete identity)=204`

View File

@@ -0,0 +1,76 @@
# Issue #277/#302 트러블슈팅 기록: 로그인 후 공백 화면 + 새로고침 시 signin 회귀
## 기준 시점
- 2026-02-23 KST
- 재현 환경: `https://sss.hmac.kr` (WASM 배포)
## 증상
- 로그인 직후 URL은 `/{locale}` 또는 `/{locale}/dashboard`로 보이지만 화면이 렌더링되지 않음
- 이후 새로고침하면 `/{locale}/signin`으로 되돌아감
- 콘솔/백엔드 수집 로그:
- `Null check operator used on a null value`
- `wasm-function[765]` 포함 스택 반복
## 스택 매핑 결과 (source-map + no-strip-wasm)
- 매핑 커맨드:
- `python3 scripts/map_wasm_stack.py --wasm userfront/build/web/main.dart.wasm --sourcemap userfront/build/web/main.dart.wasm.map --frame ...`
- 핵심 프레임:
- `wasm-function[765]` -> `_TypeError._throwNullCheckErrorWithCurrentStack`
- 상위 프레임 -> Flutter `NavigatorState.didUpdateWidget/_updatePages` 경로
- 결론:
- 단일 위젯 null 접근보다, 라우트 갱신 타이밍/중복 네비게이션 경쟁에서 `Navigator` 내부에서 터지는 양상
## 지금까지 시행착오와 실패 내역
1. `LocaleGate`, `LanguageSelector``EasyLocalization.of(context)` null 방어만 적용
- 결과: 동일 예외 재발
- 이유: 루트 원인은 로케일 위젯 단일 null 접근이 아니라 네비게이션 경쟁 구간
2. `/ko` 루트에서 signin 강제 리다이렉트만 강화
- 결과: 최초 진입은 일부 개선됐지만 로그인 직후/새로고침 회귀 지속
- 이유: 로그인 성공 경로가 루트(`/{locale}`)와 엮이면서 라우트 재평가가 중첩
3. 로그인 화면에서 `AuthNotifier.notify()` + `context.go(...)` 동시 수행
- 결과: 간헐적 경쟁 상태 유발 가능성 확인
- 조치: 로컬 네비게이션 1회 가드 도입(`_goLocalizedHomeOnce`)
4. cookie 세션 승격이 토큰 저장 이후 덮어쓰는 경합
- 결과: 일부 흐름에서 저장 상태 불안정 가능성
- 조치: `cookie_session_policy` 추가, 토큰 존재 시 불필요한 cookie 승격 차단
5. `/:locale` 엔트리가 redirect 없이 매칭되는 구조
- 결과: `/ko` 직접 진입 시 페이지 스택 재계산 과정에서 `NavigatorState.didUpdateWidget/_updatePages` 경로 null check 재발
- 이유: `/ko`는 실질 화면이 아닌 분기 지점인데, 명시적 redirect 경로가 없으면 라우트 갱신 타이밍 경쟁에 취약
- 조치: `/:locale`를 redirect 전용 엔트리로 확정(비로그인 `/{locale}/signin`, 로그인 `/{locale}/dashboard`)
## 최종 반영 방향 (이번 패치)
1. 로그인 성공 기본 경로를 명시적으로 `/{locale}/dashboard`로 고정
- `buildLocalizedHomePath()` 반환값을 `/{locale}/dashboard`로 변경
- `/:locale` 엔트리는 `/:locale/dashboard`로 redirect 전용 처리
2. 라우터/화면 역할 분리
- 보호 경로 검사는 router redirect에서 수행
- 대시보드는 필요 시 cookie 세션 복구를 1회 시도 후 signin 이동
3. 중복 네비게이션 억제
- 로그인 성공 시 내부 이동은 1회만 수행
## 검증
- 추가 테스트:
- `userfront/test/login_navigation_race_test.dart`
- `userfront/test/cookie_session_policy_test.dart`
- `userfront/test/router_redirect_widget_test.dart` (`/{locale}` 직접 진입 시 signin/dashboard 분기 검증)
- 갱신 테스트:
- `userfront/test/locale_utils_test.dart` (home path `/{locale}/dashboard` 기준)
- 실행:
- `flutter test`
- `flutter test --platform chrome test/router_redirect_widget_test.dart test/login_navigation_race_test.dart test/cookie_session_policy_test.dart`
## 남은 리스크
- 실제 브라우저 저장소 정책(localStorage 차단/쿠키 정책)에 따라 세션 판정이 달라질 수 있음
- 운영 검증 시 네트워크/스토리지 상태를 함께 수집해야 원인 분리 가능
## 운영 확인 체크리스트
1. 비로그인으로 `/{locale}` 접속 시 즉시 `/{locale}/signin` 이동
2. 로그인 성공 시 `/{locale}/dashboard` 진입
3. `/{locale}/dashboard`에서 새로고침 후 세션 유지 (동일 브라우저)
4. 실패 시 `RECOVERY_NAV_NULL_CHECK`와 wasm frame 동시 수집

View File

@@ -0,0 +1,94 @@
# Issue #281: locale_storage 리팩터링 계획
## 목적
- `locale_storage` 관련 로직에서 정책 지식(키, fallback, migration)을 한 곳으로 모아 변경 비용을 줄입니다.
- 테스트를 내부 구현 의존(`webStorage`)에서 파사드 의존(`LocaleStorage`) 중심으로 바꿔 회귀 위험을 낮춥니다.
- 테스트 강제 훅(`forceMemoryStorageForTests`, `forceSessionStorageForTests`)의 진입점을 단일화합니다.
## 현재 문제 요약
1. 저장 정책 지식이 여러 파일에 분산되어 있습니다.
2. 테스트가 구현 세부사항에 결합되어 정책 변경 시 함께 깨질 가능성이 큽니다.
3. 플랫폼별 훅 wiring이 반복되어 확장 시 누락 가능성이 있습니다.
## 변경 범위
- 대상 파일
- `userfront/lib/core/i18n/locale_storage.dart`
- `userfront/lib/core/i18n/locale_storage_stub.dart`
- `userfront/lib/core/i18n/locale_storage_web.dart`
- `userfront/test/locale_storage_platform_test.dart`
- `userfront/test/helpers/web_storage.dart`
- `userfront/test/helpers/web_storage_stub.dart`
- `userfront/test/helpers/web_storage_web.dart`
## 리팩터링 단계
### 1) 저장 정책 공통화
- 저장 키 상수(`locale`, legacy `baron_locale`)와 migration 로직을 공통 모듈로 이동합니다.
- fallback 순서(local -> session -> memory)를 공통 정책 함수로 추출합니다.
- `locale_storage_web.dart`는 정책 모듈 호출 위주로 단순화합니다.
### 2) 테스트 결합도 축소
- 테스트 assertion의 중심을 `LocaleStorage` API로 이동합니다.
- `webStorage` 직접 검증은 최소화하고, 필요한 경우 정책 모듈의 관찰 포인트를 제한적으로 제공합니다.
### 3) 테스트 훅 단일 진입
- `LocaleStorage` 파사드에서만 테스트 훅을 제어하도록 정리합니다.
- 플랫폼 구현은 훅 내부 세부 정책을 직접 노출하지 않도록 인터페이스를 정돈합니다.
### 4) 회귀 테스트 보강
- legacy key migration(`baron_locale -> locale`) 회귀 테스트를 명시적으로 유지합니다.
- storage access 실패/비가용 상황에서 fallback 순서를 검증하는 테스트를 추가합니다.
## 완료 기준(DoD)
- 정책 변경 시 수정 포인트가 공통 모듈 중심으로 줄어듭니다.
- 기존 locale 저장/조회 동작과 migration 동작이 유지됩니다.
- VM 기반 테스트가 안정적으로 통과하며 fallback/migration 회귀 케이스가 포함됩니다.
## 구현 시 주의사항
- 외부에서 사용하는 public API 시그니처는 가능한 유지합니다.
- 테스트 편의를 위한 훅은 운영 코드 경로에 영향이 없도록 격리합니다.
- 리팩터링 중간 단계에서도 테스트가 통과하도록 작은 단위로 나눠 적용합니다.
## 롤백 기준
- locale 저장/복구가 실패하거나, 웹 환경에서 fallback 동작이 달라지는 경우 즉시 이전 커밋 단위로 되돌립니다.
- migration 동작이 깨지는 경우 해당 단계만 우선 revert하고 정책 모듈 분리부터 재진행합니다.
## 구현 결과 (2026-02-20)
### 반영된 코드 변경
- 공통 계약/디버그 상태 타입 추가
- `userfront/lib/core/i18n/locale_storage_backend.dart`
- 저장 정책 상수/판단 로직 분리
- `userfront/lib/core/i18n/locale_storage_policy.dart`
- 파사드 단일 테스트 진입점 정리
- `userfront/lib/core/i18n/locale_storage.dart`
- `setTestModeForTests`, `clearForTests`, `seedLegacyForTests`, `debugStateForTests` 추가
- 기존 `forceMemoryStorageForTests`, `forceSessionStorageForTests` 호환 유지
- 플랫폼 구현 리팩터링
- `userfront/lib/core/i18n/locale_storage_web.dart`
- `userfront/lib/core/i18n/locale_storage_stub.dart`
- fallback 순서(local -> session -> memory) 유지
- legacy migration 시 legacy key(`baron_locale`)를 local/session/memory 전체에서 정리
- 테스트 정리
- `userfront/test/locale_storage_platform_test.dart``LocaleStorage` API 중심 검증으로 전환
- 삭제: `userfront/test/helpers/web_storage.dart`
- 삭제: `userfront/test/helpers/web_storage_stub.dart`
- 삭제: `userfront/test/helpers/web_storage_web.dart`
### CI/워크플로우 반영
- 파일: `.gitea/workflows/code_check.yml`
- `workflow_dispatch.inputs` 복원
- `run_lint`
- `run_backend_tests`
- `run_userfront_tests`
- 각 job 실행 조건 복원
- lint: `inputs.run_lint`
- backend-tests: `inputs.run_backend_tests`
- userfront-tests: `inputs.run_userfront_tests`
- userfront-tests 정책 정리
- `flutter test` 단일 실행으로 운영
- `locale_storage` 정책 검증은 VM 테스트(`locale_storage_platform_test.dart`)로 통합
- 브라우저 설치/`--platform chrome` 단계 제거
### 검증 결과
- `cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos` 통과
- `cd userfront && flutter test` 통과
- `cd userfront && flutter test test/locale_storage_platform_test.dart` 통과

View File

@@ -0,0 +1,47 @@
# #303 로그인 링크/코드 진입 실패 (`/{locale}/l/{shortCode}`) 대응
## 요약
- 로그인 링크 발송은 정상이나, 링크 클릭 후 로그인 검증 진입이 실패하는 사례가 있었습니다.
- 특히 locale prefix가 포함된 short-code 경로(`/{locale}/l/{shortCode}`)에서 재현되었습니다.
- 원인은 라우터의 public path 판별과 short-code 추출 로직이 locale prefix 경로를 고려하지 못한 점이었습니다.
## 증상
- 링크 클릭 URL이 `https://.../ko/l/AB123456` 형태일 때 로그인 검증이 자동 시작되지 않음
- 비로그인 상태에서 해당 경로 접근 시 signin으로 리다이렉트되어 short-code 검증이 끊김
## 원인
1. Public path 판별에서 `/l/` 경로가 제외되어 있었음
2. `LoginScreen`의 short-code 추출이 `uri.pathSegments.first == 'l'`에만 의존
- `/{locale}/l/{shortCode}`에서는 첫 세그먼트가 locale이므로 추출 실패
## 조치 내용
1. 경로 정책 분리
- `userfront/lib/features/auth/domain/login_link_route_policy.dart` 신규 추가
- public path 판별(`isPublicAuthPath`)과 short-code 추출(`extractLoginShortCode`)을 공용화
2. 라우터 반영
- `userfront/lib/main.dart` redirect에서 `isPublicAuthPath` 사용
- `/l/` 경로를 public path로 허용
3. 로그인 화면 반영
- `userfront/lib/features/auth/presentation/login_screen.dart`에서
`extractLoginShortCode(Uri.base)`로 short-code를 추출하도록 변경
- locale prefix 유무와 관계없이 short-code 검증 진입 가능
## 테스트
### 재현 테스트 (Failing first)
- `flutter test test/login_link_route_policy_test.dart`
- 초기 실패 확인:
- localized short-code 추출 실패
- localized short-code public path 판별 실패
### 수정 후 회귀 테스트
- `flutter test test/login_link_route_policy_test.dart` 통과
- `flutter test test/router_redirect_widget_test.dart` 통과
## 영향 범위
- 링크/코드 로그인 진입 라우팅 (`/l/{shortCode}``/{locale}/l/{shortCode}`)
- 기존 `/verify`, `/signin`, `/login` 경로에는 동작 변화 없음
## 관련 이슈
- Gitea: #303 `[bug][auth] 링크 클릭/코드 입력 로그인 실패 재현 및 수정`

View File

@@ -0,0 +1,45 @@
# Issue #434 트러블슈팅 기록: 대시보드 세션 시작 시간이 `Unknown`으로 표시됨
## 기준 시점
- 2026-03-24 KST
- 대상 화면: UserFront 대시보드 상단 세션 정보 칩
## 증상
- 로그인 후 대시보드의 세션 시작 시간이 `Unknown` 또는 `알 수 없음`으로 표시됨
- 특히 동일 브라우저의 cookie session 승격 경로에서 재현됨
## 원인
1. 기존 대시보드는 저장된 로컬 토큰만 파싱해 `iat` 또는 `auth_time`을 읽었습니다.
2. cookie mode에서는 `AuthTokenStore.setCookieMode()`가 로컬 토큰을 제거하고 cookie 플래그만 유지합니다.
3. 그 결과 대시보드는 파싱할 JWT가 없어 항상 fallback 문구로 떨어졌습니다.
## 수정 방향
1. Backend `/api/v1/user/me` 응답에 Kratos `sessions/whoami``authenticated_at` 값을 `sessionAuthenticatedAt`으로 포함합니다.
2. UserFront 대시보드는 세션 시각 계산 시 다음 우선순위를 사용합니다.
- JWT의 `iat` 또는 `auth_time`
- profile의 `sessionAuthenticatedAt`
3. 두 값이 모두 없을 때만 `ui.userfront.session.unknown` fallback을 사용합니다.
## 반영 파일
- `backend/internal/domain/auth_models.go`
- `backend/internal/handler/auth_handler.go`
- `backend/docs/openapi.yaml`
- `userfront/lib/features/profile/data/models/user_profile_model.dart`
- `userfront/lib/features/dashboard/domain/session_time_resolver.dart`
- `userfront/lib/features/dashboard/presentation/dashboard_screen.dart`
## 회귀 테스트
- Backend
- `backend/internal/handler/auth_handler_session_profile_test.go`
- UserFront
- `userfront/test/dashboard_session_time_resolver_test.dart`
- `userfront/test/dashboard_screen_smoke_test.dart`
## 검증 명령
- `GOCACHE=/tmp/go-build go test ./internal/handler -run 'TestGetMe_IncludesSessionAuthenticatedAt' -count=1`
- `flutter test test/dashboard_session_time_resolver_test.dart`
- `flutter test test/dashboard_screen_smoke_test.dart`
## 남은 참고사항
- Hydra introspection fallback만 사용되는 토큰 경로에서는 `sessionAuthenticatedAt`이 비어 있을 수 있습니다.
- 이 경우에도 JWT claim이 없으면 기존 fallback 문구를 유지합니다.

View File

@@ -0,0 +1,36 @@
# Issue #614 일반 RP Consent 반복 노출
## 현상
- `https://ssob.hmac.kr/`의 나의 App 현황에서 일반 서비스 클라이언트 바로가기를 열 때 Hydra consent 화면이 매번 노출되었습니다.
- `DevFront`, `AdminFront`는 동일 경로에서 consent 화면이 반복 노출되지 않았습니다.
## 원인
- 일반 RP 생성/수정 API가 Hydra OAuth2 client의 `skip_consent` 값을 전달하지 않았습니다.
- 백엔드 DTO와 DevFront 설정 모델에도 해당 필드가 없어 신규/기존 RP를 신뢰 앱으로 제어할 수 없었습니다.
- `remember: true` consent 세션은 이미 적용되어 있었지만, Hydra client 자체의 `skip_consent`와는 별도 정책입니다.
## 조치
- `domain.HydraClient``skip_consent` JSON 필드를 추가했습니다.
- Dev API는 `skipConsent` 요청 값을 받을 수 있지만, DevFront UI에는 별도 체크박스를 추가하지 않습니다.
- 신규 RP 생성 시 `skipConsent`가 생략되면 기본값을 `true`로 Hydra에 전달합니다.
- 기존 RP 수정 시 현재 값이 없으면 `true`로 보정하고, 명시적으로 `false`를 선택하면 그대로 Hydra에 전달합니다.
## 검증
- `TestCreateClient_DefaultsSkipConsentToTrue`
- 신규 RP 생성 요청에서 `skipConsent`가 생략되어도 Hydra payload의 `skip_consent``true`인지 검증합니다.
- `TestCreateClient_AllowsExplicitSkipConsentFalse`
- 신규 RP 생성 요청에서 명시한 `skipConsent: false`가 Hydra payload에 보존되는지 검증합니다.
- `TestUpdateClient_AllowsExplicitSkipConsentFalse`
- 기존 RP 수정 요청에서 `skipConsent: false`가 Hydra update payload에 보존되는지 검증합니다.
## 실행 결과
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/handler -run 'Test(CreateClient_(DefaultsSkipConsentToTrue|AllowsExplicitSkipConsentFalse)|UpdateClient_AllowsExplicitSkipConsentFalse)' -count=1`
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/handler -count=1`
- `cd devfront && npx biome check src/features/clients/ClientGeneralPage.tsx src/lib/devApi.ts src/locales/en.toml src/locales/ko.toml src/locales/template.toml --formatter-enabled=false --organize-imports-enabled=false`
- `cd devfront && npx tsc -b --pretty false`
- `node tools/i18n-scanner/index.js`
- `node tools/i18n-scanner/value-check.js`
## 수동 테스트용 RP
- `tools/consent-demo-page/index.php`를 추가했습니다.
- DevFront에서 테스트용 RP를 따로 만들고, 데모 페이지의 `.env`에 해당 `CLIENT_ID``REDIRECT_URI`를 설정하면 브라우저 기반 OIDC/consent 흐름을 확인할 수 있습니다.

View File

@@ -0,0 +1,43 @@
# Issue #663: 한맥가족 사용자 테넌트 동기화 및 직무/직급 표시 정리
## 개요
adminfront 사용자 관리 화면과 orgfront 조직도/조직 선택기 사이의 한맥가족 사용자 표시 기준을 정리했다.
기존에는 사용자 상세 화면에서 `metadata.hanmacFamily` 값에 의존해 한맥가족 탭을 결정했다. 이 방식은 기존 사용자가 이미 한맥가족 테넌트 트리에 소속되어 있어도 metadata 플래그가 없으면 외부 기업 회원으로 분류될 수 있었다.
## 정책 기준
- `docs/organization-chart-policy.md`에 따라 한맥가족 조직은 `COMPANY_GROUP` 아래의 `COMPANY`/`USER_GROUP` 테넌트 계층으로 판단한다.
- 한맥가족 사용자의 직무/직급은 단일 사용자 필드보다 소속별 `additionalAppointments`를 우선한다.
- orgfront 표시명은 직무가 있으면 `이름(직무) 직급` 형태를 사용한다.
## 구현 요약
- adminfront
- 한맥가족 root tenant 및 subtree 판정 유틸을 추가했다.
- 기존 사용자의 `tenant`, `joinedTenants`, `companyCode`, `tenantSlug`를 기준으로 한맥가족 여부를 계산한다.
- 사용자 상세 초기화 시 한맥가족 subtree 소속이면 `metadata.hanmacFamily`가 없어도 한맥가족 탭을 표시한다.
- 한맥가족 저장 시 기존 단일 `position`/`jobTitle` payload를 비우고 `additionalAppointments`를 사용한다.
- orgfront
- 사용자 표시명 공통 유틸을 추가했다.
- 조직도와 조직 선택기 모두 동일한 표시명 로직을 사용한다.
- `metadata.additionalAppointments`에 현재 테넌트와 매칭되는 직무/직급이 있으면 이를 우선 사용한다.
## 검증
- RED 확인
- `adminfront`: 한맥가족 subtree 사용자 판정 유틸 부재로 unit test 실패 확인.
- `orgfront`: 조직도/조직 선택기에서 `이름(직무) 직급` 표시가 없어 Playwright test 실패 확인.
- GREEN 확인
- `adminfront`: `npm run test:unit -- src/features/users/orgChartPicker.test.ts`
- `adminfront`: `npm run test:unit`
- `adminfront`: `npm run build`
- `orgfront`: `npm run lint`
- `orgfront`: `npm run build`
- `orgfront`: `npx playwright test tests/orgchart-picker.spec.ts tests/orgchart-vector-render.spec.ts --project=chromium`
## 남은 정책/운영 메모
- 이슈는 `한맥가족사 조직도 반영` 마일스톤에 연결했다.
- 해당 마일스톤은 Due Date가 비어 있다. 목표 Due Date를 정해 마일스톤에 반영하는 것이 좋다.

View File

@@ -0,0 +1,28 @@
# 이슈 #489 작업 완료 보고서
## 작업 개요
`devfront`에서 'Headless Login (자체 로그인 UI 사용)' 옵션을 활성화하여 생성한 PKCE 앱이 연동 앱 목록에서 'Server side App'으로 잘못 표기되는 현상을 수정했습니다.
## 상세 반영 내용
### 1. 백엔드 로직 수정 (`backend/internal/handler/dev_handler.go`)
- `mapClientSummary` 함수에서 클라이언트 유형(Type)을 결정하는 로직을 보완했습니다.
- 기존에는 `TokenEndpointAuthMethod``"none"`인 경우에만 `pkce`로 분류했으나, 이제는 `private_key_jwt` 방식이더라도 메타데이터에 `headless_login_enabled: true` 설정이 있다면 `pkce` 유형으로 올바르게 인식하도록 수정했습니다.
- `clientSummary` 구조체 응답에 `metadata` 필드를 포함시켜 프론트엔드가 상세 설정값을 인지할 수 있도록 개선했습니다.
### 2. 프론트엔드 API 타입 정의 수정 (`devfront/src/lib/devApi.ts`)
- `ClientSummary` 인터페이스에 백엔드에서 전달되는 `metadata?: Record<string, any>` 필드를 추가하여 타입 안정성을 확보했습니다.
### 3. 다국어 리소스 추가 (`locales/*.toml`)
- `ko.toml`, `en.toml`, `template.toml` 파일의 `[ui.dev.clients.type]` 섹션에 `pkce_headless` 키를 추가했습니다.
- **한국어**: `"PKCE (Headless Login)"`
- **영어**: `"PKCE (Headless Login)"`
### 4. 연동 앱 목록 UI 개선 (`devfront/src/features/clients/ClientsPage.tsx`)
- 클라이언트 목록 테이블의 '유형' 뱃지 렌더링 로직을 수정했습니다.
- `client.type``pkce`이면서 메타데이터의 `headless_login_enabled`가 활성화된 경우, 단순히 "PKCE"가 아닌 **"PKCE (Headless Login)"**으로 명확하게 표시되도록 변경했습니다.
## 검증 결과
- **프론트엔드**: `devfront` Playwright E2E 테스트 60개 전체 통과 확인.
- **백엔드**: 관련 핸들러 유닛 테스트 정상 통과 확인.
- **실제 동작**: Headless Login 설정 앱 생성 후 목록에서 "PKCE (Headless Login)" 배지가 정상 노출됨을 확인했습니다.

View File

@@ -0,0 +1,86 @@
# userfront locale 전환 문제 분석 및 대응
## 증상
- URL은 `/ko``/en`으로 변경되지만 실제 텍스트가 바뀌지 않음.
- 브라우저에서 `window.localStorage.getItem('locale')`가 계속 `null`.
- 빌드/배포 후에도 동일 증상 유지.
## 현재 구조 요약
- Flutter Web + `easy_localization` 사용.
- 경로 기반 로케일: `/:locale` 라우트 + `LocaleGate`로 로케일 적용.
- 번역 리소스: `userfront/assets/translations/*.toml`.
- 로케일 저장: `localStorage` key `locale`.
## 이미 적용한 변경
- `easy_localization` + TOML loader 적용.
- `LocaleGate`에서 로케일 적용 및 저장.
- `LanguageSelector`에서 로케일 저장 + URL 변경.
- `userfront/assets/translations``en.toml`, `ko.toml`, `template.toml` 포함.
- `pubspec.yaml` assets 등록.
- GoRouter 호환성 수정(빌드 오류 대응).
## 원인 후보(우선순위)
1) **로케일 저장이 실제로 실행되지 않음**
- `LanguageSelector` 클릭 이벤트가 동작하지 않거나,
- `LocaleGate`가 호출되지 않는 라우트 진입(예: 라우트 변경 없이 SPA 상태만 변경).
2) **서비스 워커/캐시로 인해 구 번들이 계속 로드됨**
- Flutter Web 기본 `flutter_service_worker.js`가 캐시를 고정.
- 기존 번들이 남아 `LocaleStorage.write()` 반영 전 코드가 계속 실행될 가능성.
3) **번역 리소스 미로딩**
- `assets/translations/en.toml`, `ko.toml` 요청이 404 또는 미요청.
- Nginx 경로/정적 파일 설정에서 `assets/`가 누락된 경우.
4) **en.toml 키 누락으로 fallback 문자열이 표시됨**
- UI에서 `tr(..., fallback: '한국어')`를 쓰는 경우,
en.toml에 키가 없으면 한국어가 그대로 노출됨.
## 확인 방법(권장 순서)
1) **로케일 저장 확인**
- 언어 변경 후 콘솔:
- `window.localStorage.getItem('locale')`
- `null`이면 저장 로직 실행 실패.
2) **번역 리소스 요청 확인**
- DevTools Network에서:
- `/assets/translations/ko.toml`
- `/assets/translations/en.toml`
- 404 또는 미요청이면 assets 로딩 문제.
3) **로케일 반영 확인**
- UI 내 임시 텍스트로 `context.locale.languageCode` 출력
- 로케일이 바뀌는데 텍스트가 그대로면 TOML 키 누락 가능성.
4) **캐시 무효화**
- 하드 리로드(Shift+Reload)
- service worker 제거 후 재접속
## 현재까지 실행한 대응
- `LocaleGate`에서 로케일 동일 여부와 상관없이 `LocaleStorage.write()`가 실행되도록 수정.
- 라우터 로케일 파싱/전환 로직 보강.
- 번역 리소스 파일 배치 및 assets 등록.
## 앞으로의 대응 계획
1) **서비스 워커 캐시 무효화 전략**
- `flutter_service_worker.js` 버전 갱신 또는 Nginx 캐시 무력화 정책 적용.
- 배포 시 `index.html`/`flutter_service_worker.js` 캐시 제어 헤더 추가.
2) **번역 파일 누락 방지**
- `scripts/sync_userfront_locales.sh`를 CI에서 자동 실행.
- en/ko 키 누락 검증 테스트 추가.
3) **로케일 저장 검증**
- 언어 변경 후 `localStorage`에 값이 반드시 들어오는지 E2E 체크 추가.
4) **fallback 사용 최소화**
- userfront에서 `fallback` 문자열(한국어) 남용 구간 정리.
- en.toml에 키가 없을 때 한국어가 보이는 현상 방지.
## 참고 파일
- `userfront/lib/core/i18n/locale_gate.dart`
- `userfront/lib/core/widgets/language_selector.dart`
- `userfront/lib/core/i18n/locale_storage_web.dart`
- `userfront/lib/core/i18n/toml_asset_loader.dart`
- `userfront/assets/translations/en.toml`
- `userfront/assets/translations/ko.toml`

View File

@@ -0,0 +1,89 @@
# 유저 그룹 기반 ReBAC 권한 아키텍처 (User Group-based ReBAC)
이 문서는 Baron SSO의 이슈 #239를 통해 구현된 유저 그룹 중심의 권한 체계와 Ory Keto를 이용한 ReBAC(Relationship-Based Access Control) 설계 방식을 설명합니다.
## 1. 개요
기존의 '테넌트 그룹(Tenant Group)' 방식에서 '유저 그룹(User Group)' 방식으로 전환하여, 권한 부여의 주체(Subject)를 그룹화하고 자원(Tenant)에 대한 권한을 상속받는 구조로 설계되었습니다.
## 2. 권한 상속 다이어그램
```mermaid
graph TD
%% Entities
subgraph Identity [사용자 계정]
U1[User: A]
U2[User: B]
end
subgraph Subjects [권한 부여 주체]
UG[User Group: 개발팀]
end
subgraph Resources [보호 대상 자원]
T1[Tenant: Project Alpha]
T2[Tenant: Project Beta]
RP[Relying Party: Auth App]
end
%% Relationships
U1 -- "is member of" --> UG
U2 -- "is member of" --> UG
UG -- "assigned role: manage" --> T1
UG -- "assigned role: view" --> T2
%% Inheritance Logic (Keto ReBAC)
T1 -- "owns" --> RP
%% Direct Inheritance
U1 -. "inherits: manage" .-> T1
U1 -. "inherits: view" .-> T2
U2 -. "inherits: manage" .-> T1
%% Recursive Permission
T1 -. "allows access" .-> RP
U1 -. "can manage" .-> RP
%% Styles
style Identity fill:#f9f,stroke:#333,stroke-width:2px
style Subjects fill:#bbf,stroke:#333,stroke-width:2px
style Resources fill:#dfd,stroke:#333,stroke-width:2px
linkStyle 4,5,6,7,8,9 stroke:#ff944d,stroke-width:2px,stroke-dasharray: 5 5
```
## 3. 기술적 관계 설계 (Ory Keto Tuples)
Ory Keto 내부적으로는 다음과 같은 관계 튜플(Relationship Tuples)을 통해 권한을 관리합니다.
### 3.1 그룹 멤버십 (Group Membership)
사용자를 특정 유저 그룹의 멤버로 등록합니다.
- **Tuple:** `Tenant:<GroupID>#members@User:<UserID>`
- **의미:** `GroupID`에 해당하는 유저 그룹 tenant의 멤버로 `UserID` 사용자를 등록한다.
### 3.2 테넌트 권한 할당 (Tenant Role Assignment)
유저 그룹 전체에 특정 테넌트에 대한 역할을 부여합니다.
- **Tuple:** `Tenant:<TenantID>#<Relation>@Tenant:<GroupID>#members`
- **의미:** `GroupID` 유저 그룹 tenant의 모든 멤버는 `TenantID` 테넌트에 대해 `<Relation>`(예: `view`, `manage`, `admins`) 권한을 가진다.
### 3.3 자원 소유 및 전파 (Resource Ownership)
테넌트가 소유한 하위 자원(RP, API Key 등)에 대한 권한 전파 규칙입니다.
- **Tuple:** `RelyingParty:<ClientID>#parents@Tenant:<TenantID>`
- **검증 논리:** 사용자가 `ClientID`에 대한 `view` 권한을 요청하면, Keto는 해당 사용자가 부모인 `TenantID`에 대해 `view` 권한이 있는지 역추적하여 승인합니다.
## 4. 주요 장점
1. **중앙 집중식 관리:** 사용자의 부서 이동이나 퇴사 시, 개별 테넌트의 권한을 수정할 필요 없이 유저 그룹의 멤버십만 변경하면 모든 연관 권한이 즉시 회수/부여됩니다.
2. **복합 권한 구성:** 하나의 그룹이 여러 테넌트에 대해 서로 다른 수준의 권한을 가질 수 있어, 실제 조직 구조와 프로젝트 협업 모델을 유연하게 반영할 수 있습니다.
3. **Zanzibar 스타일 확장성:** Google Zanzibar 논리를 따르는 Ory Keto를 활용함으로써, 향후 수만 명의 사용자와 수천 개의 테넌트 환경에서도 성능 저하 없이 정교한 권한 체크가 가능합니다.
## 5. 현재 구현 기준 주의사항
- 현재 Baron SSO는 별도 `UserGroup` namespace를 사용하지 않습니다.
- 유저 그룹은 `Tenant` namespace 내부의 특수 tenant(`type = USER_GROUP`)로 표현합니다.
- 따라서 group membership과 group-based role assignment는 모두 `Tenant:<groupId>#members` subject set을 기준으로 해석해야 합니다.
## 6. 관련 구현 파일
- **Backend Service:** `backend/internal/service/user_group_service.go`
- **Backend Handler:** `backend/internal/handler/user_group_handler.go`
- **Frontend API:** `adminfront/src/lib/adminApi.ts`
- **Frontend UI:** `adminfront/src/features/user-groups/`

View File

@@ -0,0 +1,123 @@
# 사용자 projection 가시성 감사 보고서
작성 시각: 2026-06-08 16:55 KST
관련 이슈:
- #1035: adminfront 사용자 레지스트리 total이 Kratos 250건 제한으로 잘못 표시됨
- #1036: 사용자 projection 가시성 영향 범위 검증 및 WORKS 비교 표 row count 표시
## 결론
`총 250명` 표시는 단순 UI 문제가 아니라, Kratos partial list를 full snapshot처럼 처리한 projection 동기화 버그였습니다.
현재 로컬 DB와 API는 다음 상태입니다.
| 항목 | 건수 | 설명 |
| --- | ---: | --- |
| users 전체 | 2,114 | `deleted_at` 포함 전체 row |
| visible users | 1,917 | `deleted_at IS NULL`, adminfront/orgfront 사용자 목록 기준 |
| soft-deleted users | 197 | 사용자 삭제 또는 과거 projection 문제로 숨겨진 row |
| CSV 원본 줄 수 | 1,887 | 헤더 포함 |
| CSV 실제 데이터 행 | 1,886 | 헤더 제외 |
| 이번 import 사용자 | 1,886 | 모두 DB 매칭, 모두 visible |
| import ID에 없는 기존 사용자 | 228 | visible 31, soft-deleted 197 |
따라서 “기존 221명 + 신규 1887명” 계산은 그대로 적용하면 안 됩니다.
- `1887`은 CSV 헤더 포함 줄 수이고 실제 신규 데이터 행은 `1886`입니다.
- 현재 DB에서 import ID에 없는 기존 사용자는 `228`명입니다.
- 그중 `197`명은 soft-delete 상태라 adminfront/orgfront visible total에는 포함되지 않습니다.
- 현재 화면 기준 총 사용자는 `신규 visible 1886 + 기존 visible 31 = 1917`입니다.
## 생성한 대조 파일
보고 파일 위치:
- `reports/user-projection-visibility-audit-20260608-1645/existing_users_not_in_saman_import.csv`
- `reports/user-projection-visibility-audit-20260608-1645/imported_users_missing_or_soft_deleted.csv`
파일 내용:
- `existing_users_not_in_saman_import.csv`: 이번 CSV import ID에 없는 기존 사용자 228명 전체 목록입니다.
- `imported_users_missing_or_soft_deleted.csv`: 이번 import 사용자 중 DB 누락 또는 soft-delete 상태인 사용자 목록입니다. 현재는 헤더만 있고 데이터 row는 0건입니다.
## 삭제 정책 확인
adminfront 사용자 삭제는 다음 순서로 동작합니다.
1. Kratos identity 삭제를 시도합니다.
2. WORKS 연동 범위 사용자이면 WORKS delete outbox를 enqueue합니다.
3. 로컬 `users` row는 GORM `Delete`로 soft-delete 합니다.
따라서 adminfront에서 사용자 삭제를 했다고 로컬 DB row가 hard-delete 되는 구조는 아닙니다. `users.deleted_at`에 값이 들어가고, 일반 조회에서는 제외됩니다.
예외적으로 사용자 생성/재생성 시 email unique 충돌을 풀기 위한 `Unscoped` hard-delete 경로가 일부 존재합니다. 이 경로는 일반 사용자 삭제 정책과 다릅니다.
## 영향 범위 검증
### adminfront 사용자 레지스트리
- `GET /api/v1/admin/users?limit=50&offset=0`
- 응답 total: `1917`
- adminfront `/users` 화면 문구: `총 1917명의 사용자가 등록되어 있습니다.`
### orgfront 사용자 소비 경로
현재 orgfront 조직도와 picker는 `fetchUsers(5000, 0)` 형태로 사용자 목록을 한 번 가져옵니다.
검증 결과:
- `GET /api/v1/admin/users?limit=5000&offset=0`
- items: `1917`
- total: `1917`
- soft-deleted 기존 사용자 197명 중 응답 포함: `0`
현재 visible 사용자가 5,000명 미만이라 orgfront의 현 방식은 전체 visible 사용자를 모두 받습니다. 다만 사용자가 5,000명을 넘으면 partial 조회가 될 수 있으므로 cursor 기반 전환이 필요합니다.
### WORKS comparison API와 화면
WORKS comparison은 Baron visible 사용자뿐 아니라 WORKS에만 존재하는 remote row도 함께 보여주는 비교 화면입니다. 따라서 사용자 목록 visible total과 1:1로 일치하지 않습니다.
`includeMatched=true` 기준 최신 API 결과:
| 구분 | 전체 | matched | missing_in_worksmobile | missing_external_key | missing_in_baron |
| --- | ---: | ---: | ---: | ---: | ---: |
| users | 2,110 | 1,874 | 41 | 6 | 189 |
| groups | 187 | 185 | 1 | - | 1 |
WORKS 화면 row count 표시:
- 구성원: `표시 232 / 전체 2110`
- 조직/그룹: `표시 2 / 전체 187`
구성원 `표시 232`는 기본 필터와 보호 계정 제거가 적용된 화면 노출 row 수입니다. `전체 2110`은 API comparison row 전체입니다.
soft-deleted 기존 사용자 197명과 WORKS comparison `baronId`를 대조한 결과:
- soft-deleted Baron row 포함: `0`
## 왜 업데이트가 많았는가
이번 문제는 단일 UI 카운트 문제가 아니라 다음 경계가 한꺼번에 얽힌 문제였습니다.
1. Kratos identity list는 partial list인데 backend projection sync가 full snapshot으로 처리했습니다.
2. projection sync가 local DB soft-delete까지 수행해 사용자 가시성 자체를 손상시켰습니다.
3. adminfront 사용자 목록, orgfront 조직도, WORKS comparison이 모두 사용자 projection을 다른 방식으로 소비하고 있었습니다.
4. WORKS comparison은 사용자 목록이 아니라 Baron/WORKS 양쪽 차이를 보여주는 비교 화면이라 total 의미가 달랐습니다.
5. 운영자가 partial data인지 바로 볼 수 있도록 WORKS 표 row count가 필요했습니다.
## pagination 정리
현재 구조는 다음과 같습니다.
- Ory/Kratos -> backend projection sync: 현재 `ListIdentities()` partial 조회입니다. offset/cursor 전체 순회가 아닙니다.
- backend projection -> adminfront 사용자 목록: `cursor`가 있으면 cursor pagination, 없으면 offset pagination을 받습니다. adminfront는 infinite query로 `nextCursor`를 사용합니다.
- backend projection -> orgfront 조직도/picker: 현재 `limit=5000&offset=0` 단일 offset 조회입니다.
- WORKS comparison: backend가 비교 결과 배열을 만들어 내려주고, adminfront가 검색/필터 후 화면 row를 표시합니다.
## 재발 방지 조치
- 사용자 목록 API는 Kratos가 아니라 local projection DB를 primary source로 사용합니다.
- Kratos partial list에 없는 사용자를 projection sync에서 삭제하지 않도록 수정했습니다.
- WORKS comparison에서 soft-deleted local user가 들어와도 comparison row로 노출되지 않도록 방어 테스트와 로직을 추가했습니다.
- WORKS comparison 표에 `표시 N / 전체 M` row count를 표시했습니다.

View File

@@ -0,0 +1,82 @@
# UserFront Error Handling Policy
## 1. 목적
- UserFront의 `/error` 화면에서 에러 코드 노출 정책을 일관되게 유지합니다.
- Ory에서 전달되는 표준 에러 코드는 별도 bypass 규칙으로 처리합니다.
- 내부 코드와 번역 리소스의 누락을 자동 검증합니다.
## 2. 처리 원칙
1. 프로덕션에서 에러 분기 기준은 `error` 문자열이 아니라 `code`입니다.
2. `ORY error code`는 bypass 규칙으로 처리합니다.
3. ORY 코드 외 항목은 내부 whitelist를 기준으로 노출합니다.
4. whitelist/bypass에 없는 코드는 `unknown_error`로 처리합니다.
## 3. 코드 분류
기준 파일: `userfront/lib/core/constants/error_whitelist.dart`
### 3.1 Internal whitelist
- `settings_disabled`
- `invalid_session`
- `verification_required`
- `recovery_expired`
- `recovery_invalid`
- `rate_limited`
- `not_found`
- `bad_request`
- `password_or_email_mismatch`
### 3.2 ORY bypass
- `access_denied`
- `consent_required`
- `interaction_required`
- `invalid_client`
- `invalid_grant`
- `invalid_request`
- `invalid_scope`
- `login_required`
- `request_forbidden`
- `server_error`
- `temporarily_unavailable`
- `unauthorized_client`
- `unsupported_response_type`
## 4. i18n 키 구조
- 내부 whitelist: `msg.userfront.error.whitelist.{code}`
- ORY bypass: `msg.userfront.error.ory.{code}`
리소스는 아래 파일 모두에 키가 있어야 합니다.
- `locales/template.toml`
- `locales/ko.toml`
- `locales/en.toml`
- `userfront/assets/translations/template.toml`
- `userfront/assets/translations/ko.toml`
- `userfront/assets/translations/en.toml`
## 5. 검증
키 누락 검증 스크립트:
```bash
./scripts/verify_userfront_error_i18n.sh
```
화면 동작 테스트:
```bash
cd userfront
flutter test test/error_screen_test.dart
```
## 6. Backend `code` 주입/매핑 경로
- 기본 매핑 함수: `backend/internal/response/error_response.go`
- `404 -> not_found`
- `429 -> rate_limited`
- legacy 응답 보강 미들웨어: `backend/internal/middleware/error_code_enricher.go`
- 핸들러가 `{"error": ...}`만 반환해도 status 기반 `code`를 주입
- 신규 권장 패턴: `response.Error(...)` 또는 공통 helper(`errorJSON`, `errorJSONCode`)로 핸들러에서 명시 코드 반환
UserFront는 위 경로로 전달된 `code`를 기준으로 whitelist/ory/unknown 분기를 수행합니다.
## 7. 관련 이슈
- `#164` `[UserFront] 에러 노출 whitelist 정의 및 적용`
- `#259` `백엔드 i18n/에러 메시지 fallback 정책 재정리 및 반영 계획 수립`
- `#260` `[Backend] 에러 응답 code 통일 구현 계획 (phase rollout)`

View File

@@ -0,0 +1,307 @@
# Wiki Update Draft: Error-Handling-Policy
대상 위키 페이지:
- `Error-Handling-Policy`
이 문서는 위키에 바로 반영할 수 있도록, 기존 `Error Handling Policy` 원문 구조를 유지하면서 최근 backend 로그 정책과 headless login 디버그 규칙까지 함께 정리한 초안입니다.
반영 의도:
- 기존 whitelist 중심의 prod 에러 노출 정책을 유지합니다.
- 최근 추가된 headless login 세분화 오류 코드와 backend debug log 규칙을 같은 문맥에서 관리합니다.
- UI 노출 정책과 backend API 응답 계약을 분리해 해석합니다.
---
# Error Handling Policy
본 문서는 이슈 `#164`([UserFront] 에러 노출 whitelist 정의 및 적용)를 기준으로 정리한 **프로덕션 에러 노출 정책**입니다.
## 0) 범위와 해석 기준
- 본 정책은 `userfront`, `adminfront`, `devfront`, `backend`가 공통으로 참고하는 에러 노출 기준입니다.
- `Backend`는 기계 판독 가능한 `code`와 안전한 짧은 `error` 문자열을 내려주는 책임을 가집니다.
- `Front``code`를 기준으로 사용자 문구를 번역/매핑해 표시합니다.
- 따라서 아래 표에서 한국어 문구는 **최종 사용자 노출 기준**, 영문 `error` 문자열은 **backend 응답 예시**로 해석합니다.
## 1) 기본 원칙
- 프로덕션은 **whitelist 방식**만 허용합니다.
- whitelist에 없는 에러는 **일반 오류 메시지**로 대체합니다.
- 상세 원문 메시지는 **프로덕션에서 비노출**합니다.
- **Ory Stack(Kratos/Hydra/Oathkeeper)에서 발생한 에러 코드는 그대로 pass-through** 합니다.
- **Custom 에러만 whitelist로 관리**합니다. (Ory 코드 제외)
## 2) 노출 정책 및 Ory 에러 처리 (통합)
### 2.1 Ory 에러 pass-through 원칙
- **Ory Stack(Kratos/Hydra/Oathkeeper)에서 발생한 에러 코드는 그대로 pass-through** 합니다.
- Ory 에러는 **whitelist 대상에서 제외**합니다.
- 단, 보안/UX 관점의 **blacklist 후보**는 예외적으로 `unknown_error`로 치환할 수 있습니다.
### 2.2 Custom whitelist (Ory 매핑 없음)
Ory 스택에서 발생하지 않으며 **매핑 대상이 없는 Custom 에러**만 관리합니다.
이 중 **HTTP 상태 코드와 일치하는 항목**은 별도 표로 구분합니다.
#### 2.2.1 HTTP status와 일치하는 Custom 에러
| error_code | http_status | 사용자 메시지 (기본) | Backend `error` 예시 | 설명 |
|---|---:|---|---|
| `not_found` | 404 | 요청한 페이지를 찾을 수 없습니다. | Not found | 경로 오류 |
| `rate_limited` | 429 | 요청이 많습니다. 잠시 후 다시 시도해 주세요. | Too many requests | 제한 초과 |
#### 2.2.2 HTTP status와 무관한 Custom 에러
| error_code | 사용자 메시지 (기본) | Backend `error` 예시 | 설명 |
|---|---|---|
| `password_or_email_mismatch` | 이메일 혹은 비밀번호가 일치하지 않습니다. | Invalid credentials | 비밀번호 입력 오류 |
| `invalid_client_assertion_parse` | 클라이언트 인증 정보 형식이 올바르지 않습니다. | Client assertion format is invalid | headless login assertion 형식 오류 |
| `invalid_client_assertion_signature` | 클라이언트 인증 정보 검증에 실패했습니다. | Client assertion signature verification failed | headless login assertion 서명 검증 실패 |
| `invalid_client_assertion_iss_sub` | 클라이언트 인증 정보 발급 주체가 올바르지 않습니다. | Client assertion issuer or subject mismatch | headless login assertion `iss/sub` 불일치 |
| `invalid_client_assertion_expired` | 클라이언트 인증 정보가 만료되었습니다. | Client assertion has expired | headless login assertion 만료 |
| `invalid_client_assertion_not_before` | 클라이언트 인증 정보가 아직 활성 상태가 아닙니다. | Client assertion is not active yet | headless login assertion 활성 시각 전 |
| `invalid_client_assertion_iat_future` | 클라이언트 인증 정보 발급 시각이 올바르지 않습니다. | Client assertion issued-at time is invalid | headless login assertion `iat` 미래 시각 |
| `invalid_client_assertion_audience` | 클라이언트 인증 정보 대상이 일치하지 않습니다. | Client assertion audience mismatch | headless login assertion `aud` 불일치 |
| `invalid_client_assertion_jwks_load` | 클라이언트 공개키 검증에 실패했습니다. | Headless login jwks verification failed | headless login `jwksUri` 조회/파싱 실패 계열 |
### 2.3 HTTP status 핸들링 정책
- **Ory error 코드가 존재하면 pass-through 우선**합니다.
- Ory error 코드가 없고, **HTTP status가 404/429**인 경우:
- `404` -> `not_found`
- `429` -> `rate_limited`
- 위 조건에 해당하지 않는 Custom 에러는 **기본 정책(`unknown_error`)**을 적용합니다.
### 2.4 비노출(기본) 에러 처리
whitelist에 없는 모든 **Custom 에러**는 아래 공통 처리 규칙을 따릅니다.
- 사용자 메시지: **"일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."**
- 오류 종류는 `unknown_error`로 고정합니다.
- 상세 원문 메시지는 사용자에게 표시하지 않습니다.
### 2.5 Ory 에러 blacklist 후보 (검토용)
Ory 에러를 pass-through 하더라도, 아래 유형은 **보안/UX 관점에서 숨김 처리(blacklist)** 후보입니다.
- `security_csrf_violation`
- `security_identity_mismatch`
- `browser_location_change_required`
- `server_error`
- `temporarily_unavailable`
> **제안**: 위 코드는 prod에서 `unknown_error`로 치환하고, log/audit에만 원문을 남기는 방식이 안전합니다.
### 2.6 Oathkeeper 경유 에러 처리
현재 설정(`docker/ory/oathkeeper/oathkeeper.yml`)에는 에러 변환 로직이 없고, `errors.fallback: json`만 정의되어 있습니다.
즉, Oathkeeper는 **에러 코드를 변환하지 않고 JSON으로 그대로 반환**합니다.
따라서 Ory Stack 에러는 **Oathkeeper를 통과하더라도 그대로 유지**된다고 가정합니다.
## 3) UI 정책
- 공통 에러 화면에는 아래 항목을 표시합니다.
- 제목
- 사용자 메시지
- **오류 종류(`error_code`)**
- **홈으로 이동 버튼**
- `error_id`가 있는 경우에만 표시합니다.
- Backend의 `error` 문자열은 최종 사용자 문구의 Source of Truth가 아닙니다.
- Front는 가능하면 `code` 기준으로 번역 리소스를 선택하고, `error`는 fallback 또는 운영 진단 보조 텍스트로만 사용합니다.
## 4) 구현 가이드
- 에러 표시 로직은 **whitelist 검사 후** 결정합니다.
- 예시:
- `if error_code in whitelist: message = whitelist_message`
- `else: error_code = "unknown_error", message = default_message`
## 5) 프로덕션 전용 동작 및 테스트 요구사항
### 5.1 프로덕션 전용 동작
- whitelist 적용(비노출/`unknown_error` 치환)은 **프로덕션에서만** 동작해야 합니다.
- **Ory Stack 에러는 prod에서도 pass-through** 합니다. (단, blacklist 후보는 예외 처리 가능)
- 프로덕션 판정 기준:
- Front: `APP_ENV``prod` 또는 `production`일 때만 활성화
- 테스트/로컬: override 옵션을 사용해 프로덕션/비프로덕션 동작을 강제할 수 있어야 합니다.
### 5.2 테스트 요구사항
최소 아래 케이스를 자동 테스트로 보장합니다.
- **Prod + whitelist 코드**: 사용자 메시지는 whitelist 메시지, `error_code`는 원래 코드 유지
- **Prod + 비-whitelist 코드**: 사용자 메시지는 기본 메시지, `error_code``unknown_error`
- **Prod + `error_id` 없음**: `error_id` 표시 없음
- **Non-prod + `error_code` 존재**: 원본 에러 코드/설명 표시
- **Non-prod + `description` 없음**: 기본 설명 노출
권장 테스트 위치:
- `userfront/test/error_screen_test.dart`
권장 테스트 방식:
- `ErrorScreen(isProdOverride: true/false, ...)`로 환경을 강제하여 동작 검증
- `AuthProxyService.isProdEnv``APP_ENV`에 의존하므로, 테스트에서 직접 환경 변수에 의존하지 않도록 override 사용
### 5.3 재현 테스트 우선 원칙
- 에러 처리 로직 변경 시, 먼저 재현 테스트를 작성합니다.
- 테스트는 최소한 `status`, `code`, `error` 응답 계약을 검증해야 합니다.
- 사용자 노출 메시지는 번역 리소스로 처리하고, API는 기계 판독 가능한 `code`를 우선 계약으로 유지합니다.
- 원문(한글/영문) 에러 문자열이 바뀌어도 `code` 기반 동작은 깨지지 않아야 합니다.
- 운영 이슈에서 확보한 `req_id`, 로그 패턴, 실제 응답 payload는 가능하면 테스트 케이스 설명에 남겨 회귀 근거를 보존합니다.
### 5.4 회귀 테스트 기준
- 인증 실패(예: password mismatch)에서 `code`가 기대값으로 반환되는지 검증합니다.
- 4xx/5xx 주요 에러 경로에 대해 최소 1개 이상의 핸들러 테스트를 유지합니다.
- 운영 이슈로 확인된 에러 케이스는 반드시 회귀 테스트 케이스로 승격합니다.
### 5.5 Headless Login 실패 코드 회귀 기준
Headless login 경로는 기존 generic `invalid_client_assertion`만으로는 운영 진단이 느렸기 때문에, 아래 코드를 별도 회귀 대상으로 유지합니다.
- `invalid_client_assertion_parse`
- `invalid_client_assertion_signature`
- `invalid_client_assertion_iss_sub`
- `invalid_client_assertion_expired`
- `invalid_client_assertion_not_before`
- `invalid_client_assertion_iat_future`
- `invalid_client_assertion_audience`
- `invalid_client_assertion_jwks_load`
- `password_or_email_mismatch`
권장 테스트 위치:
- `backend/internal/handler/auth_handler_login_test.go`
최소 검증 항목:
- 응답 `status`
- 응답 `code`
- 응답 `error`
- debug 레벨에서만 진단 필드가 로그에 포함되는지 여부
## 6) 변경 관리
- 에러 코드 추가/삭제는 **이슈 등록 후** 반영합니다.
- 사용자 메시지는 제품 문구 기준에 따라 수정합니다.
## 7) Ory 에러 코드(참고)
아래는 Ory(Kratos/Hydra)에서 **기본 제공되는 에러 코드**를 참고용으로 정리합니다.
### 7.1 Ory Kratos `error.id`
Kratos Self-Service Flow의 `error.id`는 다음 코드들이 공식 문서/SDK에 명시되어 있습니다.
- `session_inactive`
- `session_already_available`
- `session_aal1_required`
- `session_refresh_required`
- `security_csrf_violation`
- `security_identity_mismatch`
- `browser_location_change_required`
### 7.2 Ory Hydra / OAuth2·OIDC 표준 에러
Hydra는 OAuth2/OIDC 표준 에러 코드(`error` 필드)를 사용합니다.
- OAuth2 표준:
- `invalid_request`
- `unauthorized_client`
- `access_denied`
- `unsupported_response_type`
- `invalid_scope`
- `server_error`
- `temporarily_unavailable`
- OIDC 표준:
- `consent_required`
## 8) 에러 코드 관리 위치 제안 (아키텍처 기준)
에러 코드는 **Backend에서 표준화하고, Front에서 사용자 문구로 매핑**하는 구조가 가장 안전합니다.
### 8.1 Backend (단일 진입점, 표준화의 Source of Truth)
- 외부(Ory Kratos/Hydra/Oathkeeper) 및 내부 에러를 **표준 `error_code`로 변환**하는 로직을 Backend에 둡니다.
- 권장 위치:
- `backend/internal/handler/` 또는 `backend/internal/service/` 하위에 `error_mapper.go` 성격의 모듈
- 예시: `backend/internal/service/error_mapper.go`
- Backend 응답은 다음을 보장합니다.
- `error_code``error_id`를 일관 포맷으로 내려줌
- whitelist 외 코드는 `unknown_error`로 치환 (프로덕션 기준)
### 8.2 Front (문구 매핑, 표현 계층)
- 사용자 메시지와 UI 표시는 Front에서 담당합니다.
- 현재 위치(유지 또는 통합 권장):
- `userfront/lib/core/constants/error_whitelist.dart`
- `userfront/lib/features/auth/presentation/error_screen.dart`
- Admin/DevFront에서도 같은 whitelist를 사용해야 하므로, 아래 중 하나로 통일을 권장합니다.
1. Backend에서 error whitelist 리스트를 내려주는 API 제공
2. 공용 패키지/공용 파일로 관리 후 각 Front에서 참조
### 8.3 Ory Stack / Gateway
- Ory(Kratos/Hydra)와 Oathkeeper는 **원문 에러만 발생시키고 표준화는 하지 않음**을 원칙으로 합니다.
- Gateway/Proxy 레이어는 **에러 코드를 변환하지 않음**이 안전합니다.
## 9) Backend Log Level Policy
에러 응답 정책과 운영 디버깅 정책은 분리하되, 실제 운영에서 함께 보게 되는 경우가 많으므로 backend 로그 레벨 규칙을 같이 관리합니다.
### 9.1 기준 변수
- `APP_ENV`
- `BACKEND_LOG_LEVEL` (optional override)
### 9.2 기본 규칙
- `APP_ENV=dev|local|development`
- backend `slog` 기본 레벨은 `debug`
- text handler 사용
- 그 외 환경(`stage`, `production`, `prod` 등)
- backend `slog` 기본 레벨은 `info`
- JSON handler 사용
### 9.3 운영 override
- 운영/스테이징에서 장애 분석이 필요한 경우에만 `BACKEND_LOG_LEVEL=debug`를 일시적으로 설정합니다.
- 허용 값:
- `debug`
- `info`
- `warn`
- `error`
예시:
```env
APP_ENV=stage
BACKEND_LOG_LEVEL=debug
```
### 9.4 Headless Login 디버그 필드
- headless login 경로는 기본적으로 `reason_code` 중심으로 실패 원인을 기록합니다.
- `debug` 레벨일 때만 추가 진단 필드를 남깁니다.
- `expected_audiences`
- `received_audiences`
- `received_kid`
- `claim_issuer`
- `claim_subject`
- `claim_expires_at`
- `claim_not_before`
- `claim_issued_at`
- `login_challenge_prefix`
### 9.5 응답과 로그의 역할 분리
- API 응답은 `2번 정책`에 따라 `code + 짧은 안전 메시지`까지만 포함합니다.
- 상세 실패 원인은 구조화 로그에서 확인합니다.
- 같은 실패라도 응답에는 축약된 정보만, debug 로그에는 운영 진단용 필드를 남기는 것이 기본 원칙입니다.
### 9.6 민감 정보 비노출 원칙
- 아래 값은 로그에 직접 남기지 않습니다.
- raw `client_assertion`
- password
- session token
- cookie
### 9.7 운영 메모
- 운영에서는 기본적으로 `info`를 유지합니다.
- 장애 분석이 끝나면 `BACKEND_LOG_LEVEL` override는 즉시 제거합니다.
- 클라이언트 로그 정책(`CLIENT_LOG_DEBUG`)과 backend logger 정책(`BACKEND_LOG_LEVEL`)은 별도입니다.
## 10) 부록: Ory UI Error Codes 처리 원칙 (요약)
Ory Kratos UI 문서의 원칙을 기반으로, Baron 정책에 반영해야 할 핵심 처리 방침을 요약합니다.
- 메시지는 **root / method / field** 레벨에 붙을 수 있으며, UI에서 범위를 고려해 표시해야 합니다.
- UI 메시지는 **`id`, `text`, `type`, `context`** 형태로 전달되며, `id`는 **고정된 값**입니다.
- 메시지 `id`는 **7자리 규칙(xyyzzzz)**을 따릅니다.
- `x`: 메시지 타입 (1=info, 4=input validation error, 5=generic error)
- `yy`: 모듈/플로우 (01=login, 02=logout, 03=MFA, 04=registration, 05=settings, 06=recovery, 07=verification)
- `zzzz`: 구체 메시지 ID
- SPA/Native UI에서는 Ory가 **에러 응답을 직접 반환**하는 경우가 있으므로, **UserFront에 에러 ID별 처리 로직**이 필요합니다. (예: flow 만료/재시작, 인증 단계 재진입 등)
- Ory는 React 레퍼런스 구현에서 에러 처리 로직 예시를 제공합니다.
- UI 메시지 목록은 **machine readable JSON**으로 제공합니다.
## 11) References
- Ory Kratos UI error codes: https://www.ory.com/docs/kratos/concepts/ui-user-interface#ui-error-codes
- Ory Kratos React error handling example: https://github.com/ory/kratos-react-nextjs-ui/blob/master/pkg/errors.tsx
- Ory Kratos UI messages (machine readable): https://github.com/ory/docs/blob/master/docs/kratos/concepts/messages.json
- Ory Kratos User-facing errors: https://www.ory.com/docs/kratos/self-service/flows/user-facing-errors
- Ory Kratos Advanced Integration (SPAs and 422 error, `browser_location_change_required`): https://www.ory.sh/docs/kratos/bring-your-own-ui/custom-ui-advanced-integration
- Ory Kratos API (Postman) - Create Login Flow for Native Apps (`session_already_available`, `session_aal1_required`, `security_csrf_violation`): https://www.postman.com/ory-docs/ory/request/uy54y0r/create-login-flow-for-native-apps
- Ory Kratos API (Postman) - Create Registration Flow for Native Apps (`session_already_available`, `security_csrf_violation`): https://www.postman.com/ory-docs/ory/request/5vmu1ui/create-registration-flow-for-native-apps
- Ory Kratos API (Postman) - Create Settings Flow for Browsers (`session_inactive`, `security_csrf_violation`, `security_identity_mismatch`): https://www.postman.com/ory-docs/ory/request/pyfglhb/create-settings-flow-for-browsers
- Ory Kratos Client Docs (`session_refresh_required` 등): https://docs.rs/crate/ory-client/latest/source/docs/FrontendApi.md
- RFC 6749 (OAuth2 error codes): https://www.rfc-editor.org/rfc/rfc6749.txt
- OpenID Connect Core 1.0 (`consent_required`): https://openid.net/specs/openid-connect-core-1_0-31.html
---
## 로컬 참조 문서
- `docs/backend-log-policy.md`
- `docs/client-log-policy.md`
- `docs/test-plan/backend-test-inventory.md`

View File

@@ -0,0 +1,112 @@
# WORKS only 사용자 복구 보고서
관련 이슈: #1037
## 요약
- 작업 시각: 2026-06-09 KST
- 대상: WORKS 비교 결과에서 `missing_in_baron`으로 보이던 사용자 row
- 최신 후보 수: 189명
- 조치: Baron `users.deleted_at` soft-delete 해제
- Kratos 조치: 직접 삽입/수정 없음. 189개 Kratos identity가 모두 현재 DB에 active 상태로 존재함을 확인함
- 최종 결과: WORKS 사용자 비교의 `missing_in_baron` 0건
## 원인
이전 사용자 projection 동기화 코드가 Kratos `ListIdentities()` 결과를 전체 identity 목록으로 간주했습니다. 해당 API 결과는 제한된 페이지 결과였고, 그 목록에 없던 기존 사용자가 Baron `users`에서 soft-delete 처리되었습니다.
이로 인해 WORKS에는 사용자가 남아 있고 `externalKey`도 Baron 사용자 UUID를 가리키지만, Baron 비교 로직에서는 soft-deleted 사용자가 visible 사용자로 조회되지 않아 `missing_in_baron`으로 표시되었습니다.
## 대조 결과
복구 전 후보 189건 기준:
| 구분 | 현재 Baron | 과거 Baron 백업 | 현재 Kratos | 과거 Kratos 백업 |
| --- | ---: | ---: | ---: | ---: |
| 후보 | 189 | 189 | 189 | 189 |
| visible 사용자 | 0 | 189 | - | - |
| soft-delete 사용자 | 189 | 0 | - | - |
| 누락 사용자 | 0 | 0 | 0 | 0 |
| active identity | - | - | 189 | 189 |
| credential 보유 identity | - | - | 189 | 189 |
확인한 백업:
- `backups/pre-saman-works-users-20260608-063605Z`
- `backups/pre-works-only-user-recovery-20260609-083105KST`
상세 리포트:
- `reports/works-only-user-recovery-20260609-0832/missing_in_baron_external_keys.csv`
- `reports/works-only-user-recovery-20260609-0832/current_baron_candidate_status.csv`
- `reports/works-only-user-recovery-20260609-0832/pre_saman_baron_candidate_status.csv`
- `reports/works-only-user-recovery-20260609-0832/current_kratos_candidate_status.csv`
- `reports/works-only-user-recovery-20260609-0832/pre_saman_kratos_candidate_status.csv`
- `reports/works-only-user-recovery-20260609-0832/post_recovery_baron_candidate_status.csv`
## 조치 내용
복구 전 DB 백업을 생성했습니다.
- `make dump DUMP_SERVICES=postgres,ory-postgres BACKUP=backups/pre-works-only-user-recovery-20260609-083105KST`
이후 후보 189건에 대해 현재 Baron DB에서 다음 조건으로만 soft-delete를 해제했습니다.
- WORKS `missing_in_baron` row의 `externalKey`가 UUID여야 함
- 해당 UUID가 현재 Baron `users.id`에 존재해야 함
- 해당 Baron row가 `deleted_at IS NOT NULL`이어야 함
- 해당 UUID가 현재 Kratos `identities.id`에 active 상태로 존재해야 함
복구 결과:
- 복구된 Baron 사용자: 189명
- 전체 Baron `users`: 2114명
- visible 사용자: 2106명
- soft-delete 사용자: 8명
- 후보 189명 중 Kratos active identity: 189명
- 후보 189명 중 credential 보유 identity: 189명
## 최종 검증
실제 Kratos admin 세션으로 로컬 게이트웨이를 경유해 검증했습니다.
- `GET /api/v1/admin/users?limit=5000&offset=0`
- HTTP 200
- `total=2106`
- `items=2106`
- `GET /api/v1/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/comparison?includeMatched=true`
- HTTP 200
- 사용자 비교 총 2110건
- `matched=2073`
- `missing_in_baron=0`
- `missing_in_worksmobile=30`
- `needs_update=1`
- `missing_external_key=6`
- 조직/그룹 비교 총 187건
- 조직/그룹 `missing_in_baron=1`
테스트:
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/service -run 'TestWorksmobileSyncServiceSkipsSoftDeletedUsersInComparison' -count=1`
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/handler ./internal/repository -run 'Test.*User|Test.*Projection|Test.*SoftDeleted|Test.*ListUsers' -count=1`
- `BASE_URL=http://127.0.0.1:5173 npm --prefix adminfront test -- worksmobile.spec.ts --project=chromium`
결과:
- Go service 테스트 통과
- Go handler/repository 테스트 통과
- adminfront Worksmobile Playwright 4개 테스트 통과
## 재발 방지
이미 적용된 코드 변경으로 다음 조건을 방어합니다.
- admin 사용자 목록은 Kratos 250개 제한 결과가 아니라 로컬 projection repository를 기준으로 조회합니다.
- projection replace 동기화는 Kratos partial list에 없는 사용자를 삭제 처리하지 않습니다.
- WORKS 비교 로직은 soft-deleted Baron 사용자를 visible 사용자로 취급하지 않습니다.
- WORKS 비교 UI에는 필터링 후 표시 row와 전체 row 수를 함께 표시합니다.
남은 확인 항목:
- 조직/그룹 비교의 `missing_in_baron=1`은 사용자 복구와 별개 항목입니다. 별도 이슈로 원인 확인이 필요합니다.
- `missing_external_key=6` 사용자 row는 WORKS 측 externalKey가 없으므로 자동 복구 대상에서 제외했습니다.

View File

@@ -0,0 +1,64 @@
# Worksmobile API Rate Limit 정책
작성일: 2026-06-09
## 목적
Worksmobile relay worker는 외부 API 제한을 초과하지 않도록 Works API 호출에 rate limit을 적용한다.
일반 조회, 수동 enqueue, 비교 화면처럼 backend 내부 처리량이 병목이 아닌 경로는 기본 제약하지 않는다.
## 기준
- 제한 단위: API별
- 제한값: 240 requests/min
- 환산 간격: 같은 API key 기준 요청 시작 간격 250ms 이상
- API key: HTTP method + normalized path
- query string은 rate limit key에서 제외한다.
- 리소스 ID가 포함된 path segment는 `{id}`로 정규화한다.
예:
- `POST /v1.0/users`
- `PATCH /v1.0/users/{id}`
- `POST /v1.0/users/{id}/alias-emails/{id}`
- `GET /v1.0/orgunits`
- `PATCH /scim/v2/Users/{id}`
- `POST /oauth2/v2.0/token`
## 적용 범위
Backend `WorksmobileHTTPClient`는 optional limiter 주입 지점을 제공한다.
기본 client 생성자는 limiter를 갖지 않고, `WorksmobileRelayWorker`에 전달되는 client copy에만 limiter를 적용한다.
또한 Worksmobile relay worker에만 Redis leader lock을 적용해 여러 backend replica 중 하나만 outbox relay를 수행하게 한다.
적용 대상:
- Worksmobile relay worker가 수행하는 Directory API 조회/쓰기
- Worksmobile relay worker가 수행하는 SCIM API 조회/쓰기
- Worksmobile relay worker가 수행하는 OAuth token 요청
기본 제외 대상:
- admin 화면의 조회/비교 API
- 수동 sync 요청의 enqueue 단계
- Worksmobile outbox에 job을 적재하는 내부 처리
## 구현 정책
- production 기본 client는 limiter를 갖지 않는다.
- relay worker에는 limiter를 가진 client copy를 전달한다.
- relay worker는 Redis leader lock을 보유한 replica에서만 `worksmobile_outbox` ready job을 조회한다.
- leader lock은 Worksmobile relay 전용이며, 다른 relay worker나 일반 backend 작업에는 적용하지 않는다.
- leader lock 설정은 환경변수로 열지 않고 코드 상수로 고정한다.
- key: `baron:worksmobile:relay:leader`
- ttl: 30s
- 기본 limiter는 API key별 요청 시작 시각을 직렬화한다.
- context가 취소되면 대기 중인 요청은 `ctx.Err()`로 실패한다.
- 테스트나 특수 호출자는 `WorksmobileRateLimiter`를 주입해 limiter 동작을 검증하거나 대체할 수 있다.
## 운영 메모
- 현재 worker 정책은 burst를 허용하지 않는 보수적 제한이다.
- 외부 API가 `Retry-After`를 제공하는 경우, 별도 retry/backoff 정책을 추가할 수 있다.
- 여러 backend replica에서 relay worker가 동시에 시작되더라도 Redis leader lock으로 하나의 replica만 relay를 수행한다.
- Redis 연결이 초기화되지 않은 환경에서는 leader lock을 주입하지 않으며, 단일 인스턴스/dev 실행처럼 기존 방식으로 동작한다.

View File

@@ -0,0 +1,449 @@
# 웍스모바일 Directory 연동 기술 검토
## 개요
- 대상 Epic: `orgfront`와 웍스모바일 Directory API 간 한맥가족 사용자/조직 연동
- 관련 이슈: #668 한맥가족 이메일 local-part unique 정책
- 대상 마일스톤: `한맥가족사 조직도 반영 및 웍스모바일 연동` (`id=42`)
- 기준 SoT: `hanmac-family` 테넌트 subtree 하위 Kratos identity
- 작성일: 2026-05-04
## 현재 Baron SSO 구조 요약
Baron SSO는 Ory Stack을 SoT로 두고, PostgreSQL은 read-model 및 비즈니스 메타데이터 저장소로 사용합니다. `docs/SoT_Architecture_Policy.md``docs/tenant-usergroup-policy.md` 기준으로 Identity는 Kratos, 권한/멤버십은 Keto, 테넌트/조직 메타데이터는 PostgreSQL이 담당합니다.
현재 사용자 생성 흐름은 다음과 같습니다.
- `backend/internal/handler/user_handler.go`
- `CreateUser`: `companyCode`로 tenant slug를 찾아 `traits["tenant_id"]`에 tenant UUID를 저장합니다.
- `BulkCreateUsers`: CSV/import row별로 tenant slug를 tenant UUID로 변환하고 Kratos identity를 생성합니다.
- Kratos identity id는 `users.id`로 그대로 저장됩니다. 이 값이 웍스모바일 `userExternalKey` 후보입니다.
- `mapToLocalUser``traits["tenant_id"]``users.tenant_id`로 저장합니다.
- `backend/internal/handler/tenant_handler.go`
- `CreateTenant`: `TenantService.RegisterTenant` 호출 후 domains/config를 별도로 저장합니다.
- `UpdateTenant`: tenant 필드와 parent relation을 갱신합니다.
- `backend/internal/service/keto_relay_worker.go`
- `keto_outbox`를 polling하여 Keto relation을 비동기로 반영합니다.
한맥가족 이메일 정책은 이미 #668에서 다음 방향으로 구현되어 있습니다.
- `hanmac-family` root tenant와 descendant subtree에서 email local-part를 unique로 강제합니다.
- local-part unique 검사는 `archived`를 포함한 모든 사용자 상태를 대상으로 합니다.
- 단건 생성은 중복 시 `409 Conflict`로 차단합니다.
- bulk import는 `@domain` 입력 시 이름 기반 local-part를 제안하고, 생성 직전 재검증합니다.
- `preboarding`, `baron_guest`, `extended_leave`, `archived` 사용자는 Worksmobile 구성원 생성/갱신/backfill 대상에서 제외합니다.
- `baron_guest`, `extended_leave`, `archived` 상태로 전환된 사용자는 기존 Worksmobile 계정 delete/deprovision 대상입니다.
## 웍스모바일 Directory API 확인 사항
공식 문서 기준 Directory API는 구성원, 조직, 그룹, 직급, 직책, 사용자 유형 등을 관리합니다.
확인한 주요 엔드포인트와 제약은 다음과 같습니다.
- 인증
- API 호출에는 OAuth 2.0 Access Token이 필요합니다.
- 시스템 연동에는 서비스 계정 인증(JWT) 방식이 적합합니다.
- 필요한 scope는 최소 `directory`이며, 구성원만 다룰 경우 `user`, 조직만 다룰 경우 `orgunit`도 사용 가능합니다.
- 구성원
- `POST https://www.worksapis.com/v1.0/users`
- 필수 주요 필드: `domainId`, `email`, `userName`
- SSO 사용 시 `userExternalKey`가 필요합니다.
- `userExternalKey`는 테넌트 내 unique이며 `%`, `\`, `#`, `/`, `?`를 사용할 수 없습니다.
- `organizations[].orgUnits[].orgUnitId`는 resource id 또는 `externalKey:{orgUnitExternalKey}` 형태를 사용할 수 있습니다.
- 조직
- `POST https://www.worksapis.com/v1.0/orgunits`
- 필수 주요 필드: `domainId`, `orgUnitName`, `displayOrder`
- `orgUnitExternalKey`는 테넌트 내 unique이며 `%`, `\`, `#`, `/`, `?`를 사용할 수 없습니다.
- `parentOrgUnitId`는 resource id 또는 `externalKey:{orgUnitExternalKey}` 형태를 사용할 수 있습니다.
- External Key Mapping
- `POST /users/external-keys`
- `POST /orgunits/external-keys`
- 기존 웍스모바일 리소스에 External Key가 없는 경우 초기 bulk mapping에 사용합니다.
- 호출 제한/운영 주의
- 조직 추가/수정/부분 수정/이동 API는 도메인당 단일 스레드로 1초에 1회, 순서대로 호출해야 합니다.
- 동일 구성원에 대한 추가/수정/부분수정/전배 API는 동시에 호출하지 않아야 합니다.
- Directory API 조직 연동 배치는 직급/직책/사용자 유형 -> 조직 -> 구성원 -> 그룹 순서를 권장합니다.
- API 동시 호출은 5회 이상 하지 않도록 관리해야 하며, 특히 조직 API는 단일 스레드가 필요합니다.
## AdminFront bulk 생성과 NAVERWORKS bulk 생성 비교
구현 전에 `adminfront`의 기존 조직/사용자 bulk 생성 방식과 `adminfront/NAVERWORKS_member_add_sample_English.csv`의 구성원 bulk 필드를 비교했습니다.
### Baron/AdminFront 기존 방식
- 조직 bulk
- `adminfront/src/features/tenants/utils/tenantCsvImport.ts`
- 주요 컬럼: `tenant_id`, `name`, `type`, `parent_tenant_id`, `parent_tenant_slug`, `slug`, `memo`, `email_domain`
- Baron tenant tree를 직접 생성/갱신합니다.
- parent는 Baron tenant UUID 또는 slug 기준으로 해석합니다.
- 사용자 bulk
- `adminfront/src/features/users/components/UserBulkUploadModal.tsx`
- `parseUserCSV``BulkUserItem`으로 변환한 뒤 `/api/v1/admin/users/bulk`로 전송합니다.
- 주요 컬럼: `email`, `name`, `phone`, `role`, `tenant_slug`, `department`, `position`, `jobTitle`, `employee_id`
- 사용자 import 중 없는 tenant를 미리 생성할 수 있고, #668 한맥가족 email local-part unique preview를 거칩니다.
### NAVERWORKS sample 방식
- 구성원 bulk sample
- 파일: `adminfront/NAVERWORKS_member_add_sample_English.csv`
- 주요 컬럼: `LastName`, `FirstName`, `ID`, `Personal email`, `Sub email`, `User type`, `Level`, `Organization`, `Position`, `Mobile/Country code`, `Mobile/Numbers`, `Responsibilities`, `Workplace`, `Entry Date`, `Employee number`, `Account activation time`
- `ID`는 Baron loginId 및 Worksmobile userExternalKey와는 다른 계정 local-part 성격입니다.
- `Organization``org.1|org.2|org.3|myteam`처럼 path 문자열로 제공됩니다.
- `Employee number`는 Baron metadata의 `employee_id`로 보존합니다.
### 구현 반영
- `parseUserCSV`를 quoted CSV/BOM에 대응하도록 보강했습니다.
- NAVERWORKS 구성원 sample 필드를 Baron bulk user field로 흡수합니다.
- `Sub email`의 첫 이메일 -> Baron `email`
- `ID` -> Baron `loginId`, metadata `naverworks_id`
- `FirstName` + `LastName` -> Baron `name`
- `Mobile/Country code` + `Mobile/Numbers` -> Baron `phone`
- `Organization` path leaf -> Baron `department`, tenant import name
- `Position` -> Baron `position`
- `Responsibilities` -> Baron `jobTitle`
- `Employee number` -> metadata `employee_id`
- Worksmobile API payload 생성 시에는 request body의 external key/domainId를 사용하지 않고 Baron UUID와 `tenant.config.worksmobile.domainMappings``.env`의 domainId 값을 server-side 계산합니다.
## 매핑 설계
### External Key
Baron 내부 UUID는 웍스모바일 External Key 제한 문자와 충돌하지 않으므로 그대로 사용할 수 있습니다.
- 구성원 `userExternalKey`: Kratos identity UUID, 즉 `users.id`
- 조직 `orgUnitExternalKey`: Baron `tenants.id`
- 조직 지정: `externalKey:{tenant.ID}`
- 구성원 지정: `externalKey:{user.ID}` 또는 email/resource id
이 선택은 "Kratos account가 사용자 SoT"라는 정책과 맞습니다. 사용자 생성 후 Worksmobile resource id가 생기더라도 Baron의 primary mapping은 Kratos UUID를 유지하고, Worksmobile resource id는 캐시/응답 추적용으로만 보관하는 것이 좋습니다.
### 조직
Baron tenant를 Worksmobile orgunit으로 보냅니다.
- 대상 tenant: `hanmac-family` root 하위 subtree 중 `COMPANY`, `USER_GROUP`
- 제외 후보: `PERSONAL`, system/global 성격 tenant
- `orgUnitName`: `tenant.name`
- `orgUnitExternalKey`: `tenant.id`
- `parentOrgUnitId`: parent가 Worksmobile 동기화 대상이면 `externalKey:{parentTenant.ID}`
- `domainId`: tenant domain 또는 root integration config에서 email domain별로 해석
- `displayOrder`: 동일 parent 내 deterministic order 필요. 1차 구현은 `name asc`, `created_at asc`, 또는 별도 `config.worksmobile.displayOrder` 정책 중 하나를 선택해야 합니다.
주의할 점은 Worksmobile `orgunits``domainId`를 필수로 요구한다는 점입니다. Baron은 한맥가족 root 아래에 여러 이메일 도메인과 법인/조직 subtree를 둘 수 있으므로, 우선 `tenant.config.worksmobile.domainMappings`를 지원하되 운영 domainId는 `.env`의 다음 값을 fallback으로 사용합니다.
- `SAMAN_DOMAIN_ID`: 삼안 계열
- `HANMAC_DOMAIN_ID`: 한맥 계열
- `GPDTDC_DOMAIN_ID`: 총괄기획&기술개발센터
- `BARONGROUP_DOMAIN_ID`: 위 세 가지에 속하지 않는 모든 한맥가족사
분류 순서는 config mapping -> 삼안 -> 한맥 -> GPDTDC -> BARONGROUP fallback입니다.
개발/스테이징에서 테스트한 값은 프로덕션에도 동일하게 반영해야 합니다. 네이버웍스 쪽 backend가 동일 환경이므로 이 mapping은 env-only 값으로 숨기지 않고 seed/config migration 등 코드 레벨에서 추적 가능한 값으로 남깁니다.
```json
{
"worksmobile": {
"enabled": true,
"tenantId": "hanmac-family",
"domainMappings": {
"hanmaceng.co.kr": 10000001,
"samaneng.com": 10000002
}
}
}
```
### 구성원
Baron Kratos identity를 Worksmobile user로 보냅니다.
- `userExternalKey`: Kratos UUID (`users.id`)
- `email`: #668 정책으로 확정된 email
- `userName.lastName`: `traits["name"]` 또는 `users.name` 전체를 우선 입력합니다. 성/이름 분리가 확정되기 전까지는 API 필수 조건 충족을 위해 전체 표시명을 `lastName`에 둡니다.
- `cellPhone`: normalized phone
- `employeeNumber`: metadata의 `employee_id` 또는 schema login ID 값이 있으면 사용
- `privateEmail`: 기본 매핑하지 않습니다. NAVERWORKS sample의 `Personal email`은 Baron metadata에는 보존하지만 Worksmobile payload에는 기본 전송하지 않습니다.
- `aliasEmails`: 한맥 tenant에 속하고 `employee_id`가 있으면 `employee_id@hanmaceng.co.kr`을 추가합니다.
- `locale`: 별도 지정이 없으면 `ko_KR`
- `passwordConfig.passwordCreationType`: `ADMIN` 값을 구성원 생성 시에만 사용합니다.
- `passwordConfig.password`: 구성원 생성 시 숫자, 영문, 기호를 모두 포함한 16자리 난수 초기 비밀번호를 생성합니다.
- `task`: Baron `jobTitle`을 우선 사용
- `organizations`
- 원직: 대표 tenant 또는 `additionalAppointments` 중 primary로 선택된 tenant
- 겸직: `metadata.additionalAppointments` 또는 Keto `joinedTenants`
- `orgUnits[].orgUnitId`: `externalKey:{tenant.ID}`
- `levelId`, `positionId`, `userTypeId`: 이번 scope에서는 External Key mapping을 사용하지 않고 사용자 정보 업데이트 필드로 최대한 커버
- `isManager`: `additionalAppointments[].isOwner == true` 또는 Keto owners/admins relation을 기준으로 변환
초기 비밀번호는 Worksmobile user upsert outbox payload에 `loginEmail`, `initialPassword` 형태로 함께 보관하고, adminfront의 한맥가족 Worksmobile 관리 화면에서 `email,initialPassword,status,lastError` CSV로 다운로드할 수 있게 합니다. 생성 성공/실패 판정은 outbox 작업 상태(`processed`, `failed`)와 함께 확인할 수 있으며, 운영상 평문 초기 비밀번호가 포함되므로 다운로드 권한은 `hanmac-family` tenant manage 권한으로 제한하고 보존 기간 정책을 별도 확정해야 합니다.
현재 backend `CreateUser``UpdateUser`는 adminfront가 보내는 top-level `additionalAppointments``metadata.additionalAppointments`를 수용합니다. 한맥가족 단건 생성에서 대표 `tenantSlug` 없이 appointment만 오는 경우에는 first/primary appointment tenant를 대표 tenant로 해석해 Kratos traits, local read-model, Worksmobile enqueue가 누락되지 않게 합니다.
### 구성원 수정과 비밀번호 정책
Worksmobile 구성원 수정 API에는 PUT(`user-update-put`)과 PATCH(`user-update-patch`)가 있지만, 비밀번호 변경 경로로 사용하지 않습니다.
- Worksmobile Directory API에서 관리자 지정 초기 비밀번호 값은 별도 top-level `password`가 아니라 `passwordConfig.password`입니다.
- `passwordConfig.passwordCreationType`은 생성 방식이고, `passwordConfig.password`가 실제 초기 비밀번호 값입니다.
- 공식 문서와 개발자 포럼 확인 결과, `passwordConfig.passwordCreationType``passwordConfig.password`는 모두 구성원 등록 시에만 실제 반영됩니다.
- PUT/PATCH request body에 `passwordConfig.password`를 포함해도 기존 구성원의 비밀번호 변경에는 반영되지 않습니다.
- 따라서 Baron SSO는 WORKS Mobile 구성원 생성 시에만 초기 비밀번호를 설정하고, 생성 이후 Baron 사용자 비밀번호 변경을 WORKS Mobile PUT/PATCH로 전파하지 않습니다.
- 생성 이후 WORKS Mobile 비밀번호 변경은 WORKS Mobile 관리자 페이지 또는 WORKS Mobile이 제공하는 별도 운영 절차에서 직접 처리합니다.
- PATCH/PUT payload에는 `passwordConfig`를 포함하지 않습니다.
- `privateEmail`도 기존 정책대로 기본 전송하지 않습니다.
- 기존 WORKS Mobile 구성원에 대한 일반 속성/조직/겸직 동기화는 생성 효율을 위해 먼저 `POST /v1.0/users`를 시도하고, `409 Conflict`일 때 `PATCH /v1.0/users/{email}`로 전환합니다.
- PUT은 전체 교체 성격이 강하고 누락 필드 초기화 위험이 있으므로 현 scope에서는 사용하지 않습니다. 모든 Baron -> WORKS 변경 반영은 부분 수정 PATCH를 우선합니다.
### 구성원 비밀번호 관리 링크
Baron SSO는 생성 이후 WORKS Mobile 비밀번호 값을 직접 수정하지 않습니다. 운영자가 비밀번호 수정을 요청할 때는 해당 WORKS 계정의 식별자를 이용해 WORKS Mobile 관리자 비밀번호 관리 화면을 새 창으로 엽니다.
사용 URL:
```text
https://auth.worksmobile.com/integrate/password/manage?usage=admin&targetUserTenantId={회사테넌트}&targetUserDomainId={회사도메인}&targetUserIdNo={변경대상works_USER_ID}&accessUrl=https://admin.worksmobile.com/assets/self-close.html
```
전제와 기준:
- 브라우저 사용자는 `auth.worksmobile.com`에 관리자 권한으로 로그인되어 있어야 합니다.
- `targetUserTenantId`는 Baron tenant UUID가 아니라 WORKS Mobile 회사 tenant 식별자입니다. Baron SSO backend는 `WORKS_ADMIN_TENANT_ID` 환경 변수로 이 값을 adminfront overview에 노출합니다.
- `targetUserDomainId`는 WORKS Mobile 비교 결과의 `worksmobileDomainId`를 사용합니다.
- `targetUserIdNo`는 WORKS Mobile 비교 결과의 `worksmobileId`를 사용합니다.
- adminfront는 세 값이 모두 있을 때만 비밀번호 관리 버튼을 활성화합니다.
- 이 링크는 WORKS Mobile 관리자 화면을 여는 기능이며, Baron SSO backend에서 password 또는 `passwordConfig` 변경 API를 호출하지 않습니다.
## 비동기 아키텍처 권장안
Worksmobile API를 handler에서 직접 호출하지 않고, 별도 outbox와 relay worker를 둡니다.
권장 신규 테이블:
- `worksmobile_outbox`
- `id`
- `resource_type`: `ORGUNIT`, `USER`
- `resource_id`: Baron tenant/user UUID
- `action`: `UPSERT`, `DELETE`, `EXTERNAL_KEY_MAP`
- `payload`: JSONB
- `dedupe_key`
- `status`: `pending`, `processing`, `processed`, `failed`
- `retry_count`, `last_error`
- `next_attempt_at`, `processed_at`, `created_at`, `updated_at`
- `worksmobile_resource_mappings`
- `baron_resource_type`
- `baron_resource_id`
- `external_key`
- `worksmobile_resource_id`
- `domain_id`
- `last_synced_at`
권장 신규 service/client:
- `backend/internal/service/worksmobile_client.go`
- `backend/internal/service/worksmobile_sync_service.go`
- `backend/internal/service/worksmobile_relay_worker.go`
- `backend/internal/repository/worksmobile_outbox_repository.go`
현재 구현은 `WORKS_ADMIN_*` OAuth 또는 directory token을 사용하는 `WorksmobileHTTPClient``WorksmobileRelayWorker`를 통해 `worksmobile_outbox`의 pending 사용자 작업을 Directory API로 전달합니다. 사용자 생성은 `POST https://www.worksapis.com/v1.0/users`를 먼저 호출하고, 이미 존재하는 구성원으로 `409 Conflict`가 발생하면 `PATCH /v1.0/users/{email}`로 전환합니다.
SCIM은 주경로로 사용하지 않습니다. 기존 검토에서 SCIM은 다음 이유로 보류했습니다.
- Directory API의 `passwordConfig.passwordCreationType = ADMIN` 생성 정책을 그대로 표현하기 어렵습니다.
- SCIM 경로는 검증 조건 때문에 private mail 성격의 email 값을 강제해야 하는 문제가 있었습니다.
- Baron 정책은 `privateEmail` 기본 미전송이므로 SCIM을 주경로로 삼지 않습니다.
`WORKS_ADMIN_OAUTH_CLIENT_ID`, `WORKS_ADMIN_OAUTH_CLIENT_SECRET`, service account private key는 Directory API 호출용 토큰 발급에 사용합니다. OAuth redirect URI 등록이 필요한 경우 다음 경로를 사용합니다.
```text
http://localhost:5000/api/v1/admin/worksmobile/oauth/callback
```
로컬 Playwright 검증에서는 위 callback 경로가 브라우저에서 도달 가능함을 확인했습니다.
Worker 정책:
- orgunit 작업은 `domainId`별 단일 worker lane, 최소 1초 간격
- user 작업은 같은 `userExternalKey` 단위로 순차 처리
- 전체 동시성은 5 미만
- 409는 idempotent conflict로 보고 user PATCH 전환
- 404 parent orgunit은 parent job 선처리 후 retry
- 429/5xx는 exponential backoff
## AdminFront 운영 화면 배치와 권한 정책
Worksmobile 운영 화면은 `orgfront`가 아니라 `adminfront`의 tenant detail 하위에 둡니다. 데이터 성격상 tenant 관리자 도구이며, 실제 사용자 동선은 `hanmac-family` tenant detail에서 바로 확인할 수 있게 만드는 쪽이 맞습니다.
화면 노출 정책:
- 전역 메뉴에는 Worksmobile 메뉴를 추가하지 않습니다.
- `adminfront` tenant list에서 한맥가족 root tenant detail에 들어갔을 때만 Worksmobile 탭 또는 버튼을 표시합니다.
- 노출 조건은 tenant detail API 응답의 `slug == "hanmac-family"` 또는 동일한 canonical 식별자로 판단합니다.
- 별도 운영 URL을 새 탭으로 여는 방식은 허용합니다. 단, 새 탭 화면도 tenant-scoped route여야 하며 URL을 직접 입력해도 같은 권한 검사를 통과해야 합니다.
- `orgfront`는 필요 시 read-only sync badge나 adminfront deep link 정도만 담당하고, 생성/재시도/삭제 같은 운영 액션은 제공하지 않습니다.
권한 강제 정책:
- frontend의 탭 숨김은 UX 보조일 뿐이며 보안 경계로 보지 않습니다.
- backend endpoint는 `/api/v1/admin/tenants/:tenantId/worksmobile/*`처럼 tenant-scoped 형태로 둡니다.
- handler 또는 middleware에서 `tenantId`가 존재하는지, 해당 tenant가 정확히 `hanmac-family` root인지 먼저 확인합니다.
- 요청자는 `super_admin`이거나 `Tenant:{tenantId}`에 대한 관리 권한을 Keto로 통과해야 합니다. 기존 `RequireKetoPermission(..., "Tenant", "manage")` 또는 이에 준하는 relation을 사용합니다.
- user/orgunit 개별 동작은 대상 resource가 `hanmac-family` subtree 안에 있는지 추가로 확인합니다.
- Worksmobile `domainId`, `userExternalKey`, `orgUnitExternalKey`는 request body의 임의 입력을 신뢰하지 않고 server-side tenant config와 Baron UUID에서 계산합니다.
- tenant가 `hanmac-family`가 아니거나, 사용자가 해당 tenant를 관리할 수 없거나, 대상 resource가 subtree 밖이면 작업을 생성하지 않고 `403 Forbidden` 또는 존재 노출을 줄이는 `404 Not Found`로 차단합니다.
관리 화면에서 필요한 최소 기능:
- Worksmobile config/domain mapping 조회
- 조직/구성원별 최근 sync 상태와 마지막 오류 조회
- 단건 조직/구성원 sync enqueue
- 실패 작업 retry
- backfill dry-run 결과 조회 및 제한된 실행 버튼
## 삽입 지점
### 기존 계정 bulk/backfill
1. `hanmac-family` subtree tenant 전체를 읽습니다.
2. Worksmobile에 이미 존재하는 조직/구성원의 External Key Mapping을 먼저 수집하거나 Developer Console CSV mapping으로 보정합니다.
3. Baron tenant를 depth asc로 정렬해 `ORGUNIT UPSERT` outbox를 생성합니다.
4. Baron user를 `users.id` 기준으로 읽고 `USER UPSERT` outbox를 생성합니다.
5. `USER UPSERT`는 해당 사용자의 orgunit mapping이 processed인 뒤 실행합니다.
### 신규 tenant 생성
- 후보 위치: `TenantHandler.CreateTenant`에서 `replaceTenantDomains` 성공 후
- 더 좋은 위치: `TenantService.RegisterTenant`가 tenant/domain/config/outbox를 하나의 transaction으로 저장하도록 정리한 뒤 같은 transaction 안에서 `worksmobile_outbox`를 생성
- 조건: 생성된 tenant가 `hanmac-family` subtree 하위이고 type이 `COMPANY` 또는 `USER_GROUP`
### tenant 수정
- 후보 위치: `TenantHandler.UpdateTenant`에서 `h.DB.Save(&tenant)` 및 domain update 성공 후
- parent 변경 시 Worksmobile orgunit move 또는 patch가 필요합니다.
- 현재 `UpdateTenant`는 Keto parent outbox를 tenant 저장 전에 생성하므로, Worksmobile 이전에 outbox transaction 정합성 개선을 권장합니다.
### 신규 사용자 단건 생성
- 후보 위치: `UserHandler.CreateUser`에서 Kratos 생성, local DB sync, login ID sync, Keto outbox enqueue 후
- payload에는 `identityID`, email, name, phone, tenantID, metadata/additionalAppointments를 포함합니다.
- `hanmac-family` scope가 아니면 enqueue하지 않습니다.
### 신규 사용자 bulk 생성
- 후보 위치: `UserHandler.BulkCreateUsers`에서 row별 local DB sync와 Keto outbox enqueue 후
- row별 partial success를 유지하고, Worksmobile enqueue 실패는 사용자 생성 실패와 분리하는 것이 좋습니다.
- 단, enqueue 실패는 audit/error로 남기고 운영자가 재시도할 수 있어야 합니다.
### 사용자 수정/소속 변경
- 후보 위치: `UserHandler.UpdateUser`에서 Kratos update와 local DB sync 후
- `email`, `name`, `phone`, `companyCode`, `tenant_id`, `metadata.additionalAppointments` 변경 시 `USER UPSERT` enqueue
- `suspended`는 Worksmobile suspend로 동기화합니다.
- `temporary_leave`는 Worksmobile 계정을 유지합니다.
- `preboarding`은 Worksmobile 계정을 생성하지 않습니다.
- `baron_guest`, `extended_leave`, `archived`는 Worksmobile delete/deprovision으로 동기화합니다.
- Baron user delete는 Worksmobile delete로 동기화합니다.
- 기존 `inactive` 입력은 `preboarding`, `leave_of_absence` 입력은 `temporary_leave`, `baron_only` 입력은 `baron_guest`로 호환 처리합니다.
- backend bootstrap은 위 legacy `users.status` 값이 남아 있으면 canonical 상태값으로 자동 정규화합니다.
## 테스트 전략
기능 추가이므로 테스트를 먼저 작성합니다.
### Backend unit
- Worksmobile request mapper
- tenant UUID -> `orgUnitExternalKey`
- parent tenant -> `parentOrgUnitId = externalKey:{parentID}`
- Kratos UUID -> `userExternalKey`
- `additionalAppointments` -> `organizations[].orgUnits[]`
- #668 email final value 사용
- External Key validator
- `%`, `\`, `#`, `/`, `?` 포함 시 reject
- Domain mapping resolver
- email domain -> `domainId`
- missing mapping -> blocking error 또는 skipped job
### Backend repository/service
- `worksmobile_outbox` enqueue idempotency
- orgunit depth order enqueue
- user job이 orgunit processed 전에는 보류되는지 확인
- retry/backoff/status transition
- 409 conflict 시 get/patch 전환
### Handler
- `CreateTenant`가 한맥가족 subtree tenant 생성 시 `ORGUNIT UPSERT` job을 생성
- `CreateUser`가 한맥가족 사용자 생성 시 `USER UPSERT` job을 생성
- 한맥가족 외부 tenant는 job을 만들지 않음
- `BulkCreateUsers`에서 row별 성공 결과와 Worksmobile enqueue 결과가 분리됨
### Frontend unit
- CSV alias가 Worksmobile/Baron 컬럼을 정상 매핑하는지 확인
- `additionalAppointments`를 유지한 채 사용자 생성/수정 payload가 만들어지는지 확인
- tenant detail이 `hanmac-family` root일 때만 Worksmobile 탭/버튼을 표시하는지 확인
- non-hanmac tenant detail이나 직접 URL 접근에서 Worksmobile 운영 화면이 차단되는지 확인
### E2E/manual
- adminfront에서 `hanmac-family` 하위 조직 생성 -> outbox 생성 -> mock Worksmobile orgunit create 확인
- bulk import로 기존 계정 생성 -> #668 email preview -> Worksmobile user create mock 확인
- 신규 구성원 단건 생성 -> Worksmobile user create mock 확인
- orgfront 조직도 표시가 기존 `joinedTenants`/`additionalAppointments` 기준과 충돌하지 않는지 확인
- adminfront tenant list -> 한맥가족 detail -> Worksmobile 운영 화면 새 탭 -> 단건 sync 결과 확인
- non-hanmac tenant detail에서는 Worksmobile UI가 보이지 않고 직접 URL 접근도 backend에서 차단되는지 확인
## 주요 리스크와 선행 결정
1. `domainId` mapping 관리
- Worksmobile API는 `domainId`가 필수입니다.
- mapping 위치는 `tenant.config.worksmobile.domainMappings`로 결정했습니다.
- 개발/스테이징에서 검증한 값이 프로덕션에도 적용되어야 하므로 코드 레벨 seed/config로 추적 가능해야 합니다.
2. `additionalAppointments` backend 정규화
- 현재 frontend는 값을 보내지만 backend 단건 생성 path는 구조적으로 처리하지 않습니다.
- Worksmobile 연동 전 Baron 내부 membership과 metadata 정합성을 먼저 맞춰야 합니다.
3. transaction 정합성
- 현재 tenant create/update와 Keto outbox는 완전한 transactional outbox가 아닙니다.
- Worksmobile outbox는 신규 구현 시 DB 변경과 같은 transaction으로 enqueue되도록 설계하는 것이 좋습니다.
4. Worksmobile orgunit rate limit
- 조직 API는 도메인당 단일 스레드/1초 1회 제약이 있으므로 worker lane 설계가 필수입니다.
5. 기존 Worksmobile 데이터 backfill
- Developer Console External Key Mapping이 비어 있는 기존 조직/구성원은 CSV mapping 또는 API external-key update가 선행되어야 합니다.
6. 상태 정책
- Baron `active`, `temporary_leave`, `suspended`는 Worksmobile 구성원 비교 및 backfill scope에 포함합니다.
- Baron `suspended`는 Worksmobile suspend로 동기화합니다.
- Baron `preboarding`은 Worksmobile 계정을 생성하지 않습니다.
- Baron `baron_guest`, `extended_leave`, `archived`는 Worksmobile delete/deprovision으로 동기화합니다.
- Baron delete는 Worksmobile delete로 동기화합니다.
- 직급/직책/사용자 유형 External Key sync는 이번 scope에서 제외합니다.
7. adminfront 권한 경계
- Worksmobile 운영 화면은 `hanmac-family` root tenant detail에서만 보이게 합니다.
- 별도 URL/새 탭은 허용하지만 backend는 tenant slug, Keto 관리 권한, subtree membership을 모두 확인해야 합니다.
- UI hidden state만으로 접근 제어를 대신하지 않습니다.
## 권장 구현 순서
1. Worksmobile integration config와 `tenant.config.worksmobile.domainMappings` seed/config 정책 확정
2. `additionalAppointments` backend DTO/parser/Keto membership sync 정리
3. Worksmobile mapper unit test 작성 후 RED 확인
4. `worksmobile_outbox` repository/service/worker 구현
5. tenant orgunit enqueue 및 mock client GREEN
6. user enqueue 및 mock client GREEN
7. backfill command 또는 admin API dry-run 구현
8. adminfront 상태/재시도 UI 또는 최소 운영 조회 API 추가
9. E2E/mock integration 검증
## 문서 업데이트 후보
- `README.md`: 관리 데이터 import 정책에 Worksmobile sync 상태와 External Key 원칙 추가
- `docs/organization-chart-policy.md`: Worksmobile orgunit mapping 정책 추가
- `docs/tenant-usergroup-policy.md`: 외부 Directory sync outbox 정책 추가
- `docs/worksmobile-directory-sync-technical-review.md`: 본 문서를 위키 반영 전 검토본으로 유지

View File

@@ -0,0 +1,126 @@
# 한라 WORKS 도메인 분리 및 조직 연동 계획
## 현황 확인
- 로컬 seed 기준 `halla``hanmac-family` 직속 `COMPANY`입니다.
- 로컬 DB 기준 `halla``hanmac-family` 직속이 맞지만, 타입은 현재 `ORGANIZATION`입니다.
- 한라 하위 테넌트는 로컬 DB 기준 전체 43개입니다.
- 한라 직속 하위 조직은 10개입니다.
- 경영지원본부
- 기반사업본부
- 기술영업본부
- 시공현장
- 안전관리본부
- 업무총괄
- 영업총괄
- 운영사업소
- 임원실
- 환경플랜트사업본부
## 현재 코드의 문제
현재 WORKS 도메인 분류는 다음 네 가지 도메인만 알고 있습니다.
- `SAMAN_DOMAIN_ID`
- `HANMAC_DOMAIN_ID`
- `GPDTDC_DOMAIN_ID`
- `BARONGROUP_DOMAIN_ID`
`HALLA_DOMAIN_ID`가 없기 때문에 `halla``hallasanup.com`은 별도 도메인 루트로 판정되지 않습니다. 이 상태에서 조직 연동을 실행하면 한라 하위 조직이 HALLA 도메인이 아니라 fallback 도메인으로 분류될 수 있습니다.
또한 로컬 DB에서 `halla` 타입이 `ORGANIZATION`이면 “별도 회사 도메인 루트”라는 의미가 약합니다. seed와 실제 DB를 `COMPANY`로 맞추는 마이그레이션이 필요합니다.
## 목표 구조
Baron 내부 구조:
- `hanmac-family`
- `halla` (`COMPANY`, WORKS domain root, `HALLA_DOMAIN_ID`, `hallasanup.com`)
- 한라 하위 조직들 (`ORGANIZATION`)
한맥가족 직속 회사/그룹 배치 순서:
1. `gpdtdc` - 총괄기획&기술개발센터
2. `saman` - 삼안
3. `hanmac` - 한맥기술
4. `baron-group` - 바론그룹
5. `halla` - 한라산업개발
WORKS Mobile 구조:
- `HALLA_DOMAIN_ID`
- 한라 depth 1 조직은 HALLA 도메인의 최상위 org unit으로 생성합니다.
- 한라 depth 2 이상 조직은 Baron의 부모 조직 external key를 따라 하위 org unit으로 생성합니다.
- `halla` 회사 테넌트 자체는 WORKS org unit으로 만들지 않습니다. 도메인 루트 분류 기준으로만 사용합니다.
예상 매핑:
- `halla-mgmt-support-hq` -> `HALLA_DOMAIN_ID`, `parentOrgUnitId=""`
- `halla-mgmt-support` -> `HALLA_DOMAIN_ID`, `parentOrgUnitId="externalKey:d656c134-a50b-43b9-8c2d-fb3738dd0f9f"`
- `site-gyeongsan-road` -> `HALLA_DOMAIN_ID`, 실제 Baron parent external key 유지
## 구현 계획
1. 도메인 루트 판정 추가
- `isWorksmobileDomainRootTenant``halla`, `hallasanup.com`, `한라산업개발`을 추가합니다.
- `worksmobileTenantDomainIDEnvKey`가 한라 테넌트를 `HALLA_DOMAIN_ID`로 반환하도록 추가합니다.
2. 이메일 도메인 판정 추가
- `ResolveWorksmobileAccountDomainIDFromEmail``hallasanup.com -> HALLA_DOMAIN_ID`를 추가합니다.
- `worksmobileDomainIDEnvKeyFromEmail`에도 같은 매핑을 추가합니다.
- 사용자 주 이메일 또는 alias가 `hallasanup.com`이면 HALLA 계정/조직 이메일로 매핑되도록 검증합니다.
3. WORKS remote 조회 범위 추가
- `worksmobileDomainEnvMappings``HALLA_DOMAIN_ID`와 label `한라산업개발`을 추가합니다.
- `WorksmobileDomainIDsFromEnv`가 HALLA 도메인도 remote user/group 조회 대상으로 포함해야 합니다.
4. 로컬 데이터 마이그레이션
- `halla``hanmac-family` 직속인지 확인합니다.
- `halla` 타입을 `COMPANY`로 맞춥니다.
- `halla` 도메인을 `hallasanup.com`으로 유지합니다.
- 필요한 경우 `halla` 관련 기존 WORKS outbox pending 작업을 정리하거나 재등록합니다.
5. 조직 연동 순서
- 먼저 comparison dry-run으로 HALLA 도메인 예상값을 확인합니다.
- 한라 하위 조직만 대상으로 org unit upsert를 등록합니다.
- WORKS에서 같은 external key가 다른 도메인에 이미 붙어 있으면 기존 도메인 external key를 clear한 뒤 HALLA 도메인에 재등록합니다.
- 조직 연동 성공 후 사용자 연동을 진행합니다.
6. 사용자 연동 기준
- Baron representative/primary가 `halla` 또는 한라 하위 조직이면 `HALLA_DOMAIN_ID` 조직 membership을 생성합니다.
- 주 이메일이 `hallasanup.com`이면 HALLA domain account로 생성합니다.
- 다른 회사 주 이메일을 가진 겸직 사용자는 계정 domain은 주 이메일 기준으로 유지하고, HALLA 조직은 `organizations[]`의 추가 조직으로 매핑합니다.
## 테스트 계획
- `worksmobile_mapper_test.go`
- `halla``hallasanup.com``HALLA_DOMAIN_ID`로 resolve되는지 검증합니다.
- `hallasanup.com` 이메일이 HALLA account domain으로 resolve되는지 검증합니다.
- `WorksmobileDomainIDsFromEnv`에 HALLA 도메인이 포함되는지 검증합니다.
- `worksmobile_sync_service_test.go`
- 한맥가족 직속 회사 `halla`를 domain root로 판정하는 테스트를 추가합니다.
- 한라 depth 1 조직은 `parentOrgUnitId=""`로 생성되는지 검증합니다.
- 한라 depth 2 조직은 `parentOrgUnitId="externalKey:<parent>"`로 생성되는지 검증합니다.
- comparison에서 같은 external key가 다른 domain에 있으면 HALLA domain 기준으로 update/rekey 대상이 되는지 검증합니다.
- live/E2E
- `HALLA_DOMAIN_ID`가 설정된 환경에서 한라 하위 조직 org unit provisioning dry-run을 실행합니다.
- 실제 upsert 후 worksmobile 메뉴의 최근 작업에서 processed/failed 및 실패 사유를 확인합니다.
## 운영 순서 제안
1. `HALLA_DOMAIN_ID` 환경값을 dev/local에 먼저 설정합니다.
2. 코드에 HALLA 도메인 분류를 추가하고 테스트를 통과시킵니다.
3. 로컬 DB의 `halla` 타입을 `COMPANY`로 마이그레이션합니다.
4. worksmobile comparison에서 한라 하위 조직만 필터링해 예상 도메인과 parent를 확인합니다.
5. 조직 upsert를 먼저 수행합니다.
6. 실패 작업이 있으면 최근 작업 이력에서 원인을 확인하고 external key 충돌부터 해소합니다.
7. 조직이 모두 정상 처리된 뒤 사용자 sync를 진행합니다.
## 주의점
- `halla` 회사 테넌트 자체를 org unit으로 만들면 HALLA 도메인 최상위에 “한라산업개발” 조직이 중복으로 생길 수 있습니다.
- 기존에 한라 하위 조직 external key가 `BARONGROUP_DOMAIN_ID`에 생성되어 있으면, WORKS API가 같은 external key의 다른 domain 중복을 허용하지 않을 수 있습니다.
- 사용자 sync는 조직 upsert가 끝난 뒤 진행해야 `organizations[].orgUnits[].orgUnitId` 참조 실패를 줄일 수 있습니다.
- 로컬 DB 타입과 seed 타입이 다르면 이후 seed/마이그레이션 테스트가 계속 흔들릴 수 있으므로 DB 보정이 먼저 필요합니다.

File diff suppressed because it is too large Load Diff