forked from baron/baron-sso
kratos SSOT 재설계
This commit is contained in:
232
Makefile
232
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 <target> [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."; \
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex items-center gap-3 border-b border-border pb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.ory_ssot.projection_card.title",
|
||||
"Backend user read model",
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.ory_ssot.projection_card.description",
|
||||
"PostgreSQL read model status used by admin search and statistics.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">
|
||||
{t("ui.admin.ory_ssot.loading", "Loading")}
|
||||
</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.ory_ssot.summary.status", "Status")}
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
<StatusBadge
|
||||
ready={projection?.ready ?? false}
|
||||
status={projection?.status ?? "unknown"}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.ory_ssot.summary.local_users", "Local users")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{projection?.projectedUsers ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.ory_ssot.summary.last_synced",
|
||||
"Last read-model refresh",
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(projection?.lastSyncedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
{t("ui.admin.ory_ssot.summary.updated_at", "Updated at")}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(projection?.updatedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{projection?.lastError ? (
|
||||
<div className="flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
|
||||
<span>{projection.lastError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex items-center gap-3 border-b border-border pb-4">
|
||||
<div>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<WorksmobileAccountStatusFilter>("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 ? (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
role="tablist"
|
||||
aria-label="WORKS 계정 상태"
|
||||
>
|
||||
{worksmobileAccountStatusFilterOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
size="sm"
|
||||
variant={
|
||||
accountStatusFilter === option.value ? "default" : "outline"
|
||||
}
|
||||
aria-selected={accountStatusFilter === option.value}
|
||||
onClick={() => onAccountStatusFilterChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Dialog
|
||||
@@ -1603,6 +1647,13 @@ function ComparisonTable({
|
||||
>
|
||||
{getWorksmobileComparisonStatusLabel(row.status)}
|
||||
</Badge>
|
||||
{row.worksmobileAccountStatus && (
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline">
|
||||
WORKS {row.worksmobileAccountStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{formatWorksmobileUpdateDetails(row).map((detail) => (
|
||||
<div
|
||||
key={detail}
|
||||
|
||||
@@ -6,6 +6,14 @@ export type WorksmobileComparisonFilter =
|
||||
| "needs_update"
|
||||
| "matched";
|
||||
|
||||
export type WorksmobileAccountStatusFilter =
|
||||
| "all"
|
||||
| "active"
|
||||
| "invited"
|
||||
| "suspended"
|
||||
| "inactive"
|
||||
| "deleted";
|
||||
|
||||
export type WorksmobileComparisonSummary = {
|
||||
total: number;
|
||||
matched: number;
|
||||
@@ -204,6 +212,22 @@ export function getWorksmobileSelectedUpdateUserIds(
|
||||
.filter((id): id is string => 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"];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,7 +322,6 @@ func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"userProjection": projectionStatus,
|
||||
"identityCache": cacheStatus,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
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)
|
||||
|
||||
@@ -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,10 +783,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
if h.UserRepo != nil {
|
||||
var tenantIDs []string
|
||||
if tenantSlug != "" {
|
||||
if targetTenantID == "" {
|
||||
if tenantSlug != "" && targetTenantID == "" {
|
||||
return c.JSON(userListResponse{
|
||||
Items: []userSummary{},
|
||||
Limit: limit,
|
||||
@@ -757,66 +792,27 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
users, total, nextCursor, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantIDs, 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 {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to list users")
|
||||
}
|
||||
items := make([]userSummary, 0, len(users))
|
||||
for _, user := range users {
|
||||
items = append(items, h.mapLocalUserSummary(c.Context(), user))
|
||||
}
|
||||
if cursorRaw != "" {
|
||||
offset = 0
|
||||
}
|
||||
return c.JSON(userListResponse{
|
||||
Items: items,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Total: total,
|
||||
Cursor: cursorRaw,
|
||||
NextCursor: nextCursor,
|
||||
})
|
||||
slog.Warn("Identity mirror unavailable for user list", "error", err)
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity mirror unavailable")
|
||||
}
|
||||
|
||||
if h.KratosAdmin == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
}
|
||||
|
||||
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
|
||||
@@ -835,15 +831,9 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Search filtering
|
||||
if search != "" {
|
||||
matchesSearch := strings.Contains(email, searchLower) ||
|
||||
strings.Contains(name, searchLower)
|
||||
|
||||
if !matchesSearch {
|
||||
if !identityMatchesSearch(identity, searchLower) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, identity)
|
||||
}
|
||||
|
||||
@@ -875,19 +865,6 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
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,
|
||||
@@ -898,10 +875,6 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
slog.Warn("Kratos unavailable for user list", "error", err)
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider unavailable")
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
if h.KratosAdmin == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
@@ -912,27 +885,31 @@ 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 != "" {
|
||||
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 {
|
||||
// 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 err != nil || identity == nil {
|
||||
if identity == nil {
|
||||
return errorJSON(c, fiber.StatusNotFound, "user not found")
|
||||
@@ -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,
|
||||
|
||||
46
backend/internal/handler/user_handler_live_test.go
Normal file
46
backend/internal/handler/user_handler_live_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -16,6 +16,7 @@ RUN apt-get update \
|
||||
postgresql-client \
|
||||
sed \
|
||||
tar \
|
||||
unzip \
|
||||
util-linux \
|
||||
zstd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
;;
|
||||
|
||||
@@ -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="$(
|
||||
|
||||
52
test/make_help_target_test.sh
Executable file
52
test/make_help_target_test.sh
Executable file
@@ -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"
|
||||
76
test/restore_input_path_test.sh
Executable file
76
test/restore_input_path_test.sh
Executable file
@@ -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"
|
||||
94
works_users_barongroup.CSV
Normal file
94
works_users_barongroup.CSV
Normal file
@@ -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,
|
||||
|
119
works_users_halla.CSV
Normal file
119
works_users_halla.CSV
Normal file
@@ -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
|
||||
|
306
works_users_hanmac.CSV
Normal file
306
works_users_hanmac.CSV
Normal file
@@ -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
|
||||
|
Reference in New Issue
Block a user