diff --git a/Makefile b/Makefile index bb5da047..1e6ee42c 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,10 @@ endif DUMP_SERVICES ?= all RESTORE_SERVICES ?= all +FILE_PATH ?= +RESTORE_INPUT ?= $(or $(FILE_PATH),$(word 2,$(MAKECMDGOALS))) +CONFIRM_RESTORE ?= +ALLOW_NON_EMPTY_RESTORE ?= false DUMP_MODE ?= maintenance BACKUP_USE_DOCKER ?= true BACKUP_TOOLS_IMAGE ?= baron-sso-backup-tools:local @@ -44,54 +48,104 @@ BACKUP_DOCKER_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV) endif BACKUP_DOCKER_RUN = docker run --rm $(BACKUP_DOCKER_ENV_ARGS) -e BACKUP_REPO_ROOT=/workspace -v /var/run/docker.sock:/var/run/docker.sock -v "$(CURDIR)":/workspace -v /tmp:/tmp -w /workspace $(BACKUP_TOOLS_IMAGE) -.PHONY: build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app backup-tools-build dump restore dump-verify restore-verify dump-list restore-plan upload-cloud dump-upload-cloud +.PHONY: help build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory ensure-restore-containers up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app backup-tools-build dump restore dump-verify restore-verify dump-list restore-plan upload-cloud dump-upload-cloud + +help: ## 생성된 타깃과 옵션 목록 표시 + @printf "Usage:\n make [OPTION=value ...]\n\n" + @printf "Targets:\n" + @awk ' \ + BEGIN { current = ""; printed_section = 0 } \ + /^# --- .+ ---/ { \ + current = $$0; \ + gsub(/^# ---[[:space:]]*/, "", current); \ + gsub(/[[:space:]]*---$$/, "", current); \ + next; \ + } \ + /^[[:alnum:]_.-]+:([^=]|$$)/ { \ + line = $$0; \ + target = line; \ + sub(/:.*/, "", target); \ + if (target ~ /^\.|%/) { next } \ + if (seen[target]++) { next } \ + desc = ""; \ + if (line ~ /##/) { \ + desc = line; \ + sub(/^.*##[[:space:]]*/, "", desc); \ + } \ + if (current != "" && current != printed_section) { \ + printf "\n %s\n", current; \ + printed_section = current; \ + } \ + if (desc != "") { \ + printf " %-36s %s\n", target, desc; \ + } else { \ + printf " %-36s\n", target; \ + } \ + } \ + ' Makefile + @printf "\nOptions:\n" + @awk ' \ + /^[A-Z][A-Z0-9_]+[[:space:]]*\?=/ { \ + name = $$1; \ + value = $$0; \ + sub(/[[:space:]]*\?=.*/, "", name); \ + sub(/^[^?]+\?=[[:space:]]*/, "", value); \ + printf " %-32s default: %s\n", name, value; \ + } \ + ' Makefile + @printf "\nRestore Safety:\n" + @printf " CONFIRM_RESTORE=baron-sso 복구 실행 의도를 명시하는 필수 확인값\n" + @printf " ALLOW_NON_EMPTY_RESTORE=true 비어 있지 않은 복구 대상에 덮어쓰는 승인된 복구에서만 사용\n" + @printf "\nRestore Examples:\n" + @printf " make restore-plan FILE_PATH=stg.today.tar.gz CONFIRM_RESTORE=baron-sso\n" + @printf " make restore FILE_PATH=stg.today.tar.gz CONFIRM_RESTORE=baron-sso ALLOW_NON_EMPTY_RESTORE=true\n" # --- 인증 설정 빌드/검증 --- -build-auth-config: +build-auth-config: ## 인증 설정 파일 생성 @echo "Building auth config..." @mkdir -p config/.generated @bash scripts/auth_config.sh build -validate-auth-config: build-auth-config +validate-auth-config: build-auth-config ## 인증 설정 값 검증 @echo "Validating auth config..." @bash scripts/auth_config.sh validate -verify-auth-config: validate-auth-config +verify-auth-config: validate-auth-config ## 인증 설정 연결 상태 확인 @echo "Verifying auth config wiring..." @bash scripts/auth_config.sh verify -render-ory-config: validate-auth-config +render-ory-config: validate-auth-config ## Ory 설정 파일 렌더링 @echo "Rendering Ory config..." @bash scripts/render_ory_config.sh # --- 기본 실행 --- # 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음) -up: up-all +up: up-all ## 전체 로컬 스택 실행 -up-all: ensure-networks render-ory-config +up-all: ensure-networks render-ory-config ## 인프라, Ory, 앱 스택 모두 실행 @echo "Starting ALL stacks (infra + ory + app)..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos # --- 개별 스택 실행 --- -up-infra: ensure-networks +up-infra: ensure-networks ## 인프라 스택 실행 @echo "Starting Infra stack (postgres/clickhouse/redis)..." docker compose -f $(COMPOSE_INFRA) up -d -up-ory: ensure-networks render-ory-config +up-ory: ensure-networks render-ory-config ## Ory 스택 실행 @echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos -up-app: ensure-networks render-ory-config +up-app: ensure-networks render-ory-config ## 앱 스택 실행 @echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d -up-backend: ensure-networks render-ory-config +up-backend: ensure-networks render-ory-config ## 백엔드 컨테이너만 실행 @echo "Starting Backend only..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d backend -ensure-networks: +ensure-networks: ## 개발용 Docker 네트워크 보장 @echo "Ensuring Docker networks..." @for network in $(DEV_NETWORKS); do \ if ! docker network inspect "$$network" >/dev/null 2>&1; then \ @@ -102,7 +156,7 @@ ensure-networks: fi; \ done -ensure-infra: ensure-networks +ensure-infra: ensure-networks ## 인프라 스택 실행 상태 보장 @echo "Ensuring Infra stack..." @missing=0; \ for container in $(INFRA_CONTAINERS); do \ @@ -118,7 +172,7 @@ ensure-infra: ensure-networks echo "Infra stack is already running."; \ fi -ensure-ory: ensure-networks render-ory-config +ensure-ory: ensure-networks render-ory-config ## Ory 스택 실행 상태 보장 @echo "Ensuring Ory stack..." @missing=0; \ for container in $(ORY_CONTAINERS); do \ @@ -135,26 +189,74 @@ ensure-ory: ensure-networks render-ory-config docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos; \ fi -up-dev: ensure-infra ensure-ory +ensure-restore-containers: ## 복구 대상 저장소 컨테이너 실행 상태 보장 + @echo "Ensuring restore target containers..." + @if [ "$(CONFIRM_RESTORE)" != "baron-sso" ]; then \ + echo "Skipping restore target container startup until CONFIRM_RESTORE=baron-sso is provided."; \ + exit 0; \ + fi + @$(MAKE) --no-print-directory ensure-networks + @ensure_restore_container() { \ + container="$$1"; \ + compose_file="$$2"; \ + compose_service="$$3"; \ + if docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null | grep -qx 'true'; then \ + echo "Restore target container $$container is already running."; \ + return 0; \ + fi; \ + if docker inspect "$$container" >/dev/null 2>&1; then \ + echo "Starting stopped restore target container $$container..."; \ + docker start "$$container"; \ + else \ + echo "Creating restore target container $$container via $$compose_file service $$compose_service..."; \ + docker compose -f "$$compose_file" up -d "$$compose_service"; \ + fi; \ + for attempt in 1 2 3 4 5 6 7 8 9 10; do \ + if docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null | grep -qx 'true'; then \ + return 0; \ + fi; \ + sleep 1; \ + done; \ + echo "ERROR: restore target container $$container did not reach running state." >&2; \ + return 1; \ + }; \ + services="$(RESTORE_SERVICES)"; \ + if [ -z "$$services" ] || [ "$$services" = "all" ]; then \ + services="postgres ory-postgres clickhouse ory-clickhouse config"; \ + else \ + services="$$(printf '%s' "$$services" | tr ',' ' ')"; \ + fi; \ + for service in $$services; do \ + case "$$service" in \ + postgres) ensure_restore_container baron_postgres compose.infra.yaml postgres ;; \ + ory-postgres) ensure_restore_container ory_postgres compose.ory.yaml postgres ;; \ + clickhouse) ensure_restore_container baron_clickhouse compose.infra.yaml clickhouse ;; \ + ory-clickhouse) ensure_restore_container ory_clickhouse compose.ory.yaml ory_clickhouse ;; \ + config) ;; \ + *) echo "ERROR: unknown restore service: $$service" >&2; exit 1 ;; \ + esac; \ + done + +up-dev: ensure-infra ensure-ory ## 개발 기본 스택 준비 @echo "Dev stack is up (infra + ory)." -up-front-dev: up-infra up-ory up-backend +up-front-dev: up-infra up-ory up-backend ## 프론트 개발용 의존 스택 준비 @echo "Dev stack is up (infra + ory + backend)." -dev: up-dev +dev: up-dev ## 개발 앱 컨테이너를 포그라운드로 실행 @echo "Starting development app containers in foreground attach mode..." BACKEND_LOG_LEVEL=info CLIENT_LOG_DEBUG=false VITE_CLIENT_LOG_DEBUG=false docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES) -dev-debug: up-dev +dev-debug: up-dev ## 디버그 로그로 개발 앱 컨테이너 실행 @echo "Starting development app containers in foreground attach debug mode..." BACKEND_LOG_LEVEL=debug CLIENT_LOG_DEBUG=true VITE_CLIENT_LOG_DEBUG=true USERFRONT_FLUTTER_RUN_FLAGS=--debug docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES) # --- 종료 (Down) --- -down: +down: ## 전체 로컬 스택 중지 @echo "Stopping ALL stacks (infra + ory + app)..." docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down -drop: +drop: ## 로컬 스택 컨테이너, 볼륨, 로컬 이미지 제거 @echo "Dropping Baron SSO local Docker stack containers, volumes, and local images..." -docker compose $(COMPOSE_DROP_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down -v --rmi local @echo "Removing any remaining fixed-name Baron SSO containers..." @@ -163,25 +265,25 @@ drop: done @echo "Drop complete. External Docker networks are preserved." -down-app: +down-app: ## 앱 스택 중지 @echo "Stopping App stack..." docker compose -f $(COMPOSE_APP) down -down-backend: +down-backend: ## 백엔드 컨테이너 중지 @echo "Stopping Backend only..." docker compose -f $(COMPOSE_APP) stop backend -down-infra: +down-infra: ## 인프라 스택 중지 @echo "Stopping Infra stack..." docker compose -f $(COMPOSE_INFRA) down -down-ory: +down-ory: ## Ory 스택 중지 @echo "Stopping Ory stack..." docker compose -f $(COMPOSE_ORY) down # --- 유틸리티 --- # 인프라 상태 확인 -check-infra: +check-infra: ## 인프라 헬스 상태 확인 @echo "Checking infra status..." @if [ "$$(docker inspect -f '{{.State.Health.Status}}' baron_postgres 2>/dev/null)" != "healthy" ]; then \ echo "Error: PostgreSQL is not running or not healthy."; \ @@ -191,67 +293,67 @@ check-infra: echo "PostgreSQL is healthy."; \ fi -ps: +ps: ## 전체 Compose 컨테이너 상태 조회 docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) ps -logs-infra: +logs-infra: ## 인프라 스택 로그 팔로우 docker compose -f $(COMPOSE_INFRA) logs -f -logs-ory: +logs-ory: ## Ory 스택 로그 팔로우 docker compose -f $(COMPOSE_ORY) logs -f -logs-app: +logs-app: ## 앱 스택 로그 팔로우 docker compose -f $(COMPOSE_APP) logs -f # --- 백업/복구 --- -backup-tools-build: +backup-tools-build: ## 백업 도구 Docker 이미지 빌드 docker build -f $(BACKUP_TOOLS_DOCKERFILE) -t $(BACKUP_TOOLS_IMAGE) . ifeq ($(BACKUP_USE_DOCKER),true) -dump: backup-tools-build +dump: backup-tools-build ## 백업 덤프 생성 $(BACKUP_DOCKER_RUN) bash -lc 'DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh' -restore: backup-tools-build - $(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh' +restore: backup-tools-build ensure-restore-containers ## 백업 덤프 복구 + $(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh' -dump-verify: backup-tools-build +dump-verify: backup-tools-build ## 백업 덤프 검증 $(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh' -restore-verify: backup-tools-build +restore-verify: backup-tools-build ## 복구 결과 검증 $(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh' -dump-list: backup-tools-build +dump-list: backup-tools-build ## 사용 가능한 백업 덤프 목록 조회 $(BACKUP_DOCKER_RUN) bash -lc 'BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh' -restore-plan: backup-tools-build - $(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh' +restore-plan: backup-tools-build ## 복구 실행 계획 출력 + $(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh' -upload-cloud: backup-tools-build +upload-cloud: backup-tools-build ## 백업 덤프 클라우드 업로드 $(BACKUP_DOCKER_RUN) bash -lc 'WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh' else -dump: +dump: ## 백업 덤프 생성 DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh -restore: - BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh +restore: ensure-restore-containers ## 백업 덤프 복구 + RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh -dump-verify: +dump-verify: ## 백업 덤프 검증 BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh -restore-verify: +restore-verify: ## 복구 결과 검증 BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh -dump-list: +dump-list: ## 사용 가능한 백업 덤프 목록 조회 BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh -restore-plan: - BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh +restore-plan: ## 복구 실행 계획 출력 + RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh -upload-cloud: +upload-cloud: ## 백업 덤프 클라우드 업로드 WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh endif -dump-upload-cloud: dump upload-cloud +dump-upload-cloud: dump upload-cloud ## 백업 덤프 생성 후 클라우드 업로드 # --- 로컬 통합 코드 체크 --- PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright @@ -268,12 +370,12 @@ CODE_CHECK_TEST_JOBS ?= 1 PLAYWRIGHT_WORKERS ?= 1 FLUTTER_TEST_CONCURRENCY ?= 1 -code-check: code-check-lint code-check-test-jobs +code-check: code-check-lint code-check-test-jobs ## 로컬 CI 상당 코드 검사 실행 @echo "code-check complete." -code-check-lint: code-check-i18n code-check-i18n-values code-check-front-lint code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint +code-check-lint: code-check-i18n code-check-i18n-values code-check-front-lint code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint ## 로컬 린트와 정적 검사 실행 -code-check-test-jobs: +code-check-test-jobs: ## 코드 검사 테스트 작업 실행 @echo "==> run CI-equivalent test jobs (parallel)" @$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \ code-check-backend-tests \ @@ -283,20 +385,20 @@ code-check-test-jobs: code-check-devfront-tests \ code-check-orgfront-tests -code-check-i18n: +code-check-i18n: ## i18n 리소스 검사 @echo "==> i18n resource check" @mkdir -p reports node tools/i18n-scanner/index.js node tools/i18n-scanner/report.js @cat reports/i18n-report.txt -code-check-i18n-values: +code-check-i18n-values: ## i18n 번역 값 품질 검사 @echo "==> i18n value quality check" @mkdir -p reports node tools/i18n-scanner/value-check.js @cat reports/i18n-value-report.txt -code-check-go-lint: +code-check-go-lint: ## Go 포맷과 린트 검사 @echo "==> go lint/format check" @if command -v golangci-lint >/dev/null 2>&1; then \ cd backend && golangci-lint fmt -E gofmt -E gofumpt -d; \ @@ -312,11 +414,11 @@ code-check-go-lint: exit 1; \ fi -code-check-sync-userfront-locales: +code-check-sync-userfront-locales: ## UserFront 로케일 동기화 검사 @echo "==> sync userfront locales" /bin/sh ./scripts/sync_userfront_locales.sh -code-check-userfront-install: +code-check-userfront-install: ## UserFront 의존성 설치 @echo "==> install userfront dependencies" @if command -v flutter >/dev/null 2>&1; then \ cd userfront && flutter pub get; \ @@ -324,7 +426,7 @@ code-check-userfront-install: echo "WARNING: flutter not found, skipping userfront dependencies install."; \ fi -code-check-userfront-lint: +code-check-userfront-lint: ## UserFront 포맷과 analyze 검사 @echo "==> userfront format/analyze" @if command -v dart >/dev/null 2>&1; then \ cd userfront && dart format --output=none --set-exit-if-changed lib test; \ @@ -337,7 +439,7 @@ code-check-userfront-lint: echo "WARNING: flutter not found, skipping userfront analyze."; \ fi -code-check-front-lint: +code-check-front-lint: ## 프론트엔드 Biome 린트와 포맷 검사 @echo "==> adminfront biome lint/format check" rm -rf adminfront/playwright-report adminfront/test-results @if [ -d adminfront/node_modules ]; then \ @@ -366,11 +468,11 @@ code-check-front-lint: cd orgfront && ./node_modules/@biomejs/biome/bin/biome lint . cd orgfront && ./node_modules/@biomejs/biome/bin/biome format . -code-check-backend-tests: +code-check-backend-tests: ## 백엔드 Go 테스트 실행 @echo "==> backend tests" cd backend && GOCACHE=/tmp/baron-sso-go-cache go test -v ./... -code-check-userfront-tests: +code-check-userfront-tests: ## UserFront Flutter 테스트 실행 @echo "==> userfront tests (isolated workspace)" @if ! command -v flutter >/dev/null 2>&1; then \ echo "WARNING: flutter not found, skipping userfront tests."; \ @@ -396,11 +498,11 @@ code-check-userfront-tests: cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \ cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY) -code-check-adminfront-tests: +code-check-adminfront-tests: ## AdminFront 테스트 실행 @echo "==> adminfront tests" PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests -code-check-devfront-tests: +code-check-devfront-tests: ## DevFront 테스트 실행 @echo "==> devfront tests" @mkdir -p reports/devfront @rm -rf reports/devfront/playwright-report reports/devfront/test-results @@ -423,7 +525,7 @@ code-check-devfront-tests: [ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \ exit $$status -code-check-orgfront-tests: +code-check-orgfront-tests: ## OrgFront 테스트 실행 @echo "==> orgfront tests" @mkdir -p reports/orgfront @rm -rf reports/orgfront/playwright-report reports/orgfront/test-results @@ -439,7 +541,7 @@ code-check-orgfront-tests: [ -d orgfront/test-results ] && cp -R orgfront/test-results reports/orgfront/ || true; \ exit $$status -code-check-userfront-e2e-tests: +code-check-userfront-e2e-tests: ## UserFront WASM E2E 테스트 실행 @echo "==> userfront wasm playwright e2e tests (isolated workspace)" @if ! command -v flutter >/dev/null 2>&1; then \ echo "WARNING: flutter not found, skipping userfront e2e tests."; \ diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx index 3983f37d..49de2a55 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx @@ -64,14 +64,6 @@ vi.mock("../../lib/adminApi", () => ({ total: 1, })), fetchOrySSOTSystemStatus: vi.fn(async () => ({ - userProjection: { - name: "kratos_users", - status: "ready", - ready: true, - lastSyncedAt: "2026-05-11T03:00:00Z", - updatedAt: "2026-05-11T03:00:10Z", - projectedUsers: 152, - }, identityCache: { status: "ready", redisReady: true, @@ -158,8 +150,8 @@ describe("DataIntegrityPage", () => { ).toBeGreaterThan(0); expect(await screen.findByText("Redis identity cache")).toBeInTheDocument(); expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0); - expect(screen.getByText("152")).toBeInTheDocument(); expect(screen.getByText("151")).toBeInTheDocument(); + expect(screen.queryByText("Local users")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ })); await waitFor(() => { diff --git a/adminfront/src/features/projections/UserProjectionPage.test.tsx b/adminfront/src/features/projections/UserProjectionPage.test.tsx index febccde2..064d199a 100644 --- a/adminfront/src/features/projections/UserProjectionPage.test.tsx +++ b/adminfront/src/features/projections/UserProjectionPage.test.tsx @@ -15,14 +15,6 @@ let currentRole = "super_admin"; vi.mock("../../lib/adminApi", () => ({ fetchMe: vi.fn(async () => ({ role: currentRole })), fetchOrySSOTSystemStatus: vi.fn(async () => ({ - userProjection: { - name: "kratos_users", - status: "ready", - ready: true, - lastSyncedAt: "2026-05-11T03:00:00Z", - updatedAt: "2026-05-11T03:00:10Z", - projectedUsers: 152, - }, identityCache: { status: "ready", redisReady: true, @@ -74,8 +66,9 @@ describe("UserProjectionPage", () => { expect(await screen.findByText("Redis identity cache")).toBeInTheDocument(); expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0); expect(screen.getByText("관측 identity")).toBeInTheDocument(); - expect(screen.getByText("152")).toBeInTheDocument(); expect(screen.getByText("151")).toBeInTheDocument(); + expect(screen.queryByText("Local users")).not.toBeInTheDocument(); + expect(screen.queryByText("Backend 사용자 read model")).not.toBeInTheDocument(); expect(fetchOrySSOTSystemStatus).toHaveBeenCalled(); }); diff --git a/adminfront/src/features/projections/UserProjectionPage.tsx b/adminfront/src/features/projections/UserProjectionPage.tsx index c53fe0bb..8e663c00 100644 --- a/adminfront/src/features/projections/UserProjectionPage.tsx +++ b/adminfront/src/features/projections/UserProjectionPage.tsx @@ -72,7 +72,6 @@ export function UserProjectionContent({ if (confirmed) flushMutation.mutate(); }; - const projection = data?.userProjection; const identityCache = data?.identityCache; const header = ( @@ -146,79 +145,6 @@ export function UserProjectionContent({ ) : null} -
-
-
-

- {t( - "ui.admin.ory_ssot.projection_card.title", - "Backend user read model", - )} -

-

- {t( - "ui.admin.ory_ssot.projection_card.description", - "PostgreSQL read model status used by admin search and statistics.", - )} -

