1
0
forked from baron/baron-sso
Files
baron-sso/docs/worksmobile-directory-sync-technical-review.md

422 lines
26 KiB
Markdown

# 웍스모바일 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로 강제합니다.
- 단건 생성은 중복 시 `409 Conflict`로 차단합니다.
- bulk import는 `@domain` 입력 시 이름 기반 local-part를 제안하고, 생성 직전 재검증합니다.
## 웍스모바일 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를 우선합니다.
## 비동기 아키텍처 권장안
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
- `inactive`는 Worksmobile suspend로 동기화합니다.
- Baron user delete는 Worksmobile delete로 동기화합니다.
- `leave-of-absence`는 필요하지만 orgfront/Baron user status model 확장이 선행되어야 하므로 별도 scope로 분리합니다.
## 테스트 전략
기능 추가이므로 테스트를 먼저 작성합니다.
### 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 `inactive`는 Worksmobile suspend로 동기화합니다.
- Baron delete는 Worksmobile delete로 동기화합니다.
- leave-of-absence는 별도 user 상태 확장 이슈로 분리합니다.
- 직급/직책/사용자 유형 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`: 본 문서를 위키 반영 전 검토본으로 유지