# 웍스모바일 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`: 본 문서를 위키 반영 전 검토본으로 유지