-
-
- - {isLoading ? ( -
- {t("ui.admin.ory_ssot.loading", "Loading")} -
- ) : ( -
-
-
- {t("ui.admin.ory_ssot.summary.status", "Status")} -
-
- -
-
-
-
- {t("ui.admin.ory_ssot.summary.local_users", "Local users")} -
-
- {projection?.projectedUsers ?? 0} -
-
-
-
- {t( - "ui.admin.ory_ssot.summary.last_synced", - "Last read-model refresh", - )} -
-
- {formatDateTime(projection?.lastSyncedAt)} -
-
-
-
- {t("ui.admin.ory_ssot.summary.updated_at", "Updated at")} -
-
- {formatDateTime(projection?.updatedAt)} -
-
-
- )} - - {projection?.lastError ? ( -
- - {projection.lastError} -
- ) : null} -
-
diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts index 525c1a79..851a9dcf 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts @@ -10,6 +10,7 @@ import { filterWorksmobileComparisonRowsBySearch, formatWorksmobileOrgDetails, formatWorksmobilePersonName, + formatWorksmobileSelectionFailureDescription, formatWorksmobileUpdateDetails, getDefaultGroupComparisonFilters, getDefaultUserComparisonFilters, @@ -509,6 +510,48 @@ describe("TenantWorksmobilePage comparison helpers", () => { ).toEqual([rows[0]]); }); + it("filters users by WORKS account status", () => { + const rows = [ + { + resourceType: "USER", + status: "matched", + baronId: "user-1", + worksmobileAccountStatus: "active", + }, + { + resourceType: "USER", + status: "matched", + baronId: "user-2", + worksmobileAccountStatus: "suspended", + }, + { + resourceType: "USER", + status: "matched", + baronId: "user-3", + worksmobileAccountStatus: "invited", + }, + ]; + + expect( + filterWorksmobileComparisonRows( + rows, + getDefaultUserComparisonFilters(), + false, + "suspended", + ), + ).toEqual([rows[1]]); + }); + + it("formats partial Worksmobile selection failures with detailed reasons", () => { + expect( + formatWorksmobileSelectionFailureDescription(1, [ + "7e30daf6-f912-4306-befc-478feb7b74cc: target user tenant is excluded from Worksmobile sync", + ]), + ).toBe( + "성공 1건, 실패 1건\n7e30daf6-f912-4306-befc-478feb7b74cc: target user tenant is excluded from Worksmobile sync", + ); + }); + it("formats update details for changed organization rows", () => { expect( formatWorksmobileUpdateDetails({ @@ -529,6 +572,28 @@ describe("TenantWorksmobilePage comparison helpers", () => { ]); }); + it("formats update details for changed user phone and employee number", () => { + expect( + formatWorksmobileUpdateDetails({ + resourceType: "USER", + status: "needs_update", + baronId: "user-1", + baronName: "강명진", + worksmobileName: "강명진", + baronEmail: "mjkang4@hanmaceng.co.kr", + worksmobileEmail: "mjkang4@hanmaceng.co.kr", + externalKey: "user-1", + baronPhone: "+821051583696", + worksmobilePhone: "+821099998888", + baronEmployeeNumber: "mjkang4", + worksmobileEmployeeNumber: "M17205", + }), + ).toEqual([ + "전화번호: +821099998888 -> +821051583696", + "사번: M17205 -> mjkang4", + ]); + }); + it("formats WORKS account name with level on one line", () => { expect( formatWorksmobilePersonName({ diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx index 499e0390..cd86aada 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx @@ -66,6 +66,7 @@ import { filterWorksmobileComparisonRowsBySearch, formatWorksmobileOrgDetails, formatWorksmobilePersonName, + formatWorksmobileSelectionFailureDescription, formatWorksmobileUpdateDetails, getDefaultGroupComparisonFilters, getDefaultUserComparisonFilters, @@ -77,10 +78,12 @@ import { getWorksmobileSelectedUpdateUserIds, getWorksmobileSelectedWorksOnlyOrgUnitIds, summarizeWorksmobileComparison, + type WorksmobileAccountStatusFilter, type WorksmobileComparisonColumnKey, type WorksmobileComparisonColumnVisibility, type WorksmobileComparisonFilter, type WorksmobileComparisonSummary, + worksmobileAccountStatusFilterOptions, } from "./worksmobileComparison"; function worksmobileJobPayloadString(job: WorksmobileOutboxItem, key: string) { @@ -183,6 +186,8 @@ export function TenantWorksmobilePage() { const [groupFilters, setGroupFilters] = React.useState< WorksmobileComparisonFilter[] >(getDefaultGroupComparisonFilters); + const [userAccountStatusFilter, setUserAccountStatusFilter] = + React.useState("all"); const [includeUserMissingExternalKey, setIncludeUserMissingExternalKey] = React.useState(false); const [includeGroupMissingExternalKey, setIncludeGroupMissingExternalKey] = @@ -323,10 +328,11 @@ export function TenantWorksmobilePage() { return { resourceKind, count: successCount, + failures, failureCount: failures.length, }; }, - onSuccess: ({ resourceKind, count, failureCount }) => { + onSuccess: ({ resourceKind, count, failureCount, failures }) => { if (resourceKind === "users") { setSelectedUserRowKeys([]); } else { @@ -334,7 +340,10 @@ export function TenantWorksmobilePage() { } if (failureCount > 0) { toast.error("일부 WORKS 생성 작업 등록 실패", { - description: `성공 ${count}건, 실패 ${failureCount}건`, + description: formatWorksmobileSelectionFailureDescription( + count, + failures, + ), }); } else { toast.success("WORKS 생성 작업을 등록했습니다.", { @@ -418,6 +427,7 @@ export function TenantWorksmobilePage() { comparisonUsers, userFilters, includeUserMissingExternalKey, + userAccountStatusFilter, ), userSearch, ); @@ -643,6 +653,11 @@ export function TenantWorksmobilePage() { setUserFilters(nextFilters); setSelectedUserRowKeys([]); }} + accountStatusFilter={userAccountStatusFilter} + onAccountStatusFilterChange={(nextStatus) => { + setUserAccountStatusFilter(nextStatus); + setSelectedUserRowKeys([]); + }} baronOrgColumnLabel="대표 Baron 조직" includeMissingExternalKey={includeUserMissingExternalKey} onIncludeMissingExternalKeyChange={(checked) => { @@ -988,6 +1003,8 @@ function ComparisonTable({ searchPlaceholder = "이름 또는 UUID 검색", filters, onFiltersChange, + accountStatusFilter, + onAccountStatusFilterChange, baronOrgColumnLabel = "Baron 조직", includeMissingExternalKey, onIncludeMissingExternalKeyChange, @@ -1018,6 +1035,10 @@ function ComparisonTable({ searchPlaceholder?: string; filters?: WorksmobileComparisonFilter[]; onFiltersChange?: (filters: WorksmobileComparisonFilter[]) => void; + accountStatusFilter?: WorksmobileAccountStatusFilter; + onAccountStatusFilterChange?: ( + status: WorksmobileAccountStatusFilter, + ) => void; baronOrgColumnLabel?: string; includeMissingExternalKey?: boolean; onIncludeMissingExternalKeyChange?: (checked: boolean) => void; @@ -1277,6 +1298,29 @@ function ComparisonTable({ ) : null } /> + {accountStatusFilter && onAccountStatusFilterChange ? ( +
+ {worksmobileAccountStatusFilterOptions.map((option) => ( + + ))} +
+ ) : null}
{getWorksmobileComparisonStatusLabel(row.status)} + {row.worksmobileAccountStatus && ( +
+ + WORKS {row.worksmobileAccountStatus} + +
+ )} {formatWorksmobileUpdateDetails(row).map((detail) => (
Boolean(id)); } +export function formatWorksmobileSelectionFailureDescription( + successCount: number, + failures: string[], +) { + const summary = `성공 ${successCount}건, 실패 ${failures.length}건`; + const visibleFailures = failures.slice(0, 3); + if (failures.length <= visibleFailures.length) { + return [summary, ...visibleFailures].join("\n"); + } + return [ + summary, + ...visibleFailures, + `외 ${failures.length - visibleFailures.length}건 실패`, + ].join("\n"); +} + export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds( rows: WorksmobileComparisonItem[], selectedKeys: string[], @@ -251,6 +275,7 @@ const worksmobileComparisonSearchFields: Array< "externalKey", "worksmobileName", "worksmobileEmail", + "worksmobileAccountStatus", "worksmobileLevelId", "worksmobileLevelName", "worksmobileTask", @@ -292,6 +317,7 @@ export function filterWorksmobileComparisonRows( rows: WorksmobileComparisonItem[], filters: WorksmobileComparisonFilter[], onlyMissingExternalKey = false, + accountStatus: WorksmobileAccountStatusFilter = "all", ) { const allowedStatuses = new Set( filters.flatMap((filter) => worksmobileFilterStatuses[filter]), @@ -302,7 +328,15 @@ export function filterWorksmobileComparisonRows( } allowedStatuses.add("missing_external_key"); } - return rows.filter((row) => allowedStatuses.has(row.status)); + return rows.filter((row) => { + if (accountStatus !== "all") { + return row.worksmobileAccountStatus === accountStatus; + } + if (!allowedStatuses.has(row.status)) { + return false; + } + return true; + }); } export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) { @@ -358,6 +392,22 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) { if (expectedEmail && actualEmail && expectedEmail !== actualEmail) { details.push(`이메일: ${actualEmail} -> ${expectedEmail}`); } + const expectedPhone = row.baronPhone?.trim() ?? ""; + const actualPhone = row.worksmobilePhone?.trim() ?? ""; + if (expectedPhone && actualPhone && expectedPhone !== actualPhone) { + details.push(`전화번호: ${actualPhone} -> ${expectedPhone}`); + } + const expectedEmployeeNumber = row.baronEmployeeNumber?.trim() ?? ""; + const actualEmployeeNumber = row.worksmobileEmployeeNumber?.trim() ?? ""; + if ( + expectedEmployeeNumber && + actualEmployeeNumber && + expectedEmployeeNumber !== actualEmployeeNumber + ) { + details.push( + `사번: ${actualEmployeeNumber} -> ${expectedEmployeeNumber}`, + ); + } return details; } @@ -445,6 +495,18 @@ export const comparisonFilterOptions: Array<{ export const userFilterOptions = comparisonFilterOptions; +export const worksmobileAccountStatusFilterOptions: Array<{ + value: WorksmobileAccountStatusFilter; + label: string; +}> = [ + { value: "all", label: "WORKS 전체" }, + { value: "active", label: "active" }, + { value: "invited", label: "invited" }, + { value: "suspended", label: "suspended" }, + { value: "inactive", label: "inactive" }, + { value: "deleted", label: "deleted" }, +]; + export function getDefaultUserComparisonFilters(): WorksmobileComparisonFilter[] { return ["baron_only", "needs_update", "works_only"]; } diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index da71f4f8..3d57a696 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -159,6 +159,7 @@ export type UserProjectionStatus = { export type IdentityCacheStatus = { status: string; redisReady: boolean; + mirrorVersion?: string; observedCount: number; keyCount: number; lastRefreshedAt?: string; @@ -167,7 +168,6 @@ export type IdentityCacheStatus = { }; export type OrySSOTSystemStatus = { - userProjection: UserProjectionStatus; identityCache: IdentityCacheStatus; }; @@ -884,6 +884,8 @@ export type WorksmobileComparisonItem = { baronSlug?: string; baronName?: string; baronEmail?: string; + baronPhone?: string; + baronEmployeeNumber?: string; baronPrimaryOrgId?: string; baronPrimaryOrgSlug?: string; baronPrimaryOrgName?: string; @@ -894,6 +896,9 @@ export type WorksmobileComparisonItem = { externalKey?: string; worksmobileName?: string; worksmobileEmail?: string; + worksmobilePhone?: string; + worksmobileEmployeeNumber?: string; + worksmobileAccountStatus?: string; worksmobileLevelId?: string; worksmobileLevelName?: string; worksmobileTask?: string; diff --git a/backend/cmd/adminctl/main_test.go b/backend/cmd/adminctl/main_test.go index 271a61c4..d980bc87 100644 --- a/backend/cmd/adminctl/main_test.go +++ b/backend/cmd/adminctl/main_test.go @@ -1,8 +1,11 @@ package main import ( + "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "context" + "encoding/csv" + "errors" "strings" "testing" ) @@ -120,6 +123,339 @@ func TestAuditWorksmobileDuplicatePhoneCountryCodesReportsAndFixes(t *testing.T) } } +func TestRecreatePendingWorksmobileUsersFromSnapshotCreatesOnlyMatchedUsers(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + rootID := "11111111-1111-1111-1111-111111111111" + tenantID := "22222222-2222-2222-2222-222222222222" + userID := "33333333-3333-3333-3333-333333333333" + client := &fakeWorksmobilePendingRecreateClient{} + output := &strings.Builder{} + writer := csv.NewWriter(output) + + counts, err := recreatePendingWorksmobileUsersFromSnapshot( + context.Background(), + []service.WorksmobileRemoteUser{ + {Email: "matched@samaneng.com", ID: "works-1", ExternalID: userID, DisplayName: "Matched"}, + {Email: "missing@samaneng.com", ID: "works-2", ExternalID: "44444444-4444-4444-4444-444444444444", DisplayName: "Missing"}, + }, + func(ctx context.Context, remote service.WorksmobileRemoteUser) (domain.User, bool) { + if remote.ExternalID != userID { + return domain.User{}, false + } + return domain.User{ + ID: userID, + Email: "matched@samaneng.com", + Name: "Matched User", + Status: domain.UserStatusActive, + TenantID: &tenantID, + }, true + }, + map[string]domain.Tenant{ + rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany}, + tenantID: {ID: tenantID, Slug: "team", Name: "Team", Type: domain.TenantTypeOrganization, ParentID: &rootID}, + }, + nil, + "hanmac-family2026", + 0, + writer, + client, + ) + + if err != nil { + t.Fatalf("recreatePendingWorksmobileUsersFromSnapshot returned error: %v", err) + } + if counts.OK != 1 || counts.Skipped != 1 || counts.Errors != 0 { + t.Fatalf("counts=%+v, want ok=1 skipped=1 errors=0", counts) + } + if len(client.patchedUsers) != 1 || client.patchedUsers[0].identifier != "matched@samaneng.com" { + t.Fatalf("patched users=%v", client.patchedUsers) + } + if !strings.Contains(client.patchedUsers[0].payload.Email, ".old") { + t.Fatalf("tombstone email=%q", client.patchedUsers[0].payload.Email) + } + if len(client.patchedUsers[0].payload.AliasEmails) != 0 { + t.Fatalf("tombstone alias emails were not cleared: %v", client.patchedUsers[0].payload.AliasEmails) + } + if len(client.patchedUsers[0].payload.Organizations) == 0 || client.patchedUsers[0].payload.Organizations[0].Email != client.patchedUsers[0].payload.Email { + t.Fatalf("tombstone organization email was not updated: %+v", client.patchedUsers[0].payload.Organizations) + } + if len(client.deletedUsers) != 1 || client.deletedUsers[0] != client.patchedUsers[0].payload.Email { + t.Fatalf("deleted users=%v", client.deletedUsers) + } + if len(client.createdUsers) != 1 { + t.Fatalf("created users=%d, want 1", len(client.createdUsers)) + } + if client.createdUsers[0].PasswordConfig.Password != "hanmac-family2026" { + t.Fatal("initial password was not applied to recreated user") + } + if strings.Contains(output.String(), "missing@samaneng.com") && !strings.Contains(output.String(), "baron user not found") { + t.Fatalf("missing user skip reason was not written: %s", output.String()) + } +} + +func TestRecreatePendingWorksmobileUsersFromSnapshotRollsBackWhenCreateFails(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + rootID := "11111111-1111-1111-1111-111111111111" + tenantID := "22222222-2222-2222-2222-222222222222" + userID := "33333333-3333-3333-3333-333333333333" + client := &fakeWorksmobilePendingRecreateClient{createErr: errors.New("create failed")} + output := &strings.Builder{} + writer := csv.NewWriter(output) + + counts, err := recreatePendingWorksmobileUsersFromSnapshot( + context.Background(), + []service.WorksmobileRemoteUser{{Email: "matched@samaneng.com", ID: "works-1", ExternalID: userID}}, + func(ctx context.Context, remote service.WorksmobileRemoteUser) (domain.User, bool) { + return domain.User{ + ID: userID, + Email: "matched@samaneng.com", + Name: "Matched User", + Status: domain.UserStatusActive, + TenantID: &tenantID, + }, true + }, + map[string]domain.Tenant{ + rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany}, + tenantID: {ID: tenantID, Slug: "team", Name: "Team", Type: domain.TenantTypeOrganization, ParentID: &rootID}, + }, + nil, + "hanmac-family2026", + 0, + writer, + client, + ) + + if err != nil { + t.Fatalf("recreatePendingWorksmobileUsersFromSnapshot returned error: %v", err) + } + if counts.OK != 0 || counts.Errors != 1 { + t.Fatalf("counts=%+v, want ok=0 errors=1", counts) + } + if len(client.patchedUsers) != 2 { + t.Fatalf("patched users=%v", client.patchedUsers) + } + if client.patchedUsers[1].payload.Email != "matched@samaneng.com" { + t.Fatalf("rollback email=%q, want matched@samaneng.com", client.patchedUsers[1].payload.Email) + } + if !strings.Contains(output.String(), "create failed") || !strings.Contains(output.String(), "ok") { + t.Fatalf("rollback result was not written: %s", output.String()) + } +} + +func TestImportHanmacWorksmobileUsersFromRowsSkipsExistingRemoteLocalPart(t *testing.T) { + t.Setenv("HANMAC_DOMAIN_ID", "300286336") + rootID := "11111111-1111-1111-1111-111111111111" + companyID := "22222222-2222-2222-2222-222222222222" + tenantID := "33333333-3333-3333-3333-333333333333" + client := &fakeWorksmobilePendingRecreateClient{} + store := &fakeHanmacWorksmobileUserStore{} + output := &strings.Builder{} + writer := csv.NewWriter(output) + + counts, err := importHanmacWorksmobileUsersFromRows( + context.Background(), + []hanmacWorksmobileImportRow{{ + Email: "new@hanmaceng.co.kr", + Name: "New User", + Role: "user", + TenantSlug: "infra-structures", + EmployeeID: "M25001", + SubEmail: "legacy@hanmaceng.co.kr", + }}, + domain.Tenant{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"}, + map[string]domain.Tenant{ + "infra-structures": {ID: tenantID, Slug: "infra-structures", Name: "구조부", Type: domain.TenantTypeOrganization, ParentID: &companyID}, + }, + map[string]domain.Tenant{ + rootID: {ID: rootID, Slug: "hanmac-family", Name: "한맥가족"}, + companyID: {ID: companyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID}, + tenantID: {ID: tenantID, Slug: "infra-structures", Name: "구조부", Type: domain.TenantTypeOrganization, ParentID: &companyID}, + }, + []service.WorksmobileRemoteUser{{ + Email: "owner@hanmaceng.co.kr", + AliasEmails: []string{"legacy@hanmaceng.co.kr"}, + }}, + nil, + store, + "hanmac-family2026", + 0, + true, + writer, + client, + ) + + if err != nil { + t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err) + } + if counts.OK != 0 || counts.Skipped != 1 || counts.Errors != 0 { + t.Fatalf("counts=%+v, want ok=0 skipped=1 errors=0", counts) + } + if len(store.saved) != 0 { + t.Fatalf("saved users=%d, want 0", len(store.saved)) + } + if len(client.createdUsers) != 0 { + t.Fatalf("created Worksmobile users=%d, want 0", len(client.createdUsers)) + } + if !strings.Contains(output.String(), "legacy") || !strings.Contains(output.String(), "local-part already exists") { + t.Fatalf("result did not include conflict reason: %s", output.String()) + } +} + +func TestImportHanmacWorksmobileUsersFromRowsSavesBaronUserAndCreatesWorksmobileUser(t *testing.T) { + t.Setenv("HANMAC_DOMAIN_ID", "300286336") + rootID := "11111111-1111-1111-1111-111111111111" + companyID := "22222222-2222-2222-2222-222222222222" + tenantID := "33333333-3333-3333-3333-333333333333" + client := &fakeWorksmobilePendingRecreateClient{} + store := &fakeHanmacWorksmobileUserStore{} + output := &strings.Builder{} + writer := csv.NewWriter(output) + + counts, err := importHanmacWorksmobileUsersFromRows( + context.Background(), + []hanmacWorksmobileImportRow{{ + Email: "new@hanmaceng.co.kr", + Name: "New User", + Phone: "010-1234-5678", + Role: "user", + TenantSlug: "infra-structures", + Grade: "과장", + EmployeeID: "M25001", + SubEmail: "new.alias@hanmaceng.co.kr", + }}, + domain.Tenant{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"}, + map[string]domain.Tenant{ + "infra-structures": {ID: tenantID, Slug: "infra-structures", Name: "구조부", Type: domain.TenantTypeOrganization, ParentID: &companyID}, + }, + map[string]domain.Tenant{ + rootID: {ID: rootID, Slug: "hanmac-family", Name: "한맥가족"}, + companyID: {ID: companyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID}, + tenantID: {ID: tenantID, Slug: "infra-structures", Name: "구조부", Type: domain.TenantTypeOrganization, ParentID: &companyID}, + }, + nil, + nil, + store, + "hanmac-family2026", + 0, + true, + writer, + client, + ) + + if err != nil { + t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err) + } + if counts.OK != 1 || counts.Skipped != 0 || counts.Errors != 0 || counts.BaronCreated != 1 { + t.Fatalf("counts=%+v, want ok=1 baronCreated=1", counts) + } + if len(store.saved) != 1 { + t.Fatalf("saved users=%d, want 1", len(store.saved)) + } + if store.saved[0].TenantID == nil || *store.saved[0].TenantID != tenantID { + t.Fatalf("saved tenant=%v, want %s", store.saved[0].TenantID, tenantID) + } + if store.saved[0].Metadata["employee_id"] != "M25001" || store.saved[0].Metadata["sub_email"] != "new.alias@hanmaceng.co.kr" { + t.Fatalf("metadata=%v", store.saved[0].Metadata) + } + if len(client.createdUsers) != 1 { + t.Fatalf("created Worksmobile users=%d, want 1", len(client.createdUsers)) + } + if client.createdUsers[0].Email != "new@hanmaceng.co.kr" { + t.Fatalf("created email=%q", client.createdUsers[0].Email) + } + if client.createdUsers[0].PasswordConfig.Password != "hanmac-family2026" { + t.Fatal("initial password was not applied") + } + if !strings.Contains(strings.Join(client.createdUsers[0].AliasEmails, ","), "new.alias@hanmaceng.co.kr") { + t.Fatalf("alias emails=%v", client.createdUsers[0].AliasEmails) + } +} + +func TestImportHanmacWorksmobileUsersFromRowsKeepsExternalSubEmailOutOfWorksmobileAliases(t *testing.T) { + t.Setenv("HANMAC_DOMAIN_ID", "300286336") + rootID := "11111111-1111-1111-1111-111111111111" + companyID := "22222222-2222-2222-2222-222222222222" + tenantID := "33333333-3333-3333-3333-333333333333" + client := &fakeWorksmobilePendingRecreateClient{} + store := &fakeHanmacWorksmobileUserStore{} + output := &strings.Builder{} + writer := csv.NewWriter(output) + + counts, err := importHanmacWorksmobileUsersFromRows( + context.Background(), + []hanmacWorksmobileImportRow{{ + Email: "external-alias@hanmaceng.co.kr", + Name: "External Alias", + Role: "user", + TenantSlug: "infra-structures", + EmployeeID: "M25002", + SubEmail: "external@gmail.com", + }}, + domain.Tenant{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"}, + map[string]domain.Tenant{ + "infra-structures": {ID: tenantID, Slug: "infra-structures", Name: "구조부", Type: domain.TenantTypeOrganization, ParentID: &companyID}, + }, + map[string]domain.Tenant{ + rootID: {ID: rootID, Slug: "hanmac-family", Name: "한맥가족"}, + companyID: {ID: companyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID}, + tenantID: {ID: tenantID, Slug: "infra-structures", Name: "구조부", Type: domain.TenantTypeOrganization, ParentID: &companyID}, + }, + nil, + nil, + store, + "hanmac-family2026", + 0, + true, + writer, + client, + ) + + if err != nil { + t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err) + } + if counts.OK != 1 || counts.Errors != 0 || counts.Skipped != 0 { + t.Fatalf("counts=%+v, want ok=1", counts) + } + if store.saved[0].Metadata["sub_email"] != nil { + t.Fatalf("external sub_email should not be stored as Worksmobile alias metadata: %v", store.saved[0].Metadata) + } + if store.saved[0].Metadata["external_sub_email"] != "external@gmail.com" { + t.Fatalf("external_sub_email=%v", store.saved[0].Metadata["external_sub_email"]) + } + if strings.Contains(strings.Join(client.createdUsers[0].AliasEmails, ","), "external@gmail.com") { + t.Fatalf("external sub email was sent as alias: %v", client.createdUsers[0].AliasEmails) + } +} + +func TestBuildAdminctlWorksmobileOrgUnitPayloadClearsDomainRootParent(t *testing.T) { + t.Setenv("HANMAC_DOMAIN_ID", "300286336") + rootID := "11111111-1111-1111-1111-111111111111" + companyID := "22222222-2222-2222-2222-222222222222" + orgID := "33333333-3333-3333-3333-333333333333" + root := domain.Tenant{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"} + company := domain.Tenant{ID: companyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID} + org := domain.Tenant{ID: orgID, Slug: "management-support", Name: "경영지원부", Type: domain.TenantTypeOrganization, ParentID: &companyID} + + payload, err := buildAdminctlWorksmobileOrgUnitPayload(org, root, map[string]domain.Tenant{ + rootID: root, + companyID: company, + orgID: org, + }) + + if err != nil { + t.Fatalf("buildAdminctlWorksmobileOrgUnitPayload returned error: %v", err) + } + if payload.DomainID != 300286336 { + t.Fatalf("domainID=%d, want 300286336", payload.DomainID) + } + if payload.Email != "management-support@hanmaceng.co.kr" { + t.Fatalf("email=%q, want management-support@hanmaceng.co.kr", payload.Email) + } + if payload.ParentOrgUnitID != "" { + t.Fatalf("parentOrgUnitID=%q, want empty for domain-root child", payload.ParentOrgUnitID) + } +} + type fakeWorksmobilePhoneAuditClient struct { users []service.WorksmobileRemoteUser patches []fakeWorksmobilePhonePatch @@ -138,3 +474,100 @@ func (f *fakeWorksmobilePhoneAuditClient) PatchUser(ctx context.Context, identif f.patches = append(f.patches, fakeWorksmobilePhonePatch{identifier: identifier, payload: payload}) return nil } + +type fakeWorksmobilePendingRecreateClient struct { + createdUsers []service.WorksmobileUserPayload + deletedUsers []string + undeletedUsers []string + patchedUsers []fakeWorksmobilePendingRecreatePatch + createErr error +} + +type fakeWorksmobilePendingRecreatePatch struct { + identifier string + payload service.WorksmobileUserPatchPayload +} + +func (f *fakeWorksmobilePendingRecreateClient) CreateOrgUnit(ctx context.Context, payload service.WorksmobileOrgUnitPayload) error { + return nil +} + +func (f *fakeWorksmobilePendingRecreateClient) UpsertOrgUnit(ctx context.Context, payload service.WorksmobileOrgUnitPayload, matchLocalPart string) error { + return nil +} + +func (f *fakeWorksmobilePendingRecreateClient) DeleteOrgUnit(ctx context.Context, orgUnitID string) error { + return nil +} + +func (f *fakeWorksmobilePendingRecreateClient) CreateUser(ctx context.Context, payload service.WorksmobileUserPayload) error { + if f.createErr != nil { + return f.createErr + } + f.createdUsers = append(f.createdUsers, payload) + return nil +} + +func (f *fakeWorksmobilePendingRecreateClient) UpsertUser(ctx context.Context, payload service.WorksmobileUserPayload) error { + return nil +} + +func (f *fakeWorksmobilePendingRecreateClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error { + return nil +} + +func (f *fakeWorksmobilePendingRecreateClient) ResetUserPassword(ctx context.Context, userID string, password string) error { + return nil +} + +func (f *fakeWorksmobilePendingRecreateClient) DeleteUser(ctx context.Context, userID string) error { + f.deletedUsers = append(f.deletedUsers, userID) + return nil +} + +func (f *fakeWorksmobilePendingRecreateClient) PatchUser(ctx context.Context, identifier string, payload service.WorksmobileUserPatchPayload) error { + f.patchedUsers = append(f.patchedUsers, fakeWorksmobilePendingRecreatePatch{identifier: identifier, payload: payload}) + return nil +} + +func (f *fakeWorksmobilePendingRecreateClient) SetUserActive(ctx context.Context, userID string, active bool) error { + return nil +} + +func (f *fakeWorksmobilePendingRecreateClient) ListUsers(ctx context.Context) ([]service.WorksmobileRemoteUser, error) { + return nil, nil +} + +func (f *fakeWorksmobilePendingRecreateClient) ListGroups(ctx context.Context) ([]service.WorksmobileRemoteGroup, error) { + return nil, nil +} + +func (f *fakeWorksmobilePendingRecreateClient) UndeleteUser(ctx context.Context, userID string) error { + f.undeletedUsers = append(f.undeletedUsers, userID) + return nil +} + +type fakeHanmacWorksmobileUserStore struct { + users map[string]domain.User + saved []domain.User +} + +func (f *fakeHanmacWorksmobileUserStore) FindByEmail(ctx context.Context, email string) (domain.User, bool, error) { + if f.users == nil { + return domain.User{}, false, nil + } + user, ok := f.users[strings.ToLower(strings.TrimSpace(email))] + return user, ok, nil +} + +func (f *fakeHanmacWorksmobileUserStore) Save(ctx context.Context, user *domain.User) (bool, error) { + created := true + if f.users == nil { + f.users = map[string]domain.User{} + } else if _, ok := f.users[strings.ToLower(strings.TrimSpace(user.Email))]; ok { + created = false + } + f.users[strings.ToLower(strings.TrimSpace(user.Email))] = *user + f.saved = append(f.saved, *user) + return created, nil +} diff --git a/backend/cmd/adminctl/worksmobile_sync.go b/backend/cmd/adminctl/worksmobile_sync.go index 3677994d..9cbe8106 100644 --- a/backend/cmd/adminctl/worksmobile_sync.go +++ b/backend/cmd/adminctl/worksmobile_sync.go @@ -15,34 +15,55 @@ import ( "strings" "time" + "github.com/google/uuid" "gorm.io/gorm" ) type worksmobileSyncConfig struct { - TenantSlug string - SyncOrgUnits bool - UsersCSV string - InspectUsersCSV string - InspectOrgUnitsCSV string - UndeleteUsersCSV string - RemoveAliasesCSV string - FindNumberStrippedAliasesOutput string - DuplicatePhoneCountryCodeOutput string - FixDuplicatePhoneCountryCode bool - PendingUsersOutput string - ResetPendingUsersPassword string - ResetPendingUsersResultOutput string - ComparisonOutput string - AlignBaronFromWorksOutput string - AlignBaronFromWorksExclude string - InspectOutput string - CredentialBatchID string - Process bool - SerializeOrgUnits bool - SerializeUsersBatch string - BatchSize int - Delay time.Duration - MaxCycles int + TenantSlug string + SyncOrgUnits bool + UsersCSV string + InspectUsersCSV string + InspectOrgUnitsCSV string + UpsertOrgUnitID string + UndeleteUsersCSV string + RemoveAliasesCSV string + FindNumberStrippedAliasesOutput string + DuplicatePhoneCountryCodeOutput string + FixDuplicatePhoneCountryCode bool + PendingUsersOutput string + ResetPendingUsersPassword string + ResetPendingUsersResultOutput string + DeletePendingUsersResultOutput string + ForceDeleteUsersCSV string + ForceDeleteUsersResultOutput string + ForceDeleteUsersLimit int + CreateUsersCSV string + CreateUsersPassword string + CreateUsersResultOutput string + CreateUsersLimit int + CreateUsersForcePasswordChange bool + ImportHanmacUsersCSV string + ImportHanmacUsersResultOutput string + ImportHanmacUsersPassword string + ImportHanmacUsersLimit int + ImportHanmacUsersForcePasswordChange bool + RecreatePendingUsersPassword string + RecreatePendingUsersResultOutput string + RecreatePendingUsersLimit int + RecreatePendingUsersCreateDelay time.Duration + ActivateAllUsersOutput string + ComparisonOutput string + AlignBaronFromWorksOutput string + AlignBaronFromWorksExclude string + InspectOutput string + CredentialBatchID string + Process bool + SerializeOrgUnits bool + SerializeUsersBatch string + BatchSize int + Delay time.Duration + MaxCycles int } func runWorksmobileSync(args []string) error { @@ -95,6 +116,11 @@ func runWorksmobileSync(args []string) error { return err } } + if config.UpsertOrgUnitID != "" { + if err := upsertSingleWorksmobileOrgUnit(ctx, db, tenantRepo, *root, config.UpsertOrgUnitID, newWorksmobileAdminClient()); err != nil { + return err + } + } if config.UndeleteUsersCSV != "" { if err := undeleteWorksmobileUsers(ctx, config.UndeleteUsersCSV, config.InspectOutput, newWorksmobileAdminClient()); err != nil { return err @@ -115,10 +141,47 @@ func runWorksmobileSync(args []string) error { return err } } - if config.PendingUsersOutput != "" || config.ResetPendingUsersPassword != "" { + var pendingUsers []service.WorksmobileRemoteUser + if config.PendingUsersOutput != "" || config.ResetPendingUsersPassword != "" || config.DeletePendingUsersResultOutput != "" || config.RecreatePendingUsersPassword != "" { if err := exportAndMaybeResetPendingWorksmobileUsers(ctx, config.PendingUsersOutput, config.ResetPendingUsersResultOutput, config.ResetPendingUsersPassword, newWorksmobileAdminClient()); err != nil { return err } + if config.DeletePendingUsersResultOutput != "" || config.RecreatePendingUsersPassword != "" { + pendingUsers, err = readPendingWorksmobileUsersCSV(config.PendingUsersOutput) + if err != nil { + return err + } + } + } + if config.DeletePendingUsersResultOutput != "" { + if err := deletePendingWorksmobileUsers(ctx, config.DeletePendingUsersResultOutput, pendingUsers, newWorksmobileAdminClient()); err != nil { + return err + } + } + if config.ForceDeleteUsersCSV != "" { + if err := forceDeleteWorksmobileUsersFromCSV(ctx, config.ForceDeleteUsersCSV, config.ForceDeleteUsersResultOutput, config.ForceDeleteUsersLimit, newWorksmobileAdminClient()); err != nil { + return err + } + } + if config.CreateUsersCSV != "" { + if err := createWorksmobileUsersFromCSV(ctx, db, tenantRepo, userRepo, *root, config.CreateUsersCSV, config.CreateUsersResultOutput, config.CreateUsersPassword, config.CreateUsersLimit, config.CreateUsersForcePasswordChange, newWorksmobileAdminClient()); err != nil { + return err + } + } + if config.ImportHanmacUsersCSV != "" { + if err := importHanmacUsersAndCreateWorksmobileAccounts(ctx, db, tenantRepo, userRepo, *root, config.ImportHanmacUsersCSV, config.ImportHanmacUsersResultOutput, config.ImportHanmacUsersPassword, config.ImportHanmacUsersLimit, config.ImportHanmacUsersForcePasswordChange, newWorksmobileAdminClient()); err != nil { + return err + } + } + if config.RecreatePendingUsersPassword != "" { + if err := recreatePendingWorksmobileUsers(ctx, db, tenantRepo, userRepo, *root, config.PendingUsersOutput, config.RecreatePendingUsersResultOutput, config.RecreatePendingUsersPassword, config.RecreatePendingUsersLimit, config.RecreatePendingUsersCreateDelay, newWorksmobileAdminClient()); err != nil { + return err + } + } + if config.ActivateAllUsersOutput != "" { + if err := activateAllWorksmobileUsers(ctx, config.ActivateAllUsersOutput, newWorksmobileSCIMClient()); err != nil { + return err + } } if config.ComparisonOutput != "" { if err := exportWorksmobileNeedsUpdateComparison(ctx, syncService, root.ID, config.ComparisonOutput); err != nil { @@ -151,6 +214,7 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error) fs.StringVar(&config.UsersCSV, "users-csv", "", "CSV containing user_id column to regenerate and enqueue user upserts") fs.StringVar(&config.InspectUsersCSV, "inspect-users-csv", "", "CSV containing email or login_email column to compare with remote Worksmobile users") fs.StringVar(&config.InspectOrgUnitsCSV, "inspect-orgunits-csv", "", "CSV containing orgunit_external_key or tenant_id column to compare with remote Worksmobile orgUnits") + fs.StringVar(&config.UpsertOrgUnitID, "upsert-orgunit-id", "", "single Baron tenant id to upsert as a Worksmobile orgUnit") fs.StringVar(&config.UndeleteUsersCSV, "undelete-users-csv", "", "CSV containing email or login_email column to undelete Worksmobile users") fs.StringVar(&config.RemoveAliasesCSV, "remove-aliases-csv", "", "CSV containing owner_email and alias_email columns to remove Worksmobile aliases") fs.StringVar(&config.FindNumberStrippedAliasesOutput, "find-number-stripped-aliases-output", "", "output CSV path for aliases whose local-part equals owner local-part with trailing digits removed") @@ -159,6 +223,25 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error) fs.StringVar(&config.PendingUsersOutput, "pending-users-output", "", "output CSV path for current Worksmobile users whose isPending flag is true") fs.StringVar(&config.ResetPendingUsersPassword, "reset-pending-users-password", "", "reset current Worksmobile pending users to this password") fs.StringVar(&config.ResetPendingUsersResultOutput, "reset-pending-users-result-output", "", "output CSV path for pending-user password reset results") + fs.StringVar(&config.DeletePendingUsersResultOutput, "delete-pending-users-result-output", "", "output CSV path for pending-user delete results") + fs.StringVar(&config.ForceDeleteUsersCSV, "force-delete-users-csv", "", "CSV containing email or user_id column to force-delete Worksmobile users") + fs.StringVar(&config.ForceDeleteUsersResultOutput, "force-delete-users-result-output", "", "output CSV path for Worksmobile force-delete results") + fs.IntVar(&config.ForceDeleteUsersLimit, "force-delete-users-limit", 0, "maximum users to force-delete; 0 means all") + fs.StringVar(&config.CreateUsersCSV, "create-users-csv", "", "CSV containing user_external_key or email column to create Worksmobile users from Baron") + fs.StringVar(&config.CreateUsersPassword, "create-users-password", "", "initial password for --create-users-csv") + fs.StringVar(&config.CreateUsersResultOutput, "create-users-result-output", "", "output CSV path for Worksmobile create results") + fs.IntVar(&config.CreateUsersLimit, "create-users-limit", 0, "maximum users to create; 0 means all") + fs.BoolVar(&config.CreateUsersForcePasswordChange, "create-users-force-password-change", true, "request password change at next login for --create-users-csv") + fs.StringVar(&config.ImportHanmacUsersCSV, "import-hanmac-users-csv", "", "CSV containing Hanmac internal users to upsert into Baron and create in Worksmobile") + fs.StringVar(&config.ImportHanmacUsersResultOutput, "import-hanmac-users-result-output", "", "output CSV path for Hanmac user import and Worksmobile create results") + fs.StringVar(&config.ImportHanmacUsersPassword, "import-hanmac-users-password", "", "initial password for --import-hanmac-users-csv") + fs.IntVar(&config.ImportHanmacUsersLimit, "import-hanmac-users-limit", 0, "maximum Hanmac users to import; 0 means all") + fs.BoolVar(&config.ImportHanmacUsersForcePasswordChange, "import-hanmac-users-force-password-change", true, "request password change at next login for --import-hanmac-users-csv") + fs.StringVar(&config.RecreatePendingUsersPassword, "recreate-pending-users-password", "", "delete and recreate current Worksmobile pending users with this initial password") + fs.StringVar(&config.RecreatePendingUsersResultOutput, "recreate-pending-users-result-output", "", "output CSV path for pending-user delete/recreate results") + fs.IntVar(&config.RecreatePendingUsersLimit, "recreate-pending-users-limit", 0, "maximum pending users to delete/recreate; 0 means all") + fs.DurationVar(&config.RecreatePendingUsersCreateDelay, "recreate-pending-users-create-delay", 0, "delay between pending-user tombstone patch and recreated user create") + fs.StringVar(&config.ActivateAllUsersOutput, "activate-all-users-output", "", "output CSV path for activating every non-active Worksmobile user") fs.StringVar(&config.ComparisonOutput, "comparison-output", "", "output CSV path for current Worksmobile user comparison rows whose status is needs_update") fs.StringVar(&config.AlignBaronFromWorksOutput, "align-baron-from-works-output", "", "output CSV path for one-time Baron user updates from current Worksmobile needs_update rows") fs.StringVar(&config.AlignBaronFromWorksExclude, "align-baron-from-works-exclude", "", "comma-separated emails or local-parts to exclude from --align-baron-from-works-output") @@ -173,8 +256,8 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error) if err := fs.Parse(args); err != nil { return config, err } - if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && !config.Process { - return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --comparison-output, --align-baron-from-works-output, or --process") + if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && !config.Process { + return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, or --process") } if config.ResetPendingUsersPassword != "" && config.ResetPendingUsersResultOutput == "" { return config, fmt.Errorf("--reset-pending-users-result-output is required with --reset-pending-users-password") @@ -182,6 +265,30 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error) if config.ResetPendingUsersPassword != "" && config.PendingUsersOutput == "" { return config, fmt.Errorf("--pending-users-output is required with --reset-pending-users-password") } + if config.DeletePendingUsersResultOutput != "" && config.PendingUsersOutput == "" { + return config, fmt.Errorf("--pending-users-output is required with --delete-pending-users-result-output") + } + if config.ForceDeleteUsersCSV != "" && config.ForceDeleteUsersResultOutput == "" { + return config, fmt.Errorf("--force-delete-users-result-output is required with --force-delete-users-csv") + } + if config.CreateUsersCSV != "" && config.CreateUsersPassword == "" { + return config, fmt.Errorf("--create-users-password is required with --create-users-csv") + } + if config.CreateUsersCSV != "" && config.CreateUsersResultOutput == "" { + return config, fmt.Errorf("--create-users-result-output is required with --create-users-csv") + } + if config.ImportHanmacUsersCSV != "" && config.ImportHanmacUsersPassword == "" { + return config, fmt.Errorf("--import-hanmac-users-password is required with --import-hanmac-users-csv") + } + if config.ImportHanmacUsersCSV != "" && config.ImportHanmacUsersResultOutput == "" { + return config, fmt.Errorf("--import-hanmac-users-result-output is required with --import-hanmac-users-csv") + } + if config.RecreatePendingUsersPassword != "" && config.RecreatePendingUsersResultOutput == "" { + return config, fmt.Errorf("--recreate-pending-users-result-output is required with --recreate-pending-users-password") + } + if config.RecreatePendingUsersPassword != "" && config.PendingUsersOutput == "" { + return config, fmt.Errorf("--pending-users-output is required with --recreate-pending-users-password") + } return config, nil } @@ -211,6 +318,89 @@ func enqueueWorksmobileOrgUnits(ctx context.Context, db *gorm.DB, syncService se return enqueued, skipped, failed, nil } +func upsertSingleWorksmobileOrgUnit(ctx context.Context, db *gorm.DB, tenantRepo repository.TenantRepository, root domain.Tenant, orgUnitID string, client service.WorksmobileDirectoryClient) error { + tenantIDs, err := activeTenantSubtreeIDs(ctx, db, root.ID) + if err != nil { + return err + } + tenants, err := tenantRepo.FindByIDs(ctx, tenantIDs) + if err != nil { + return err + } + tenantByID := map[string]domain.Tenant{root.ID: root} + for _, tenant := range tenants { + tenantByID[tenant.ID] = tenant + } + targetID := strings.TrimSpace(orgUnitID) + target, ok := tenantByID[targetID] + if !ok || targetID == root.ID { + return fmt.Errorf("--upsert-orgunit-id must be an active tenant inside %s subtree", root.Slug) + } + payload, err := buildAdminctlWorksmobileOrgUnitPayload(target, root, tenantByID) + if err != nil { + return err + } + if err := client.UpsertOrgUnit(ctx, payload, strings.TrimSpace(target.Slug)); err != nil { + return err + } + fmt.Printf("worksmobile orgunit upserted: tenant_id=%s slug=%s name=%s domain_id=%d\n", target.ID, target.Slug, target.Name, payload.DomainID) + return nil +} + +func buildAdminctlWorksmobileOrgUnitPayload(tenant domain.Tenant, root domain.Tenant, tenantByID map[string]domain.Tenant) (service.WorksmobileOrgUnitPayload, error) { + domainTenant := adminctlWorksmobileDomainTenant(tenant, tenantByID) + payload, err := service.BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, domainTenant, root.Config, 1) + if err != nil { + return service.WorksmobileOrgUnitPayload{}, err + } + if adminctlShouldClearWorksmobileOrgUnitParent(tenant, tenantByID, root.ID) { + payload.ParentOrgUnitID = "" + } + return payload, nil +} + +func adminctlWorksmobileDomainTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant { + current := tenant + for { + if adminctlIsWorksmobileDomainRootTenant(current) { + return current + } + if current.ParentID == nil || strings.TrimSpace(*current.ParentID) == "" { + return tenant + } + parent, ok := tenantByID[*current.ParentID] + if !ok { + return tenant + } + current = parent + } +} + +func adminctlShouldClearWorksmobileOrgUnitParent(tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootID string) bool { + if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" { + return false + } + if *tenant.ParentID == rootID { + return true + } + parent, ok := tenantByID[*tenant.ParentID] + return ok && adminctlIsWorksmobileDomainRootTenant(parent) +} + +func adminctlIsWorksmobileDomainRootTenant(tenant domain.Tenant) bool { + switch strings.ToLower(strings.TrimSpace(tenant.Slug)) { + case "saman", "hanmac", "gpdtdc", "halla", "hanlla", "baron-group": + return true + } + for _, candidate := range tenant.Domains { + switch strings.ToLower(strings.TrimSpace(candidate.Domain)) { + case "samaneng.com", "hanmaceng.co.kr", "baroncs.co.kr", "hallasanup.com", "brsw.kr": + return true + } + } + return false +} + func activeTenantSubtreeIDs(ctx context.Context, db *gorm.DB, rootID string) ([]string, error) { var ids []string err := db.WithContext(ctx).Raw(` @@ -907,6 +1097,1193 @@ func resetPendingWorksmobileUserPasswords(ctx context.Context, outputPath string return nil } +func deletePendingWorksmobileUsers(ctx context.Context, outputPath string, pendingUsers []service.WorksmobileRemoteUser, client service.WorksmobileDirectoryClient) error { + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + writer := csv.NewWriter(file) + defer writer.Flush() + header := []string{ + "email", + "user_id", + "user_external_key", + "display_name", + "delete_identifier", + "status", + "error", + } + if err := writer.Write(header); err != nil { + return err + } + okCount := 0 + errorCount := 0 + skippedCount := 0 + for _, remote := range pendingUsers { + identifier := strings.TrimSpace(remote.Email) + if identifier == "" { + identifier = strings.TrimSpace(remote.ID) + } + status := "ok" + errorMessage := "" + if identifier == "" { + status = "skipped" + errorMessage = "delete identifier is empty" + skippedCount++ + } else if err := client.DeleteUser(ctx, identifier); err != nil { + status = "error" + errorMessage = err.Error() + errorCount++ + } else { + okCount++ + } + if err := writer.Write([]string{ + remote.Email, + remote.ID, + remote.ExternalID, + remote.DisplayName, + identifier, + status, + errorMessage, + }); err != nil { + return err + } + writer.Flush() + if err := writer.Error(); err != nil { + return err + } + } + fmt.Printf("worksmobile pending users delete result written: %s targets=%d ok=%d skipped=%d errors=%d\n", outputPath, len(pendingUsers), okCount, skippedCount, errorCount) + return nil +} + +func forceDeleteWorksmobileUsersFromCSV(ctx context.Context, usersCSV string, outputPath string, limit int, client *service.WorksmobileHTTPClient) error { + if limit < 0 { + return fmt.Errorf("--force-delete-users-limit cannot be negative") + } + targets, err := readPendingWorksmobileUsersCSV(usersCSV) + if err != nil { + return err + } + if limit > 0 && len(targets) > limit { + targets = targets[:limit] + } + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + writer := csv.NewWriter(file) + defer writer.Flush() + header := []string{"email", "user_id", "user_external_key", "display_name", "force_delete_identifier", "status", "error"} + if err := writer.Write(header); err != nil { + return err + } + okCount := 0 + skippedCount := 0 + errorCount := 0 + for _, remote := range targets { + identifier := strings.TrimSpace(remote.Email) + if identifier == "" { + identifier = strings.TrimSpace(remote.ID) + } + status := "ok" + errorMessage := "" + if identifier == "" { + status = "skipped" + errorMessage = "force-delete identifier is empty" + skippedCount++ + } else if err := client.ForceDeleteUser(ctx, identifier); err != nil { + status = "error" + errorMessage = err.Error() + errorCount++ + } else { + okCount++ + } + if err := writer.Write([]string{remote.Email, remote.ID, remote.ExternalID, remote.DisplayName, identifier, status, errorMessage}); err != nil { + return err + } + writer.Flush() + if err := writer.Error(); err != nil { + return err + } + } + fmt.Printf("worksmobile users force-delete result written: %s targets=%d ok=%d skipped=%d errors=%d\n", outputPath, len(targets), okCount, skippedCount, errorCount) + return nil +} + +func createWorksmobileUsersFromCSV(ctx context.Context, db *gorm.DB, tenantRepo repository.TenantRepository, userRepo repository.UserRepository, root domain.Tenant, usersCSV string, outputPath string, password string, limit int, forcePasswordChange bool, client *service.WorksmobileHTTPClient) error { + if limit < 0 { + return fmt.Errorf("--create-users-limit cannot be negative") + } + targets, err := readPendingWorksmobileUsersCSV(usersCSV) + if err != nil { + return err + } + if limit > 0 && len(targets) > limit { + targets = targets[:limit] + } + tenantIDs, err := activeTenantSubtreeIDs(ctx, db, root.ID) + if err != nil { + return err + } + tenants, err := tenantRepo.FindByIDs(ctx, tenantIDs) + if err != nil { + return err + } + tenantByID := map[string]domain.Tenant{root.ID: root} + for _, tenant := range tenants { + tenantByID[tenant.ID] = tenant + } + userIDs := make([]string, 0, len(targets)) + seenUserIDs := map[string]bool{} + for _, remote := range targets { + userID := strings.TrimSpace(remote.ExternalID) + if userID == "" || seenUserIDs[userID] { + continue + } + if _, err := uuid.Parse(userID); err != nil { + continue + } + seenUserIDs[userID] = true + userIDs = append(userIDs, userID) + } + users, err := userRepo.FindByIDs(ctx, userIDs) + if err != nil { + return err + } + userByID := map[string]domain.User{} + for _, user := range users { + userByID[user.ID] = user + } + + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + writer := csv.NewWriter(file) + defer writer.Flush() + header := []string{"email", "user_id", "user_external_key", "display_name", "baron_user_id", "baron_status", "status", "error"} + if err := writer.Write(header); err != nil { + return err + } + okCount := 0 + skippedCount := 0 + errorCount := 0 + var changePasswordAtNextLogin *bool + if forcePasswordChange { + value := true + changePasswordAtNextLogin = &value + } + for _, remote := range targets { + status := "ok" + errorMessage := "" + baronUserID := "" + baronStatus := "" + user, ok := userByID[strings.TrimSpace(remote.ExternalID)] + if !ok { + found, err := userRepo.FindByEmail(ctx, strings.TrimSpace(remote.Email)) + if err == nil && found != nil { + user = *found + ok = true + } + } + if !ok { + status = "skipped" + errorMessage = "baron user not found" + skippedCount++ + } else { + baronUserID = user.ID + baronStatus = user.Status + if user.TenantID == nil { + status = "skipped" + errorMessage = "baron user has no tenant" + skippedCount++ + } else if !domain.IsWorksProvisionedUserStatus(user.Status) { + status = "skipped" + errorMessage = "baron user status is excluded from Worksmobile sync" + skippedCount++ + } else { + tenant, ok := tenantByID[*user.TenantID] + if !ok { + status = "skipped" + errorMessage = "baron user tenant is outside Worksmobile sync scope" + skippedCount++ + } else { + payload, err := service.BuildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, root.Config) + if err != nil { + status = "skipped" + errorMessage = err.Error() + skippedCount++ + } else { + payload.PasswordConfig = service.WorksmobilePasswordConfig{ + PasswordCreationType: "ADMIN", + Password: strings.TrimSpace(password), + ChangePasswordAtNextLogin: changePasswordAtNextLogin, + } + if err := client.CreateUser(ctx, payload); err != nil { + status = "error" + errorMessage = err.Error() + errorCount++ + } else { + okCount++ + } + } + } + } + } + if err := writer.Write([]string{remote.Email, remote.ID, remote.ExternalID, remote.DisplayName, baronUserID, baronStatus, status, errorMessage}); err != nil { + return err + } + writer.Flush() + if err := writer.Error(); err != nil { + return err + } + } + fmt.Printf("worksmobile users create result written: %s targets=%d ok=%d skipped=%d errors=%d\n", outputPath, len(targets), okCount, skippedCount, errorCount) + return nil +} + +type hanmacWorksmobileImportRow struct { + Email string + Name string + Phone string + Role string + TenantSlug string + Department string + Grade string + Position string + JobTitle string + EmployeeID string + SubEmail string +} + +type hanmacWorksmobileImportCounts struct { + Targets int + OK int + Skipped int + Errors int + BaronCreated int + BaronUpdated int +} + +type hanmacWorksmobileUserStore interface { + FindByEmail(ctx context.Context, email string) (domain.User, bool, error) + Save(ctx context.Context, user *domain.User) (created bool, err error) +} + +type worksmobileUserCreateClient interface { + CreateUser(ctx context.Context, payload service.WorksmobileUserPayload) error +} + +func importHanmacUsersAndCreateWorksmobileAccounts(ctx context.Context, db *gorm.DB, tenantRepo repository.TenantRepository, userRepo repository.UserRepository, root domain.Tenant, usersCSV string, outputPath string, password string, limit int, forcePasswordChange bool, client service.WorksmobileDirectoryClient) error { + if limit < 0 { + return fmt.Errorf("--import-hanmac-users-limit cannot be negative") + } + rows, err := readHanmacWorksmobileImportRowsCSV(usersCSV) + if err != nil { + return err + } + tenantIDs, err := activeTenantSubtreeIDs(ctx, db, root.ID) + if err != nil { + return err + } + tenants, err := tenantRepo.FindByIDs(ctx, tenantIDs) + if err != nil { + return err + } + tenantByID := map[string]domain.Tenant{root.ID: root} + tenantBySlug := map[string]domain.Tenant{strings.ToLower(strings.TrimSpace(root.Slug)): root} + for _, tenant := range tenants { + tenantByID[tenant.ID] = tenant + tenantBySlug[strings.ToLower(strings.TrimSpace(tenant.Slug))] = tenant + } + remoteUsers, err := client.ListUsers(ctx) + if err != nil { + return err + } + localUsers, err := userRepo.FindByTenantIDs(ctx, append([]string{root.ID}, tenantIDs...)) + if err != nil { + return err + } + localPartOwners := localWorksmobileLocalPartOwners(localUsers, tenantByID) + + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + writer := csv.NewWriter(file) + defer writer.Flush() + + counts, err := importHanmacWorksmobileUsersFromRows( + ctx, + rows, + root, + tenantBySlug, + tenantByID, + remoteUsers, + localPartOwners, + &repositoryHanmacWorksmobileUserStore{repo: userRepo}, + strings.TrimSpace(password), + limit, + forcePasswordChange, + writer, + client, + ) + if err != nil { + return err + } + if err := writer.Error(); err != nil { + return err + } + fmt.Printf("hanmac users import and Worksmobile create result written: %s targets=%d ok=%d skipped=%d errors=%d baron_created=%d baron_updated=%d\n", outputPath, counts.Targets, counts.OK, counts.Skipped, counts.Errors, counts.BaronCreated, counts.BaronUpdated) + return nil +} + +type repositoryHanmacWorksmobileUserStore struct { + repo repository.UserRepository +} + +func (s *repositoryHanmacWorksmobileUserStore) FindByEmail(ctx context.Context, email string) (domain.User, bool, error) { + user, err := s.repo.FindByEmail(ctx, email) + if err == nil { + return *user, true, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return domain.User{}, false, nil + } + return domain.User{}, false, err +} + +func (s *repositoryHanmacWorksmobileUserStore) Save(ctx context.Context, user *domain.User) (bool, error) { + created := false + if strings.TrimSpace(user.ID) == "" { + user.ID = uuid.NewString() + created = true + if err := s.repo.Create(ctx, user); err != nil { + return false, err + } + return created, nil + } + if _, exists, err := s.FindByEmail(ctx, user.Email); err != nil { + return false, err + } else if !exists { + created = true + } + if created { + return true, s.repo.Create(ctx, user) + } + return false, s.repo.Update(ctx, user) +} + +func readHanmacWorksmobileImportRowsCSV(path string) ([]hanmacWorksmobileImportRow, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + reader := csv.NewReader(file) + rows, err := reader.ReadAll() + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + header := rows[0] + required := []string{"email", "name", "tenant_slug"} + for _, name := range required { + if slices.Index(header, name) < 0 { + return nil, fmt.Errorf("CSV must contain %s column: %s", name, path) + } + } + value := func(row []string, name string) string { + index := slices.Index(header, name) + if index < 0 || index >= len(row) { + return "" + } + return strings.TrimSpace(row[index]) + } + result := make([]hanmacWorksmobileImportRow, 0, len(rows)-1) + for _, row := range rows[1:] { + email := strings.ToLower(value(row, "email")) + if email == "" { + continue + } + result = append(result, hanmacWorksmobileImportRow{ + Email: email, + Name: value(row, "name"), + Phone: value(row, "phone"), + Role: value(row, "role"), + TenantSlug: strings.ToLower(value(row, "tenant_slug")), + Department: value(row, "department"), + Grade: value(row, "grade"), + Position: value(row, "position"), + JobTitle: value(row, "jobTitle"), + EmployeeID: value(row, "employee_id"), + SubEmail: strings.ToLower(value(row, "sub_email")), + }) + } + return result, nil +} + +func importHanmacWorksmobileUsersFromRows(ctx context.Context, rows []hanmacWorksmobileImportRow, root domain.Tenant, tenantBySlug map[string]domain.Tenant, tenantByID map[string]domain.Tenant, remoteUsers []service.WorksmobileRemoteUser, localPartOwners map[string]string, store hanmacWorksmobileUserStore, password string, limit int, forcePasswordChange bool, writer *csv.Writer, client worksmobileUserCreateClient) (hanmacWorksmobileImportCounts, error) { + if limit < 0 { + return hanmacWorksmobileImportCounts{}, fmt.Errorf("--import-hanmac-users-limit cannot be negative") + } + if limit > 0 && len(rows) > limit { + rows = rows[:limit] + } + header := []string{"email", "name", "tenant_slug", "tenant_id", "baron_user_id", "baron_action", "works_status", "local_part_candidates", "conflict_local_part", "conflict_owner", "error"} + if err := writer.Write(header); err != nil { + return hanmacWorksmobileImportCounts{}, err + } + counts := hanmacWorksmobileImportCounts{Targets: len(rows)} + remoteLocalPartOwners := remoteWorksmobileLocalPartOwners(remoteUsers) + if localPartOwners == nil { + localPartOwners = map[string]string{} + } + claimedLocalParts := map[string]string{} + writeResult := func(row hanmacWorksmobileImportRow, tenantID string, baronUserID string, baronAction string, status string, candidates []string, conflictLocalPart string, conflictOwner string, errorMessage string) error { + if err := writer.Write([]string{row.Email, row.Name, row.TenantSlug, tenantID, baronUserID, baronAction, status, strings.Join(candidates, ";"), conflictLocalPart, conflictOwner, errorMessage}); err != nil { + return err + } + writer.Flush() + return writer.Error() + } + var changePasswordAtNextLogin *bool + if forcePasswordChange { + value := true + changePasswordAtNextLogin = &value + } + for _, row := range rows { + status := "ok" + errorMessage := "" + baronAction := "" + baronUserID := "" + tenantID := "" + conflictLocalPart := "" + conflictOwner := "" + + candidates, err := hanmacWorksmobileImportLocalParts(row) + if err != nil { + status = "skipped" + errorMessage = err.Error() + counts.Skipped++ + if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil { + return counts, err + } + continue + } + tenant, ok := tenantBySlug[strings.ToLower(strings.TrimSpace(row.TenantSlug))] + if !ok { + status = "skipped" + errorMessage = "tenant_slug not found in Worksmobile sync scope" + counts.Skipped++ + if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil { + return counts, err + } + continue + } + tenantID = tenant.ID + if conflictLocalPart, conflictOwner = findHanmacImportLocalPartConflict(candidates, remoteLocalPartOwners, localPartOwners, claimedLocalParts, row.Email); conflictLocalPart != "" { + status = "skipped" + errorMessage = "local-part already exists" + counts.Skipped++ + if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil { + return counts, err + } + continue + } + + user, exists, err := store.FindByEmail(ctx, row.Email) + if err != nil { + status = "error" + errorMessage = err.Error() + counts.Errors++ + if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil { + return counts, err + } + continue + } + if !exists { + user.ID = uuid.NewString() + baronAction = "created" + } else { + baronAction = "updated" + } + applyHanmacWorksmobileImportRowToUser(&user, row, tenant) + baronUserID = user.ID + created, err := store.Save(ctx, &user) + if err != nil { + status = "error" + errorMessage = err.Error() + counts.Errors++ + if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil { + return counts, err + } + continue + } + if created { + baronAction = "created" + counts.BaronCreated++ + } else { + baronAction = "updated" + counts.BaronUpdated++ + } + payload, err := service.BuildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, root.Config) + if err != nil { + status = "skipped" + errorMessage = err.Error() + counts.Skipped++ + if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil { + return counts, err + } + continue + } + payload.PasswordConfig = service.WorksmobilePasswordConfig{ + PasswordCreationType: "ADMIN", + Password: password, + ChangePasswordAtNextLogin: changePasswordAtNextLogin, + } + if err := client.CreateUser(ctx, payload); err != nil { + status = "error" + errorMessage = err.Error() + counts.Errors++ + } else { + counts.OK++ + for _, localPart := range candidates { + claimedLocalParts[localPart] = row.Email + localPartOwners[localPart] = row.Email + } + } + if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil { + return counts, err + } + } + return counts, nil +} + +func applyHanmacWorksmobileImportRowToUser(user *domain.User, row hanmacWorksmobileImportRow, tenant domain.Tenant) { + user.Email = strings.ToLower(strings.TrimSpace(row.Email)) + user.Name = strings.TrimSpace(row.Name) + user.Phone = strings.TrimSpace(row.Phone) + user.Role = domain.NormalizeRole(row.Role) + user.Status = domain.UserStatusActive + user.AffiliationType = "internal" + user.TenantID = &tenant.ID + user.Department = strings.TrimSpace(row.Department) + user.Grade = strings.TrimSpace(row.Grade) + user.Position = strings.TrimSpace(row.Position) + user.JobTitle = strings.TrimSpace(row.JobTitle) + if user.Metadata == nil { + user.Metadata = domain.JSONMap{} + } + if employeeID := strings.TrimSpace(row.EmployeeID); employeeID != "" { + user.Metadata["employee_id"] = employeeID + } + delete(user.Metadata, "sub_email") + delete(user.Metadata, "external_sub_email") + if subEmail := strings.ToLower(strings.TrimSpace(row.SubEmail)); subEmail != "" { + if aliasSubEmail := hanmacWorksmobileImportAliasSubEmail(row); aliasSubEmail != "" { + user.Metadata["sub_email"] = aliasSubEmail + } else { + user.Metadata["external_sub_email"] = subEmail + } + } + user.Metadata["additionalAppointments"] = []any{map[string]any{ + "tenantId": tenant.ID, + "tenantSlug": tenant.Slug, + "tenantName": tenant.Name, + "isPrimary": true, + }} +} + +func hanmacWorksmobileImportLocalParts(row hanmacWorksmobileImportRow) ([]string, error) { + candidates := make([]string, 0, 3) + addEmailLocalPart := func(email string) error { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil + } + localPart, err := domain.ExtractNormalizedEmailLocalPart(email) + if err != nil { + return err + } + if !slices.Contains(candidates, localPart) { + candidates = append(candidates, localPart) + } + return nil + } + if err := addEmailLocalPart(row.Email); err != nil { + return candidates, fmt.Errorf("invalid email: %w", err) + } + if aliasSubEmail := hanmacWorksmobileImportAliasSubEmail(row); aliasSubEmail != "" { + if err := addEmailLocalPart(aliasSubEmail); err != nil { + return candidates, fmt.Errorf("invalid sub_email: %w", err) + } + } + employeeID := strings.ToLower(strings.TrimSpace(row.EmployeeID)) + if employeeID != "" && !slices.Contains(candidates, employeeID) { + candidates = append(candidates, employeeID) + } + if len(candidates) == 0 { + return candidates, errors.New("email local-part is empty") + } + return candidates, nil +} + +func hanmacWorksmobileImportAliasSubEmail(row hanmacWorksmobileImportRow) string { + subEmail := strings.ToLower(strings.TrimSpace(row.SubEmail)) + if subEmail == "" { + return "" + } + _, primaryDomain, err := domain.SplitEmailDomain(row.Email) + if err != nil { + return "" + } + _, subDomain, err := domain.SplitEmailDomain(subEmail) + if err != nil { + return "" + } + if subDomain != primaryDomain { + return "" + } + return subEmail +} + +func remoteWorksmobileLocalPartOwners(remoteUsers []service.WorksmobileRemoteUser) map[string]string { + owners := map[string]string{} + for _, remote := range remoteUsers { + owner := strings.ToLower(strings.TrimSpace(remote.Email)) + if owner == "" { + owner = strings.TrimSpace(remote.ID) + } + for _, value := range []string{remote.Email, remote.UserName} { + if localPart := normalizedWorksmobileLocalPart(value); localPart != "" { + owners[localPart] = owner + } + } + for _, alias := range remote.AliasEmails { + if localPart := normalizedWorksmobileLocalPart(alias); localPart != "" { + owners[localPart] = owner + } + } + } + return owners +} + +func localWorksmobileLocalPartOwners(users []domain.User, tenantByID map[string]domain.Tenant) map[string]string { + owners := map[string]string{} + for _, user := range users { + owner := strings.ToLower(strings.TrimSpace(user.Email)) + if localPart := normalizedWorksmobileLocalPart(user.Email); localPart != "" { + owners[localPart] = owner + } + if user.TenantID == nil { + continue + } + tenant, ok := tenantByID[*user.TenantID] + if !ok { + continue + } + for _, alias := range service.BuildWorksmobileAliasEmails(user, tenant) { + if localPart := normalizedWorksmobileLocalPart(alias); localPart != "" { + owners[localPart] = owner + } + } + } + return owners +} + +func normalizedWorksmobileLocalPart(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + if strings.Contains(value, "@") { + localPart, err := domain.ExtractNormalizedEmailLocalPart(value) + if err != nil { + return "" + } + return localPart + } + return value +} + +func findHanmacImportLocalPartConflict(candidates []string, remoteOwners map[string]string, localOwners map[string]string, claimedOwners map[string]string, email string) (string, string) { + normalizedEmail := strings.ToLower(strings.TrimSpace(email)) + for _, localPart := range candidates { + if owner := remoteOwners[localPart]; owner != "" { + return localPart, owner + } + if owner := localOwners[localPart]; owner != "" && !strings.EqualFold(owner, normalizedEmail) { + return localPart, owner + } + if owner := claimedOwners[localPart]; owner != "" && !strings.EqualFold(owner, normalizedEmail) { + return localPart, owner + } + } + return "", "" +} + +type worksmobileUndeleteClient interface { + UndeleteUser(ctx context.Context, userID string) error +} + +type worksmobilePatchClient interface { + PatchUser(ctx context.Context, identifier string, payload service.WorksmobileUserPatchPayload) error +} + +func recreatePendingWorksmobileUsers(ctx context.Context, db *gorm.DB, tenantRepo repository.TenantRepository, userRepo repository.UserRepository, root domain.Tenant, pendingCSVPath string, outputPath string, password string, limit int, createDelay time.Duration, client service.WorksmobileDirectoryClient) error { + if limit < 0 { + return fmt.Errorf("--recreate-pending-users-limit cannot be negative") + } + pendingUsers, err := readPendingWorksmobileUsersCSV(pendingCSVPath) + if err != nil { + return err + } + if limit > 0 && len(pendingUsers) > limit { + pendingUsers = pendingUsers[:limit] + } + tenantIDs, err := activeTenantSubtreeIDs(ctx, db, root.ID) + if err != nil { + return err + } + tenants, err := tenantRepo.FindByIDs(ctx, tenantIDs) + if err != nil { + return err + } + tenantByID := map[string]domain.Tenant{root.ID: root} + for _, tenant := range tenants { + tenantByID[tenant.ID] = tenant + } + userIDs := make([]string, 0, len(pendingUsers)) + seenUserIDs := map[string]bool{} + for _, remote := range pendingUsers { + userID := strings.TrimSpace(remote.ExternalID) + if userID == "" || seenUserIDs[userID] { + continue + } + if _, err := uuid.Parse(userID); err != nil { + continue + } + seenUserIDs[userID] = true + userIDs = append(userIDs, userID) + } + users, err := userRepo.FindByIDs(ctx, userIDs) + if err != nil { + return err + } + userByID := map[string]domain.User{} + for _, user := range users { + userByID[user.ID] = user + } + + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + writer := csv.NewWriter(file) + defer writer.Flush() + header := []string{ + "email", + "user_id", + "user_external_key", + "display_name", + "baron_user_id", + "baron_status", + "tombstone_email", + "tombstone_status", + "tombstone_error", + "create_status", + "create_error", + "cleanup_status", + "cleanup_error", + "rollback_status", + "rollback_error", + } + if err := writer.Write(header); err != nil { + return err + } + resolveUser := func(ctx context.Context, remote service.WorksmobileRemoteUser) (domain.User, bool) { + user, ok := userByID[strings.TrimSpace(remote.ExternalID)] + if !ok { + found, err := userRepo.FindByEmail(ctx, strings.TrimSpace(remote.Email)) + if err == nil && found != nil { + user = *found + ok = true + } + } + return user, ok + } + counts, err := recreatePendingWorksmobileUsersFromSnapshot(ctx, pendingUsers, resolveUser, tenantByID, root.Config, strings.TrimSpace(password), createDelay, writer, client) + if err != nil { + return err + } + fmt.Printf("worksmobile pending users recreate result written: %s targets=%d ok=%d skipped=%d errors=%d\n", outputPath, counts.Targets, counts.OK, counts.Skipped, counts.Errors) + return nil +} + +type worksmobilePendingUserResolver func(ctx context.Context, remote service.WorksmobileRemoteUser) (domain.User, bool) + +type worksmobilePendingRecreateCounts struct { + Targets int + OK int + Skipped int + Errors int +} + +func recreatePendingWorksmobileUsersFromSnapshot(ctx context.Context, pendingUsers []service.WorksmobileRemoteUser, resolveUser worksmobilePendingUserResolver, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap, password string, createDelay time.Duration, writer *csv.Writer, client service.WorksmobileDirectoryClient) (worksmobilePendingRecreateCounts, error) { + patchClient, ok := client.(worksmobilePatchClient) + if !ok { + return worksmobilePendingRecreateCounts{}, errors.New("worksmobile client does not support pending user tombstone patch") + } + counts := worksmobilePendingRecreateCounts{Targets: len(pendingUsers)} + for _, remote := range pendingUsers { + result := worksmobilePendingRecreateResult{ + Remote: remote, + TombstoneStatus: "skipped", + CreateStatus: "skipped", + CleanupStatus: "skipped", + RollbackStatus: "skipped", + } + user, ok := resolveUser(ctx, remote) + if !ok { + result.TombstoneError = "baron user not found" + counts.Skipped++ + if err := writePendingRecreateResult(writer, result); err != nil { + return counts, err + } + continue + } + result.BaronUserID = user.ID + result.BaronStatus = user.Status + if user.TenantID == nil { + result.TombstoneError = "baron user has no tenant" + counts.Skipped++ + if err := writePendingRecreateResult(writer, result); err != nil { + return counts, err + } + continue + } + tenant, ok := tenantByID[*user.TenantID] + if !ok { + result.TombstoneError = "baron user tenant is outside Worksmobile sync scope" + counts.Skipped++ + if err := writePendingRecreateResult(writer, result); err != nil { + return counts, err + } + continue + } + if !domain.IsWorksProvisionedUserStatus(user.Status) { + result.TombstoneError = "baron user status is excluded from Worksmobile sync" + counts.Skipped++ + if err := writePendingRecreateResult(writer, result); err != nil { + return counts, err + } + continue + } + payload, err := service.BuildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, rootConfig) + if err != nil { + result.TombstoneError = err.Error() + counts.Skipped++ + if err := writePendingRecreateResult(writer, result); err != nil { + return counts, err + } + continue + } + payload.PasswordConfig = service.WorksmobilePasswordConfig{ + PasswordCreationType: "ADMIN", + Password: strings.TrimSpace(password), + } + identifier := strings.TrimSpace(remote.Email) + if identifier == "" { + identifier = strings.TrimSpace(remote.ID) + } + if identifier == "" { + result.TombstoneError = "patch identifier is empty" + counts.Skipped++ + if err := writePendingRecreateResult(writer, result); err != nil { + return counts, err + } + continue + } + tombstoneEmail, tombstoneExternalKey, err := worksmobilePendingTombstoneIdentity(remote, payload) + if err != nil { + result.TombstoneError = err.Error() + counts.Skipped++ + if err := writePendingRecreateResult(writer, result); err != nil { + return counts, err + } + continue + } + result.TombstoneEmail = tombstoneEmail + originalPatch := service.NewWorksmobileUserPatchPayload(payload) + tombstonePatch := originalPatch + tombstonePatch.Email = tombstoneEmail + tombstonePatch.UserExternalKey = tombstoneExternalKey + tombstonePatch.AliasEmails = nil + tombstonePatch.Organizations = worksmobileOrganizationsWithEmail(tombstonePatch.Organizations, tombstoneEmail) + if err := patchClient.PatchUser(ctx, identifier, tombstonePatch); err != nil { + result.TombstoneStatus = "error" + result.TombstoneError = err.Error() + counts.Errors++ + if err := writePendingRecreateResult(writer, result); err != nil { + return counts, err + } + continue + } + result.TombstoneStatus = "ok" + if createDelay > 0 { + select { + case <-time.After(createDelay): + case <-ctx.Done(): + return counts, ctx.Err() + } + } + if err := client.CreateUser(ctx, payload); err != nil { + result.CreateStatus = "error" + result.CreateError = err.Error() + counts.Errors++ + if rollbackErr := patchClient.PatchUser(ctx, tombstoneEmail, originalPatch); rollbackErr != nil { + result.RollbackStatus = "error" + result.RollbackError = rollbackErr.Error() + } else { + result.RollbackStatus = "ok" + } + if err := writePendingRecreateResult(writer, result); err != nil { + return counts, err + } + continue + } + result.CreateStatus = "ok" + if err := client.DeleteUser(ctx, tombstoneEmail); err != nil { + result.CleanupStatus = "error" + result.CleanupError = err.Error() + counts.Errors++ + if err := writePendingRecreateResult(writer, result); err != nil { + return counts, err + } + continue + } + result.CleanupStatus = "ok" + counts.OK++ + if err := writePendingRecreateResult(writer, result); err != nil { + return counts, err + } + } + return counts, nil +} + +type worksmobilePendingRecreateResult struct { + Remote service.WorksmobileRemoteUser + BaronUserID string + BaronStatus string + TombstoneEmail string + TombstoneStatus string + TombstoneError string + CreateStatus string + CreateError string + CleanupStatus string + CleanupError string + RollbackStatus string + RollbackError string +} + +func writePendingRecreateResult(writer *csv.Writer, result worksmobilePendingRecreateResult) error { + if err := writer.Write([]string{ + result.Remote.Email, + result.Remote.ID, + result.Remote.ExternalID, + result.Remote.DisplayName, + result.BaronUserID, + result.BaronStatus, + result.TombstoneEmail, + result.TombstoneStatus, + result.TombstoneError, + result.CreateStatus, + result.CreateError, + result.CleanupStatus, + result.CleanupError, + result.RollbackStatus, + result.RollbackError, + }); err != nil { + return err + } + writer.Flush() + return writer.Error() +} + +func readPendingWorksmobileUsersCSV(path string) ([]service.WorksmobileRemoteUser, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + reader := csv.NewReader(file) + rows, err := reader.ReadAll() + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + header := rows[0] + emailIndex := slices.Index(header, "email") + userIDIndex := slices.Index(header, "user_id") + externalKeyIndex := slices.Index(header, "user_external_key") + displayNameIndex := slices.Index(header, "display_name") + domainIDIndex := slices.Index(header, "domain_id") + if emailIndex < 0 || userIDIndex < 0 || externalKeyIndex < 0 { + return nil, fmt.Errorf("CSV must contain email, user_id, and user_external_key columns: %s", path) + } + users := make([]service.WorksmobileRemoteUser, 0, len(rows)-1) + for _, row := range rows[1:] { + if emailIndex >= len(row) || userIDIndex >= len(row) || externalKeyIndex >= len(row) { + continue + } + remote := service.WorksmobileRemoteUser{ + Email: strings.TrimSpace(row[emailIndex]), + ID: strings.TrimSpace(row[userIDIndex]), + ExternalID: strings.TrimSpace(row[externalKeyIndex]), + } + if domainIDIndex >= 0 && domainIDIndex < len(row) { + fmt.Sscan(strings.TrimSpace(row[domainIDIndex]), &remote.DomainID) + } + if displayNameIndex >= 0 && displayNameIndex < len(row) { + remote.DisplayName = strings.TrimSpace(row[displayNameIndex]) + } + users = append(users, remote) + } + return users, nil +} + +func worksmobilePendingTombstoneIdentity(remote service.WorksmobileRemoteUser, payload service.WorksmobileUserPayload) (string, string, error) { + email := strings.ToLower(strings.TrimSpace(remote.Email)) + if email == "" { + email = strings.ToLower(strings.TrimSpace(payload.Email)) + } + localPart, domainPart, ok := strings.Cut(email, "@") + if !ok || strings.TrimSpace(localPart) == "" || strings.TrimSpace(domainPart) == "" { + return "", "", fmt.Errorf("pending user email is invalid: %s", email) + } + suffix := strings.ReplaceAll(strings.TrimSpace(remote.ID), "-", "") + if len(suffix) > 12 { + suffix = suffix[:12] + } + if suffix == "" { + suffix = time.Now().UTC().Format("20060102150405") + } + base := strings.ToLower(strings.Trim(localPart, ".-_")) + if len(base) > 18 { + base = base[:18] + } + tombstoneEmail := fmt.Sprintf("%s.old%s@%s", base, suffix, domainPart) + tombstoneExternalKey := "pending-replaced-" + suffix + return tombstoneEmail, tombstoneExternalKey, nil +} + +func worksmobileOrganizationsWithEmail(organizations []service.WorksmobileUserOrganization, email string) []service.WorksmobileUserOrganization { + next := make([]service.WorksmobileUserOrganization, len(organizations)) + copy(next, organizations) + for i := range next { + next[i].Email = email + } + return next +} + +func activateAllWorksmobileUsers(ctx context.Context, outputPath string, client *service.WorksmobileHTTPClient) error { + remoteUsers, err := client.ListUsers(ctx) + if err != nil { + return err + } + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + writer := csv.NewWriter(file) + defer writer.Flush() + header := []string{ + "email", + "user_id", + "user_external_key", + "display_name", + "domain_id", + "domain_name", + "before_status", + "is_awaiting", + "is_pending", + "is_suspended", + "is_deleted", + "active", + "action", + "error", + } + if err := writer.Write(header); err != nil { + return err + } + updatedCount := 0 + skippedCount := 0 + errorCount := 0 + for _, remote := range remoteUsers { + status := strings.TrimSpace(remote.AccountStatus) + if status == "" { + status = "unknown" + } + action := "skipped" + errorMessage := "" + switch { + case strings.TrimSpace(remote.Email) == "": + errorMessage = "email is empty" + skippedCount++ + case remote.IsDeleted || status == "deleted": + errorMessage = "deleted user cannot be activated by SCIM active patch" + skippedCount++ + case status == "active": + skippedCount++ + default: + if err := client.SetSCIMUserActiveByID(ctx, remote.ID, true); err != nil { + action = "error" + errorMessage = err.Error() + errorCount++ + } else { + action = "activated" + updatedCount++ + } + } + if err := writer.Write([]string{ + remote.Email, + remote.ID, + remote.ExternalID, + remote.DisplayName, + fmt.Sprint(remote.DomainID), + remote.DomainName, + status, + fmt.Sprint(remote.IsAwaiting), + fmt.Sprint(remote.IsPending), + fmt.Sprint(remote.IsSuspended), + fmt.Sprint(remote.IsDeleted), + fmt.Sprint(remote.Active), + action, + errorMessage, + }); err != nil { + return err + } + writer.Flush() + if err := writer.Error(); err != nil { + return err + } + } + fmt.Printf("worksmobile activate-all users result written: %s remote_users=%d activated=%d skipped=%d errors=%d\n", outputPath, len(remoteUsers), updatedCount, skippedCount, errorCount) + return nil +} + func exportWorksmobileNeedsUpdateComparison(ctx context.Context, syncService service.WorksmobileAdminService, tenantID string, outputPath string) error { comparison, err := syncService.GetComparison(ctx, tenantID, true) if err != nil { @@ -1688,6 +3065,16 @@ func newWorksmobileAdminClient() *service.WorksmobileHTTPClient { return client } +func newWorksmobileSCIMClient() *service.WorksmobileHTTPClient { + client := service.NewWorksmobileHTTPClientWithTokens( + "", + getenv("WORKS_ADMIN_SCIM_TOKEN", getenv("SAMAN_SCIM_LONGLIVE_TOKEN", "")), + ) + client.BaseURL = strings.TrimSpace(getenv("WORKS_ADMIN_API_BASE_URL", "")) + client.RateLimiter = service.NewWorksmobileAPIRateLimiter(180, time.Minute) + return client +} + func getenvFileOrValue(fileKey string, valueKey string, fallback string) (string, error) { if path := strings.TrimSpace(getenv(fileKey, "")); path != "" { data, err := readEnvPath(path) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e5c74185..8f7ba5dc 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -395,6 +395,16 @@ func main() { relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo) userHandler.UserProjectionRepo = userProjectionRepo + userHandler.IdentityCache = redisService + go func() { + startedAt := time.Now() + count, err := userHandler.WarmIdentityMirror(context.Background()) + if err != nil { + slog.Warn("Identity mirror warmup failed", "error", err, "latency", time.Since(startedAt).String()) + return + } + slog.Info("Identity mirror warmup completed", "identities", count, "latency", time.Since(startedAt).String()) + }() tenantHandler.SetWorksmobileSyncer(worksmobileService) userHandler.SetWorksmobileSyncer(worksmobileService) worksmobileHandler := handler.NewWorksmobileHandler(worksmobileService) diff --git a/backend/internal/domain/identity_cache.go b/backend/internal/domain/identity_cache.go index 8b86fd7f..1b338963 100644 --- a/backend/internal/domain/identity_cache.go +++ b/backend/internal/domain/identity_cache.go @@ -5,6 +5,7 @@ import "time" type IdentityCacheStatus struct { Status string `json:"status"` RedisReady bool `json:"redisReady"` + MirrorVersion string `json:"mirrorVersion,omitempty"` ObservedCount int64 `json:"observedCount"` KeyCount int64 `json:"keyCount"` LastRefreshedAt *time.Time `json:"lastRefreshedAt,omitempty"` diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index 942f94ca..da14ef4f 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -307,13 +307,6 @@ func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error { if !requireSuperAdminProfile(c) { return nil } - if h == nil || h.UserProjectionRepo == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"}) - } - projectionStatus, err := h.UserProjectionRepo.GetStatus(c.Context()) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } cacheStatus := domain.IdentityCacheStatus{ Status: "unavailable", @@ -321,6 +314,7 @@ func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error { LastError: "identity cache service unavailable", } if h.IdentityCache != nil { + var err error cacheStatus, err = h.IdentityCache.GetIdentityCacheStatus(c.Context()) if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()}) @@ -328,8 +322,7 @@ func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error { } return c.JSON(fiber.Map{ - "userProjection": projectionStatus, - "identityCache": cacheStatus, + "identityCache": cacheStatus, }) } diff --git a/backend/internal/handler/admin_handler_test.go b/backend/internal/handler/admin_handler_test.go index 40c03f1e..ddd7afd7 100644 --- a/backend/internal/handler/admin_handler_test.go +++ b/backend/internal/handler/admin_handler_test.go @@ -209,7 +209,7 @@ func TestAdminHandler_UserProjectionStatusReturnsProjectionStateForSuperAdmin(t require.Equal(t, int64(152), body.ProjectedUsers) } -func TestAdminHandler_GetOrySSOTSystemStatusReturnsProjectionAndIdentityCache(t *testing.T) { +func TestAdminHandler_GetOrySSOTSystemStatusReturnsIdentityCacheOnly(t *testing.T) { syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC) cache := &fakeIdentityCacheAdmin{ status: domain.IdentityCacheStatus{ @@ -222,15 +222,6 @@ func TestAdminHandler_GetOrySSOTSystemStatusReturnsProjectionAndIdentityCache(t }, } h := &AdminHandler{ - UserProjectionRepo: &fakeAdminUserProjectionRepo{ - status: domain.UserProjectionStatus{ - Name: domain.UserProjectionNameKratos, - Status: domain.UserProjectionStatusReady, - Ready: true, - LastSyncedAt: &syncedAt, - ProjectedUsers: 152, - }, - }, IdentityCache: cache, } app := fiber.New() @@ -246,11 +237,11 @@ func TestAdminHandler_GetOrySSOTSystemStatusReturnsProjectionAndIdentityCache(t require.Equal(t, http.StatusOK, resp.StatusCode) var body struct { - UserProjection domain.UserProjectionStatus `json:"userProjection"` - IdentityCache domain.IdentityCacheStatus `json:"identityCache"` + UserProjection *domain.UserProjectionStatus `json:"userProjection,omitempty"` + IdentityCache domain.IdentityCacheStatus `json:"identityCache"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) - require.Equal(t, int64(152), body.UserProjection.ProjectedUsers) + require.Nil(t, body.UserProjection) require.True(t, body.IdentityCache.RedisReady) require.Equal(t, int64(151), body.IdentityCache.ObservedCount) require.Equal(t, int64(153), body.IdentityCache.KeyCount) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 37cd6848..fbee515b 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -24,7 +24,6 @@ import ( "time" "github.com/gofiber/fiber/v2" - "github.com/google/uuid" ) // OryProviderAPI defines the subset of Ory Provider used by UserHandler @@ -44,6 +43,7 @@ type UserHandler struct { UserProjectionRepo repository.UserProjectionRepository UserGroupRepo repository.UserGroupRepository AuditRepo domain.AuditRepository + IdentityCache domain.RedisRepository Worksmobile service.WorksmobileSyncer } @@ -589,6 +589,24 @@ func profileTenantAccessKeys(profile *domain.UserProfileResponse) map[string]boo return allowed } +func identityMirrorKey(identityID string) string { + return "identity:mirror:" + strings.TrimSpace(identityID) +} + +type identityMirrorLister interface { + ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error) +} + +type identityMirrorStatusReader interface { + GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) +} + +type identityMirrorFlusher interface { + FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) +} + +const identityMirrorVersion = "kratos-full-pagination-v1" + func profileCanAccessTenant(profile *domain.UserProfileResponse, tenantID, tenantSlug string) bool { allowed := profileTenantAccessKeys(profile) if id := strings.ToLower(strings.TrimSpace(tenantID)); id != "" && allowed[id] { @@ -654,6 +672,26 @@ func kratosIdentityCursorKey(identity service.KratosIdentity) (time.Time, string return timestamp, identity.ID } +func identityMatchesSearch(identity service.KratosIdentity, searchLower string) bool { + if searchLower == "" { + return true + } + if strings.Contains(strings.ToLower(identity.ID), searchLower) { + return true + } + if strings.Contains(strings.ToLower(extractTraitString(identity.Traits, "email")), searchLower) { + return true + } + if strings.Contains(strings.ToLower(extractTraitString(identity.Traits, "name")), searchLower) { + return true + } + rawTraits, err := json.Marshal(identity.Traits) + if err != nil { + return false + } + return strings.Contains(strings.ToLower(string(rawTraits)), searchLower) +} + func (h *UserHandler) ListUsers(c *fiber.Ctx) error { // [New] Get requester profile from middleware var requesterRole string @@ -745,161 +783,96 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { } } - if h.UserRepo != nil { - var tenantIDs []string + if tenantSlug != "" && targetTenantID == "" { + return c.JSON(userListResponse{ + Items: []userSummary{}, + Limit: limit, + Offset: offset, + Total: 0, + Cursor: cursorRaw, + }) + } + + if requesterRole != domain.RoleSuperAdmin && tenantSlug != "" && !manageableSlugs[targetTenantID] && !manageableSlugs[strings.ToLower(tenantSlug)] { + return c.JSON(userListResponse{ + Items: []userSummary{}, + Limit: limit, + Offset: offset, + Total: 0, + Cursor: cursorRaw, + }) + } + + identities, err := h.listIdentitiesFromMirrorOrKratos(c.Context()) + if err != nil { + slog.Warn("Identity mirror unavailable for user list", "error", err) + return errorJSON(c, fiber.StatusServiceUnavailable, "identity mirror unavailable") + } + + filtered := make([]service.KratosIdentity, 0, len(identities)) + searchLower := strings.ToLower(search) + + for _, identity := range identities { + tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id")) + + // Tenant Admin & Member filtering + if requesterRole != domain.RoleSuperAdmin { + hasAccess := manageableSlugs[tID] + if !hasAccess { + continue + } + } + + // Dedicated tenantSlug filter if tenantSlug != "" { - if targetTenantID == "" { - return c.JSON(userListResponse{ - Items: []userSummary{}, - Limit: limit, - Offset: offset, - Total: 0, - Cursor: cursorRaw, - }) - } - if requesterRole != domain.RoleSuperAdmin && !manageableSlugs[targetTenantID] && !manageableSlugs[strings.ToLower(tenantSlug)] { - return c.JSON(userListResponse{ - Items: []userSummary{}, - Limit: limit, - Offset: offset, - Total: 0, - Cursor: cursorRaw, - }) - } - tenantIDs = append(tenantIDs, targetTenantID) - } else if requesterRole != domain.RoleSuperAdmin { - for key := range manageableSlugs { - if _, err := uuid.Parse(key); err == nil { - tenantIDs = append(tenantIDs, key) - } - } - if len(tenantIDs) == 0 { - return c.JSON(userListResponse{ - Items: []userSummary{}, - Limit: limit, - Offset: offset, - Total: 0, - Cursor: cursorRaw, - }) + matches := tID == targetTenantID + if !matches { + continue } } - users, total, nextCursor, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantIDs, cursorRaw) + if !identityMatchesSearch(identity, searchLower) { + continue + } + filtered = append(filtered, identity) + } + + pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey) + total := int64(len(filtered)) + nextCursor := "" + var pageIdentities []service.KratosIdentity + if cursorRaw != "" { + pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey) if err != nil { - return errorJSON(c, fiber.StatusInternalServerError, "failed to list users") + return errorJSON(c, fiber.StatusBadRequest, "invalid cursor") } - items := make([]userSummary, 0, len(users)) - for _, user := range users { - items = append(items, h.mapLocalUserSummary(c.Context(), user)) + offset = 0 + } else { + if offset > len(filtered) { + offset = len(filtered) } - if cursorRaw != "" { - offset = 0 + end := min(offset+limit, len(filtered)) + pageIdentities = filtered[offset:end] + if total > int64(end) && len(pageIdentities) > 0 { + lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1]) + nextCursor = pagination.Encode(lastTimestamp, lastID) } - return c.JSON(userListResponse{ - Items: items, - Limit: limit, - Offset: offset, - Total: total, - Cursor: cursorRaw, - NextCursor: nextCursor, - }) } - if h.KratosAdmin == nil { - return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") + items := make([]userSummary, 0, len(pageIdentities)) + for _, identity := range pageIdentities { + summary := h.mapIdentitySummary(c.Context(), identity) + items = append(items, summary) } - identities, err := h.KratosAdmin.ListIdentities(c.Context()) - if err == nil { - filtered := make([]service.KratosIdentity, 0, len(identities)) - searchLower := strings.ToLower(search) - - for _, identity := range identities { - email := strings.ToLower(extractTraitString(identity.Traits, "email")) - name := strings.ToLower(extractTraitString(identity.Traits, "name")) - tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id")) - - // Tenant Admin & Member filtering - if requesterRole != domain.RoleSuperAdmin { - hasAccess := manageableSlugs[tID] - if !hasAccess { - continue - } - } - - // Dedicated tenantSlug filter - if tenantSlug != "" { - matches := tID == targetTenantID - if !matches { - continue - } - } - - // Search filtering - if search != "" { - matchesSearch := strings.Contains(email, searchLower) || - strings.Contains(name, searchLower) - - if !matchesSearch { - continue - } - } - filtered = append(filtered, identity) - } - - pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey) - total := int64(len(filtered)) - nextCursor := "" - var pageIdentities []service.KratosIdentity - if cursorRaw != "" { - pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey) - if err != nil { - return errorJSON(c, fiber.StatusBadRequest, "invalid cursor") - } - offset = 0 - } else { - if offset > len(filtered) { - offset = len(filtered) - } - end := min(offset+limit, len(filtered)) - pageIdentities = filtered[offset:end] - if total > int64(end) && len(pageIdentities) > 0 { - lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1]) - nextCursor = pagination.Encode(lastTimestamp, lastID) - } - } - - items := make([]userSummary, 0, len(pageIdentities)) - for _, identity := range pageIdentities { - summary := h.mapIdentitySummary(c.Context(), identity) - items = append(items, summary) - } - - // [Lazy Sync] Asynchronously update local DB with fresh data from Kratos - // This ensures that member counts (which use local DB) eventually match reality - if h.UserRepo != nil { - go func(ids []service.KratosIdentity) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - for _, identity := range ids { - localUser := h.mapToLocalUser(identity) - _ = h.UserRepo.Update(ctx, localUser) - } - }(filtered) - } - - return c.JSON(userListResponse{ - Items: items, - Limit: limit, - Offset: offset, - Total: total, - Cursor: cursorRaw, - NextCursor: nextCursor, - }) - } - - slog.Warn("Kratos unavailable for user list", "error", err) - return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider unavailable") + return c.JSON(userListResponse{ + Items: items, + Limit: limit, + Offset: offset, + Total: total, + Cursor: cursorRaw, + NextCursor: nextCursor, + }) } func (h *UserHandler) GetUser(c *fiber.Ctx) error { @@ -912,26 +885,30 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusBadRequest, "user id is required") } + if identity := h.getIdentityFromMirror(userID); identity != nil { + requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) + if requester != nil && requester.Role != domain.RoleSuperAdmin { + allowedKeys := profileTenantAccessKeys(requester) + if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowedKeys) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied") + } + } + return c.JSON(h.mapIdentitySummary(c.Context(), *identity)) + } + identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err != nil || identity == nil { // [FIX] Support fixed UUID lookup fallback id, searchErr := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userID) if searchErr == nil && id != "" { - identity, err = h.KratosAdmin.GetIdentity(c.Context(), id) - } - - if err != nil || identity == nil { - // Second Fallback: By Email from local DB - if h.UserRepo != nil { - local, _ := h.UserRepo.FindByID(c.Context(), userID) - if local != nil && local.Email != "" { - id, _ = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), local.Email) - if id != "" { - identity, err = h.KratosAdmin.GetIdentity(c.Context(), id) - } - } + if cached := h.getIdentityFromMirror(id); cached != nil { + identity = cached + err = nil } } + if searchErr == nil && id != "" && (err != nil || identity == nil) { + identity, err = h.KratosAdmin.GetIdentity(c.Context(), id) + } if err != nil || identity == nil { if identity == nil { @@ -940,6 +917,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } } + h.storeIdentityMirror(*identity) // [New] Check access scope requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) @@ -953,6 +931,149 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error { return c.JSON(h.mapIdentitySummary(c.Context(), *identity)) } +func (h *UserHandler) getIdentityFromMirror(identityID string) *service.KratosIdentity { + if h == nil || h.IdentityCache == nil { + return nil + } + raw, err := h.IdentityCache.Get(identityMirrorKey(identityID)) + if err != nil || strings.TrimSpace(raw) == "" { + return nil + } + var identity service.KratosIdentity + if err := json.Unmarshal([]byte(raw), &identity); err != nil { + return nil + } + if strings.TrimSpace(identity.ID) == "" { + return nil + } + return &identity +} + +func (h *UserHandler) listIdentitiesFromMirrorOrKratos(ctx context.Context) ([]service.KratosIdentity, error) { + if h != nil && h.IdentityCache != nil { + if lister, ok := h.IdentityCache.(identityMirrorLister); ok { + identities, err := lister.ListIdentityMirrors(ctx) + if err != nil { + return nil, err + } + if h.identityMirrorReady(ctx, len(identities)) { + return identities, nil + } + } + } + if h == nil || h.KratosAdmin == nil { + return nil, errors.New("identity mirror is empty and kratos admin service is unavailable") + } + return h.rebuildIdentityMirror(ctx) +} + +func (h *UserHandler) WarmIdentityMirror(ctx context.Context) (int, error) { + identities, err := h.rebuildIdentityMirror(ctx) + if err != nil { + return 0, err + } + return len(identities), nil +} + +func (h *UserHandler) rebuildIdentityMirror(ctx context.Context) ([]service.KratosIdentity, error) { + if h == nil || h.KratosAdmin == nil { + return nil, errors.New("kratos admin service is unavailable") + } + identities, err := h.KratosAdmin.ListIdentities(ctx) + if err != nil { + return nil, err + } + h.flushIdentityMirror(ctx) + for _, identity := range identities { + h.storeIdentityMirror(identity) + } + h.markIdentityMirrorReady(len(identities)) + return identities, nil +} + +func (h *UserHandler) identityMirrorReady(ctx context.Context, identityCount int) bool { + if h == nil || h.IdentityCache == nil || identityCount == 0 { + return false + } + reader, ok := h.IdentityCache.(identityMirrorStatusReader) + if !ok { + return false + } + status, err := reader.GetIdentityCacheStatus(ctx) + if err != nil { + return false + } + return status.RedisReady && + status.Status == "ready" && + status.MirrorVersion == identityMirrorVersion && + status.ObservedCount == int64(identityCount) +} + +func (h *UserHandler) flushIdentityMirror(ctx context.Context) { + if h == nil || h.IdentityCache == nil { + return + } + flusher, ok := h.IdentityCache.(identityMirrorFlusher) + if !ok { + return + } + _, _ = flusher.FlushIdentityCache(ctx) +} + +func (h *UserHandler) markIdentityMirrorReady(identityCount int) { + if h == nil || h.IdentityCache == nil { + return + } + now := time.Now().UTC() + status := domain.IdentityCacheStatus{ + Status: "ready", + RedisReady: true, + MirrorVersion: identityMirrorVersion, + ObservedCount: int64(identityCount), + LastRefreshedAt: &now, + UpdatedAt: &now, + } + raw, err := json.Marshal(status) + if err != nil { + return + } + _ = h.IdentityCache.Set("identity:mirror:state", string(raw), 0) +} + +func (h *UserHandler) invalidateIdentityMirrorState() { + if h == nil || h.IdentityCache == nil { + return + } + _ = h.IdentityCache.Delete("identity:mirror:state") +} + +func (h *UserHandler) storeIdentityMirror(identity service.KratosIdentity) { + if h == nil || h.IdentityCache == nil || strings.TrimSpace(identity.ID) == "" { + return + } + raw, err := json.Marshal(identity) + if err != nil { + return + } + _ = h.IdentityCache.Set(identityMirrorKey(identity.ID), string(raw), 0) +} + +func (h *UserHandler) updateIdentityMirrorEntry(identity service.KratosIdentity) { + h.storeIdentityMirror(identity) + h.invalidateIdentityMirrorState() +} + +func (h *UserHandler) deleteIdentityMirrorEntry(identityID string) { + if h == nil || h.IdentityCache == nil { + return + } + identityID = strings.TrimSpace(identityID) + if identityID != "" { + _ = h.IdentityCache.Delete(identityMirrorKey(identityID)) + } + h.invalidateIdentityMirrorState() +} + func (h *UserHandler) CreateUser(c *fiber.Ctx) error { if h.OryProvider == nil || h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") @@ -1171,8 +1292,10 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if identity == nil { + h.invalidateIdentityMirrorState() return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword}) } + h.updateIdentityMirrorEntry(*identity) // [New] Local DB Sync - Ensure user exists in read-model if h.UserRepo != nil { @@ -1672,6 +1795,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { } } else { resultStatus = "created" + h.invalidateIdentityMirrorState() slog.Info("BulkCreate: New identity created", "email", userEmail, "identityID", identityID) } } @@ -2160,6 +2284,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()}) continue } + h.updateIdentityMirrorEntry(*updated) // Sync to local DB if h.UserRepo != nil { @@ -2267,6 +2392,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error { results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()}) continue } + h.deleteIdentityMirrorEntry(id) if h.Worksmobile != nil { localUser := h.mapToLocalUser(*identity) if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil { @@ -2635,6 +2761,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + h.updateIdentityMirrorEntry(*updated) // [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller if h.UserRepo != nil { @@ -2807,6 +2934,10 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } } + h.deleteIdentityMirrorEntry(userID) + if actualKratosID != userID { + h.deleteIdentityMirrorEntry(actualKratosID) + } slog.Info("[UserHandler] Successfully deleted Kratos identity", "userID", userID, "actualKratosID", actualKratosID) if h.Worksmobile != nil && identity != nil { @@ -3003,16 +3134,6 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K traits := identity.Traits role := roleFromTraits(traits) - // [FIX] Prioritize Local DB ID (the fixed UUID from user) - finalID := identity.ID - email := extractTraitString(traits, "email") - if h.UserRepo != nil && email != "" { - // 1. Try finding by email first as it's a strong identifier - if local, err := h.UserRepo.FindByEmail(ctx, email); err == nil && local != nil { - finalID = local.ID - } - } - tenantID := extractTraitString(traits, "tenant_id") tenantSlug := "" var tenantSummary *domain.Tenant @@ -3038,7 +3159,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K } summary := userSummary{ - ID: finalID, + ID: identity.ID, Email: extractTraitString(traits, "email"), LoginID: resolvePasswordLoginID(traits), CustomLoginIDs: customLoginIDs, diff --git a/backend/internal/handler/user_handler_live_test.go b/backend/internal/handler/user_handler_live_test.go new file mode 100644 index 00000000..ec4d9433 --- /dev/null +++ b/backend/internal/handler/user_handler_live_test.go @@ -0,0 +1,46 @@ +package handler + +import ( + "baron-sso-backend/internal/service" + "context" + "os" + "strconv" + "testing" + "time" +) + +func TestUserHandler_LiveWarmIdentityMirrorLatency(t *testing.T) { + if os.Getenv("BARON_LIVE_IDENTITY_MIRROR_TEST") != "1" { + t.Skip("set BARON_LIVE_IDENTITY_MIRROR_TEST=1 to run against local Kratos and Redis") + } + + redisService, err := service.NewRedisService() + if err != nil { + t.Fatalf("connect redis: %v", err) + } + kratosAdmin := service.NewKratosAdminService() + handler := &UserHandler{ + KratosAdmin: kratosAdmin, + IdentityCache: redisService, + } + + startedAt := time.Now() + count, err := handler.WarmIdentityMirror(context.Background()) + elapsed := time.Since(startedAt) + if err != nil { + t.Fatalf("warm identity mirror: %v", err) + } + + maxMillis := int64(2000) + if raw := os.Getenv("BARON_LIVE_IDENTITY_MIRROR_MAX_MS"); raw != "" { + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil || parsed <= 0 { + t.Fatalf("invalid BARON_LIVE_IDENTITY_MIRROR_MAX_MS=%q", raw) + } + maxMillis = parsed + } + t.Logf("identity mirror warmup identities=%d elapsed=%s max=%dms", count, elapsed, maxMillis) + if elapsed > time.Duration(maxMillis)*time.Millisecond { + t.Fatalf("identity mirror warmup took %s, over %dms", elapsed, maxMillis) + } +} diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 06d152db..af4a76be 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -981,15 +981,88 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT mockOry.AssertExpectations(t) } -func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) { +type identityMirrorRedisStub struct { + mockRedisRepo +} + +func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error) { + identities := make([]service.KratosIdentity, 0, len(s.data)) + for key, raw := range s.data { + if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" { + continue + } + var identity service.KratosIdentity + if err := json.Unmarshal([]byte(raw), &identity); err != nil { + continue + } + if strings.TrimSpace(identity.ID) == "" { + continue + } + identities = append(identities, identity) + } + return identities, nil +} + +func (s *identityMirrorRedisStub) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) { + raw := s.data["identity:mirror:state"] + if strings.TrimSpace(raw) == "" { + return domain.IdentityCacheStatus{RedisReady: true, Status: "empty"}, nil + } + var status domain.IdentityCacheStatus + if err := json.Unmarshal([]byte(raw), &status); err != nil { + return domain.IdentityCacheStatus{}, err + } + return status, nil +} + +func (s *identityMirrorRedisStub) FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) { + var deleted int64 + for key := range s.data { + if strings.HasPrefix(key, "identity:mirror:") || strings.HasPrefix(key, "identity:index:") { + delete(s.data, key) + deleted++ + } + } + return domain.IdentityCacheFlushResult{ + Status: "success", + FlushedKeys: deleted, + UpdatedAt: time.Now().UTC(), + }, nil +} + +func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing.T) { app := fiber.New() mockKratos := new(MockKratosAdmin) mockRepo := new(MockUserRepoForHandler) createdAt := time.Date(2026, 6, 8, 6, 30, 0, 0, time.UTC) + mirrorIdentity := service.KratosIdentity{ + ID: "mirror-user-1", + State: "active", + CreatedAt: createdAt, + UpdatedAt: createdAt, + Traits: map[string]any{ + "email": "mirror1@example.com", + "name": "Mirror One", + }, + } + rawMirrorIdentity, err := json.Marshal(mirrorIdentity) + require.NoError(t, err) + state := domain.IdentityCacheStatus{ + Status: "ready", + RedisReady: true, + MirrorVersion: identityMirrorVersion, + ObservedCount: 1, + } + rawState, err := json.Marshal(state) + require.NoError(t, err) h := &UserHandler{ KratosAdmin: mockKratos, UserRepo: mockRepo, + IdentityCache: &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{ + identityMirrorKey(mirrorIdentity.ID): string(rawMirrorIdentity), + "identity:mirror:state": string(rawState), + }}}, } app.Use(func(c *fiber.Ctx) error { @@ -1000,19 +1073,6 @@ func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) { }) app.Get("/users", h.ListUsers) - mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{}, errors.New("kratos down")).Maybe() - mockRepo.On("List", mock.Anything, 0, 10, "", []string(nil), "").Return([]domain.User{ - { - ID: "local-user-1", - Email: "local1@example.com", - Name: "Local One", - Role: domain.RoleUser, - Status: domain.UserStatusActive, - CreatedAt: createdAt, - UpdatedAt: createdAt, - }, - }, int64(1), "", nil) - req := httptest.NewRequest("GET", "/users?limit=10&offset=0", nil) resp, err := app.Test(req) @@ -1023,19 +1083,21 @@ func TestUserHandler_ListUsersUsesLocalProjectionWhenKratosFails(t *testing.T) { require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) require.Equal(t, int64(1), res.Total) require.Len(t, res.Items, 1) - require.Equal(t, "local1@example.com", res.Items[0].Email) - mockRepo.AssertExpectations(t) + require.Equal(t, "mirror-user-1", res.Items[0].ID) + require.Equal(t, "mirror1@example.com", res.Items[0].Email) + mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + mockKratos.AssertNotCalled(t, "ListIdentities", mock.Anything) } -func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *testing.T) { +func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *testing.T) { app := fiber.New() mockKratos := new(MockKratosAdmin) - mockRepo := new(MockUserRepoForHandler) + redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{}}} createdAt := time.Date(2026, 6, 8, 6, 40, 0, 0, time.UTC) h := &UserHandler{ - KratosAdmin: mockKratos, - UserRepo: mockRepo, + KratosAdmin: mockKratos, + IdentityCache: redis, } app.Use(func(c *fiber.Ctx) error { @@ -1046,27 +1108,11 @@ func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *t }) app.Get("/users", h.ListUsers) - kratosIdentities := make([]service.KratosIdentity, 250) - for i := range kratosIdentities { - kratosIdentities[i] = service.KratosIdentity{ - ID: "kratos-user", - State: "active", - CreatedAt: createdAt.Add(-time.Duration(i) * time.Second), - Traits: map[string]any{"email": "kratos@example.com", "name": "Kratos"}, - } + kratosIdentities := []service.KratosIdentity{ + {ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}}, + {ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}}, } - mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Maybe() - mockRepo.On("List", mock.Anything, 0, 50, "", []string(nil), "").Return([]domain.User{ - { - ID: "local-user-1", - Email: "local1@example.com", - Name: "Local One", - Role: domain.RoleUser, - Status: domain.UserStatusActive, - CreatedAt: createdAt, - UpdatedAt: createdAt, - }, - }, int64(2114), "next-local-cursor", nil) + mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once() req := httptest.NewRequest("GET", "/users?limit=50&offset=0", nil) resp, err := app.Test(req) @@ -1076,11 +1122,162 @@ func TestUserHandler_ListUsersUsesLocalProjectionTotalBeyondKratosPageLimit(t *t var res userListResponse require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) - require.Equal(t, int64(2114), res.Total) - require.Len(t, res.Items, 1) - require.Equal(t, "local1@example.com", res.Items[0].Email) - require.Equal(t, "next-local-cursor", res.NextCursor) - mockRepo.AssertExpectations(t) + require.Equal(t, int64(2), res.Total) + require.Len(t, res.Items, 2) + require.Equal(t, "kratos-user-1", res.Items[0].ID) + require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")]) + require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")]) + var status domain.IdentityCacheStatus + require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status)) + require.Equal(t, "ready", status.Status) + require.Equal(t, identityMirrorVersion, status.MirrorVersion) + require.Equal(t, int64(2), status.ObservedCount) + mockKratos.AssertExpectations(t) +} + +func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) { + mockKratos := new(MockKratosAdmin) + redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{ + identityMirrorKey("stale-user"): `{"id":"stale-user"}`, + }}} + createdAt := time.Date(2026, 6, 12, 18, 30, 0, 0, time.UTC) + identities := []service.KratosIdentity{ + {ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com"}}, + {ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Second), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com"}}, + } + mockKratos.On("ListIdentities", mock.Anything).Return(identities, nil).Once() + h := &UserHandler{ + KratosAdmin: mockKratos, + IdentityCache: redis, + } + + count, err := h.WarmIdentityMirror(context.Background()) + + require.NoError(t, err) + require.Equal(t, 2, count) + require.Empty(t, redis.data[identityMirrorKey("stale-user")]) + require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")]) + require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")]) + var status domain.IdentityCacheStatus + require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status)) + require.Equal(t, "ready", status.Status) + require.Equal(t, identityMirrorVersion, status.MirrorVersion) + require.Equal(t, int64(2), status.ObservedCount) + mockKratos.AssertExpectations(t) +} + +func TestUserHandler_ListUsersRebuildsLegacyReadyMirrorWithoutVersion(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + createdAt := time.Date(2026, 6, 8, 6, 55, 0, 0, time.UTC) + legacyIdentity := service.KratosIdentity{ + ID: "legacy-partial-user", + State: "active", + CreatedAt: createdAt, + UpdatedAt: createdAt, + Traits: map[string]any{ + "email": "legacy@example.com", + "name": "Legacy Partial", + }, + } + rawLegacyIdentity, err := json.Marshal(legacyIdentity) + require.NoError(t, err) + legacyState := domain.IdentityCacheStatus{ + Status: "ready", + RedisReady: true, + ObservedCount: 1, + } + rawLegacyState, err := json.Marshal(legacyState) + require.NoError(t, err) + redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{ + identityMirrorKey(legacyIdentity.ID): string(rawLegacyIdentity), + "identity:mirror:state": string(rawLegacyState), + }}} + kratosIdentities := []service.KratosIdentity{ + {ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}}, + {ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}}, + } + mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once() + + h := &UserHandler{ + KratosAdmin: mockKratos, + IdentityCache: redis, + } + + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + Role: domain.RoleSuperAdmin, + }) + return c.Next() + }) + app.Get("/users", h.ListUsers) + + req := httptest.NewRequest("GET", "/users?limit=50&offset=0", nil) + resp, err := app.Test(req) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var res userListResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) + require.Equal(t, int64(2), res.Total) + var status domain.IdentityCacheStatus + require.NoError(t, json.Unmarshal([]byte(redis.data["identity:mirror:state"]), &status)) + require.Equal(t, identityMirrorVersion, status.MirrorVersion) + mockKratos.AssertExpectations(t) +} + +func TestUserHandler_ListUsersRebuildsPartialMirrorFromKratos(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + createdAt := time.Date(2026, 6, 8, 6, 50, 0, 0, time.UTC) + partialIdentity := service.KratosIdentity{ + ID: "partial-user", + State: "active", + CreatedAt: createdAt, + UpdatedAt: createdAt, + Traits: map[string]any{ + "email": "partial@example.com", + "name": "Partial", + }, + } + rawPartialIdentity, err := json.Marshal(partialIdentity) + require.NoError(t, err) + redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{ + identityMirrorKey(partialIdentity.ID): string(rawPartialIdentity), + }}} + kratosIdentities := []service.KratosIdentity{ + {ID: "kratos-user-1", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos1@example.com", "name": "Kratos One"}}, + {ID: "kratos-user-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "kratos2@example.com", "name": "Kratos Two"}}, + } + mockKratos.On("ListIdentities", mock.Anything).Return(kratosIdentities, nil).Once() + + h := &UserHandler{ + KratosAdmin: mockKratos, + IdentityCache: redis, + } + + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + Role: domain.RoleSuperAdmin, + }) + return c.Next() + }) + app.Get("/users", h.ListUsers) + + req := httptest.NewRequest("GET", "/users?limit=50&offset=0", nil) + resp, err := app.Test(req) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var res userListResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) + require.Equal(t, int64(2), res.Total) + require.Empty(t, redis.data[identityMirrorKey("partial-user")]) + require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-1")]) + require.NotEmpty(t, redis.data[identityMirrorKey("kratos-user-2")]) + mockKratos.AssertExpectations(t) } func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) { @@ -1117,6 +1314,86 @@ func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) { require.Equal(t, int64(3), res.Total) } +func TestUserHandler_GetUserUsesIdentityMirrorBeforeKratos(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + createdAt := time.Date(2026, 6, 12, 8, 20, 0, 0, time.UTC) + userID := "2b7fd276-b25f-45ef-b691-ea9d72e701e1" + identity := service.KratosIdentity{ + ID: userID, + State: "active", + CreatedAt: createdAt, + UpdatedAt: createdAt, + Traits: map[string]any{ + "email": "mirror-user@example.com", + "name": "Mirror User", + }, + } + rawIdentity, err := json.Marshal(identity) + require.NoError(t, err) + redis := &mockRedisRepo{data: map[string]string{ + identityMirrorKey(userID): string(rawIdentity), + }} + + h := &UserHandler{ + KratosAdmin: mockKratos, + IdentityCache: redis, + } + + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + Role: domain.RoleSuperAdmin, + }) + return c.Next() + }) + app.Get("/users/:id", h.GetUser) + + req := httptest.NewRequest(http.MethodGet, "/users/"+userID, nil) + resp, err := app.Test(req) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var got userSummary + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + require.Equal(t, userID, got.ID) + require.Equal(t, "mirror-user@example.com", got.Email) + require.Equal(t, "Mirror User", got.Name) + mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything) + mockKratos.AssertNotCalled(t, "FindIdentityIDByIdentifier", mock.Anything, mock.Anything) +} + +func TestUserHandler_UpdateIdentityMirrorEntryInvalidatesReadyState(t *testing.T) { + redis := &mockRedisRepo{data: map[string]string{ + "identity:mirror:state": `{"status":"ready","redisReady":true,"observedCount":1}`, + }} + h := &UserHandler{IdentityCache: redis} + identity := service.KratosIdentity{ + ID: "user-1", + Traits: map[string]any{ + "email": "user1@example.com", + }, + } + + h.updateIdentityMirrorEntry(identity) + + require.Empty(t, redis.data["identity:mirror:state"]) + require.NotEmpty(t, redis.data[identityMirrorKey("user-1")]) +} + +func TestUserHandler_DeleteIdentityMirrorEntryInvalidatesReadyState(t *testing.T) { + redis := &mockRedisRepo{data: map[string]string{ + "identity:mirror:state": `{"status":"ready","redisReady":true,"observedCount":1}`, + identityMirrorKey("u-1"): `{"id":"u-1"}`, + }} + h := &UserHandler{IdentityCache: redis} + + h.deleteIdentityMirrorEntry("u-1") + + require.Empty(t, redis.data["identity:mirror:state"]) + require.Empty(t, redis.data[identityMirrorKey("u-1")]) +} + func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) { app := fiber.New() mockKratos := new(MockKratosAdmin) diff --git a/backend/internal/handler/worksmobile_handler.go b/backend/internal/handler/worksmobile_handler.go index 5cc1a1af..f99edd60 100644 --- a/backend/internal/handler/worksmobile_handler.go +++ b/backend/internal/handler/worksmobile_handler.go @@ -213,5 +213,19 @@ func worksmobileGuardError(c *fiber.Ctx, err error, operation string, attrs ...a if strings.Contains(err.Error(), "hanmac-family root") { return errorJSON(c, fiber.StatusNotFound, err.Error()) } + if worksmobileBadRequestError(err) { + return errorJSON(c, fiber.StatusBadRequest, err.Error()) + } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + +func worksmobileBadRequestError(err error) bool { + message := err.Error() + return strings.Contains(message, "target user tenant is excluded from Worksmobile sync") || + strings.Contains(message, "target user is outside hanmac-family subtree") || + strings.Contains(message, "target user has no tenant") || + strings.Contains(message, "target user status is excluded from Worksmobile sync") || + strings.Contains(message, "target tenant is excluded from Worksmobile sync") || + strings.Contains(message, "target tenant is not a worksmobile orgunit tenant") || + strings.Contains(message, "target orgunit is outside hanmac-family subtree") +} diff --git a/backend/internal/handler/worksmobile_handler_test.go b/backend/internal/handler/worksmobile_handler_test.go index bc7e2be3..67b015b5 100644 --- a/backend/internal/handler/worksmobile_handler_test.go +++ b/backend/internal/handler/worksmobile_handler_test.go @@ -195,6 +195,19 @@ func TestWorksmobileHandlerLogsActionFailures(t *testing.T) { require.Contains(t, logs.String(), "works user sync failed") } +func TestWorksmobileHandlerReturnsBadRequestForOutOfScopeUserSync(t *testing.T) { + h := NewWorksmobileHandler(&fakeWorksmobileAdminService{ + syncUserErr: errors.New("target user tenant is excluded from Worksmobile sync"), + }) + app := fiber.New() + app.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", h.SyncUser) + + resp, err := app.Test(httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/sync", nil)) + + require.NoError(t, err) + require.Equal(t, fiber.StatusBadRequest, resp.StatusCode) +} + type fakeWorksmobileAdminService struct { overview service.WorksmobileTenantOverview credentials []service.WorksmobileInitialPasswordCredential diff --git a/backend/internal/service/redis_service.go b/backend/internal/service/redis_service.go index 64c73512..311c99e7 100644 --- a/backend/internal/service/redis_service.go +++ b/backend/internal/service/redis_service.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "os" + "strings" "time" "github.com/go-redis/redis/v8" @@ -199,6 +200,39 @@ func (s *RedisService) FlushIdentityCache(ctx context.Context) (domain.IdentityC }, nil } +func (s *RedisService) ListIdentityMirrors(ctx context.Context) ([]KratosIdentity, error) { + if s == nil || s.Client == nil { + return nil, os.ErrInvalid + } + + keys, err := s.identityCacheKeys(ctx) + if err != nil { + return nil, err + } + identities := make([]KratosIdentity, 0, len(keys)) + for _, key := range keys { + if key == "identity:mirror:state" || !strings.HasPrefix(key, "identity:mirror:") { + continue + } + raw, err := s.Client.Get(ctx, key).Result() + if err == redis.Nil { + continue + } + if err != nil { + return nil, err + } + var identity KratosIdentity + if err := json.Unmarshal([]byte(raw), &identity); err != nil { + continue + } + if strings.TrimSpace(identity.ID) == "" { + continue + } + identities = append(identities, identity) + } + return identities, nil +} + func (s *RedisService) countIdentityCacheKeys(ctx context.Context) (int64, error) { keys, err := s.identityCacheKeys(ctx) if err != nil { diff --git a/backend/internal/service/worksmobile_client.go b/backend/internal/service/worksmobile_client.go index e51e4d05..3909fc56 100644 --- a/backend/internal/service/worksmobile_client.go +++ b/backend/internal/service/worksmobile_client.go @@ -434,6 +434,9 @@ func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) e if userID == "" { return fmt.Errorf("worksmobile user id is required") } + if c.directoryAuthConfigured() && strings.Contains(userID, "@") { + return c.sendDirectoryJSON(ctx, http.MethodDelete, "/v1.0/users/"+url.PathEscape(userID), nil) + } remote, err := c.FindUser(ctx, userID) if err != nil { return err @@ -450,6 +453,14 @@ func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) e return c.sendJSON(ctx, http.MethodDelete, "/scim/v2/Users/"+url.PathEscape(remote.ID), nil) } +func (c *WorksmobileHTTPClient) ForceDeleteUser(ctx context.Context, userID string) error { + userID = strings.TrimSpace(userID) + if userID == "" { + return fmt.Errorf("worksmobile user id is required") + } + return c.sendDirectoryJSON(ctx, http.MethodDelete, "/v1.0/users/"+url.PathEscape(userID)+"/forcedelete", nil) +} + func (c *WorksmobileHTTPClient) SetUserActive(ctx context.Context, userID string, active bool) error { userID = strings.TrimSpace(userID) if userID == "" { @@ -465,7 +476,18 @@ func (c *WorksmobileHTTPClient) SetUserActive(ctx context.Context, userID string if remote == nil { return nil } - return c.sendJSON(ctx, http.MethodPatch, "/scim/v2/Users/"+url.PathEscape(remote.ID), map[string]any{ + return c.SetSCIMUserActiveByID(ctx, remote.ID, active) +} + +func (c *WorksmobileHTTPClient) SetSCIMUserActiveByID(ctx context.Context, scimID string, active bool) error { + scimID = strings.TrimSpace(scimID) + if scimID == "" { + return fmt.Errorf("worksmobile scim user id is required") + } + if strings.TrimSpace(c.SCIMToken) == "" { + return fmt.Errorf("worksmobile scim token is not configured") + } + return c.sendJSON(ctx, http.MethodPatch, "/scim/v2/Users/"+url.PathEscape(scimID), map[string]any{ "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, "Operations": []map[string]any{ { @@ -926,6 +948,7 @@ type WorksmobileRemoteUser struct { PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"` OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"` Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"` + AccountStatus string `json:"accountStatus,omitempty"` Active bool `json:"active"` IsAwaiting bool `json:"isAwaiting"` IsPending bool `json:"isPending"` @@ -1010,12 +1033,21 @@ func worksmobileSCIMPreferredLanguage(locale string) string { } func parseWorksmobileRemoteUser(resource map[string]any) WorksmobileRemoteUser { + active := boolFromMap(resource, "active") user := WorksmobileRemoteUser{ ID: stringFromMap(resource, "id"), ExternalID: stringFromMap(resource, "externalId"), UserName: stringFromMap(resource, "userName"), DisplayName: stringFromMap(resource, "displayName"), - Active: boolFromMap(resource, "active"), + AccountStatus: normalizeWorksmobileAccountStatus( + firstStringFromMap(resource, "accountStatus", "status", "userStatus"), + active, + false, + false, + false, + false, + ), + Active: active, } if emails, ok := resource["emails"].([]any); ok { for _, raw := range emails { @@ -1077,6 +1109,14 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse user.IsPending = boolFromMap(resource, "isPending") user.IsSuspended = boolFromMap(resource, "isSuspended") user.IsDeleted = boolFromMap(resource, "isDeleted") + user.AccountStatus = normalizeWorksmobileAccountStatus( + firstStringFromMap(resource, "accountStatus", "status", "userStatus", "loginStatus"), + user.Active, + user.IsAwaiting, + user.IsPending, + user.IsSuspended, + user.IsDeleted, + ) primaryOrgUnit := parseWorksmobilePrimaryOrgUnitDetail(resource) user.PrimaryOrgUnitID = primaryOrgUnit.ID user.PrimaryOrgUnitName = primaryOrgUnit.Name @@ -1088,6 +1128,37 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse return user } +func normalizeWorksmobileAccountStatus(raw string, active bool, awaiting bool, pending bool, suspended bool, deleted bool) string { + status := strings.ToLower(strings.TrimSpace(raw)) + status = strings.ReplaceAll(status, "-", "_") + status = strings.ReplaceAll(status, " ", "_") + switch status { + case "deleted", "delete", "removed": + return "deleted" + case "suspended", "suspend", "blocked", "disabled": + return "suspended" + case "invited", "invite", "awaiting", "pending", "waiting", "not_activated", "unactivated": + return "invited" + case "inactive", "deactivated", "false": + return "inactive" + case "active", "enabled", "true": + return "active" + } + if deleted { + return "deleted" + } + if suspended { + return "suspended" + } + if awaiting || pending { + return "invited" + } + if !active { + return "inactive" + } + return "active" +} + func parseWorksmobileDirectoryGroup(resource map[string]any) WorksmobileRemoteGroup { email := firstStringFromMap(resource, "email", "mail", "groupEmail", "mailingList", "orgUnitEmail", "loginId", "userName") return WorksmobileRemoteGroup{ diff --git a/backend/internal/service/worksmobile_client_test.go b/backend/internal/service/worksmobile_client_test.go index dc870619..d2518fc2 100644 --- a/backend/internal/service/worksmobile_client_test.go +++ b/backend/internal/service/worksmobile_client_test.go @@ -64,6 +64,26 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te require.Len(t, passwordConfig["password"], 16) } +func TestWorksmobileHTTPClientDeleteUserUsesDirectDirectoryDeleteForEmail(t *testing.T) { + transport := &captureRoundTripper{ + statusCode: http.StatusOK, + body: `{}`, + } + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + DirectoryToken: "directory-token-1", + HTTPClient: &http.Client{Transport: transport}, + } + + err := client.DeleteUser(context.Background(), "target@samaneng.com") + + require.NoError(t, err) + require.Len(t, transport.requests, 1) + require.Equal(t, http.MethodDelete, transport.requests[0].Method) + require.Equal(t, "/v1.0/users/target@samaneng.com", transport.requests[0].URL.Path) + require.Equal(t, "Bearer directory-token-1", transport.requests[0].Header.Get("Authorization")) +} + func TestNewWorksmobileUserPatchPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) { payload := NewWorksmobileUserPatchPayload(WorksmobileUserPayload{ DomainID: 1001, @@ -975,6 +995,27 @@ func TestCompareWorksmobileUsersIncludesBaronAndWorksPrimaryOrg(t *testing.T) { require.Equal(t, "WORKS 기술기획", items[0].WorksmobilePrimaryOrgName) } +func TestCompareWorksmobileUsersIncludesWorksAccountStatus(t *testing.T) { + localUsers := []domain.User{ + {ID: "user-1", Email: "suspended@samaneng.com", Name: "Suspended"}, + } + remoteUsers := []WorksmobileRemoteUser{ + { + ID: "works-1", + ExternalID: "user-1", + Email: "suspended@samaneng.com", + DisplayName: "Suspended", + Active: false, + IsSuspended: true, + }, + } + + items := compareWorksmobileUsers(localUsers, remoteUsers, true, nil) + + require.Len(t, items, 1) + require.Equal(t, "suspended", items[0].WorksmobileAccountStatus) +} + func TestCompareWorksmobileUsersMarksEmailMatchWithoutExternalIDNeedsUpdate(t *testing.T) { localUsers := []domain.User{ {ID: "user-1", Email: "tester@samaneng.com", Name: "Tester"}, diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go index 26abe0ed..7c6ee414 100644 --- a/backend/internal/service/worksmobile_sync_service.go +++ b/backend/internal/service/worksmobile_sync_service.go @@ -11,6 +11,8 @@ import ( "sort" "strings" "time" + + "github.com/google/uuid" ) const ( @@ -106,6 +108,8 @@ type WorksmobileComparisonItem struct { BaronSlug string `json:"baronSlug,omitempty"` BaronName string `json:"baronName,omitempty"` BaronEmail string `json:"baronEmail,omitempty"` + BaronPhone string `json:"baronPhone,omitempty"` + BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"` BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"` BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"` BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"` @@ -116,6 +120,9 @@ type WorksmobileComparisonItem struct { ExternalKey string `json:"externalKey,omitempty"` WorksmobileName string `json:"worksmobileName,omitempty"` WorksmobileEmail string `json:"worksmobileEmail,omitempty"` + WorksmobilePhone string `json:"worksmobilePhone,omitempty"` + WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"` + WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"` WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"` WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"` WorksmobileTask string `json:"worksmobileTask,omitempty"` @@ -571,7 +578,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, ResourceType: domain.WorksmobileResourceUser, ResourceID: user.ID, Action: action, - DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID, + DedupeKey: worksmobileUserSyncDedupeKey(action, user.ID), Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status), } item.Payload["displayName"] = strings.TrimSpace(user.Name) @@ -587,6 +594,10 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, return item, nil } +func worksmobileUserSyncDedupeKey(action, userID string) string { + return "user:" + strings.ToLower(action) + ":" + userID + ":" + uuid.NewString() +} + func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { @@ -880,7 +891,7 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context, ResourceType: domain.WorksmobileResourceUser, ResourceID: user.ID, Action: action, - DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID, + DedupeKey: worksmobileUserSyncDedupeKey(action, user.ID), Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status), }) } @@ -1461,6 +1472,8 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile BaronID: user.ID, BaronName: user.Name, BaronEmail: user.Email, + BaronPhone: user.Phone, + BaronEmployeeNumber: metadataEmployeeNumber(user.Metadata), BaronPrimaryOrgID: worksmobileUserPrimaryOrgID(user), BaronPrimaryOrgSlug: worksmobileUserPrimaryOrgSlug(user, localTenants), BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants), @@ -1483,6 +1496,9 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile item.ExternalKey = remote.ExternalID item.WorksmobileName = remote.DisplayName item.WorksmobileEmail = remote.Email + item.WorksmobilePhone = remote.CellPhone + item.WorksmobileEmployeeNumber = remote.EmployeeNumber + item.WorksmobileAccountStatus = worksmobileRemoteAccountStatus(remote) item.WorksmobileLevelID = remote.LevelID item.WorksmobileLevelName = remote.LevelName item.WorksmobileTask = remote.Task @@ -1511,6 +1527,9 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile ExternalKey: remote.ExternalID, WorksmobileName: remote.DisplayName, WorksmobileEmail: remote.Email, + WorksmobilePhone: remote.CellPhone, + WorksmobileEmployeeNumber: remote.EmployeeNumber, + WorksmobileAccountStatus: worksmobileRemoteAccountStatus(remote), WorksmobileLevelID: remote.LevelID, WorksmobileLevelName: remote.LevelName, WorksmobileTask: remote.Task, @@ -1532,6 +1551,9 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile ExternalKey: remote.ExternalID, WorksmobileName: remote.DisplayName, WorksmobileEmail: remote.Email, + WorksmobilePhone: remote.CellPhone, + WorksmobileEmployeeNumber: remote.EmployeeNumber, + WorksmobileAccountStatus: worksmobileRemoteAccountStatus(remote), WorksmobileLevelID: remote.LevelID, WorksmobileLevelName: remote.LevelName, WorksmobileTask: remote.Task, @@ -1549,6 +1571,17 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile return result } +func worksmobileRemoteAccountStatus(remote WorksmobileRemoteUser) string { + return normalizeWorksmobileAccountStatus( + remote.AccountStatus, + remote.Active, + remote.IsAwaiting, + remote.IsPending, + remote.IsSuspended, + remote.IsDeleted, + ) +} + func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool { if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) { return true diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go index 94c5a144..32661eb6 100644 --- a/backend/internal/service/worksmobile_sync_service_test.go +++ b/backend/internal/service/worksmobile_sync_service_test.go @@ -204,6 +204,88 @@ func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T) require.Empty(t, request.PasswordConfig.Password) } +func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + rootID := "root-tenant" + tenantID := "saman-tenant" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "Hanmac Family", + } + tenant := domain.Tenant{ + ID: tenantID, + Slug: "saman", + Name: "Saman", + Type: domain.TenantTypeCompany, + ParentID: &rootID, + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + } + target := domain.User{ + ID: "target-user", + Email: "target@samaneng.com", + Name: "Target", + Status: domain.UserStatusActive, + TenantID: &tenantID, + } + outboxRepo := &fakeWorksmobileOutboxRepo{} + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}}, + &fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}}, + outboxRepo, + nil, + ) + + first, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "", "") + require.NoError(t, err) + second, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "", "") + + require.NoError(t, err) + require.NotNil(t, first) + require.NotNil(t, second) + require.Len(t, outboxRepo.created, 2) + require.NotEqual(t, outboxRepo.created[0].DedupeKey, outboxRepo.created[1].DedupeKey) +} + +func TestWorksmobileSyncServiceCreatesDistinctAutomaticUserSyncHistoryJobs(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + rootID := "root-tenant" + tenantID := "saman-tenant" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "Hanmac Family", + } + tenant := domain.Tenant{ + ID: tenantID, + Slug: "saman", + Name: "Saman", + Type: domain.TenantTypeCompany, + ParentID: &rootID, + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + } + target := domain.User{ + ID: "target-user", + Email: "target@samaneng.com", + Name: "Target", + Status: domain.UserStatusActive, + TenantID: &tenantID, + } + outboxRepo := &fakeWorksmobileOutboxRepo{} + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}}, + &fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}}, + outboxRepo, + nil, + ) + + require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), target)) + require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), target)) + + require.Len(t, outboxRepo.created, 2) + require.NotEqual(t, outboxRepo.created[0].DedupeKey, outboxRepo.created[1].DedupeKey) +} + func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" @@ -2050,6 +2132,10 @@ func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t require.Len(t, items, 1) require.Equal(t, "needs_update", items[0].Status) + require.Equal(t, user.Phone, items[0].BaronPhone) + require.Equal(t, "+821099998888", items[0].WorksmobilePhone) + require.Equal(t, "EMP001", items[0].BaronEmployeeNumber) + require.Equal(t, "EMP999", items[0].WorksmobileEmployeeNumber) } func TestCompareWorksmobileUsersMarksMalformedRemoteKoreanPhoneNeedsUpdate(t *testing.T) { diff --git a/config-restored/compose/compose.ory.yaml b/config-restored/compose/compose.ory.yaml index 07eb83e9..b8c6e55d 100644 --- a/config-restored/compose/compose.ory.yaml +++ b/config-restored/compose/compose.ory.yaml @@ -236,21 +236,12 @@ services: # 기본 RP (Admin Front 등) 자동 등록 컨테이너 init-rp: - image: alpine:latest + image: oryd/hydra:${HYDRA_CLI_VERSION:-v26.2.0} env_file: - .env + entrypoint: ["/bin/sh", "-ec"] command: - - /bin/sh - - -ec - | - apk add --no-cache curl tar - HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}" - HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}" - HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}" - curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz" - tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra - rm /tmp/hydra.tar.gz - hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" adminfront >/dev/null 2>&1 || true hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" devfront >/dev/null 2>&1 || true hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" orgfront >/dev/null 2>&1 || true @@ -270,21 +261,21 @@ services: --endpoint "$${HYDRA_ADMIN_URL}" \ --id devfront \ --name "DevFront" \ - --grant-type authorization_code,refresh_token \ - --response-type code \ - --scope openid,offline_access,profile,email \ - --token-endpoint-auth-method none \ - --redirect-uri ${DEVFRONT_CALLBACK_URLS} + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri ${DEVFRONT_CALLBACK_URLS} hydra create oauth2-client \ --endpoint "$${HYDRA_ADMIN_URL}" \ --id orgfront \ --name "OrgFront" \ - --grant-type authorization_code,refresh_token \ - --response-type code \ - --scope openid,offline_access,profile,email \ - --token-endpoint-auth-method none \ - --redirect-uri ${ORGFRONT_CALLBACK_URLS} + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri ${ORGFRONT_CALLBACK_URLS} hydra create oauth2-client \ --endpoint "$${HYDRA_ADMIN_URL}" \ diff --git a/config-restored/compose/docker-compose.yaml b/config-restored/compose/docker-compose.yaml index 889ac824..49fc0262 100644 --- a/config-restored/compose/docker-compose.yaml +++ b/config-restored/compose/docker-compose.yaml @@ -55,6 +55,10 @@ services: build: context: . dockerfile: ./adminfront/Dockerfile + args: + VITE_ADMIN_PUBLIC_URL: ${ADMINFRONT_URL} + VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY} + VITE_OIDC_CLIENT_ID: adminfront container_name: baron_adminfront env_file: - .env @@ -80,6 +84,10 @@ services: build: context: . dockerfile: ./devfront/Dockerfile + args: + VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL} + VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY} + VITE_OIDC_CLIENT_ID: devfront container_name: baron_devfront env_file: - .env @@ -105,6 +113,10 @@ services: build: context: . dockerfile: ./orgfront/Dockerfile + args: + VITE_ORGFRONT_PUBLIC_URL: ${ORGFRONT_URL} + VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY} + VITE_OIDC_CLIENT_ID: orgfront container_name: baron_orgfront env_file: - .env @@ -172,6 +184,33 @@ services: 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 + + blackbox-exporter: + image: prom/blackbox-exporter:v0.25.0 + container_name: baron_blackbox_exporter + restart: unless-stopped + ports: + - "9115:9115" + volumes: + - ./docker/monitor/blackbox.yml:/etc/blackbox_exporter/config.yml:ro + networks: + - baron_net + - ory-net + networks: baron_net: external: true diff --git a/config-restored/env.redacted b/config-restored/env.redacted index da4420a1..d9dae45c 100644 --- a/config-restored/env.redacted +++ b/config-restored/env.redacted @@ -1,6 +1,8 @@ -APP_ENV=dev +APP_ENV=stage BACKEND_LOG_LEVEL=debug CLIENT_LOG_DEBUG=true +WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com +WORKS_ADMIN_OAUTH_TOKEN_URL=REDACTED TZ=Asia/Seoul IDP_PROVIDER=ory @@ -16,6 +18,7 @@ CLICKHOUSE_PASSWORD=REDACTED BACKEND_PORT=3000 ADMINFRONT_PORT=5173 DEVFRONT_PORT=5174 +ORGFRONT_PORT= USERFRONT_PORT=5000 OATHKEEPER_API_URL=http://oathkeeper:4456 @@ -26,10 +29,11 @@ DB_NAME=baron_sso COOKIE_SECRET=REDACTED JWT_SECRET=REDACTED REDIS_ADDR=redis:6389 -CORS_ALLOWED_ORIGINS='*' +CORS_ALLOWED_ORIGINS=https://sso.hmac.kr AUDIT_WORKER_COUNT=5 AUDIT_QUEUE_SIZE=2000 -PROFILE_CACHE_TTL= +PROFILE_CACHE_TTL=30m +ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600 NAVER_CLOUD_ACCESS_KEY=REDACTED NAVER_CLOUD_SECRET_KEY=REDACTED NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:364022321777:baroncs @@ -38,19 +42,15 @@ AWS_REGION=ap-northeast-2 AWS_ACCESS_KEY_ID=REDACTED AWS_SECRET_ACCESS_KEY=REDACTED AWS_SES_SENDER=support@baroncs.co.kr -# ADMIN_EMAIL=admin@hmac.kr -ADMIN_EMAIL=su-@samaneng.com +ADMIN_EMAIL=admin@hmac.kr ADMIN_PASSWORD=REDACTED -USERFRONT_URL=http://localhost:5000 -# USERFRONT_URL=http://172.16.9.189:5000 -ADMINFRONT_URL=http://localhost:5173 -DEVFRONT_URL=http://localhost:5174 -VITE_ORGCHART_URL=http://localhost:5175 -ORGFRONT_URL=http://localhost:5175 +USERFRONT_URL=https://sso.hmac.kr +ADMINFRONT_URL=https://sadmin.hmac.kr +DEVFRONT_URL=https://sdev.hmac.kr +ORGFRONT_URL=https://sorg.hmac.kr BACKEND_PUBLIC_URL=${USERFRONT_URL} BACKEND_URL=${USERFRONT_URL} -# OATHKEEPER_PUBLIC_URL=http://172.16.9.189:5000 -OATHKEEPER_PUBLIC_URL=http://localhost:5000 +OATHKEEPER_PUBLIC_URL=https://sso.hmac.kr ORY_POSTGRES_TAG=17-trixie ORY_POSTGRES_USER=ory @@ -60,15 +60,16 @@ KRATOS_DB=ory_kratos HYDRA_DB=ory_hydra KETO_DB=ory_keto KRATOS_VERSION=v26.2.0-distroless +KRATOS_UI_NODE_VERSION=v26.2.0 HYDRA_VERSION=v26.2.0-distroless KETO_VERSION=v26.2.0-distroless ORY_SDK_URL=http://kratos:4433 KRATOS_PUBLIC_URL=http://kratos:4433 KRATOS_ADMIN_URL=http://kratos:4434 -KRATOS_BROWSER_URL=http://localhost:5000/auth -KRATOS_UI_URL=http://localhost:5000 +KRATOS_BROWSER_URL=https://sso.hmac.kr/auth +KRATOS_UI_URL=https://sso.hmac.kr HYDRA_ADMIN_URL=http://hydra:4445 -HYDRA_PUBLIC_URL=http://localhost:5000/oidc +HYDRA_PUBLIC_URL=https://sso.hmac.kr/oidc JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json OATHKEEPER_VERSION=v26.2.0 OATHKEEPER_UID=1001 @@ -80,40 +81,17 @@ OATHKEEPER_HEALTH_ENABLED=true CSRF_COOKIE_NAME=REDACTED CSRF_COOKIE_SECRET=REDACTED -# Frontend OIDC configs for Staging -VITE_OIDC_AUTHORITY=http://localhost:5000/oidc -ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback -DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback -ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback +# Frontend/Ory URL configs for Staging +VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc +ADMINFRONT_CALLBACK_URLS=https://sadmin.hmac.kr/auth/callback +DEVFRONT_CALLBACK_URLS=https://sdev.hmac.kr/auth/callback +ORGFRONT_CALLBACK_URLS=https://sorg.hmac.kr/auth/callback +KRATOS_ALLOWED_RETURN_URLS_JSON= +KRATOS_ALLOWED_RETURN_URLS_EXTRA= # OATHKEEPER_INTROSPECT_CLIENT_ID= # OATHKEEPER_INTROSPECT_CLIENT_SECRET= -#Worksmobile -SAMAN_DOMAIN_ID=300285955 -HANMAC_DOMAIN_ID=300286336 -GPDTDC_DOMAIN_ID=300286337 -BARONGROUP_DOMAIN_ID=300286645 -HALLA_DOMAIN_ID=300293726 -SAMAN_TENANT_ID=300285955 -SAMAN_SCIM_LONGLIVE_TOKEN=REDACTED -WORKS_ADMIN_OAUTH_CLIENT_ID=JrD1iPz73ugTFV5XL_zO -WORKS_ADMIN_OAUTH_CLIENT_SECRET=REDACTED -WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT=e3n9j.serviceaccount@samaneng.com -WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE=REDACTED -WORKS_DEFAULT_DOMAIN_SAMAN=samaneng.com -WORKS_DEFAULT_DOMAIN_HANMAC=hanmaceng.co.kr -WORKS_DEFAULT_DOMAIN_GPDTDC=baroncs.co.kr -WORKS_DEFAULT_DOMAIN_BARONGROUP=brsw.kr -WORKS_DEFAULT_DOMAIN_HALLA=hallasanup.com -WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com -WORKS_ADMIN_OAUTH_TOKEN_URL=REDACTED - -WORKS_DRIVE_OAUTH_CLIENT_ID=9JapAnmjI9M_1SqDp4Uj -WORKS_DRIVE_OAUTH_CLIENT_SECRET=REDACTED -WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT=h4bq6.serviceaccount@samaneng.com -WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE=REDACTED -WORKS_DRIVE_APP_PASSWORD=REDACTED -WORKS_DRIVE_OAUTH_REDIRECT_URI=https://drive.hmac.kr/works/callback -WORKS_DRIVE_OAUTH_REFRESH_TOKEN=REDACTED -WORKS_DRIVE_SHARED_DRIVE_ID=@2001000000540386 -WORKS_DRIVE_PARENT_FILE_ID=QDIwMDEwMDAwMDA1NDAzODZ8MzQ3MjYxMzYwMzE0NjY2NDk2OXxEfDA +# Monitoring & Alerts +SMS_WEBHOOK_PORT=8080 +MONITOR_RECIPIENT_PHONES=01012345678,01098765432 +LOKI_URL=http://llm_gateway_loki:3100/loki/api/v1/push diff --git a/config-restored/gateway.tar.zst b/config-restored/gateway.tar.zst index de064f5f..ad85b16c 100644 Binary files a/config-restored/gateway.tar.zst and b/config-restored/gateway.tar.zst differ diff --git a/config-restored/generated-ory.tar.zst b/config-restored/generated-ory.tar.zst index ea4f8dfd..00fba0e3 100644 Binary files a/config-restored/generated-ory.tar.zst and b/config-restored/generated-ory.tar.zst differ diff --git a/docker/backup-tools/Dockerfile b/docker/backup-tools/Dockerfile index 37bb7509..065d317b 100644 --- a/docker/backup-tools/Dockerfile +++ b/docker/backup-tools/Dockerfile @@ -16,6 +16,7 @@ RUN apt-get update \ postgresql-client \ sed \ tar \ + unzip \ util-linux \ zstd \ && rm -rf /var/lib/apt/lists/* diff --git a/scripts/backup/restore-plan.sh b/scripts/backup/restore-plan.sh index 5e829b78..344081a3 100755 --- a/scripts/backup/restore-plan.sh +++ b/scripts/backup/restore-plan.sh @@ -2,4 +2,4 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BACKUP="${BACKUP:-${1:-}}" DUMP_FILE="${DUMP_FILE:-}" RESTORE_REPORT="${RESTORE_REPORT:-}" "$script_dir/restore.sh" --dry-run +RESTORE_INPUT="${RESTORE_INPUT:-${FILE_PATH:-${1:-}}}" BACKUP="${BACKUP:-}" DUMP_FILE="${DUMP_FILE:-}" RESTORE_REPORT="${RESTORE_REPORT:-}" "$script_dir/restore.sh" --dry-run diff --git a/scripts/backup/restore.sh b/scripts/backup/restore.sh index 011b06cc..3d35657f 100755 --- a/scripts/backup/restore.sh +++ b/scripts/backup/restore.sh @@ -14,6 +14,7 @@ if [[ "${1:-}" == "--dry-run" ]]; then fi repo_root="$(backup_repo_root)" +restore_input="${RESTORE_INPUT:-${FILE_PATH:-}}" backup_input="${BACKUP:-}" dump_file="${DUMP_FILE:-}" backup_source="directory" @@ -107,8 +108,36 @@ trap on_restore_error ERR trap cleanup_restore_input EXIT resolve_backup_input() { - if [[ -n "$backup_input" && -n "$dump_file" ]]; then - backup_die "set only one of BACKUP or DUMP_FILE for restore." + local input_count=0 + + [[ -n "$restore_input" ]] && input_count=$((input_count + 1)) + [[ -n "$backup_input" ]] && input_count=$((input_count + 1)) + [[ -n "$dump_file" ]] && input_count=$((input_count + 1)) + + if [[ "$input_count" -gt 1 ]]; then + backup_die "set only one restore input: RESTORE_INPUT, BACKUP, or DUMP_FILE." + fi + + if [[ -n "$restore_input" ]]; then + backup_require_path "$restore_input" + if [[ -d "$restore_input" ]]; then + backup_dir="$restore_input" + backup_source="directory" + return + fi + + if [[ ! -f "$restore_input" ]]; then + backup_die "restore input must be a backup directory or supported archive: $restore_input" + fi + + case "$restore_input" in + *.tar.zst | *.tar.gz | *.tgz | *.zip) + dump_file="$restore_input" + ;; + *) + backup_die "unsupported restore input file extension: $restore_input" + ;; + esac fi if [[ -n "$backup_input" ]]; then @@ -134,6 +163,10 @@ resolve_backup_input() { *.tar.gz | *.tgz) tar -xzf "$dump_file" -C "$temp_extract_dir" ;; + *.zip) + backup_require_command unzip + unzip -q "$dump_file" -d "$temp_extract_dir" + ;; *) backup_die "unsupported DUMP_FILE archive format: $dump_file" ;; diff --git a/test/backup_make_targets_test.sh b/test/backup_make_targets_test.sh index 19833e77..673421be 100755 --- a/test/backup_make_targets_test.sh +++ b/test/backup_make_targets_test.sh @@ -2,6 +2,9 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +positional_restore_path="/tmp/baron-sso-restore-positional.tar.gz" +trap 'rm -f "$positional_restore_path"' EXIT INT TERM +: >"$positional_restore_path" fail() { echo "ERROR: $*" >&2 @@ -22,6 +25,8 @@ grep -Fq "docker-cli" "$repo_root/docker/backup-tools/Dockerfile" \ || fail "backup-tools image must include docker CLI for containerized dump/restore orchestration." grep -Fq "perl" "$repo_root/docker/backup-tools/Dockerfile" \ || fail "backup-tools image must include perl for legacy ClickHouse schema dump decoding." +grep -Fq "unzip" "$repo_root/docker/backup-tools/Dockerfile" \ + || fail "backup-tools image must include unzip for .zip restore archives." dump_dry_run="$( make --dry-run --always-make -C "$repo_root" dump DUMP_SERVICES="postgres,config" DUMP_MODE="maintenance" 2>&1 @@ -48,6 +53,24 @@ assert_dry_run_contains "$restore_dry_run" "DUMP_FILE=\"backups/example.tar.zst\ assert_dry_run_contains "$restore_dry_run" "RESTORE_SERVICES=\"postgres,config\"" assert_dry_run_contains "$restore_dry_run" "CONFIRM_RESTORE=\"baron-sso\"" assert_dry_run_contains "$restore_dry_run" "RESTORE_REPORT=\"reports/restore-report.json\"" +assert_dry_run_contains "$restore_dry_run" "Ensuring restore target containers" +assert_dry_run_contains "$restore_dry_run" "ensure_restore_container baron_postgres compose.infra.yaml postgres" +assert_dry_run_contains "$restore_dry_run" "ensure_restore_container ory_postgres compose.ory.yaml postgres" + +restore_file_path_dry_run="$( + make --dry-run --always-make -C "$repo_root" restore FILE_PATH="backups/example.tar.zst" RESTORE_SERVICES="postgres" CONFIRM_RESTORE="baron-sso" 2>&1 +)" + +assert_dry_run_contains "$restore_file_path_dry_run" "RESTORE_INPUT=\"backups/example.tar.zst\"" +assert_dry_run_contains "$restore_file_path_dry_run" "RESTORE_SERVICES=\"postgres\"" +assert_dry_run_contains "$restore_file_path_dry_run" "CONFIRM_RESTORE=\"baron-sso\"" + +restore_positional_dry_run="$( + make --dry-run --always-make -C "$repo_root" restore "$positional_restore_path" RESTORE_SERVICES="config" CONFIRM_RESTORE="baron-sso" 2>&1 +)" + +assert_dry_run_contains "$restore_positional_dry_run" "RESTORE_INPUT=\"$positional_restore_path\"" +assert_dry_run_contains "$restore_positional_dry_run" "RESTORE_SERVICES=\"config\"" for target in dump-verify restore-verify dump-list restore-plan; do target_dry_run="$( diff --git a/test/make_help_target_test.sh b/test/make_help_target_test.sh new file mode 100755 index 00000000..be8c39da --- /dev/null +++ b/test/make_help_target_test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +assert_contains() { + local output="$1" + local expected="$2" + grep -Fq -- "$expected" <<<"$output" || fail "help output must contain: $expected" +} + +help_output="$( + make -C "$repo_root" help 2>&1 +)" + +assert_contains "$help_output" "Usage:" +assert_contains "$help_output" "Targets:" +assert_contains "$help_output" "Options:" +assert_contains "$help_output" "Restore Safety:" + +for target in up dev code-check dump restore-plan code-check-userfront-e2e-tests; do + assert_contains "$help_output" "$target" +done + +for option in DEV_SERVICES CODE_CHECK_TEST_JOBS PLAYWRIGHT_WORKERS BACKUP_USE_DOCKER DUMP_SERVICES RESTORE_SERVICES; do + assert_contains "$help_output" "$option" +done + +for restore_usage in \ + "CONFIRM_RESTORE=baron-sso" \ + "ALLOW_NON_EMPTY_RESTORE=true" \ + "make restore-plan FILE_PATH=stg.today.tar.gz CONFIRM_RESTORE=baron-sso" \ + "make restore FILE_PATH=stg.today.tar.gz CONFIRM_RESTORE=baron-sso ALLOW_NON_EMPTY_RESTORE=true"; do + assert_contains "$help_output" "$restore_usage" +done + +for description in \ + "전체 로컬 스택 실행" \ + "개발 앱 컨테이너를 포그라운드로 실행" \ + "로컬 CI 상당 코드 검사 실행" \ + "백업 덤프 생성" \ + "복구 실행 계획 출력" \ + "UserFront WASM E2E 테스트 실행"; do + assert_contains "$help_output" "$description" +done + +echo "OK: make help exposes generated targets and configurable options" diff --git a/test/restore_input_path_test.sh b/test/restore_input_path_test.sh new file mode 100755 index 00000000..449cf34b --- /dev/null +++ b/test/restore_input_path_test.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmp_dir="$(mktemp -d /tmp/baron-sso-restore-input.XXXXXX)" + +cleanup() { + rm -rf "$tmp_dir" +} +trap cleanup EXIT INT TERM + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +assert_contains() { + local output="$1" + local expected="$2" + grep -Fq -- "$expected" <<<"$output" || fail "output must contain: $expected" +} + +run_restore_plan() { + local output + output="$("$@" 2>&1)" + assert_contains "$output" "Restore plan for" + assert_contains "$output" "Services: config" +} + +backup_dir="$tmp_dir/baron-sso-backup-test" +mkdir -p "$backup_dir" +printf '{"restore_policy":{}}\n' >"$backup_dir/manifest.json" + +tar_zst="$tmp_dir/baron-sso-backup-test.tar.zst" +tar_gz="$tmp_dir/baron-sso-backup-test.tar.gz" +zip_file="$tmp_dir/baron-sso-backup-test.zip" +unsupported_file="$tmp_dir/baron-sso-backup-test.tar.bz2" + +tar --zstd -cf "$tar_zst" -C "$tmp_dir" "$(basename "$backup_dir")" +tar -czf "$tar_gz" -C "$tmp_dir" "$(basename "$backup_dir")" +(cd "$tmp_dir" && python3 -m zipfile -c "$zip_file" "$(basename "$backup_dir")") +: >"$unsupported_file" + +run_restore_plan \ + env RESTORE_INPUT="$backup_dir" RESTORE_SERVICES=config CONFIRM_RESTORE=baron-sso RESTORE_REPORT="$tmp_dir/dir-report.json" \ + "$repo_root/scripts/backup/restore.sh" --dry-run + +run_restore_plan \ + env RESTORE_INPUT="$tar_zst" RESTORE_SERVICES=config CONFIRM_RESTORE=baron-sso RESTORE_REPORT="$tmp_dir/tar-zst-report.json" \ + "$repo_root/scripts/backup/restore.sh" --dry-run + +run_restore_plan \ + env RESTORE_INPUT="$tar_gz" RESTORE_SERVICES=config CONFIRM_RESTORE=baron-sso RESTORE_REPORT="$tmp_dir/tar-gz-report.json" \ + "$repo_root/scripts/backup/restore.sh" --dry-run + +run_restore_plan \ + env RESTORE_INPUT="$zip_file" RESTORE_SERVICES=config CONFIRM_RESTORE=baron-sso RESTORE_REPORT="$tmp_dir/zip-report.json" \ + "$repo_root/scripts/backup/restore.sh" --dry-run + +run_restore_plan \ + env DUMP_FILE="$zip_file" RESTORE_SERVICES=config CONFIRM_RESTORE=baron-sso RESTORE_REPORT="$tmp_dir/direct-zip-report.json" \ + "$repo_root/scripts/backup/restore.sh" --dry-run + +if env RESTORE_INPUT="$unsupported_file" RESTORE_SERVICES=config CONFIRM_RESTORE=baron-sso RESTORE_REPORT="$tmp_dir/unsupported-report.json" \ + "$repo_root/scripts/backup/restore.sh" --dry-run >/tmp/baron-sso-restore-unsupported.out 2>&1; then + fail "unsupported restore archive extension must fail." +fi +assert_contains "$(cat /tmp/baron-sso-restore-unsupported.out)" "unsupported restore input file extension" + +if env RESTORE_INPUT="$backup_dir" BACKUP="$backup_dir" RESTORE_SERVICES=config CONFIRM_RESTORE=baron-sso RESTORE_REPORT="$tmp_dir/ambiguous-report.json" \ + "$repo_root/scripts/backup/restore.sh" --dry-run >/tmp/baron-sso-restore-ambiguous.out 2>&1; then + fail "ambiguous restore inputs must fail." +fi +assert_contains "$(cat /tmp/baron-sso-restore-ambiguous.out)" "set only one restore input" + +echo "OK: restore input path inference supports directories, tar archives, and zip archives" diff --git a/works_users_barongroup.CSV b/works_users_barongroup.CSV new file mode 100644 index 00000000..37dffb02 --- /dev/null +++ b/works_users_barongroup.CSV @@ -0,0 +1,94 @@ +email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,sub_email, +sgkim6@jangheon.com,±,010-4847-5596,user,jangheon,,λ,ǥ̻,,M02302,civilksk@jangheon.com, +mjkim7@jangheon.com,,010-5502-0787,user,jangheon-business-support,,,,,J10313,kmj@jangheon.com, +mkchae@jangheon.com,ָ,010-4056-7247,user,jangheon-business-support,,,,,J22305,mkchae2@jangheon.com, +smlee3@jangheon.com,̼,010-4764-0606,user,jangheon-production,,,,,P13302,jini3474@jangheon.com, +dcchoi@jangheon.com,ֵ,010-9554-1212,user,jangheon-production,,,,,J22301,cdc0601@jangheon.com, +ibkim@jangheon.com,ι,010-8033-9739,user,jangheon-production,,,,,J23308,dlsqja9739@jangheon.com, +syhan@brsw.kr,ѻ,010-8600-2267,user,baroncs,,,,,B24029,ko85han@naver.com, ̴ϼ ϰ +hkkang@brsw.kr,ȣ,010-2041-5974,user,baroncs,,,,,B26008,, ̴ϼ ϰ +evenlee@brsw.kr,̿,010-3761-9642,user,baroncs,,,,,J06102,ywlee@hanmaceng.co.kr, ̴ϼ ϰ +atom20002@brsw.kr,,010-2925-4600,user,baroncs,,,,,T02230,, ̴ϼ ϰ +gsyang@brsw.kr,Լ,010-4220-8962,user,baroncs,,,,,B22006,, ̴ϼ ϰ +dhshin1@brsw.kr,ŵȣ,010-7247-1113,user,baroncs,,,,,B22012,realtajoal@naver.com, ̴ϼ ϰ +shmoon1@brsw.kr,,010-4165-0386,user,baroncs,,,,,B21354,b21354@hanmaceng.co.kr, ̴ϼ ϰ +yjlee1@brsw.kr,̿,010-2080-6816,user,baroncs,,,,,B20338,b20338@hanmaceng.co.kr, ̴ϼ ϰ +dkkwon@brsw.kr,Ǵ,010-8031-4206,user,baroncs,,,,,B20323,dkk0425@naver.com, ̴ϼ ϰ +yskim2@brsw.kr,迹,010-7258-5687,user,baroncs,,,,,B20337,b20337@hanmaceng.co.kr, ̴ϼ ϰ +cjbang@brsw.kr,,010-8536-3668,user,baroncs,,,,,B22021,b22021@hanmaceng.co.kr, ̴ϼ ϰ +jwchoi6@brsw.kr,,010-8562-4709,user,baroncs,,,,,B26005,, ̴ϼ ϰ +jmhyen@brsw.kr,,010-6369-7053,user,baroncs,,,,,B20328,jmh@hallasanup.com, +smyoo2@brsw.kr,¹A,010-9244-6437,user,baroncs,,,,,B23048,smin@hallasanup.com, +chjung@pre-cast.co.kr,ȭ,010-4146-2835,,ptc-project-management,,̻,,,P06208,jung@pre-cast.co.kr, +josb@pre-cast.co.kr,,010-2724-8892,,ptc-project-management,,,,,P20201,josb@pre-cast.co.kr, +nksung@pre-cast.co.kr,ü,010-9595-8283,,ptc-project-management,,,,,P24303,sunga13@hanmaceng.co.kr, +skkim1@pre-cast.co.kr,輺,010-9441-3823,,ptc-construction,ð,,,,P09306,winner1293@pre-cast.co.kr, +hslee6@pre-cast.co.kr,ȿ,010-3879-5938,,ptc-design,,,,,P10301,madbaby17@pre-cast.co.kr, +kimks@pre-cast.co.kr,,010-9466-1653,,ptc-construction,ð,,,,P11302,diejsa@pre-cast.co.kr, +swkim5@pre-cast.co.kr,,010-4857-3636,,tdc,߼,,,,P11202,P11202@pre-cast.co.kr, +eschoi1@pre-cast.co.kr,,010-3218-7208,,ptc-sales,,,,,P11201,viewso@pre-cast.co.kr, +yskim10@pre-cast.co.kr,뼱,010-4026-5196,,ptc-design,,,,,P16203,sunofseoul@pre-cast.co.kr, +sjyoon@pre-cast.co.kr,,010-8942-3749,,ptc-sales,,,,,P17201,yoonsj0328@pre-cast.co.kr, +jhchoi11@pre-cast.co.kr,,010-4488-3672,,ptc-construction,ð,,,,P17301,nada3672@pre-cast.co.kr, +shkim15@pre-cast.co.kr,ؼ,010-4660-4410,,ptc-construction,ð,,,,P17202,haiskk@pre-cast.co.kr, +jykim5@pre-cast.co.kr,,010-7412-1729,,tdc,߼,å,,,P20202,P20202@pre-cast.co.kr, +sekim2@pre-cast.co.kr,,010-4090-6977,,ptc-design,,,,,P19303,ssongeun@pre-cast.co.kr, +hwkang@pre-cast.co.kr,,010-3352-3444,,ptc-sales,,,,,P19203,khwgogo@pre-cast.co.kr, +ysmun2@pre-cast.co.kr,,010-2833-5718,,tdc,߼,,,,P20305,P20305@pre-cast.co.kr, +mjkim7@pre-cast.co.kr,,010-3595-7611,,ptc-design,,,,,P22301,P22301@pre-cast.co.kr, +hrguk2@pre-cast.co.kr,,010-6477-9711,,tdc,߼,,,,P22304,P22304@pre-cast.co.kr, +hkryu@pre-cast.co.kr,ѱ,010-9770-2445,,ptc-design,,̻,,,P22201,P22201@pre-cast.co.kr, +yssong@pre-cast.co.kr,,010-2528-1174,,ptc-design,,,,,P22306,P22306@pre-cast.co.kr, +iyji@pre-cast.co.kr,ο,010-2528-1174,,ptc-executive,ӿ,,,,P23301,P23301@pre-cast.co.kr, +hkchoi2@pre-cast.co.kr,,010-6297-6815,,ptc-design,,,,,P23201,aia1324@pre-cast.co.kr, +hechoi@pre-cast.co.kr,,010-3453-2360,,tdc,߼,,,,P23304,P23304@pre-cast.co.kr, +shchun@hanmaceng.co.kr,,010-2513-7763,,ptc-design,,,μ,,P22101,san4747@hanmaceng.co.kr, +syseo@hanmaceng.co.kr,,010-5408-7916,,ptc-executive,ӿ,ȸ,,,M01103,sys55@hanmaceng.co.kr, +leeks@pre-cast.co.kr,̱Ǽ,010-2760-7826,,ptc-executive,ӿ,λ,ǥ̻,,P15101,motion70@pre-cast.co.kr, +khlee5@pre-cast.co.kr,̰ȯ,010-3391-3054,,,,,,,P25502,p25502@pre-cast.co.kr, +iglee@jangheon.co.kr,ΰ,010-3877-9086,,js-construction-hq,Ǽ,̻,,,J25510,J25510@jangheon.co.kr, +baemg@jangheon.co.kr,蹮,010-3751-9058,,jangheon-sanup-executive,ӿ,,ǥ̻,,j25101,j25101@jangheon.co.kr, +jklee7@jangheon.co.kr,,010-8725-7034,,jangheon-sanup-executive,ӿ,,,,M04101,M04101@jangheon.co.kr, +sygwak@jangheon.co.kr,¿,010-5167-5306,,js-construction-hq,Ǽ,,,,j25602,j25602@jangheon.co.kr, +gskim@jangheon.co.kr,谩,010-8882-4315,,js-construction-hq,Ǽ,,,,j24307,j24307@jangheon.co.kr, +dwkim2@jangheon.co.kr,赿,010-4472-1337,,js-construction-hq,Ǽ,,,,J22205,J22205@jangheon.co.kr, +rgkim@jangheon.co.kr,跮,010-8165-1700,,jangheon-sanup-executive,ӿ,λ,,,rgkim@,rgkim@jangheon.co.kr, +bskim7@jangheon.co.kr,,010-9292-0111,,js-construction-hq,Ǽ,,,,j22202,j22202@jangheon.co.kr, +sckim5@jangheon.co.kr,ö,010-3372-3194,,js-construction-hq,Ǽ,̻,,,j22202,j22202@jangheon.co.kr, +swkim5@jangheon.co.kr,輺,010-9544-0506,,js-tech-sales-hq,,,,,yi04@j,yi04@jangheon.co.kr, +shkim7@jangheon.co.kr,輺ȯ,010-4184-1100,,js-construction-hq,Ǽ,,,,j25301,j25301@jangheon.co.kr, +iykim@jangheon.co.kr,ο,010-8667-8536,,js-construction-hq,Ǽ,,,,kiy853,kiy8536@jangheon.co.kr, +jkim@jangheon.co.kr,,010-4657-6970,,js-tech-sales-hq,,̻,,,kimjin,kimjin1971@jangheon.co.kr, +synam@jangheon.co.kr,,010-3612-9067,,js-construction-hq,Ǽ,̻,,,j25506,j25506@jangheon.co.kr, +jtmoon@jangheon.co.kr,Ź,010-8752-0433,,js-tech-sales-hq,,,,,jtmoon,jtmoon@jangheon.co.kr, +swpark5@jangheon.co.kr,ڼ,010-4350-3467,,js-tech-sales-hq,,,,,J24301,J24301@jangheon.co.kr, +sipark2@jangheon.co.kr,ڼ,010-6266-5913,,js-construction-hq,Ǽ,,,,j24501,j24501@jangheon.co.kr, +ebpark@jangheon.co.kr,,010-8247-5542,,,,,,,j25305,j25305@jangheon.co.kr, +bjseo@jangheon.co.kr,,010-2491-8391,,js-construction-hq,Ǽ,,,,j25604,j25604@jangheon.co.kr, +jgan@jangheon.co.kr,,010-9770-6249,,js-construction-hq,Ǽ,,,,j25504,j25504@jangheon.co.kr, +jsyang@jangheon.co.kr,,010-6859-2624,,js-construction-hq,Ǽ,,,,cejs76,cejs76@jangheon.co.kr, +dhyun@jangheon.co.kr,,010-6243-0081,,js-construction-hq,Ǽ,,,,doohyu,doohyuny04@jangheon.co.kr, +siyoun@jangheon.co.kr,,010-7749-1076,,js-construction-hq,Ǽ,,,,j22201,j22201@jangheon.co.kr, +cyyun2@jangheon.co.kr,濭,010-4560-5469,,js-construction-hq,Ǽ,,,,J25601,J25601@jangheon.co.kr, +sslee3@jangheon.co.kr,̼,010-3528-7895,,js-construction-hq,Ǽ,̻,,,j24503,j24503@jangheon.co.kr, +shlee25@jangheon.co.kr,ȯ,010-3354-2945,,js-construction-hq,Ǽ,,,,j13308,j13308@jangheon.co.kr, +jylee9@jangheon.co.kr,翵,010-7265-2258,,js-construction-hq,Ǽ,,,,young7,young7471@jangheon.co.kr, +jhlee8@jangheon.co.kr,,010-9031-7789,,js-tech-sales-hq,,̻,ֹ,,J07210,fb1t@jangheon.co.kr, +jklee9@jangheon.co.kr,߰,010-6317-9794,,js-tech-sales-hq,,̻,,,first7,first7777@jangheon.co.kr, +chlee7@jangheon.co.kr,âȣ,010-3807-3517,,js-construction-hq,Ǽ,̻,,,j24502,j24502@jangheon.co.kr, +hblee@jangheon.co.kr,ȣ,010-4678-5592,,js-construction-hq,Ǽ,,,,J23301,J23301@jangheon.co.kr, +hjlee5@jangheon.co.kr,,010-8590-3055,,jangheon-sanup-executive,ӿ,λ,,,j24101,j24101@jangheon.co.kr, +wscheung@jangheon.co.kr,켺,010-4769-9299,,js-tech-sales-hq,,,,,j23307,j23307@jangheon.co.kr, +dicho@jangheon.co.kr,,010-9141-9180,,js-tech-sales-hq,,븮,,,j21303,j21303@jangheon.co.kr, +bgcho@jangheon.co.kr,ΰ,010-6358-1326,,js-construction-hq,Ǽ,,,,j25603,j25603@jangheon.co.kr, +sgjoo@jangheon.co.kr,ֻ,010-5306-5502,,,,,,,twojoo,twojoo96@jangheon.co.kr, +sdcha@jangheon.co.kr,,010-3610-8458,,js-construction-hq,Ǽ,,,,csd915,csd915@jangheon.co.kr, +tschoi@jangheon.co.kr,¼,010-7118-1126,,js-construction-hq,Ǽ,,ֹ,,khw04@,khw04@jangheon.co.kr, +jmhwang1@jangheon.co.kr,Ȳ,010-9329-1936,,js-tech-sales-hq,,,,,jmhwan,jmhwang@jangheon.co.kr, +ystyoo@jangheon.co.kr,,010-5237-4923,,jangheon-sanup-executive,,,,,J15101,, +iskim2@jangheon.co.kr,μ,010-5374-2674,,js-construction-hq,Ǽ,,,,kiss04,kiss0408@jangheon.co.kr, +dhkim7@jangheon.co.kr,赿ȯ,011-721-5401,,jangheon-sanup-executive,,Ӱ,,,J16301,, +jgkim2@jangheon.co.kr,,010-3532-4954,,,,̻,,,j26302,j26302@jangheon.co.kr, +sychoi@jangheon.co.kr,ּ,010-9103-4302,,js-construction-hq,Ǽ,븮,,,J23303,, +jwpark1@jangheon.co.kr,,010-3530-2436,,js-construction-hq,Ǽ,,,,p25301,p25301@jangheon.co.kr, +dwpark@jangheon.co.kr,ڴ,010-3956-8218,,,,̻,,,J26501,J26501@jangheon.co.kr, diff --git a/works_users_halla.CSV b/works_users_halla.CSV new file mode 100644 index 00000000..56a5c90d --- /dev/null +++ b/works_users_halla.CSV @@ -0,0 +1,119 @@ +email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,sub_email +khkim@hallasanup.com,김광현,010-7411-4180,,hanlla-mgmt-support-hq,,전무이사,,,HM00112,ezfree@hallasanup.com +dcsong@hallasanup.com,송동찬,010-5414-6973,,hanlla-mgmt-support,,이사,,,HM02597,sdc0327@hallasanup.com +jhlee@hallasanup.com,이준하,010-9189-9705,,hanlla-mgmt-support,,차장,,,HM01357,jhlee@hallasanup.com +wspark@hallasanup.com,박우식,010-4851-8193,,hanlla-mgmt-support,,상무이사,,,HM00417,pws9@hallasanup.com +islee@hallasanup.com,이인서,010-5405-8044,,hanlla-business-support,,부장,,,HM02581,islee@hallasanup.com +kaseo@hallasanup.com,서경아,010-5242-9794,,hanlla-business-support,,차장,,,HF00442,sky@hallasanup.com +jhlee1@hallasanup.com,이종희,010-8626-8710,,hanlla-operations,,차장,,,HM02420,jhlee01@hallasanup.com +bjyoo@hallasanup.com,유붕종,010-4519-2634,,hanlla-general-business,,전무이사,,,HM01775,yoo660327@hallasanup.com +hckim@hallasanup.com,김희철,010-3790-0273,,hanlla-operations,,부장,,,HM00564,mecha011@hallasanup.com +jhchoi@hallasanup.com,최재혁,010-8831-7961,,site-gwangju-wastewater,,부장,,,HM02713,jh7184@hallasanup.com +hjjang@hallasanup.com,장홍재,010-5024-0588,,site-gwangju-wastewater,,차장,,,HM02747,hjjang@hallasanup.com +cwpark@hallasanup.com,박찬웅,010-2519-8190,,site-gwangju-wastewater,,대리,,,HM02643,fpeld15@hallasanup.com +jwlee@hallasanup.com,이정우,010-3729-2726,,site-gwangju-wastewater,,부장,,,HM02624,ljw1069@hallasanup.com +yjkang@hallasanup.com,강영진1,010-9254-4559,,site-gwangju-wastewater,,부장,,,HM02553,bukzy@hallasanup.com +swchoi@hallasanup.com,최승우,010-3816-5053,,site-gwangju-wastewater,,부장,,,HM02735,paikiki@hallasanup.com +dkkim@hallasanup.com,김동균1,010-9125-6361,,site-gwangju-wastewater,,부장,,,HM02697,kdk73@hallasanup.com +yjlee@hallasanup.com,이여진,010-2349-8106,,site-gwangju-wastewater,,사원,,,HF02728,yeojin@hallasanup.com +bmlee@hallasanup.com,이병문,010-3565-4737,,hanlla-env-plant-hq,,전무이사,,,HM00056,bmlee@hallasanup.com +syyang@hallasanup.com,양승엽,010-6246-7872,,hanlla-infra-project-mgmt,,상무이사,,,HM01976,ysungyup@hallasanup.com +dhkim@hallasanup.com,김동현,010-2463-5874,,hanlla-infra-project-mgmt,,부장,,,HM02775,kdh0202@hallasanup.com +sjkim@hallasanup.com,김승주,010-2929-1268,,hanlla-infra-project-mgmt,,대리,,,HM02678,ksj@hallasanup.com +skyang@hallasanup.com,양승경,010-2753-4994,,hanlla-infra-project-mgmt,,과장,,,HF01029,tmdrud86@hallasanup.com +djkwon@hallasanup.com,권대준,010-2458-3373,,hanlla-general-sales,,전무이사,,,HM02570,jjoony@hallasanup.com +wgkim@hallasanup.com,김원근,010-7634-7872,,hanlla-executive,,사장,,,HM01774,kimwg7872@hallasanup.com +btkim@hallasanup.com,김범태,010-4653-5690,,site-docheok-silchon-road,,부장,,,HM02722,70kbt@hallasanup.com +skhong@hallasanup.com,홍성관,010-9458-1766,,site-docheok-silchon-road,,차장,,,HM02719,kwany@hallasanup.com +dkchoi@hallasanup.com,최덕규,010-3315-5579,,site-docheok-silchon-road,,차장,,,HM02718,cdk20001@hallasanup.com +jhkim3@hallasanup.com,김종호,010-2758-9606,,site-docheok-silchon-road,,부장,,,HM02715,civilman12@hallasanup.com +jmkoo@hallasanup.com,구자민,010-5057-3073,,site-docheok-silchon-road,,대리,,,HM02721,wkals4582@hallasanup.com +khpark@hallasanup.com,박기황,010-2527-4607,,site-docheok-silchon-road,,부장,,,HM02717,parkkhhh@hallasanup.com +ehpark@hallasanup.com,박은호,010-9740-6634,,site-docheok-silchon-road,,차장,,,HM02734,eunho6634@hallasanup.com +kjkoo@hallasanup.com,구교진,010-3151-2660,,site-docheok-silchon-road,,사원,,,HM02638,kkj0509@hallasanup.com +shhong@hallasanup.com,홍순화,010-6624-6733,,site-docheok-silchon-road,,사원,,,HF02737,hongga328@hallasanup.com +dhlee@hallasanup.com,이동훈,010-3427-8276,,site-busan-new-port,,부장,,,HM02542,dhlee88@hallasanup.com +jgsong@hallasanup.com,송재규,010-9282-8213,,site-busan-new-port,,차장,,,HM02308,jgsong@hallasanup.com +wkjung@hallasanup.com,정원근,010-8923-2936,,site-busan-new-port,,부장,,,HM00365,jwg2936@hallasanup.com +syseok@hallasanup.com,석성용2,010-5654-5175,,site-busan-new-port,,차장,,,HM02644,ssssam4080@hallasanup.com +nrha@hallasanup.com,하누리,010-3005-5453,,site-busan-new-port,,차장,,,HM01015,nurry@hallasanup.com +jsjun@hallasanup.com,전종식2,010-2285-2324,,site-busan-new-port,,부장,,,HM02736,shik2324@hallasanup.com +rklee@hallasanup.com,이령경,010-5747-0944,,site-busan-new-port,,사원,,,HF02761,kyeong951104@hallasanup.com +hslee@hallasanup.com,이한소,010-8820-4650,,site-busan-new-port,,과장,,,HM02760,lhso111@hallasanup.com +bslee@hallasanup.com,이병석,010-4520-5573,,site-busan-new-port,,부장,,,HM02766,hopesuck20@hallasanup.com +smpark@hallasanup.com,박성민3,010-3631-8923,,site-busan-new-port,,이사,,,HM02780,smpark@hallasanup.com +jichoi@hallasanup.com,최재인,010-2205-0870,,site-bucheon-gulpocheon,,차장,,,HM02511,dia044653@hallasanup.com +ytyoo@hallasanup.com,유연태,010-8735-1567,,site-bucheon-gulpocheon,,부장,,,HM02730,yyt1567@hallasanup.com +hjkim@hallasanup.com,김희준,010-6288-8751,,site-bucheon-gulpocheon,,부장,,,HM02496,s09j15e@hallasanup.com +hssong@hallasanup.com,송홍섭,010-8963-1595,,site-bucheon-gulpocheon,,차장,,,HM02714,SH1029@hallasanup.com +mgkong@hallasanup.com,공민구,010-7752-8755,,site-bucheon-gulpocheon,,대리,,,HM02603,kongkevin@hallasanup.com +jhkim2@hallasanup.com,김진혁1,010-3708-8916,,site-bucheon-gulpocheon,,사원,,,HM02711,jhk752@hallasanup.com +shshin@hallasanup.com,신상훈,010-9140-8549,,site-bucheon-gulpocheon,,부장,,,HM02748,sshun1105@hallasanup.com +ksmoon@hallasanup.com,문경수,010-5435-8013,,site-bucheon-gulpocheon,,부장,,,HM02773,mks@hallasanup.com +silee@hallasanup.com,이송이,010-8923-8029,,site-bucheon-gulpocheon,,사원,,,HF02693,lovelyanne87@hallasanup.com +hyjeong@hallasanup.com,정호영,010-8596-4936,,site-sudokwon-landfill-2,,과장,,,HM01990,jhymhn@hallasanup.com +shcho@hallasanup.com,조성훈1,010-3999-9502,,site-sudokwon-landfill-2,,부장,,,HM02534,hoon96@hallasanup.com +mkryu@hallasanup.com,류문경,010-9489-6975,,site-sudokwon-landfill-2,,부장,,,HM02751,ryu9643019@hallasanup.com +shhan@hallasanup.com,한성호,010-9393-7097,,site-sudokwon-landfill-2,,부장,,,HM02763,hsh7097@hallasanup.com +syoh@hallasanup.com,오세영,010-2868-8953,,site-sudokwon-landfill-2,,이사,,,HM01969,Osy8953530@hallasanup.com +yssong@hallasanup.com,송영석,010-2731-6730,,site-sudokwon-landfill-2,,부장,,,HM02517,sys6730@hallasanup.com +dwlee@hallasanup.com,이대원,010-2313-9963,,site-sudokwon-landfill-2,,차장,,,HM02472,leedw@hallasanup.com +rrpark@hallasanup.com,박루리,010-9146-9934,,site-sudokwon-landfill-2,,사원,,,HF02774,parkruri@hallasanup.com +pkson@hallasanup.com,손판국,010-9367-5967,,site-sincheon-sewage,,부장,,,HM02563,sagibry@hallasanup.com +mjlee@hallasanup.com,이명준,010-6399-5159,,site-sincheon-sewage,,차장,,,HM02491,myungjoon@hallasanup.com +bspark@hallasanup.com,박봉식,010-4009-2670,,site-sincheon-sewage,,과장,,,HM02733,bspark1216@hallasanup.com +mhbeak@hallasanup.com,백무현,010-2235-6749,,site-sincheon-sewage,,부장,,,HM02742,hyeonoo@hallasanup.com +jwnam@hallasanup.com,남준우,010-6305-6648,,site-sincheon-sewage,,대리,,,HM02755,nju6305200@hallasanup.com +jklee1@hallasanup.com,이중곤,010-7166-5994,,site-sincheon-sewage,,차장,,,HM02779,wndwkdia0@hallasanup.com +hjlee@hallasanup.com,이현진,010-4858-5229,,site-sincheon-sewage,,대리,,,HM02732,hjlee@hallasanup.com +suan@hallasanup.com,안소은,010-7761-5241,,site-sincheon-sewage,,사원,,,HF02746,an016363@hallasanup.com +jwjung@hallasanup.com,정재욱,010-8543-5111,,site-apo-sewage,,부장,,,HM02727,wjdwodnr77@hallasanup.com +ybkim@hallasanup.com,김유빈,010-4112-2647,,site-apo-sewage,,사원,,,HM02596,ybkim@hallasanup.com +smjoo@hallasanup.com,주성민,010-9938-8420,,site-apo-sewage,,과장,,,HM02547,jsm@hallasanup.com +jhkim@hallasanup.com,김제희,010-2703-0072,,site-apo-sewage,,부장,,,HM02551,yeskjh72@hallasanup.com +dyshin@hallasanup.com,신동엽,010-2523-3364,,site-apo-sewage,,부장,,,HM01968,shindongyop@hallasanup.com +hkjang@hallasanup.com,장현관,010-5195-0348,,site-apo-sewage,,차장,,,HM02544,eddy2001@hallasanup.com +hglee@hallasanup.com,이희곤,010-5358-1021,,site-apo-sewage,,부장,,,HM02622,lhg1021@hallasanup.com +jhkim1@hallasanup.com,김정훈1,010-5275-8622,,site-apo-sewage,,차장,,,HM02710,jhk7510@hallasanup.com +tylee@hallasanup.com,이태영,010-2805-2836,,site-apo-sewage,,부장,,,HM00813,black0112@hallasanup.com +syjeon@hallasanup.com,전소영,010-9600-2199,,site-apo-sewage,,사원,,,HF02560,jsy2220@hallasanup.com +hykwon@hallasanup.com,권해용1,010-6522-8153,,ops-anseong-wwtp,,부장,,,HM02507,khy11k@hallasanup.com +jyher@hallasanup.com,허지연,010-2735-5266,,ops-anseong-wwtp,,과장,,,HF02744,jyheo@hallasanup.com +gykim@hallasanup.com,김기영,010-5399-2520,,hanlla-safety-hq,,전무이사,,,HM00126,young@hallasanup.com +wjyun@hallasanup.com,윤원종,010-8243-8083,,hanlla-safety-team,,사원,,,HM02740,yunwj0808@hallasanup.com +ddkang@hallasanup.com,강대득,010-8738-9165,,site-yeoju-bupyeongcheon,,부장,,,HM02504,kdd3880@hallasanup.com +hypark@hallasanup.com,박흥열,010-2312-9853,,site-yeoju-bupyeongcheon,,과장,,,HM02749,as8742@hallasanup.com +kyryu@hallasanup.com,류길용,010-2621-6152,,site-yeoju-bupyeongcheon,,과장,,,HM02741,bbangrky@hallasanup.com +sklee@hallasanup.com,이상규,010-8650-0875,,site-yeoju-bupyeongcheon,,차장,,,HM02762,dorajisa@hallasanup.com +jhjung@hallasanup.com,정지호,010-9013-9596,,site-yeoju-bupyeongcheon,,부장,,,HM02765,need1971@hallasanup.com +jykim@hallasanup.com,김지용,010-5685-7038,,site-okjeong-sewage,,차장,,,HM02610,gmn1038@hallasanup.com +jschoi@hallasanup.com,최종순,010-2890-0856,,site-okjeong-sewage,,차장,,,HM02505,ddjjs@hallasanup.com +wjkim@hallasanup.com,김원준,010-3194-3222,,site-okjeong-sewage,,과장,,,HM02422,sirio@hallasanup.com +djpark@hallasanup.com,박대중,010-6561-5255,,site-okjeong-sewage,,부장,,,HM02689,ptgsun1@hallasanup.com +wslee@hallasanup.com,이왕석,010-8291-3846,,site-okjeong-sewage,,부장,,,HM02750,king3846@hallasanup.com +swkim@hallasanup.com,김성원,011-9612-3648,,site-okjeong-sewage,,부장,,,HM02114,swkim@hallasanup.com +jhlee3@hallasanup.com,이정현,010-4059-2983,,site-okjeong-sewage,,부장,,,HM02696,leejh1978@hallasanup.com +jslim@hallasanup.com,임진수,010-3330-6379,,ops-onsan-bio,,부장,,,HM00563,7878lim@hallasanup.com +mjsong@hallasanup.com,송미정,010-9617-0424,,ops-onsan-bio,,과장,,,HF02776,mjsong@hallasanup.com +hdcho@hallasanup.com,조효덕,010-6231-8878,,site-onsan-sewage,,부장,,,HM01956,hyodeok@hallasanup.com +sbroh@hallasanup.com,노승복,010-8556-7193,,site-onsan-sewage,,부장,,,HM02527,n2316@hallasanup.com +hjchoi@hallasanup.com,최호진,010-9934-6379,,site-onsan-sewage,,과장,,,HM02668,chjmn1@hallasanup.com +ycmoon@hallasanup.com,문영철,010-3694-6212,,site-onsan-sewage,,부장,,,HM02738, +hcchu@hallasanup.com,추현철,010-6611-0147,,site-onsan-sewage,,부장,,,HM02633,chc0147@hallasanup.com +ylko@hallasanup.com,고영일,010-3371-9853,,site-onsan-sewage,,부장,,,HM02686,kyi9404@hallasanup.com +jykim1@hallasanup.com,김준연,010-7568-7712,,site-onsan-sewage,,차장,,,HM02701,jybarara1013@hallasanup.com +sjan@hallasanup.com,안석진,010-9172-9466,,site-onsan-sewage,,부장,,,HM02706,jinni4004@hallasanup.com +trkim@hallasanup.com,김태림,010-4764-0601,,site-onsan-sewage,,사원,,,HF02770,sksek80@hallasanup.com +hjlim@hallasanup.com,임해중,010-3582-7323,,hanlla-operations-office,,전무이사,,,HM00152,lhj@hallasanup.com +scshin@hallasanup.com,신성철,010-9037-5830,,hanlla-operations-office,,부장,,,HM01146,thrkr2@hallasanup.com +mskim@hallasanup.com,김민수,010-6760-7435,,ops-ulsan-incineration,,상무이사,,,HM00061,mskim@hallasanup.com +kokim@hallasanup.com,김노은,010-3139-6341,,ops-ulsan-incineration,,과장,,,HF00468,kko1665@hallasanup.com +dhyang@hallasanup.com,양도영,010-6695-7083,,site-jangnyang-sewage,,차장,,,HM02651,yangdo10@hallasanup.com +jochoi@hallasanup.com,최정의,010-6617-8946,,site-jangnyang-sewage,,과장,,,HM02525,tmzdk23@hallasanup.com +jklee@hallasanup.com,이재광,010-9398-6830,,site-jangnyang-sewage,,이사,,,HM01963,ljk3030@hallasanup.com +wshwang@hallasanup.com,황운식,010-4598-6225,,site-jangnyang-sewage,,부장,,,HM02634,hwsaaa@hallasanup.com +hsson@hallasanup.com,손현수,010-3836-4155,,site-jangnyang-sewage,,과장,,,HM02685,happy09005@hallasanup.com +jalee@hallasanup.com,이지애1,010-3521-2095,,site-jangnyang-sewage,,사원,,,HF02702,jiae82@hallasanup.com +skkang@hallasanup.com,강신규,010-3684-4964,,hanlla-env-project-mgmt,,부장,,,HM01966,skang@hallasanup.com +hykang@hallasanup.com,강화영,010-8838-1886,,hanlla-env-project-mgmt,,과장,,,HM02625,kanghwayoung@hallasanup.com +khkim1@hallasanup.com,김경한,010-4620-1183,,hanlla-env-plant-design,,상무이사,,,HM01832,khankim@hallasanup.com diff --git a/works_users_hanmac.CSV b/works_users_hanmac.CSV new file mode 100644 index 00000000..f871664d --- /dev/null +++ b/works_users_hanmac.CSV @@ -0,0 +1,306 @@ +,email,name,phone,role,tenant_slug,,department,grade,position,jobTitle,employee_id,sub_email +교통부,sskim6@hanmaceng.co.kr,김상수,010-5216-9660,user,traffic,,,전무,부서장,,M12203,sskim@hanmaceng.co.kr +,yjkim8@hanmaceng.co.kr,김용종,010-6721-0814,user,traffic,,,상무,,,M21445,topyj@hanmaceng.co.kr +,kkimyk@hanmaceng.co.kr,김영권,010-6405-1797,user,traffic,,,상무,,,M02259,kkimyk@hanmaceng.co.kr +,yheo@hanmaceng.co.kr,허윤,010-2569-1504,user,traffic,,,과장,,,M25042,m25042@hanmaceng.co.kr +,jhkim12@hanmaceng.co.kr,김준형,010-2623-9508,user,traffic,,,대리,,,M21435,m21435@hanmaceng.co.kr +,siyoon@hanmaceng.co.kr,윤선일,010-4353-7600,user,traffic,,,대리,,,M23010,m23010@hanmaceng.co.kr +,nhlee@hanmaceng.co.kr,이난희,010-4398-3410,user,traffic,,,사원,,,M25044,m25044@hanmaceng.co.kr +,shmun@hanmaceng.co.kr,문수하,010-5648-0314,user,traffic,,,사원,,,M25068,m25068@hanmaceng.co.kr +구조부,ykshin2@hanmaceng.co.kr,신영각,010-3290-6057,user,infra-structures,,,부사장,,,M16208,twindol@hanmaceng.co.kr +,shyoon@hanmaceng.co.kr,윤성호,010-5286-8476,user,infra-structures,,,부사장,부서장,,M10103,feel365@hanmaceng.co.kr +,jboh@hanmaceng.co.kr,오재범,010-9173-3560,user,infra-structures,,,전무,,,M03202,jbeom@hanmaceng.co.kr +,jykim9@hanmaceng.co.kr,김재용,010-9627-8774,user,infra-structures,,,상무,,,M03228,kjy730@hanmaceng.co.kr +,shhwang@hanmaceng.co.kr,황승현,010-8727-8306,user,infra-structures,,,상무,,,M02308,hshksy7367@hanmaceng.co.kr +,shlee23@hanmaceng.co.kr,이세화,010-9479-2199,user,infra-structures,,,부장,,,M18204,shlee81@hanmaceng.co.kr +,dskim5@hanmaceng.co.kr,김대성,010-8709-6322,user,infra-structures,,,차장,,,M24016,m24016@hanmaceng.co.kr +,sdkim4@hanmaceng.co.kr,김성도,010-5191-2520,user,infra-structures,,,과장,,,M21439,m21439@hanmaceng.co.kr +,jmsong@hanmaceng.co.kr,송재명,010-2393-5797,user,infra-structures,,,과장,,,M24017,m24017@hanmaceng.co.kr +,ohjeon@hanmaceng.co.kr,전오현,010-2464-6231,user,infra-structures,,,과장,,,M25067,m25067@hanmaceng.co.kr +,ycchoi@hanmaceng.co.kr,최영철,010-9746-4146,user,infra-structures,,,과장,,,M25018,m25018@hanmaceng.co.kr +,jmhwang2@hanmaceng.co.kr,황지만,010-2235-5634,user,infra-structures,,,과장,,,M23042,m23042@hanmaceng.co.kr +,twkim2@hanmaceng.co.kr,김태욱,010-6577-1853,user,infra-structures,,,대리,,,M23006,m23006@hanmaceng.co.kr +,jkban@hanmaceng.co.kr,반진관,010-2677-4083,user,infra-structures,,,대리,,,M21407,m21407@hanmaceng.co.kr +,mjoh@hanmaceng.co.kr,오민종,010-8258-3400,user,infra-structures,,,대리,,,M23054,m23054@hanmaceng.co.kr +,mkjeon@hanmaceng.co.kr,전민기,010-9945-0295,user,infra-structures,,,대리,,,M24011,m24011@hanmaceng.co.kr +,jmson@hanmaceng.co.kr,손지민,010-5195-8257,user,infra-structures,,,사원,,,M22055,m22055@hanmaceng.co.kr +,wsshim@hanmaceng.co.kr,심우석,010-7548-7885,user,infra-structures,,,사원,,,M25017,m25017@hanmaceng.co.kr +지반,hmlee3@hanmaceng.co.kr,이한민,010-3255-9884,user,infra-geotech-tunnel,,,상무,부서장,,M20105,fastmin@hanmaceng.co.kr +,jilee2@hanmaceng.co.kr,이종익,010-3869-3451,user,infra-geotech-tunnel,,,상무,,,M07223,jongiklee@hanmaceng.co.kr +,sbsim@hanmaceng.co.kr,심성보,010-7242-2931,user,infra-geotech-tunnel,,,이사,,,M24036,m24036@hanmaceng.co.kr +,jkyu@hanmaceng.co.kr,유재극,010-8227-0078,user,infra-geotech-tunnel,,,이사,,,M20211,visyase@hanmaceng.co.kr +,tskim2@hanmaceng.co.kr,김태식,010-9822-8592,user,infra-geotech-tunnel,,,차장,,,M21451,m21451@hanmaceng.co.kr +,mskang2@hanmaceng.co.kr,강민수,010-7933-0183,user,infra-geotech-tunnel,,,과장,,,M25021,m25021@hanmaceng.co.kr +,sbpark2@hanmaceng.co.kr,박상빈,010-8281-7928,user,infra-geotech-tunnel,,,과장,,,M23076,m23076@hanmaceng.co.kr +,mjsong2@hanmaceng.co.kr,송민제,010-7268-3500,user,infra-geotech-tunnel,,,과장,,,M24075,m22022@hanmaceng.co.kr +,swpark4@hanmaceng.co.kr,박선우,010-4823-7458,user,infra-geotech-tunnel,,,대리,,,M20321,m20321@hanmaceng.co.kr +,sjjeon@hanmaceng.co.kr,전수진,010-3791-6816,user,infra-geotech-tunnel,,,대리,,,M21468,m21468@hanmaceng.co.kr +,jtkim2@hanmaceng.co.kr,김진토,010-9370-4673,user,infra-geotech-tunnel,,,사원,,,M26014,m26014@hanmaceng.co.kr +도시,cgkim@hanmaceng.co.kr,김춘근,010-3354-8398,user,land-env-urban-planning,,,전무,,,M21413,m21413@hanmaceng.co.kr +,hyjeong2@hanmaceng.co.kr,정혜연,010-2391-2158,user,land-env-urban-planning,,,전무,부서장,,M14101,myisland@hanmaceng.co.kr +,hhshin@hanmaceng.co.kr,신현호,010-9316-2217,user,land-env-urban-planning,,,상무,,,M21458,m21458@hanmaceng.co.kr +,dynam@hanmaceng.co.kr,남동윤,010-5416-1408,user,land-env-urban-planning,,,이사,,,M21472,m21472@hanmaceng.co.kr +,hkchoi2@hanmaceng.co.kr,최현근,010-6205-2355,user,land-env-urban-planning,,,이사,,,M24045,m24045@hanmaceng.co.kr +,gwkim2@hanmaceng.co.kr,김건우,010-9324-5023,user,land-env-urban-planning,,,부장,,,M14301,kgenu@hanmaceng.co.kr +,jwshin3@hanmaceng.co.kr,신진우,010-3434-2949,user,land-env-urban-planning,,,부장,,,M21414,m21414@hanmaceng.co.kr +,skryu@hanmaceng.co.kr,류성균,010-4211-5202,user,land-env-urban-planning,,,부장,,,M25022,m25022@hanmaceng.co.kr +,jkhwang@hanmaceng.co.kr,황재광,010-4780-0137,user,land-env-urban-planning,,,차장,,,M25050,m25050@hanmaceng.co.kr +,jwbae2@hanmaceng.co.kr,배진우,010-7501-1272,user,land-env-urban-planning,,,과장,,,M24073,m24073@hanmaceng.co.kr +,yjlee7@hanmaceng.co.kr,이예지,010-9937-1276,user,land-env-urban-planning,,,과장,,,M21462,m21462@hanmaceng.co.kr +,dhchoi@hanmaceng.co.kr,최대헌,010-2214-9153,user,land-env-urban-planning,,,과장,,,M24038,m24038@hanmaceng.co.kr +,ghkim@hanmaceng.co.kr,김관후,010-6302-3157,user,land-env-urban-planning,,,대리,,,M22051,m22051@hanmaceng.co.kr +,mssong@hanmaceng.co.kr,송미성,010-6258-4972,user,land-env-urban-planning,,,대리,,,M25027,m25027@hanmaceng.co.kr +,shshin@hanmaceng.co.kr,신송희,010-9518-4988,user,land-env-urban-planning,,,대리,,,M21422,m21422@hanmaceng.co.kr +,hjchae@hanmaceng.co.kr,채호주,010-9038-8389,user,land-env-urban-planning,,,대리,,,M23070,m23070@hanmaceng.co.kr +,wskim4@hanmaceng.co.kr,김원섭,010-9117-7449,user,land-env-urban-planning,,,사원,,,M24062,m24062@hanmaceng.co.kr +,jeeim@hanmaceng.co.kr,임지은,010-3994-3634,user,land-env-urban-planning,,,사원,,,M25047,m25047@hanmaceng.co.kr +수자,HGLEE4@hanmaceng.co.kr,이홍규,010-6201-6542,user,land-env-water-resources,,,부사장,,,M19103,hglee@hanmaceng.co.kr +,JCHYUN@hanmaceng.co.kr,현종철,010-6204-3628,user,land-env-water-resources,,,전무,부서장,,M21410,m21410@hanmaceng.co.kr +,JJLEE4@hanmaceng.co.kr,이재진,010-2470-7923,user,land-env-water-resources,,,이사,,,M08206,ljj7924@hanmaceng.co.kr +,BHLEE@hanmaceng.co.kr,이병화,010-9133-5068,user,land-env-water-resources,,,이사,,,M21428,m21428@hanmaceng.co.kr +,SCSEO@hanmaceng.co.kr,서순창,010-9899-8042,user,land-env-water-resources,,,부장,,,M21425,m21425@hanmaceng.co.kr +,JBYOO@hanmaceng.co.kr,유재범,010-7100-7942,user,land-env-water-resources,,,차장,,,M16308,jboom@hanmaceng.co.kr +,DYKIM@hanmaceng.co.kr,김덕용,010-6235-5976,user,land-env-water-resources,,,차장,,,M22058,m22058@hanmaceng.co.kr +,SHHYUN@hanmaceng.co.kr,현석훈,010-9932-0236,user,land-env-water-resources,,,차장,,,M21454,m21454@hanmaceng.co.kr +,YHHWANG2@hanmaceng.co.kr,황윤희,010-8484-8721,user,land-env-water-resources,,,과장,,,M24025,m24025@hanmaceng.co.kr +,SHJUNG@hanmaceng.co.kr,정승훈,010-5192-8290,user,land-env-water-resources,,,대리,,,M25012,m25012@hanmaceng.co.kr +,MSKIM13@hanmaceng.co.kr,김명식,010-2538-4933,user,land-env-water-resources,,,대리,,,M22008,myungsik@hanmaceng.co.kr +,DBLEE@hanmaceng.co.kr,이다빈,010-9738-1325,user,land-env-water-resources,,,대리,,,M25011,m25011@hanmaceng.co.kr +,WJCHO@hanmaceng.co.kr,조우진,010-3327-0817,user,land-env-water-resources,,,대리,,,M23058,m23058@hanmaceng.co.kr +상하,sspark3@hanmaceng.co.kr,박승신,010-2272-1436,user,water-sewerage,,,부사장,부서장,,M22009,m22009@hanmaceng.co.kr +,dwlee4@hanmaceng.co.kr,이동욱,010-6339-1959,user,water-sewerage,,,전무이사,,,M20104,quaaahoo@hanmaceng.co.kr +,ywgil@hanmaceng.co.kr,길이원,010-9917-0157,user,water-sewerage,,,차장,,,M20217,m20217@hanmaceng.co.kr +,dwkim5@hanmaceng.co.kr,김동욱,010-7705-7489,user,water-sewerage,,,과장,,,M26010,m26010@hanmaceng.co.kr +,sjkim10@hanmaceng.co.kr,김성주,010-3885-8068,user,water-sewerage,,,과장,,,M25058,m25058@hanmaceng.co.kr +,yjim@hanmaceng.co.kr,임연재,010-4223-3881,user,water-sewerage,,,대리,,,M22066,m22066@hanmaceng.co.kr +,bkkim@hanmaceng.co.kr,김병국,010-9303-3245,user,water-sewerage,,,대리,,,M26003,m26003@hanmaceng.co.kr +,yhlim@hanmaceng.co.kr,임유화,010-8383-3522,user,water-sewerage,,,사원,,,M23038,m23038@hanmaceng.co.kr +,ybsim@hanmaceng.co.kr,심윤보,010-5125-3016,user,water-sewerage,,,사원,,,M24065,m24065@hanmaceng.co.kr +,dhhwang2@hanmaceng.co.kr,황동훈,010-2505-1873,user,water-sewerage,,,사원,,,M25043,m25043@hanmaceng.co.kr +안전,sako@hanmaceng.co.kr,고삼암,010-5361-7995,user,safety-diagnosis,,,부회장,,,M04503,ksam51@hanmaceng.co.kr +,hsbyoun@hanmaceng.co.kr,변한석,010-6641-9995,user,safety-diagnosis,,,전무,부서장,,M22057,m22057@hanmaceng.co.kr +,kjlee2@hanmaceng.co.kr,이기종,010-3899-9830,user,safety-diagnosis,,,전무,,,M06505,lkj0997@hanmaceng.co.kr +,jsjo@hanmaceng.co.kr,조증식,010-5350-6952,user,safety-diagnosis,,,전무,,,M23081,m23081@hanmaceng.co.kr +,hclim@hanmaceng.co.kr,임현철,010-8749-9448,user,safety-diagnosis,,,상무,,,M21455,m21455@hanmaceng.co.kr +,jyson2@hanmaceng.co.kr,손재용,010-9009-8961,user,safety-diagnosis,,,이사,,,M08306,son0802@hanmaceng.co.kr +,hmyeon@hanmaceng.co.kr,연훈모,010-7923-8703,user,safety-diagnosis,,,차장,,,M24029,yhm8703@hanmaceng.co.kr +,sgjeon2@hanmaceng.co.kr,전성구,010-9199-8060,user,safety-diagnosis,,,차장,,,M21429,m21429@hanmaceng.co.kr +,ejkim@hanmaceng.co.kr,김응진,010-9960-1522,user,safety-diagnosis,,,과장,,,M21457,m21457@hanmaceng.co.kr +,tykim3@hanmaceng.co.kr,김태연,010-7171-5261,user,safety-diagnosis,,,과장,,,M20306,ququ44@hanmaceng.co.kr +,wjna@hanmaceng.co.kr,나원준,010-6652-8978,user,safety-diagnosis,,,과장,,,M25014,m25014@hanmaceng.co.kr +,gspark3@hanmaceng.co.kr,박건석,010-6225-0356,user,safety-diagnosis,,,과장,,,M25015,m25015@hanmaceng.co.kr +,ghpark4@hanmaceng.co.kr,박건희,010-6434-7402,user,safety-diagnosis,,,과장,,,M25016,m25016@hanmaceng.co.kr +,ghbeak@hanmaceng.co.kr,백건휘,010-8762-9098,user,safety-diagnosis,,,과장,,,M23001,m23001@hanmaceng.co.kr +,hcyun@hanmaceng.co.kr,윤현철,010-9604-4569,user,safety-diagnosis,,,과장,,,M23017,m23017@hanmaceng.co.kr +,jmlee3@hanmaceng.co.kr,이지명,010-4212-0867,user,safety-diagnosis,,,과장,,,M23014,m23014@hanmaceng.co.kr +,dvkim@hanmaceng.co.kr,김동빈,010-9417-1161,user,safety-diagnosis,,,대리,,,M25030,m25030@hanmaceng.co.kr +,tykim2@hanmaceng.co.kr,김태윤,010-2866-1853,user,safety-diagnosis,,,대리,,,M20312,xotls10@hanmaceng.co.kr +,cgyu@hanmaceng.co.kr,유찬근,010-2908-1628,user,safety-diagnosis,,,대리,,,M25059,m25059@hanmaceng.co.kr +,mjlee2@hanmaceng.co.kr,이명진,010-7577-6386,user,safety-diagnosis,,,대리,,,M24023,m24023@hanmaceng.co.kr +,whjeong@hanmaceng.co.kr,정원호,010-8470-3996,user,safety-diagnosis,,,대리,,,M25029,m25029@hanmaceng.co.kr +,jhkim34@hanmaceng.co.kr,김진호,010-4300-2625,user,safety-diagnosis,,,사원,,,M24022,m24022@hanmaceng.co.kr +,hopark@hanmaceng.co.kr,박현옥,010-4002-5162,user,safety-diagnosis,,,사원,,,M24084,m24084@hanmaceng.co.kr +,jsyu@hanmaceng.co.kr,유지성,010-9821-1155,user,safety-diagnosis,,,사원,,,M23037,m23037@hanmaceng.co.kr +,dhhan2@hanmaceng.co.kr,한동화,010-2054-4899,user,safety-diagnosis,,,과장,,,M25031,gksehdghk123@naver.com +,ychong@hanmaceng.co.kr,홍영철,010-9732-5097,user,safety-diagnosis,,,전무,,,M25032,hycoso@hanmail.net +임원실,khlee5@hanmaceng.co.kr,이경훈,010-3733-1890,user,hanmac,,,사장,대표이사,,M02107,khoonri@hanmaceng.co.kr +경영지원,sryou@hanmaceng.co.kr,유승열,010-5330-6503,user,sales-support,,,부사장,,,M10102,srryu65@hanmaceng.co.kr +,hwshin2@brsw.kr,신현우,010-8979-5720,user,baroncs,,,전무,부서장,,T02303,hwshin@hanmaceng.co.kr +,jhkim35@brsw.kr,김재헌,010-8785-2798,user,baroncs,,,상무,,,J08206,rurouno@hanmaceng.co.kr +,yioh@hanmaceng.co.kr,오윤익,010-4033-3566,user,경영지원부(SLUG값 없음),,,차장,,,M23040,m23040@hanmaceng.co.kr +,hmchae@hanmaceng.co.kr,채희문,010-9213-7842,user,경영지원부(SLUG값 없음),,,차장,,,M21321,m21321@hanmaceng.co.kr +,aylee@hanmaceng.co.kr,이애연,010-3356-0412,user,경영지원부(SLUG값 없음),,,과장,,,M25033,dodus1@hanmaceng.co.kr +,jwkim10@brsw.kr,김지우,010-4265-5624,user,baroncs,,,과장,,,T02328,jeewoo@hanmaceng.co.kr +,jhlee21@brsw.kr,이지혜,010-9528-0710,user,baroncs,,,과장,,,J14306,leejh@hanmaceng.co.kr +,jwyu@brsw.kr,유재욱,010-2819-1746,user,baroncs,,,과장,,,B20334,b20334@hanmaceng.co.kr +,jhlee22@brsw.kr,이준희,010-3200-6180,user,baroncs,,,과장,,,B18302,b18302@hanmaceng.co.kr +,hskim14@hanmaceng.co.kr,김현수,010-5020-4890,user,경영지원부(SLUG값 없음),,,과장,,,M22033,m22033@hanmaceng.co.kr +,dghyeon@hanmaceng.co.kr,현동규,010-4023-7476,user,경영지원부(SLUG값 없음),,,과장,,,B20305,hdg0310@hanmaceng.co.kr +,cwkim@hanmaceng.co.kr,김찬웅,010-6485-6536,user,경영지원부(SLUG값 없음),,,과장,,,M23007,ktts1234@hanmaceng.co.kr +,wsseo@hanmaceng.co.kr,서우석,010-8698-0757,user,경영지원부(SLUG값 없음),,,대리,,,M24046,m24046@hanmaceng.co.kr +,yjyoon@brsw.kr,윤영주,010-2802-4130,user,baroncs,,,대리,,,B14303,hanmaceml@hanmaceng.co.kr +,ymyu@hanmaceng.co.kr,유유민,010-3697-7320,user,경영지원부(SLUG값 없음),,,대리,,,M24013,m24013@hanmaceng.co.kr +,tsnoh@hanmaceng.co.kr,노태수,010-4819-9179,user,경영지원부(SLUG값 없음),,,대리,,,M22028,b21363@hanmaceng.co.kr +,yjkim26@hanmaceng.co.kr,김윤지,010-3299-8825,user,경영지원부(SLUG값 없음),,,대리,,,M22046,b21361@hanmaceng.co.kr +,egkim@hanmaceng.co.kr,김유진,010-8494-1087,user,경영지원부(SLUG값 없음),,,사원,,,M24069,m24069@hanmaceng.co.kr +,dhahn@hanmaceng.co.kr,안동현,010-4599-4397,user,경영지원부(SLUG값 없음),,,사원,,,M24040,m24040@hanmaceng.co.kr +,scyoon@brsw.kr,윤성찬,010-2873-3557,user,baroncs,,,사원,,,B21321,b21321@hanmaceng.co.kr +,hsoh@brsw.kr,오혜성,010-4067-9435,user,baroncs,,,사원,,,B21303,b21303@hanmaceng.co.kr +,jkchung@hanmaceng.co.kr,정준규,010-5346-6479,user,경영지원부(SLUG값 없음),,,사원,,,B25024,b25024@hanmaceng.co.kr +,wwkang@hanmaceng.co.kr,강우원,010-2317-0910,user,construction-business,,,부사장,,,M26021,m26021@hanmaceng.co.kr +,esko@hanmaceng.co.kr,고은식,010-3461-1654,user,construction-business,,,부사장,,,M25019,m25019@hanmaceng.co.kr +,jgkim1@hanmaceng.co.kr,김종계,010-3328-2408,user,construction-business,,,부사장,,,M24032,m24032@hanmaceng.co.kr +,ispark2@hanmaceng.co.kr,박익수,010-4531-8079,user,construction-business,,,부사장,,,M24041,m24041@hanmaceng.co.kr +,tkpark@hanmaceng.co.kr,박택규,010-7588-8558,user,construction-business,,,부사장,,,M21444,m21444@hanmaceng.co.kr +,ksshin@hanmaceng.co.kr,신광수,010-5228-4684,user,construction-management-h,,,부사장,본부장,,M04602,sks-1211@hanmaceng.co.kr +,hsshin2@hanmaceng.co.kr,신흥섭,010-3719-1271,user,construction-business,,,부사장,,,M23029,shs206066@hanmaceng.co.kr +,ksoh@hanmaceng.co.kr,오관식,010-9769-1005,user,construction-business,,,부사장,,,M23027,m23027@hanmaceng.co.kr +,bioh@hanmaceng.co.kr,오방일,010-2217-4907,user,construction-business,,,부사장,,,M24006,m24006@hanmaceng.co.kr +,islee2@hanmaceng.co.kr,이인상,010-4584-8504,user,construction-business,,,부사장,,,M21403,m21403@hanmaceng.co.kr +,smjeon@hanmaceng.co.kr,전성모,010-7422-2371,user,construction-business,,,부사장,,,M24058,m24058@hanmaceng.co.kr +,csham@hanmaceng.co.kr,함창수,010-2837-0372,user,construction-business,,,부사장,,,M23085,m23085@hanmaceng.co.kr +,mhkang@hanmaceng.co.kr,강명호,010-2019-2252,user,construction-business,,,전무이사,,,M16514,kmh6601@hanmaceng.co.kr +,mhkang2@hanmaceng.co.kr,강민호,010-3415-5033,user,construction-business,,,전무이사,,,M26031,m26031@hanmaceng.co.kr +,gwgo@hanmaceng.co.kr,고광욱,010-5358-7394,user,construction-business,,,전무이사,,,M06501,kokw6489@hanmaceng.co.kr +,ydkoh@hanmaceng.co.kr,고영덕,010-5488-7809,user,construction-business,,,전무이사,,,M24056,m24056@hanmaceng.co.kr +,thkwon1@hanmaceng.co.kr,권태훈,010-7266-8933,user,construction-business,,,전무이사,,,M22036,m22036@hanmaceng.co.kr +,ghkim6@hanmaceng.co.kr,김기환,010-9363-8459,user,construction-business,,,전무이사,,,M23059,m23059@hanmaceng.co.kr +,djkim3@hanmaceng.co.kr,김동주,010-2630-9999,user,construction-business,,,전무이사,,,M02608,dhkdj@hanmaceng.co.kr +,djkim4@hanmaceng.co.kr,김동준,010-4459-3231,user,construction-business,,,전무이사,,,M21405,m21405@hanmaceng.co.kr +,bckim2@hanmaceng.co.kr,김병찬,010-8532-2050,user,construction-business,,,전무이사,,,M15408,chany66@hanmaceng.co.kr +,bykim@hanmaceng.co.kr,김봉용,010-8524-9039,user,construction-business,,,전무이사,,,M21401,m21401@hanmaceng.co.kr +,sdkim5@hanmaceng.co.kr,김석동,010-5388-6687,user,construction-business,,,전무이사,,,M26033,m25023@hanmaceng.co.kr +,ygkim3@hanmaceng.co.kr,김양국,010-8545-3191,user,construction-business,,,전무이사,,,M16503,js030107@hanmaceng.co.kr +,yhkim2@hanmaceng.co.kr,김용혁,010-3225-6043,user,construction-business,,,전무이사,,,M21402,m21402@hanmaceng.co.kr +,wjkim2@hanmaceng.co.kr,김우진,010-3679-5287,user,construction-business,,,전무이사,,,M24039,kwj3431@hanmaceng.co.kr +,jhkim36@hanmaceng.co.kr,김재흥,010-8865-5830,user,construction-business,,,전무이사,,,M25071,m25071@hanmaceng.co.kr +,jgkim2@hanmaceng.co.kr,김정기,010-5765-3722,user,construction-business,,,전무이사,,,M23053,m23053@hanmaceng.co.kr +,chkim4@hanmaceng.co.kr,김찬호,010-5477-3482,user,construction-business,,,전무이사,,,M23052,m23052@hanmaceng.co.kr +,hjkim13@hanmaceng.co.kr,김형주,010-6809-2222,user,construction-business,,,전무이사,,,M24052,m24052@hanmaceng.co.kr +,htkim@hanmaceng.co.kr,김홍태,010-4544-6403,user,construction-business,,,전무이사,,,M16513,oklt0720@hanmaceng.co.kr +,ggnoh@hanmaceng.co.kr,노경국,010-8865-2211,user,construction-business,,,전무이사,,,M07313,rhodr@hanmaceng.co.kr +,diryu@hanmaceng.co.kr,류동일,010-2977-6245,user,construction-business,,,전무이사,,,M24076,m24076@hanmaceng.co.kr +,bymoon@hanmaceng.co.kr,문백용,010-3768-7918,user,construction-business,,,전무이사,,,M02606,alangback@hanmaceng.co.kr +,ihmin@hanmaceng.co.kr,민인홍,010-8752-0940,user,construction-business,,,전무이사,,,M24034,m24034@hanmaceng.co.kr +,ghpark@hanmaceng.co.kr,박광환,010-6553-4504,user,construction-business,,,전무이사,,,M26005,m26005@hanmaceng.co.kr +,mjpark@hanmaceng.co.kr,박명주,010-3702-0781,user,construction-business,,,전무이사,,,M25034,m25034@hanmaceng.co.kr +,mhpark2@hanmaceng.co.kr,박명희,010-9500-6598,user,construction-business,,,전무이사,,,M26006,m26006@hanmaceng.co.kr +,yspark5@hanmaceng.co.kr,박연수,010-3671-7768,user,construction-business,,,전무이사,,,M07507,pys3804@hanmaceng.co.kr +,jspark10@hanmaceng.co.kr,박종시,010-5375-7388,user,construction-business,,,전무이사,,,M26029,m26029@hanmaceng.co.kr +,cjpark2@hanmaceng.co.kr,박창종,010-8481-6276,user,construction-business,,,전무이사,,,M24010,m24010@hanmaceng.co.kr +,cspark2@hanmaceng.co.kr,박춘선,010-2551-0986,user,construction-business,,,전무이사,,,M17503,aa200a@hanmaceng.co.kr +,smseo@hanmaceng.co.kr,서상모,010-3712-8479,user,construction-business,,,전무이사,,,M20513,m20513@hanmaceng.co.kr +,shsong2@hanmaceng.co.kr,송세화,010-2619-9140,user,construction-business,,,전무이사,,,M16507,swgamri1@hanmaceng.co.kr +,sioh@hanmaceng.co.kr,오세임,010-9246-4326,user,construction-business,,,전무이사,,,M15406,sioh9064@hanmaceng.co.kr +,gyyu@hanmaceng.co.kr,유광열,010-6891-5010,user,construction-business,,,전무이사,,,M26028,m26028@hanmaceng.co.kr +,ysyu@hanmaceng.co.kr,유영석,010-6362-6253,user,construction-business,,,전무이사,,,M24030,yec655@hanmaceng.co.kr +,jjyu@hanmaceng.co.kr,유정진,010-3830-6237,user,construction-business,,,전무이사,,,M10502,rnwlrnwl@hanmaceng.co.kr +,swyoon@hanmaceng.co.kr,윤성욱,010-2613-3354,user,construction-business,,,전무이사,,,M22049,m22049@hanmaceng.co.kr +,jdyoon@hanmaceng.co.kr,윤종대,010-5092-0797,user,construction-business,,,전무이사,,,M20405,jdyoona@hanmaceng.co.kr +,dslee3@hanmaceng.co.kr,이동식,010-5317-5303,user,construction-business,,,전무이사,,,M23011,m23011@hanmaceng.co.kr +,dhlee7@hanmaceng.co.kr,이동희,010-6334-2365,user,construction-business,,,전무이사,,,M16504,edonghee@hanmaceng.co.kr +,bglee@hanmaceng.co.kr,이병계,010-5484-3822,user,construction-business,,,전무이사,,,M26035,m24078@hanmaceng.co.kr +,srlee2@hanmaceng.co.kr,이상록,010-2694-0159,user,construction-business,,,전무이사,,,M26002,m26002@hanmaceng.co.kr +,sslee2@hanmaceng.co.kr,이상세,010-3417-2807,user,construction-business,,,전무이사,,,M23048,m23048@hanmaceng.co.kr +,swlee5@hanmaceng.co.kr,이상원,010-2260-2729,user,construction-business,,,전무이사,,,M20407,swlee410@hanmaceng.co.kr +,sglee3@hanmaceng.co.kr,이성규,010-7173-7606,user,construction-business,,,전무이사,,,M24024,m24024@hanmaceng.co.kr +,hglee3@hanmaceng.co.kr,이현구A,010-5240-2863,user,construction-business,,,전무이사,,,M25028,odysseylee69@gmail.com +,hiim@hanmaceng.co.kr,임호인,010-9984-8375,user,construction-business,,,전무이사,,,M26008,m26008@hanmaceng.co.kr +,gsjang@hanmaceng.co.kr,장길상,010-5402-8257,user,construction-business,,,전무이사,,,M21452,m21452@hanmaceng.co.kr +,esjeon@hanmaceng.co.kr,전은수,010-3664-5703,user,construction-business,,,전무이사,,,M16401,m16401@hanmaceng.co.kr +,jsjeon@hanmaceng.co.kr,전준수,010-3591-7011,user,construction-business,,,전무이사,,,M16511,junsu0904@hanmaceng.co.kr +,rgjeong@hanmaceng.co.kr,정락경,010-3205-3994,user,construction-business,,,전무이사,,,M26015,m26015@hanmaceng.co.kr +,sbjeong@hanmaceng.co.kr,정상범,010-8907-9883,user,construction-business,,,전무이사,,,M17402,cjgh707@hanmaceng.co.kr +,wcjeong2@hanmaceng.co.kr,정우창,010-6563-0034,user,construction-business,,,전무이사,,,M25004,m25004@hanmaceng.co.kr +,jhjeong3@hanmaceng.co.kr,정종호,010-5500-2532,user,construction-business,,,전무이사,,,M23078,m23078@hanmaceng.co.kr +,swcho2@hanmaceng.co.kr,조수원,010-3725-7539,user,construction-business,,,전무이사,,,M26016,m26016@hanmaceng.co.kr +,jgju@hanmaceng.co.kr,주재강,010-3690-8652,user,construction-business,,,전무이사,,,M16313,joojkangg@hanmaceng.co.kr +,hbjin@hanmaceng.co.kr,진현범,010-4365-2282,user,construction-business,,,전무이사,,,M24070,m24070@hanmaceng.co.kr +,ischae@hanmaceng.co.kr,채일석,010-2650-3232,user,construction-business,,,전무이사,,,M23025,chae32@hanmaceng.co.kr +,gjchoi@hanmaceng.co.kr,최규진,010-3856--5031,user,construction-business,,,전무이사,,,M26034,- +,hschoi3@hanmaceng.co.kr,최희섭,010-2680-2890,user,construction-business,,,전무이사,,,M26011,m26011@hanmaceng.co.kr +,sthan@hanmaceng.co.kr,한상태,010-7275-2709,user,construction-business,,,전무이사,,,M23030,m23030@hanmaceng.co.kr +,chheo@hanmaceng.co.kr,허찬행,010-7207-2339,user,construction-business,,,전무이사,,,M21322,m21322@hanmaceng.co.kr +,dihwang@hanmaceng.co.kr,황대익,010-3069-7430,user,construction-business,,,전무이사,,,J05204,dihwang@hanmaceng.co.kr +,smko@hanmaceng.co.kr,고석만,010-4995-3275,user,construction-business,,,상무이사,,,M22030,m22030@hanmaceng.co.kr +,ghkim7@hanmaceng.co.kr,김경환,010-8589-7560,user,construction-business,,,상무이사,,,M17504,pintree0522@hanmaceng.co.kr +,yskim13@hanmaceng.co.kr,김유식,010-8940-8772,user,construction-business,,,상무이사,,,M26012,m26012@hanmaceng.co.kr +,jykim10@hanmaceng.co.kr,김종용,010-9365-2389,user,construction-business,,,상무이사,,,M25072,m25072@hanmaceng.co.kr +,hgan@hanmaceng.co.kr,안흥권,010-3127-9962,user,construction-business,,,상무이사,,,M26007,m26007@hanmaceng.co.kr +,jsyang@hanmaceng.co.kr,양정선,010-9361-3569,user,construction-business,,,상무이사,,,M20406,yangjs3569@hanmaceng.co.kr +,ygyoon@hanmaceng.co.kr,윤여길,010-5658-9533,user,construction-business,,,상무이사,,,M26009,m26009@hanmaceng.co.kr +,glee2@hanmaceng.co.kr,이규,010-6339-9712,user,construction-business,,,상무이사,,,M26027,m26027@hanmaceng.co.kr +,swlee6@hanmaceng.co.kr,이선욱,010-5286-9782,user,construction-business,,,전무이사,,,M02203,sul96@hanmaceng.co.kr +,wlee@hanmaceng.co.kr,이웅,010-8604-1704,user,construction-business,,,상무이사,,,M24054,m24054@hanmaceng.co.kr +,jkchoi3@hanmaceng.co.kr,최정길,010-2240-9657,user,construction-business,,,상무이사,,,M24064,m24064@hanmaceng.co.kr +,jhchoi11@hanmaceng.co.kr,최종현,010-7416-7406,user,construction-business,,,상무이사,,,M26030,m26030@hanmaceng.co.kr +,yckang@hanmaceng.co.kr,강영철,010-5751-9279,user,construction-business,,,이사,,,M25049,m25049@hanmaceng.co.kr +,hskang@hanmaceng.co.kr,강현승,010-4662-6105,user,construction-business,,,이사,,,M22077,m22077@hanmaceng.co.kr +,dhkim7@hanmaceng.co.kr,김동한,010-6562-1795,user,construction-business,,,이사,,,M25055,m25055@hanmaceng.co.kr +,yskim11@hanmaceng.co.kr,김양석,010-4611-1307,user,construction-business,,,이사,,,M23046,herykim@hanmaceng.co.kr +,yskim12@hanmaceng.co.kr,김윤성,010-5718-0344,user,construction-business,,,이사,,,M24007,rlays80@hanmaceng.co.kr +,jmkim7@hanmaceng.co.kr,김정문,010-9899-3922,user,construction-business,,,이사,,,M23008,m23008@hanmaceng.co.kr +,sgoh@hanmaceng.co.kr,오세걸,010-8750-2827,user,construction-business,,,이사,,,M26032,m26032@hanmaceng.co.kr +,yglee2@hanmaceng.co.kr,이영규,010-3713-0529,user,construction-business,,,이사,,,M23084,m23084@hanmaceng.co.kr +,jslee5@hanmaceng.co.kr,이재성,010-3043-8848,user,construction-business,,,이사,,,M25063,m25063@hanmaceng.co.kr +,jblee@hanmaceng.co.kr,이종범,010-4046-3158,user,construction-business,,,이사,,,M25048,m25048@hanmaceng.co.kr +,jijung@hanmaceng.co.kr,정재익,010-4124-3452,user,construction-business,,,이사,,,M05305,jjaeick@hanmaceng.co.kr +,jhjeong2@hanmaceng.co.kr,정재혁,010-2300-0070,user,construction-business,,,이사,,,M26019,m26019@hanmaceng.co.kr +,ghcho@hanmaceng.co.kr,조규형,010-3357-8020,user,construction-business,,,이사,,,M24072,m23023@hanmaceng.co.kr +,sgchoi2@hanmaceng.co.kr,최성길,010-4332-0642,user,construction-business,,,이사,,,M23031,m23031@hanmaceng.co.kr +,jgchoi2@hanmaceng.co.kr,최재곤,010-9168-5632,user,construction-business,,,이사,,,M25005,m25005@hanmaceng.co.kr +,jwchoi7@hanmaceng.co.kr,최진우,010-6546-1121,user,construction-business,,,이사,,,M24077,civil96@hanmaceng.co.kr +,kskang2@hanmaceng.co.kr,강길성,010-8507-9840,user,construction-business,,,부장,,,M26004,m26004@hanmaceng.co.kr +,jhkim33@hanmaceng.co.kr,김정훈A,010-9437-8183,user,construction-business,,,부장,,,M24067,jh067@hanmaceng.co.kr +,igpark@hanmaceng.co.kr,박인규,010-2125-9748,user,construction-business,,,부장,,,M22016,m21464@hanmaceng.co.kr +,hcyang@hanmaceng.co.kr,양희찬,010-4501-0297,user,construction-business,,,부장,,,M23065,m23065@hanmaceng.co.kr +,jhyu2@hanmaceng.co.kr,유지훈,010-2842-4746,user,construction-business,,,부장,,,M25069,m25069@hanmaceng.co.kr +,dslee4@hanmaceng.co.kr,이동선,010-6369-7952,user,construction-business,,,부장,,,M26020,m26020@hanmaceng.co.kr +,jykoo@hanmaceng.co.kr,구자용,010-3042-1985,user,construction-business,,,과장,,,M20311,jayong23@hanmaceng.co.kr +,nhseo@hanmaceng.co.kr,서남호,010-2361-8585,user,construction-business,,,과장,,,M23035,m23035@hanmaceng.co.kr +,swryu@hanmaceng.co.kr,류시우,010-7554-5728,user,construction-business,,,사원,,,M24057,m24057@hanmaceng.co.kr +,cylee3@hanmaceng.co.kr,이창용,010-9304-4047,user,safety-management,,,전무이사,,,M05508,lcy3392@hanmaceng.co.kr +,mhjeong@hanmaceng.co.kr,정미희,010-5373-4697,user,safety-management,,,대리,,,M22007,kokoball22@hanmaceng.co.kr +도로부,hklee3@hanmaceng.co.kr,이현구,010-8365-3205,user,infrastructure-hq,,,부사장,본부장,,M16222,hmokja@hanmaceng.co.kr +,hslee6@hanmaceng.co.kr,이환섭,010-7763-5463,user,infra-road,,,부사장,부서장,,M22031,m22031@hanmaceng.co.kr +,dhlee8@hanmaceng.co.kr,이동훈,010-7930-2170,user,infra-road,,,전무,,,M03201,suny8561@hanmaceng.co.kr +,yglee3@hanmaceng.co.kr,이영경,010-9016-3227,user,infra-road,,,전무,,,M02213,yglee@hanmaceng.co.kr +,kysong@hanmaceng.co.kr,송기영,010-8641-5769,user,infra-road,,,상무,,,M07231,karet@hanmaceng.co.kr +,kjlee@hanmaceng.co.kr,이기주,010-3347-2661,user,infra-road,,,상무,,,M15207,lgj0906@hanmaceng.co.kr +,cykim2@hanmaceng.co.kr,김정열,010-7239-2807,user,infra-road,,,이사,,,J05215,comrade94@hanmaceng.co.kr +,cskim@hanmaceng.co.kr,김창섭,010-2725-1074,user,infra-road,,,이사,,,M22035,m22035@hanmaceng.co.kr +,yjjeong2@hanmaceng.co.kr,정유종,010-4003-0329,user,infra-road,,,이사,,,M25039,m25039@hanmaceng.co.kr +,jwlee8@hanmaceng.co.kr,이정원,010-3741-0368,user,infra-road,,,이사,,,M15206,with2u@hanmaceng.co.kr +,csjeon@hanmaceng.co.kr,전찬성,010-6267-3181,user,infra-road,,,이사,,,M07317,bravo635@hanmaceng.co.kr +,kcpark@hanmaceng.co.kr,박기철,010-3572-7073,user,infra-road,,,이사,,,M25020,m25020@hanmaceng.co.kr +,mhahn@hanmaceng.co.kr,안민형,010-3559-3246,user,infra-road,,,부장,,,M22003,m22003@hanmaceng.co.kr +,twkim@hanmaceng.co.kr,김태우,010-7317-8668,user,infra-road,,,과장,,,M20327,m20327@hanmaceng.co.kr +,wylee@hanmaceng.co.kr,이원용,010-8562-7199,user,infra-road,,,과장,,,M21460,m21460@hanmaceng.co.kr +,cnamgung@hanmaceng.co.kr,남궁찬,010-9973-7314,user,infra-road,,,과장,,,M22032,m22032@hanmaceng.co.kr +,hoseo2@hanmaceng.co.kr,서현옥,010-6314-7382,user,infra-road,,,과장,,,B15203,hyunok.seo@hanmaceng.co.kr +,jwkim12@hanmaceng.co.kr,김종우,010-5924-2083,user,infra-road,,,과장,,,M24082,m24082@hanmaceng.co.kr +,mspark3@hanmaceng.co.kr,박민수,010-4056-0530,user,infra-road,,,과장,,,M20305,msms1993@hanmaceng.co.kr +,jhjin@hanmaceng.co.kr,진장현,010-7212-9015,user,infra-road,,,과장,,,M23064,m23064@hanmaceng.co.kr +,sikwon@hanmaceng.co.kr,권순일,010-8855-1279,user,infra-road,,,대리,,,M21303,m21303@hanmaceng.co.kr +,hjjeong2@hanmaceng.co.kr,정혜진,010-3706-1119,user,infra-road,,,대리,,,M16317,hyejin@hanmaceng.co.kr +,sbchoi2@hanmaceng.co.kr,최승범,010-2504-3509,user,infra-road,,,대리,,,M21432,m21432@hanmaceng.co.kr +,hjkim12@hanmaceng.co.kr,김효진,010-5510-3281,user,infra-road,,,대리,,,M25038,m24020@hanmaceng.co.kr +,ghlee6@hanmaceng.co.kr,이교호,010-2291-1996,user,infra-road,,,사원,,,M23005,m23005@hanmaceng.co.kr +,jhsong2@hanmaceng.co.kr,송주호,010-6691-5131,user,infra-road,,,사원,,,M25007,m25007@hanmaceng.co.kr +,jhju@hanmaceng.co.kr,주지한 ,010-9658-1289,user,infra-road,,,사원,,,M25006,m25006@hanmaceng.co.kr +,wkkim2@hanmaceng.co.kr,김웅기,010-6347-0667,user,sales-support,,,부사장,,,M24050,wkikim@hanmaceng.co.kr +임원실,scjang@hanmaceng.co.kr,장석춘,010-9398-6896,user,sales-support,,,부사장,,,M19101,jangchun18@naver.com +,dskim4@hanmaceng.co.kr,김동수,010-5368-8504,user,sales-support,,,사장,,,M25066,m25066@hanmaceng.co.kr +,hjjang2@hanmaceng.co.kr,장혁진,010-8284-5434,user,sales-support,,,부장,,,M25001,m25001@hanmaceng.co.kr +,jdpark@hanmaceng.co.kr,박준동,010-6663-2312,user,sales-support,,,부사장,,,M25074,m25074@hanmaceng.co.kr +,bgahn@hanmaceng.co.kr,안병구,010-6353-7725,user,sales-support,,,부장,,,M24071,m24071@hanmaceng.co.kr +,nckim@hanmaceng.co.kr,김남철,010-8004-1523,user,sales-support,,,부사장,,,M24004,m24004@hanmaceng.co.kr +,thlee@hanmaceng.co.kr,이태환,010-5372-1458,user,sales-support,,,부사장,,,M24053,ldy1474226@naver.com +,djkim2@hanmaceng.co.kr,김대진,010-4820-0271,user,sales-support,,,사장,,,M19104,dj279531@hanmaceng.co.kr +,dhhan3@hanmaceng.co.kr,한동호,010-3841-1036,user,sales-support,,,부사장,,,M25073,m25073@hanmaceng.co.kr +,sclee3@hanmaceng.co.kr,이승철,010-3260-2533,user,sales-support,,,부사장,,,M24008,m24008@hanmaceng.co.kr +,shoh2@hanmaceng.co.kr,오세현,010-6505-8767,user,sales-support,,,부사장,,,M24049,m24049@hanmaceng.co.kr +,bjpark@hanmaceng.co.kr,박범진,010-7118-6193,user,sales-support,,,부사장,,,M23002,bjpark9361@naver.com +,mhseol@hanmaceng.co.kr,설문형,010-9901-0490,user,sales-support,,,부사장,,,M24014,m24014@hanmaceng.co.kr +,ockwon@hanmaceng.co.kr,권오춘,010-5382-3777,user,sales-support,,,부사장,,,M21436,m21436@hanmaceng.co.kr +,knlee@hanmaceng.co.kr,이규남,010-8568-2144,user,sales-support,,,부사장,,,M21102,m21102@hanmaceng.co.kr +환경,sskim7@hanmaceng.co.kr,김성수,010-8745-3105,user,land-env-assessment,,,부사장,,,M12204,m12204@hanmaceng.co.kr +,sjkang3@hanmaceng.co.kr,강선준,010-4056-6885,user,land-env-assessment,,,부사장,,,M09104,sjkang1226@hanmaceng.co.kr +,bdlee@hanmaceng.co.kr,이병도,010-5166-9329,user,land-env-assessment,,,전무,부서장,,M20101,bottleroad@hanmaceng.co.kr +,cwlee2@hanmaceng.co.kr,이찬우,010-6403-0242,user,land-env-assessment,,,전무,,,M05207,lchanw@hanmaceng.co.kr +,jwlee7@hanmaceng.co.kr,이정우,010-9408-9042,user,land-env-assessment,,,상무,,,M23071,m23071@hanmaceng.co.kr +,bwlee2@hanmaceng.co.kr,이병욱,010-2406-6787,user,land-env-assessment,,,이사,,,M16216,lbw002@hanmaceng.co.kr +,djlee2@hanmaceng.co.kr,이동준,010-4447-1980,user,land-env-assessment,,,이사,,,M20205,djlee7164@hanmaceng.co.kr +,sikim@hanmaceng.co.kr,김승일,010-2769-2104,user,land-env-assessment,,,이사,,,M21434,ryan23@hanmaceng.co.kr +,bcseo@hanmaceng.co.kr,서병철,010-3760-2579,user,land-env-assessment,,,부장,,,M12207,mintwiz@hanmaceng.co.kr +,yhkim@hanmaceng.co.kr,김용현,010-7156-1174,user,land-env-assessment,,,차장,,,M18205,kimyh1174@hanmaceng.co.kr +,ghlee5@hanmaceng.co.kr,이가형,010-6471-6300,user,land-env-assessment,,,차장,,,M22001,m22001@hanmaceng.co.kr +,dyjung2@hanmaceng.co.kr,정도영,010-4882-5145,user,land-env-assessment,,,차장,,,M22074,jdy5145@hanmaceng.co.kr +,hjcho2@hanmaceng.co.kr,조현준,010-4797-6247,user,land-env-assessment,,,과장,,,M23022,m23022@hanmaceng.co.kr +,mspark2@hanmaceng.co.kr,박민상,010-4389-8666,user,land-env-assessment,,,과장,,,M18308,minsang404@hanmaceng.co.kr +,smhong@hanmaceng.co.kr,홍세민,010-5231-6026,user,land-env-assessment,,,과장,,,M20208,tpals32@hanmaceng.co.kr +,yskim10@hanmaceng.co.kr,김용석,010-9991-6082,user,land-env-assessment,,,과장,,,M19329,kyss6082@hanmaceng.co.kr +,hjpark2@hanmaceng.co.kr,박호진,010-9257-8917,user,land-env-assessment,,,대리,,,M22064,m22064@hanmaceng.co.kr +,hglee2@hanmaceng.co.kr,이현건,010-2994-4516,user,land-env-assessment,,,대리,,,M22067,m22067@hanmaceng.co.kr +,sjyu@hanmaceng.co.kr,유수지,010-4191-2018,user,land-env-assessment,,,대리,,,M22056,m22056@hanmaceng.co.kr +,jkchoi2@hanmaceng.co.kr,최진경,010-7412-3248,user,land-env-assessment,,,사원,,,M24043,m24043@hanmaceng.co.kr diff --git a/saman_works_users.CSV b/works_users_saman.CSV similarity index 100% rename from saman_works_users.CSV rename to works_users_saman.CSV