# 킨텍스 AI 전시·행사시스템 — Open SSO · 인사(조직) API 연동 아키텍처 > 산출: 시스템 아키텍트(SA) · 작성일: 2026-07-12 · 대상: 소유자 지시("직원 인사(조직)정보는 API 방식, Open SSO 도입") > 정합 근거: [`system.md`](system.md)(SA §5·§6 인증/연동), [`app.md`](app.md)(AA 인증 계약), [`network.md`](network.md)(NA 존/아웃바운드), [`data.md`](data.md)(DA 마스터/소프트참조), [`../PLANNING.md`](../PLANNING.md) §2(6역할)·§5B(공통·시스템관리)·§8, `../DEVELOPMENT_GUIDE.md` §4·§5 > **본 문서는 설계·계약·이행계획만 정본. src·기존 인증코드 미수정(파괴적 구현 금지).** 실제 구현은 인계 백로그(§9)로 각 dev 트랙에 위임. > 확정 스택(불변): React 18/19(Vite·TS) + Spring Boot 3.x(Java 17)+MyBatis + PostgreSQL(PostGIS) + Redis. 신규 IdP(Keycloak)는 **온프레미스 인프라 구성요소**로 추가(스택 위반 아님). --- ## 0. 요지 (Executive Summary) | 항목 | 결정 | 근거 | |---|---|---| | **인증 프로토콜** | **OIDC(우선) + SAML 2.0(옵션)** — Authorization Code + PKCE | 표준·웹/모바일 공통·JWKS 검증. SAML은 고객사 레거시 IdP 대비 폴백 | | **IdP** | **Keycloak(자체 호스팅, 온프레미스)** | 오픈소스·OIDC/SAML/그룹·클레임 매핑·2FA·back-channel 로그아웃 내장. 외부 인터넷 API 아님(내부망 IdP) | | **기존 JWT+2FA 관계** | **삭제·교체 금지. 공존(coexist)** — SSO를 1차 인증, 앱 JWT를 세션토큰으로 **브로커 발급**. 로컬 로그인은 폴백으로 유지 | W12 자산(app_user·event_member·TotpService) 보존, 무중단 이행 | | **2FA** | IdP가 OTP 제공 시 **위임**(앱 OTP 강제 해제), 미제공 시 앱 TOTP 유지 | 중복 2FA 방지·기존 `TwoFactorService` 재사용 | | **인사·조직** | 로컬 마스터 유지가 아니라 **`HrDirectoryClient` 어댑터로 외부 HR API 조달** + 로컬 **읽기 캐시 스냅샷**(HR 미가용 폴백) | HR 정본화, dept/sys_user는 캐시로 전환(멱등 upsert 배치) | | **3자 매핑** | **SSO subject ↔ HR 사번(empNo) ↔ 로컬 user(app_user.id)** 매핑 테이블 신설 | 계정·조직·권한 정합 단일 키 체계 | | **이행** | **coexist → pilot(파일럿 부서) → cutover(SSO 우선) → 로컬로그인 축소** 4단계 + 롤백 게이트 | 파괴 없는 점진 전환 | **보안 정합(불변):** Open SSO(자체 IdP)·HR API(고객 내부망 인사시스템)는 **온프레미스/내부 통합 → 외부 인터넷 API 금지 규칙에 해당하지 않음**(Claude/Gemini 예외와 별개 범주). 단, 토큰·클라이언트 시크릿·HR 자격증명은 **env/시크릿 스토어 only**, 응답·로그·커밋 미노출, 마스킹 유지. --- ## 1. 현행(As-Is) 인증·조직 기준선 설계는 기존 코드를 **읽어 확인한 실제 계약** 위에 얹는다(교체 없음). | 자산 | 실체 | 본 설계에서의 처리 | |---|---|---| | `app_user`(V2) | id·email·display_name·password_hash(BCrypt)·hall_manager·otp_secret·failed_login_count·locked_until·status | **보존.** 로컬 계정 정본 → 이행 후 "SSO 매핑 대상 + 로컬 폴백" | | `event_member`(V2) | 행사 RBAC(ORGANIZER·EXHIBITOR·CONTRACTOR·HALL_MANAGER, booth/company 스코프) | **보존.** IdP는 행사 역할을 모름 → 행사 역할은 계속 로컬 권위 | | `app_user.role_code`(V31) | 전역 역할(rc 클레임) | IdP 그룹/클레임 → 전역 역할 매핑 소스로 확장 | | `dept`(V37) | 부서 트리(tenant_id·id·parent_id·sort_order·use_yn) | **HR 정본 시 읽기 캐시로 전환**(스냅샷 upsert) | | `company`(V2/V37) | 등록업체(M7 응찰 게이트) | 불변(HR 무관 — 외부 파트너) | | `JwtService` | HS256, 클레임 sub·name·roles(eventId→role)·hm·tid·rc | **불변.** SSO 성공 후 이 서비스로 **동일 형태 앱 JWT를 브로커 발급**(다운스트림 RBAC 무변경) | | `TwoFactorService`/`TotpService`/`LoginAttemptService` | 로컬 2FA·잠금·챌린지 | IdP 2FA 위임 시 우회, 로컬 폴백 시 유지 | | `JwtAuthenticationFilter` | Bearer 앱JWT → SecurityContext | **불변.** SSO 도입해도 백엔드 보호경로는 계속 앱 JWT 검증(핵심 무중단 포인트) | > **설계 핵심 원칙:** SSO는 **로그인 게이트웨이만 교체**한다. 로그인 성공의 산출물은 여전히 "기존 형태의 앱 JWT"이므로, 84개 화면·전 RBAC 가드·행사 스코프 로직은 **한 줄도 바뀌지 않는다**. HR API는 **조직 마스터의 데이터 출처만 교체**한다(dept 트리 소비자 무변경). --- ## 2. 설계 A — Open SSO 도입 (OIDC / Keycloak) ### 2-1. 채택 결정 (ADR) | # | 결정 | 이유 | 대안·트레이드오프 | |---|---|---|---| | A1 | **OIDC 우선**(SAML 옵션) | 웹+모바일(expo-auth-session) 동일 표준, JSON/JWKS, PKCE로 공개 클라이언트 안전 | SAML-only(모바일·SPA 부적합) 배제. 고객 레거시가 SAML뿐이면 Keycloak가 SAML IdP↔OIDC 브로커로 흡수 | | A2 | **Keycloak 자체 호스팅** | 오픈소스·온프레미스·그룹/클레임 매핑·OTP·Identity Brokering(외부 AD/LDAP/SAML 연합)·back-channel 로그아웃 | 상용 IdP(비용·외부 SaaS=보안 위반) 배제. 경량 대안(자체 OIDC 구현)은 표준 준수·유지비 열위 | | A3 | **브로커드 세션토큰**(IdP 토큰 → 앱 JWT 교환) | 다운스트림 RBAC·행사 스코프·tid/rc 클레임 무변경, 무상태 유지 | IdP 액세스토큰 직접 신뢰(리소스서버 모드)는 **행사 RBAC 클레임 부재**로 전 가드 재작성 필요 → 이행기엔 배제(§2-6 장기 옵션으로만) | | A4 | **로컬 로그인 폴백 유지** | IdP 장애 시 관리자·핵심 운영 지속(가용성 NFR) | IdP 단일 의존(장애 시 전면 로그인 불가) 배제 | **IdP 후보 근거:** Keycloak(1순위, 위 A2) / 대안 Authentik·ZITADEL(경량·최신 UX이나 조직 내 운영 실적·AD 연합 성숙도에서 Keycloak 우위) / Gluu·Ory Hydra(각각 무거움·인증 파트 분리 필요). **킨텍스 온프레미스·AD 연합·SAML 흡수** 요건에서 Keycloak 채택. ### 2-2. 토폴로지 (존 배치) Keycloak는 **내부 애플리케이션 존**에 배치하고, 브라우저 리다이렉트를 위해 **리버스 프록시(nginx)의 별도 vhost**(`auth.`)로만 외부 노출한다. HR API·AD 연합은 IdP↔내부망 구간. ```mermaid graph TB subgraph EDGE["엣지 / DMZ (nginx TLS 종단)"] RP[리버스 프록시
app.· auth.· api. vhost] end subgraph APPZ["애플리케이션 존 (내부망)"] FE[역할별 프론트 SPA + 공개/관람객] BE[공유 백엔드 Spring Boot × N
OIDC RP + 앱JWT 브로커] KC[Keycloak IdP
realm=KINTEX · OIDC/SAML · OTP] end subgraph DATAZ["데이터 존 (최심부)"] PG[(PostgreSQL+PostGIS
app_user·매핑·조직 캐시)] KCDB[(Keycloak DB
별도 스키마/DB)] RED[(Redis
state·nonce·세션상관)] end subgraph HRZONE["고객 내부망 (연동 대상)"] AD[AD / LDAP
직원 계정] HRAPI[HR 인사시스템 API
조직·직원 마스터] end FE -->|1 로그인 리다이렉트| RP --> KC KC -.연합(선택).-> AD FE -->|2 code+PKCE 콜백| RP --> BE BE -->|3 code→token 교환·JWKS 검증| KC BE -->|4 앱 JWT 발급| FE BE --> PG BE -.HrDirectoryClient(배치·조회).-> HRAPI KC --> KCDB BE --> RED ``` **배치 규칙** - Keycloak DB는 앱 PG와 **별도 데이터베이스/스키마**(계정 데이터 격리·백업 주기 분리). - `auth.` vhost는 IdP UI/토큰 엔드포인트만 프록시. 백오피스(admin.)와 동일하게 **접근 제한 정책**은 NA 확정. - 아웃바운드: Keycloak→AD/LDAP, 백엔드→HR API는 **내부망 구간**(인터넷 egress 아님). NA egress 화이트리스트에 인터넷 목적지 추가 없음. ### 2-3. 로그인 시퀀스 (Authorization Code + PKCE → 앱 JWT 브로커) ```mermaid sequenceDiagram participant U as 사용자(브라우저/앱) participant FE as 프론트(SPA/Expo) participant BE as 백엔드(OIDC RP) participant KC as Keycloak(IdP) U->>FE: 로그인 클릭 FE->>FE: PKCE code_verifier/challenge, state, nonce 생성 FE->>KC: /authorize (code, PKCE challenge, redirect_uri) KC->>U: 로그인 폼(+IdP OTP if 위임) U->>KC: 자격증명(+OTP) KC->>FE: redirect_uri?code=...&state=... FE->>BE: POST /api/auth/oidc/callback (code, code_verifier, state) BE->>KC: token 교환(code + code_verifier + client_secret) KC->>BE: id_token + access_token (JWT) BE->>KC: JWKS 공개키(캐시) — id_token 서명·iss·aud·nonce·exp 검증 BE->>BE: subject(sub)로 매핑 조회 → 로컬 user 해석/JIT 프로비저닝(§2-5) BE->>BE: event_member 행사역할·hall_manager·tid·rc 조립 BE->>FE: 앱 JWT(JwtService.issue) + 워크스페이스(기존 LoginResponse 형태) FE->>FE: 기존 저장키에 앱 JWT 저장 — 이후 전 API는 기존 그대로 ``` **핵심:** 5~7단계 산출물 = **현행 `LoginResponse`와 100% 동일**. `/api/auth/oidc/callback`만 신설, 나머지 전 경로 불변. ### 2-4. 토큰 갱신·로그아웃 시퀀스 | 흐름 | 설계 | |---|---| | **앱 JWT 갱신** | 현행 TTL 정책 유지. 만료 시 프론트가 **silent OIDC 재인증**(prompt=none, IdP 세션 유효 시 무마찰) → 백엔드가 앱 JWT 재브로커. 리프레시 토큰은 백엔드가 보관(HttpOnly·서버측), 프론트 미노출 | | **IdP 세션 만료** | silent 재인증 실패 → 로그인 화면. 앱 JWT 짧게, IdP 세션이 마스터 수명 | | **로그아웃** | 프론트 로컬 토큰 파기 + `/api/auth/oidc/logout` → Keycloak end-session(`id_token_hint`). **back-channel logout**: Keycloak → 백엔드 `/api/auth/oidc/backchannel-logout`(로그아웃 토큰 검증) → Redis 세션상관 무효화(다중 인스턴스 팬아웃) | | **단일 로그아웃(SLO)** | Keycloak realm SSO 로그아웃으로 연동 클라이언트 전파(웹·모바일·타 GUARDiA 연계 시) | ### 2-5. 계정 매핑 · JIT 프로비저닝 (SSO subject ↔ 로컬 user) ```mermaid graph LR SUB[OIDC sub
=IdP subject] --> MAP[user_identity 매핑] EMP[HR empNo
=사번] --> MAP LU[app_user.id
=로컬 user] --> MAP MAP --> RESOLVE{로컬 user 존재?} RESOLVE -->|Yes| LOGIN[역할 조립 → 앱 JWT] RESOLVE -->|No| JIT[JIT 프로비저닝
app_user upsert + 매핑 생성] JIT --> LOGIN ``` - **매핑 키:** IdP `sub`(불변 식별자) 우선. 보조 매칭키 = 이메일 + HR 사번(empNo, IdP 커스텀 클레임). **이메일 단독 매칭 금지**(재사용·변경 위험) — sub↔empNo 확정 후 email은 표시용. - **JIT 프로비저닝:** 첫 SSO 로그인 시 매핑 없으면 `app_user`를 **password_hash 없이(SSO 전용 플래그)** 생성 + `user_identity` 매핑 생성. 로컬 폴백 비번은 미설정(SSO 전용 계정). - **충돌 규칙:** 동일 이메일에 기존 로컬 계정 존재 → **자동 병합 금지, 관리자 수동 링크**(계정 탈취 방지). `link_status`(UNLINKED/LINKED/CONFLICT). - **역할 소스:** 전역 역할(rc)·hall_manager = IdP 그룹/클레임 매핑(§2-7). **행사 역할(event_member)은 로컬 권위 유지**(IdP는 행사를 모름). ### 2-6. 기존 JWT+2FA 공존안 (핵심) | 관심사 | 공존 규칙 | |---|---| | **1차 인증** | Keycloak(OIDC). 성공 후 백엔드가 **기존 `JwtService.issue()`로 앱 JWT 브로커** — 다운스트림 무변경 | | **로컬 로그인** | `/api/auth/login`·`/login/secure` **유지**(폴백). 관리자·핵심 운영은 IdP 장애 시 로컬 폴백 허용. 일반 사용자는 이행 단계별로 SSO 우선/로컬 차단(§8) | | **2FA** | IdP realm이 OTP 요구 시 → 앱 `requiresOtp` 위임(앱 OTP 강제 해제, 이중 2FA 방지). IdP OTP 미구성 계정·로컬 폴백 로그인 → **기존 `TwoFactorService` 그대로** | | **admin 비번** | `ADMIN_PASSWORD_ENC` env 시드 정책 **유지**(IdP 장애 시 최후 관리자 접근) | | **토큰 신뢰 모드** | 이행기 = **브로커 모드**(앱 JWT). 장기 옵션 = 리소스서버 모드(IdP 토큰 직접, 커스텀 클레임 매퍼로 행사역할 주입) — **행사 RBAC 재검증 부담으로 이행 완료 후 별도 과제**(A3) | **공존 상태 판정 로직(개념):** ``` 로그인 요청 ├─ SSO 경로(/oidc/callback): IdP 검증 성공 → 매핑 해석 → 앱 JWT (2FA는 IdP 위임) └─ 로컬 경로(/login, /login/secure): 기존 TwoFactorService (단계별 허용범위 §8) 다운스트림(전 API): 기존 JwtAuthenticationFilter가 앱 JWT만 검증 — 경로 무관 동일 ``` ### 2-7. 멀티테넌트·역할 매핑 - **tenant:** realm=단일(`KINTEX`) 또는 realm 그룹 클레임 `tenant`. 앱 `tid` 클레임 = IdP `tenant` 클레임(부재 시 기준 테넌트 `KINTEX` 폴백, 현행 `normalizeTenant` 규칙 준수). - **역할 매핑표(IdP 그룹/클레임 → 앱 역할):** | IdP 그룹/클레임 | 앱 전역 역할(rc) | hall_manager | 트랙 | |---|---|---|---| | `/kintex/admin` | ADMIN | - | 내부 백오피스 | | `/kintex/manager` | MANAGER | - | 내부 운영 | | `/kintex/hall-manager` | (rc null) | true | 내부 운영(홀) | | `/kintex/staff` | STAFF | - | 내부 직원 | | (매핑 없음·외부) | null | false | 행사역할은 event_member로 | - **6역할 정합:** 주최자·참가업체·장치/공사업체는 대개 **외부**(HR 대상 아님) → SSO 계정이라도 행사 역할은 `event_member` 초대로만 부여. 관람객/일반대중은 셀프서비스(SSO 대상 아님·별도 트랙 §8 note). ### 2-8. 웹 + 모바일 플로우 | 클라이언트 | 라이브러리 | 리다이렉트 URI | 특이사항 | |---|---|---|---| | **웹 SPA(역할별 6종)** | `oidc-client-ts` 또는 백엔드 BFF 콜백 | `https://./auth/callback` | PKCE. 역할별 번들마다 클라이언트 등록(또는 와일드카드 redirect + 역할 라우팅) | | **모바일(Expo)** | `expo-auth-session`(PKCE 기본) | `kintexai://auth/callback` (앱스킴) + `https:///auth/native-callback`(유니버설 링크 폴백) | 공개 클라이언트(시크릿 없음)=PKCE 필수. B2B 앱=SSO+IdP OTP, B2C 관람객 앱=SSO 대상 아님(셀프서비스 유지) | - **클라이언트 등록(Keycloak):** `kintex-web`(confidential, BFF 콜백) / `kintex-mobile`(public, PKCE). redirect_uri·web_origins(CORS)·post_logout_redirect 화이트리스트 등록. - **모바일 레퍼런스:** WISE 모바일(`workspace/guardia-messenger/app/uiws`) 2FA 로그인 화면 컨벤션 위에 expo-auth-session 브라우저 플로우 삽입(디자인은 Stitch 경유 — MEMORY 준수). --- ## 3. 설계 B — 인사(조직) API 연동 (HrDirectoryClient) ### 3-1. 채택 결정 (ADR) | # | 결정 | 이유 | |---|---|---| | B1 | **어댑터 패턴 `HrDirectoryClient` 인터페이스 + 고객 HR 구현** | 고객사별 HR API 상이 → 인터페이스로 격리, 구현만 교체. Mock 구현으로 개발·폴백 | | B2 | **로컬 읽기 캐시 스냅샷 유지**(HR 미가용 폴백) | HR 장애·야간 배치 실패에도 조직도·직원조회 지속(가용성). HR=정본, 로컬 dept/직원=파생 캐시 | | B3 | **증분 동기화(배치) + 실시간 조회 혼합** | 조직 트리·직원 마스터는 배치 스냅샷(멱등 upsert), 로그인 순간 신원 확인은 실시간 조회(선택) | | B4 | **PII 최소 수집·마스킹** | 직원 개인정보 경계(SA §5-3). 필요 필드만 수집, 연락처·주민식별 미수집/마스킹 | ### 3-2. 어댑터 계약 (인터페이스 · DTO) **인터페이스(개념 시그니처 — backend-dev 구현):** | 메서드 | 목적 | 동기성 | |---|---|---| | `List fetchOrgTree(tenantId, sinceRev?)` | 조직(부서) 트리 — 전체 또는 변경분 | 배치 | | `Page fetchEmployees(tenantId, sinceRev?, page)` | 직원 마스터 — 전체/증분 페이지 | 배치 | | `Optional findByEmpNo(tenantId, empNo)` | 로그인·매핑 시 단건 신원 확인 | 실시간(선택) | | `SyncCursor currentRevision(tenantId)` | 증분 동기화 커서(변경 기준점) | 배치 | **표준 DTO(계약·PII 최소):** ``` HrDept { empDeptCode, deptName, parentDeptCode, sortOrder, useYn, revision } HrEmployee { empNo, // 사번 = 매핑 정본 키 name, // 표시명 email, // SSO sub 보조 매칭·표시 deptCode, // 소속 부서(HrDept.empDeptCode 참조) positionCode, // 직급(공통코드 POSITION 매핑) employmentStatus, // 재직상태 ACTIVE|LEAVE|RETIRED revision // 증분 동기화 기준 // 미수집: 주민번호·개인 연락처·급여 등 (PII 최소) } ``` - **원격/캐시 이원화:** 서비스 계층은 항상 `HrDirectoryClient`를 부르되, HR 실패 시 `HrSnapshotRepository`(로컬 캐시)로 폴백(`degraded=true` 표기). 소비자(조직도·직원선택 컴포넌트)는 출처를 모름. ### 3-3. 동기화 전략 ```mermaid sequenceDiagram participant SCH as 스케줄러(배치) participant HC as HrDirectoryClient participant HR as 고객 HR API participant SNAP as 로컬 스냅샷(hr_dept_snapshot·hr_emp_snapshot) participant DEPT as dept(파생 캐시) SCH->>HC: fetchOrgTree(sinceRev) / fetchEmployees(sinceRev) HC->>HR: GET 조직·직원 변경분 alt HR 정상 HR->>HC: 변경 레코드 HC->>SNAP: 멱등 upsert(revision 기준) SNAP->>DEPT: dept 트리 파생 반영(use_yn·parent 매핑) else HR 미가용 HC->>SNAP: 기존 스냅샷 유지(degraded) Note over DEPT: 조직도·직원조회는 마지막 스냅샷으로 계속 서비스 end ``` - **주기:** 조직 트리·직원 = 야간 전체 리컨실 + 주간(수시) 증분. 로그인 시점 신원은 `findByEmpNo` 실시간(선택, 실패 시 스냅샷). - **멱등:** `revision`/`empNo`/`empDeptCode` 기준 upsert. 삭제는 **소프트**(useYn='N', employmentStatus=RETIRED) — 물리삭제 금지(감사·과거 참조 보존). - **정본 전환:** HR 정본화 후 `dept`·직원정보는 **읽기 전용 캐시**. 백오피스 조직 편집 UI는 "HR 마스터 편집 안내"로 전환(직접 수정 차단), 단 로컬 전용 부서(외부 파트너 그룹핑 등)는 `source='LOCAL'` 플래그로 공존 허용. ### 3-4. 기존 dept/sys_user 매핑 | 현행 | HR 연동 후 | |---|---| | `dept`(로컬 편집) | `source` 컬럼 추가 → `HR`(캐시, 읽기전용) / `LOCAL`(로컬 전용, 편집가능) 공존. HR 파생행은 배치만 갱신 | | `sys_user`(=app_user 조회) | 직원 신원(name·dept·position)은 **HR 스냅샷 조인**으로 표시, `app_user`는 로그인/권한 최소필드만 | | 조직도 화면(WISE 트리) | 데이터 출처만 HR 스냅샷 → 컴포넌트 무변경 | --- ## 4. 3자 매핑 통합 규칙 (SSO subject ↔ HR 사번 ↔ 로컬 user) | 키 | 소유 | 역할 | |---|---|---| | `sub`(OIDC subject) | Keycloak | 로그인 신원 불변 식별자 | | `empNo`(사번) | HR API | 직원·조직 정본 키(직급·부서·재직상태) | | `app_user.id` | 로컬 | 앱 계정·권한(event_member·hall_manager·tid) 앵커 | - **연결 테이블 `user_identity`**(§5): (tenant_id, app_user_id) ↔ (idp_sub, emp_no) + link_status. - **로그인 해석 순서:** `sub` → user_identity 조회 → app_user 해석 → (선택) empNo로 HR 스냅샷 조인(부서·직급 최신화) → 역할 조립 → 앱 JWT. - **불일치 처리:** sub 있으나 empNo 없음(외부 SSO 사용자) 허용 / empNo 있으나 sub 없음(미로그인 직원) 허용(사전 프로비저닝) / 이메일 충돌 = CONFLICT(관리자 수동 링크). --- ## 5. 데이터 모델 (신설 — DA 정합, 소프트참조 표준) > 신규 테이블만. **기존 테이블 변경 최소**(dept에 `source` 컬럼 순증만). 테넌트 표준(V31: tenant_id DEFAULT 'KINTEX' 복합 PK 선두)·소프트참조(FK 남발 금지, 애플리케이션 조인) 준수. 실제 DDL은 db-engineer 인계(§9). | 테이블 | 목적 | 핵심 컬럼(개념) | |---|---|---| | `user_identity` | 3자 매핑 | (tenant_id, app_user_id) PK, idp_sub UNIQUE, emp_no, link_status, provider, linked_at | | `hr_dept_snapshot` | HR 조직 캐시 | (tenant_id, emp_dept_code) PK, dept_name, parent_dept_code, sort_order, use_yn, revision, synced_at | | `hr_emp_snapshot` | HR 직원 캐시 | (tenant_id, emp_no) PK, name, email, dept_code, position_code, employment_status, revision, synced_at | | `hr_sync_log` | 배치 감사 | (tenant_id, id) PK, kind(DEPT/EMP), rows_upserted, result, degraded, started_at, finished_at | | `dept`(순증) | 출처 구분 | `source varchar(10) DEFAULT 'LOCAL'`('HR'|'LOCAL') 컬럼 추가(멱등 ALTER) | - **PII 경계:** hr_emp_snapshot는 name·email·dept·position·status만. 연락처·식별번호 미보관. email은 표시·매칭용(마스킹은 로그·감사 계층). - **소프트참조:** emp_no·emp_dept_code·idp_sub는 **소프트 참조**(FK 미설정) — HR/IdP 외부 키를 물리 FK로 묶지 않음(배치 순서·정합 유연성). 공통코드(POSITION·EMPLOYMENT_STATUS)는 common_code 순증. --- ## 6. NFR 정합 (SA system.md §3) | NFR | SSO/HR 반영 | |---|---| | **확장성** | 백엔드 무상태 유지(OIDC state/nonce는 Redis 외부화). Keycloak는 클러스터 가능. HR 배치는 백엔드와 분리 스케줄(응답성 무영향) | | **가용성/HA** | **로컬 로그인 폴백**(IdP 장애)·**HR 스냅샷 폴백**(HR 장애) 이중 degraded 모드로 코어 기능 유지. Keycloak 최소 2노드 + DB HA | | **성능** | JWKS 공개키 캐시(매 요청 IdP 미호출). 로그인만 IdP 왕복, 이후 앱 JWT 로컬 검증(현행 성능 무변경). HR은 배치 오프라인 | | **보안영역** | Keycloak=내부 애플리케이션 존, `auth.` vhost만 노출. HR/AD=내부망 구간(인터넷 egress 무증가). 토큰·시크릿 env only | | **용량** | user_identity·hr_*_snapshot는 직원 규모(수백~수천) 소량. hr_sync_log 보존정책(N개월) | --- ## 7. 보안 · 개인정보 (불변 준수) - **온프레미스 판정:** Keycloak(자체 IdP)·HR API(고객 내부 인사시스템)는 내부망 통합 → **외부 인터넷 API 금지 규칙 비해당**. NA egress 화이트리스트에 인터넷 목적지 신규 추가 없음. - **시크릿:** OIDC client_secret·HR API 자격증명·리프레시 토큰 = **env/시크릿 스토어 only**(systemd EnvironmentFile drop-in, `ADMIN_PASSWORD_ENC` 방식 준용). DB·코드·로그·커밋·API응답 미기록. - **토큰 노출 금지:** id_token/access_token/refresh_token은 응답 본문·로그 미노출. 프론트에는 앱 JWT만(리프레시는 서버 보관). - **감사:** 계정 링크/언링크·JIT 프로비저닝·HR 배치·권한 매핑 변경 = `TB_AUDIT_LOG` 전수. 로그인 성공/실패는 기존 `login_history`(이메일/IP 마스킹) 재사용. - **PII 최소화:** HR 수집 필드 최소, 삭제권·재직상태 반영(RETIRED 소프트), 보존정책 DA 확정. - **검증:** id_token 서명(JWKS)·iss·aud·exp·nonce, code 교환 시 code_verifier(PKCE), state(CSRF), back-channel logout token 검증 필수. --- ## 8. 이행 계획 (coexist → pilot → cutover → 롤백) | 단계 | 범위 | 로컬 로그인 | 완료 기준(게이트) | 롤백 | |---|---|---|---|---| | **0. 준비** | Keycloak 배치(realm/클라이언트)·매핑 테이블·HrDirectoryClient(Mock)·백엔드 `/oidc/*` 엔드포인트(피처플래그 `sso.enabled=false`) | 전면 허용(현행) | staging OIDC 왕복·앱 JWT 브로커·회귀 0 | 플래그 off = 완전 현행 | | **1. coexist** | SSO 로그인 **옵션** 노출(로그인 화면 "SSO로 로그인" 병행). JIT 프로비저닝·수동 링크 UI | 전면 허용 | 파일럿 계정 SSO 로그인·역할 정합·2FA 위임 검증 | SSO 버튼 숨김 | | **2. pilot** | **1개 부서(내부 직원)** SSO 우선 + HR 배치 실연동(스냅샷→dept). 로컬은 폴백만 | 파일럿 외 허용, 파일럿은 폴백 | HR 스냅샷 정합·조직도 HR 출처·degraded 폴백 동작 | 파일럿 부서 로컬 복귀 | | **3. cutover** | 전 내부 직원 SSO 필수(로컬은 관리자·비상만). HR 정본화(dept 읽기전용) | 관리자·비상만 | 전 직원 SSO·HR 정본·이중 2FA 없음·감사 완비 | IdP 장애 시 로컬 폴백 자동 | | **4. 정착** | 로컬 비번 로그인 축소(SSO 전용 계정 비번 미발급). 리소스서버 모드 전환은 별도 과제(A3) | 최소(비상 관리자) | 운영 안정·SLO 충족 | — | - **무중단 원칙:** 각 단계는 **피처플래그**로 제어, 언제든 이전 단계 복귀. 다운스트림(앱 JWT 소비)은 전 단계 불변 → 화면·API 회귀 0이 롤백 안전판. - **관람객/외부(B2C):** SSO 이행 대상 아님 — 셀프서비스(간편가입·게스트) 트랙 유지. 본 이행은 **내부 직원·조직** 한정. --- ## 9. 인계 백로그 (트랙별) > 본 문서는 설계 정본. 아래는 각 dev 트랙 착수 항목. **모두 피처플래그 뒤에서 순증 구현**(기존 인증코드 미파괴). | 트랙 | 항목 | 산출 | |---|---|---| | **backend-dev** | ① OIDC RP: `/api/auth/oidc/authorize-info·callback·logout·backchannel-logout`, JWKS 캐시·id_token 검증 ② 콜백 성공 후 **기존 `JwtService.issue()` 재사용**한 앱 JWT 브로커 ③ `HrDirectoryClient` 인터페이스 + Mock 구현 + 고객 어댑터 스텁 ④ 매핑 해석·JIT 프로비저닝·수동 링크 서비스 | 신규 패키지 `auth/oidc`, `hr/` (기존 `auth`·`security` 불변) | | **common-dev(인증 레이어)** | ① 로그인 화면 SSO 버튼(피처플래그)·expo-auth-session(모바일) ② `TwoFactorService.requiresOtp` IdP 위임 분기(설정형) ③ 계정 링크/언링크 마이페이지·관리자 링크 UI | 인증 공통 레이어 | | **db-engineer** | ① `user_identity`·`hr_dept_snapshot`·`hr_emp_snapshot`·`hr_sync_log` DDL(V38+, 멱등·테넌트 표준·소프트참조) ② `dept.source` 순증 ALTER ③ common_code POSITION·EMPLOYMENT_STATUS 순증 | Flyway 마이그레이션 | | **mobile** | expo-auth-session PKCE 플로우 + B2B 앱 SSO/IdP OTP, WISE 모바일 로그인 컨벤션 위 삽입, 디자인 Stitch 경유 | `mobile/` 로그인 | | **devops** | ① Keycloak 배치(systemd/컨테이너·별도 DB·realm import) ② `auth.` nginx vhost·TLS·접근제한 ③ client_secret·HR 자격증명 env drop-in(시크릿 스토어) ④ HR 배치 스케줄 | 인프라(G2 연계) | | **planner(인계)** | PLANNING §5B/§8에 **Open SSO·HR API 연동** 반영(6역할·인증 스택·조직 마스터 출처 갱신). 관람객 SSO 제외 명시 | PLANNING 개정 | | **NA/DA(정합)** | NA: `auth.` vhost·IdP↔AD/HR 내부망 구간 존 반영 / DA: hr_*_snapshot·user_identity ERD·PII 보존정책 편입 | network.md·data.md 갱신 | --- ## 10. 리스크 | # | 리스크 | 완화 | |---|---|---| | R1 | IdP 단일 장애 → 전면 로그인 불가 | 로컬 로그인 폴백 유지(§2-6)·Keycloak HA·앱 JWT 수명으로 순간 장애 흡수 | | R2 | 이메일 재사용·계정 병합 오류 → 권한 탈취 | sub↔empNo 확정 매핑, 이메일 단독 병합 금지, CONFLICT 수동 링크·감사 | | R3 | HR API 스펙 상이·미제공 | `HrDirectoryClient` 어댑터로 격리, Mock·스냅샷 폴백으로 개발·운영 지속 | | R4 | 이중 2FA(IdP+앱) 마찰 | IdP OTP 위임 시 앱 OTP 강제 해제(설정형 분기) | | R5 | 행사 RBAC 클레임 누락(리소스서버 직접 신뢰 시) | 이행기 브로커 모드 고정(앱 JWT에 event_member 조립). 직접 신뢰는 정착 후 별도 과제 | | R6 | HR 정본 전환 후 로컬 조직 편집 상실 | `dept.source=LOCAL` 공존 허용(외부 파트너 그룹핑 등 로컬 전용 유지) | | R7 | 토큰/시크릿 노출 | env only·서버측 리프레시 보관·응답/로그 미노출·JWKS 검증 | --- ## 11. 변경 이력 | 버전 | 일자 | 작성자 | 내용 | |---|---|---|---| | v1.0 | 2026-07-12 | SA | 최초 작성 — Open SSO(OIDC/Keycloak, PKCE, 브로커드 앱JWT 공존, 로컬 폴백, 2FA 위임, 6역할/멀티테넌트 매핑, 웹+모바일) + 인사·조직 API 연동(HrDirectoryClient 어댑터·스냅샷 폴백·증분 동기화·PII 최소) + 3자 매핑(sub↔empNo↔app_user) + 데이터 모델·NFR·보안·이행(coexist→pilot→cutover→롤백)·인계 백로그·리스크. 기존 W12 인증(app_user·event_member·TotpService·JwtService) 미수정 공존 설계. system.md/app.md/network.md/data.md 교차참조 |