From 9cbc9828e608cd62a6cafba5eb3290073caff4bf Mon Sep 17 00:00:00 2001 From: Lectom Date: Mon, 22 Jun 2026 17:56:20 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=95=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/image_publish.yml | 1 + .../TenantProfilePage.performance.test.tsx | 36 +- .../tenants/routes/TenantProfilePage.tsx | 2 +- .../routes/TenantWorksmobilePage.test.ts | 2 +- .../tenants/routes/TenantWorksmobilePage.tsx | 205 ++++++--- .../tenants/routes/worksmobileComparison.ts | 20 +- adminfront/src/lib/adminApi.ts | 28 ++ adminfront/tests/worksmobile.spec.ts | 201 +++++---- backend/.dockerignore | 9 + backend/Dockerfile | 44 +- backend/cmd/adminctl/worksmobile_sync.go | 149 ++++++- backend/cmd/healthcheck/main.go | 72 ++++ backend/cmd/server/main.go | 2 + .../internal/handler/worksmobile_handler.go | 20 + .../handler/worksmobile_handler_test.go | 4 + .../worksmobile_outbox_repository.go | 15 + .../worksmobile_outbox_repository_test.go | 50 +++ .../service/worksmobile_client_test.go | 14 + .../internal/service/worksmobile_mapper.go | 25 +- .../service/worksmobile_sync_service.go | 389 +++++++++++++++++- .../service/worksmobile_sync_service_test.go | 90 +++- config-restored/compose/docker-compose.yaml | 7 +- docker-compose.yaml | 7 +- docker/docker-compose.staging.template.yaml | 2 +- docker/docker-compose.template.yaml | 6 +- docker/staging_pull_compose.template.yaml | 7 +- test/backend_go_version_policy_test.sh | 9 +- 27 files changed, 1239 insertions(+), 177 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/cmd/healthcheck/main.go diff --git a/.gitea/workflows/image_publish.yml b/.gitea/workflows/image_publish.yml index 9a4c2b75..8ffea6da 100644 --- a/.gitea/workflows/image_publish.yml +++ b/.gitea/workflows/image_publish.yml @@ -91,6 +91,7 @@ jobs: with: context: ./backend file: ./backend/Dockerfile + target: production load: true tags: baron_sso/backend:${{ steps.version.outputs.image_tag }} provenance: false diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.performance.test.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.performance.test.tsx index 1260639a..fe16efc1 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.performance.test.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.performance.test.tsx @@ -76,6 +76,40 @@ describe("TenantProfilePage initial profile loading", () => { expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀"); expect(screen.getByLabelText("공개 범위")).toHaveValue("internal"); expect(screen.getByLabelText("WORKS 연동")).toHaveValue("excluded"); - expect(fetchAllTenantsMock).not.toHaveBeenCalled(); + expect(fetchAllTenantsMock).toHaveBeenCalled(); + }); + + it("resolves the persisted parent tenant label even when org config already exists", async () => { + fetchAllTenantsMock.mockResolvedValue({ + items: [ + { + id: "tenant-company", + type: "ORGANIZATION", + name: "인프라솔루션", + slug: "infra-solution", + description: "", + status: "active", + domains: [], + parentId: "tenant-root", + memberCount: 0, + config: {}, + createdAt: "2026-06-17T00:00:00Z", + updatedAt: "2026-06-17T00:00:00Z", + }, + ], + limit: 100, + offset: 0, + total: 1, + }); + + renderWithProviders( + + } /> + , + ); + + expect( + await screen.findByText(/인프라솔루션 · infra-solution/), + ).toBeInTheDocument(); }); }); diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index fb111098..13e40b5f 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -97,7 +97,7 @@ export function TenantProfilePage() { const parentQuery = useQuery({ queryKey: ["tenants", "list-all"], queryFn: () => fetchAllTenants(), - enabled: !!tenantQuery.data && !hasPersistedOrgConfig, + enabled: !!tenantQuery.data, }); const allTenants = parentQuery.data?.items ?? []; const orgConfigCandidate = tenantQuery.data diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts index 050ba557..f7102f52 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts @@ -284,7 +284,7 @@ describe("TenantWorksmobilePage comparison helpers", () => { baron: true, baronOrg: true, worksmobileId: false, - externalKey: false, + externalKey: true, worksmobileDomain: true, worksmobile: true, worksmobileOrg: true, diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx index 36d16ab1..51c6b7bb 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx @@ -29,6 +29,13 @@ import { DialogTrigger, } from "../../../components/ui/dialog"; import { Input } from "../../../components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../components/ui/select"; import { Table, TableBody, @@ -47,6 +54,7 @@ import { fetchMe, fetchWorksmobileComparison, fetchWorksmobileOverview, + importWorksmobileUsersFromWorks, retryWorksmobileJob, type WorksmobileComparisonItem, type WorksmobileOutboxItem, @@ -76,7 +84,7 @@ import { getWorksmobileRowSelectionKey, getWorksmobileSelectedActionIds, getWorksmobileSelectedCreateUserIds, - getWorksmobileSelectedUpdateUserIds, + getWorksmobileSelectedImportUserIds, getWorksmobileSelectedWorksOnlyOrgUnitIds, summarizeWorksmobileComparison, type WorksmobileAccountStatusFilter, @@ -272,6 +280,7 @@ export function TenantWorksmobilePage() { overviewQuery.refetch(); }, onError: (error) => { + overviewQuery.refetch(); toast.error("조직 Sync 작업 등록 실패", { description: getErrorMessage(error), }); @@ -285,6 +294,7 @@ export function TenantWorksmobilePage() { overviewQuery.refetch(); }, onError: (error) => { + overviewQuery.refetch(); toast.error("구성원 Sync 작업 등록 실패", { description: getErrorMessage(error), }); @@ -296,12 +306,15 @@ export function TenantWorksmobilePage() { resourceKind, ids, initialPassword, + initialPasswordUserIds, }: { resourceKind: "users" | "groups"; ids: string[]; initialPassword?: string; + initialPasswordUserIds?: string[]; }) => { const trimmedInitialPassword = initialPassword?.trim(); + const passwordUserIdSet = new Set(initialPasswordUserIds ?? []); const failures: string[] = []; let successCount = 0; for (const id of ids) { @@ -311,7 +324,7 @@ export function TenantWorksmobilePage() { tenantId, id, undefined, - trimmedInitialPassword, + passwordUserIdSet.has(id) ? trimmedInitialPassword : undefined, ); } else { await enqueueWorksmobileOrgUnitSync(tenantId, id); @@ -355,12 +368,56 @@ export function TenantWorksmobilePage() { comparisonQuery.refetch(); }, onError: (error) => { + overviewQuery.refetch(); toast.error("WORKS 생성 작업 등록 실패", { description: getErrorMessage(error), }); }, }); + const importSelectedUsersMutation = useMutation({ + mutationFn: async (worksmobileUserIds: string[]) => + importWorksmobileUsersFromWorks(tenantId, worksmobileUserIds), + onSuccess: (result) => { + setSelectedUserRowKeys([]); + const failureCount = result.failures?.length ?? 0; + const description = [ + `Baron 업데이트 ${result.updatedCount}건`, + `Baron 생성 ${result.createdCount}건`, + `external_key 반영 ${result.externalKeyUpdates}건`, + failureCount > 0 ? `실패 ${failureCount}건` : "", + ] + .filter(Boolean) + .join(", "); + if (failureCount > 0) { + toast.error("일부 Works정보 가져오기 실패", { + description: + result.failures + ?.slice(0, 3) + .map((failure) => + [ + failure.email ?? failure.worksmobileId ?? "unknown", + failure.error, + ].join(": "), + ) + .join("\n") ?? description, + }); + } else { + toast.success("Works정보 가져오기를 완료했습니다.", { + description, + }); + } + overviewQuery.refetch(); + comparisonQuery.refetch(); + }, + onError: (error) => { + overviewQuery.refetch(); + toast.error("Works정보 가져오기 실패", { + description: getErrorMessage(error), + }); + }, + }); + const syncSelectedOrgUnitsMutation = useMutation({ mutationFn: async ({ baronIds, @@ -389,6 +446,7 @@ export function TenantWorksmobilePage() { comparisonQuery.refetch(); }, onError: (error) => { + overviewQuery.refetch(); toast.error("선택 조직 동기화 작업 등록 실패", { description: getErrorMessage(error), }); @@ -561,6 +619,7 @@ export function TenantWorksmobilePage() { 작업 변경 요약 상태 + 오류 retry @@ -605,7 +664,29 @@ export function TenantWorksmobilePage() { )} - {job.status} + + + {job.status} + + + + {job.lastError ? ( + + {job.lastError} + + ) : ( + + - + + )} + {job.retryCount} - ))} - + + + + + {worksmobileAccountStatusFilterOptions.map((option) => ( + + {option.label} + + ))} + + ) : null}
@@ -1380,15 +1476,15 @@ function ComparisonTable({ > {selectedActionLabel} - {canRunUserUpdateAction && ( + {canRunUserImportAction && ( )} @@ -1437,7 +1534,7 @@ function ComparisonTable({ onClick={confirmInitialPassword} disabled={actionDisabled} > - 생성 작업 등록 + 작업 등록 diff --git a/adminfront/src/features/tenants/routes/worksmobileComparison.ts b/adminfront/src/features/tenants/routes/worksmobileComparison.ts index 02aab717..7f811b1e 100644 --- a/adminfront/src/features/tenants/routes/worksmobileComparison.ts +++ b/adminfront/src/features/tenants/routes/worksmobileComparison.ts @@ -47,7 +47,7 @@ export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonC baron: true, baronOrg: true, worksmobileId: false, - externalKey: false, + externalKey: true, worksmobileDomain: true, worksmobile: true, worksmobileOrg: true, @@ -212,6 +212,24 @@ export function getWorksmobileSelectedUpdateUserIds( .filter((id): id is string => Boolean(id)); } +export function getWorksmobileSelectedImportUserIds( + rows: WorksmobileComparisonItem[], + selectedKeys: string[], +) { + const selected = new Set(selectedKeys); + return rows + .filter( + (row) => + row.resourceType === "USER" && + (row.status === "needs_update" || + row.status === "missing_external_key" || + row.status === "missing_in_baron") && + selected.has(getWorksmobileRowSelectionKey(row)), + ) + .map((row) => row.worksmobileId) + .filter((id): id is string => Boolean(id)); +} + export function formatWorksmobileSelectionFailureDescription( successCount: number, failures: string[], diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 829c1d04..8a70ca45 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -1005,6 +1005,23 @@ export type WorksmobileComparison = { groups: WorksmobileComparisonItem[]; }; +export type WorksmobileImportUsersResult = { + updatedCount: number; + createdCount: number; + externalKeyUpdates: number; + failures?: Array<{ + worksmobileId?: string; + email?: string; + error: string; + }>; + items?: Array<{ + worksmobileId?: string; + baronId?: string; + email?: string; + action: string; + }>; +}; + export async function fetchUsers( limit = 50, offset = 0, @@ -1194,6 +1211,17 @@ export async function enqueueWorksmobileUserSync( return data; } +export async function importWorksmobileUsersFromWorks( + tenantId: string, + worksmobileUserIds: string[], +) { + const { data } = await apiClient.post( + `/v1/admin/tenants/${tenantId}/worksmobile/users/import-from-works`, + { worksmobileUserIds }, + ); + return data; +} + export async function resetWorksmobileUserPassword( tenantId: string, userId: string, diff --git a/adminfront/tests/worksmobile.spec.ts b/adminfront/tests/worksmobile.spec.ts index 6395fc89..eeb0a41d 100644 --- a/adminfront/tests/worksmobile.spec.ts +++ b/adminfront/tests/worksmobile.spec.ts @@ -356,12 +356,10 @@ test.describe("Worksmobile tenant management", () => { .getByRole("row", { name: /김누락/ }) .getByRole("checkbox") .check(); - await page - .getByRole("button", { name: "선택 구성원 WORKS에 생성" }) - .click(); + await page.getByRole("button", { name: "Works에 정보 넣기" }).click(); await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible(); await page.getByLabel("초기 비밀번호").fill("InitPass123!"); - await page.getByRole("button", { name: "생성 작업 등록" }).click(); + await page.getByRole("button", { name: "작업 등록" }).click(); await expect .poll(() => syncRequests) .toEqual([ @@ -591,11 +589,11 @@ test.describe("Worksmobile tenant management", () => { .check(); await userComparisonSection - .getByRole("button", { name: "선택 구성원 WORKS에 생성" }) + .getByRole("button", { name: "Works에 정보 넣기" }) .click(); await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible(); await page.getByLabel("초기 비밀번호").fill("InitPass123!"); - await page.getByRole("button", { name: "생성 작업 등록" }).click(); + await page.getByRole("button", { name: "작업 등록" }).click(); await expect .poll(() => syncRequests) .toEqual([ @@ -603,6 +601,12 @@ test.describe("Worksmobile tenant management", () => { userId: "user-missing", body: expect.objectContaining({ initialPassword: "InitPass123!" }), }, + { + userId: "user-update", + body: expect.not.objectContaining({ + initialPassword: expect.anything(), + }), + }, ]); const updateRowCheckbox = userComparisonSection @@ -614,8 +618,9 @@ test.describe("Worksmobile tenant management", () => { .getByRole("checkbox") .check(); await userComparisonSection - .getByRole("button", { name: "선택 구성원 업데이트 적용" }) + .getByRole("button", { name: "Works에 정보 넣기" }) .click(); + await expect(page.getByText("WORKS 초기 비밀번호")).not.toBeVisible(); await expect .poll(() => syncRequests) .toEqual([ @@ -629,6 +634,12 @@ test.describe("Worksmobile tenant management", () => { initialPassword: expect.anything(), }), }, + { + userId: "user-update", + body: expect.not.objectContaining({ + initialPassword: expect.anything(), + }), + }, ]); }); @@ -734,15 +745,13 @@ test.describe("Worksmobile tenant management", () => { .getByRole("row", { name: /실패 사용자/ }) .getByRole("checkbox") .check(); - await page - .getByRole("button", { name: "선택 구성원 WORKS에 생성" }) - .click(); + await page.getByRole("button", { name: "Works에 정보 넣기" }).click(); await page.getByLabel("초기 비밀번호").fill("InitPass123!"); - await page.getByRole("button", { name: "생성 작업 등록" }).click(); + await page.getByRole("button", { name: "작업 등록" }).click(); await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible(); await page.getByLabel("초기 비밀번호").fill("InitPass123!"); - await page.getByRole("button", { name: "생성 작업 등록" }).click(); + await page.getByRole("button", { name: "작업 등록" }).click(); await expect(page.getByText("WORKS 생성 작업 등록 실패")).toBeVisible(); await expect( @@ -917,6 +926,90 @@ test.describe("Worksmobile tenant management", () => { "Access-Control-Allow-Origin": "*", "Access-Control-Expose-Headers": "Content-Disposition", }; + const buildRecentJobs = () => [ + ...(requests.includes("org-rejected-sync") + ? [ + { + id: "job-org-rejected", + resourceType: "ORGUNIT", + resourceId: "org-rejected", + action: "UPSERT", + status: "failed", + retryCount: 0, + lastError: "target tenant is excluded from Worksmobile sync", + createdAt: "2026-05-01T00:02:00Z", + updatedAt: "2026-05-01T00:02:00Z", + payload: { + displayName: "제외팀", + matchLocalPart: "excluded-team", + requestSummary: { + orgUnitName: "제외팀", + orgUnitExternalKey: "org-rejected", + tenantSlug: "excluded-team", + }, + }, + }, + ] + : []), + { + id: "job-retry", + resourceType: "USER", + resourceId: "user-failed", + action: "sync", + status: "failed", + retryCount: 1, + lastError: "worksmobile api failed status=400 body=invalid org", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + payload: { + loginEmail: "changed-user@example.com", + displayName: "변경 사용자", + primaryLeafOrgName: "인재성장", + requestSummary: { + email: "changed-user@example.com", + displayName: "변경 사용자", + userExternalKey: "user-failed", + }, + }, + }, + { + id: "job-org-auto", + resourceType: "ORGUNIT", + resourceId: "org-auto", + action: "UPSERT", + status: "processed", + retryCount: 0, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:01:00Z", + payload: { + matchLocalPart: "people-growth", + requestSummary: { + orgUnitName: "인재성장", + email: "people-growth@example.com", + orgUnitExternalKey: "org-auto", + parentOrgUnitId: "externalKey:parent-org", + }, + }, + }, + { + id: "job-pending", + resourceType: "ORGUNIT", + resourceId: "org-pending", + action: "UPSERT", + status: "pending", + retryCount: 0, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:01:00Z", + payload: { + matchLocalPart: "halla-site", + requestSummary: { + orgUnitName: "한라 현장", + email: "halla-site@hallasanup.com", + orgUnitExternalKey: "org-pending", + }, + }, + }, + ]; await page.route("**/api/v1/**", async (route) => { const url = new URL(route.request().url()); @@ -948,65 +1041,7 @@ test.describe("Worksmobile tenant management", () => { tokenConfigured: true, adminTenantId: "works-tenant-1", }, - recentJobs: [ - { - id: "job-retry", - resourceType: "USER", - resourceId: "user-failed", - action: "sync", - status: "failed", - retryCount: 1, - createdAt: "2026-05-01T00:00:00Z", - updatedAt: "2026-05-01T00:00:00Z", - payload: { - loginEmail: "changed-user@example.com", - displayName: "변경 사용자", - primaryLeafOrgName: "인재성장", - requestSummary: { - email: "changed-user@example.com", - displayName: "변경 사용자", - userExternalKey: "user-failed", - }, - }, - }, - { - id: "job-org-auto", - resourceType: "ORGUNIT", - resourceId: "org-auto", - action: "UPSERT", - status: "processed", - retryCount: 0, - createdAt: "2026-05-01T00:00:00Z", - updatedAt: "2026-05-01T00:01:00Z", - payload: { - matchLocalPart: "people-growth", - requestSummary: { - orgUnitName: "인재성장", - email: "people-growth@example.com", - orgUnitExternalKey: "org-auto", - parentOrgUnitId: "externalKey:parent-org", - }, - }, - }, - { - id: "job-pending", - resourceType: "ORGUNIT", - resourceId: "org-pending", - action: "UPSERT", - status: "pending", - retryCount: 0, - createdAt: "2026-05-01T00:00:00Z", - updatedAt: "2026-05-01T00:01:00Z", - payload: { - matchLocalPart: "halla-site", - requestSummary: { - orgUnitName: "한라 현장", - email: "halla-site@hallasanup.com", - orgUnitExternalKey: "org-pending", - }, - }, - }, - ], + recentJobs: buildRecentJobs(), }, headers, }); @@ -1068,6 +1103,20 @@ test.describe("Worksmobile tenant management", () => { return route.fulfill({ json: { id: "job-org-sync" }, headers }); } + if ( + url.pathname.endsWith( + "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/orgunits/org-rejected/sync", + ) && + method === "POST" + ) { + requests.push("org-rejected-sync"); + return route.fulfill({ + status: 400, + json: { error: "target tenant is excluded from Worksmobile sync" }, + headers, + }); + } + if ( url.pathname.endsWith( "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/users/user-1/sync", @@ -1116,6 +1165,9 @@ test.describe("Worksmobile tenant management", () => { await page.getByPlaceholder("orgUnit tenant UUID").fill("org-1"); await page.getByRole("button", { name: "조직 Sync" }).click(); await expect.poll(() => requests).toContain("org-sync"); + await page.getByPlaceholder("orgUnit tenant UUID").fill("org-rejected"); + await page.getByRole("button", { name: "조직 Sync" }).click(); + await expect.poll(() => requests).toContain("org-rejected-sync"); await page.getByRole("tab", { name: "사용자" }).click(); await page.getByPlaceholder("Kratos user UUID").fill("user-1"); @@ -1123,6 +1175,10 @@ test.describe("Worksmobile tenant management", () => { await expect.poll(() => requests).toContain("user-sync"); await page.getByRole("tab", { name: "이력" }).click(); + const rejectedOrgRow = page.getByRole("row", { name: /제외팀/ }); + await expect(rejectedOrgRow).toContainText( + "target tenant is excluded from Worksmobile sync", + ); await expect(page.getByRole("row", { name: /변경 사용자/ })).toContainText( "changed-user@example.com", ); @@ -1136,6 +1192,9 @@ test.describe("Worksmobile tenant management", () => { .first(), ).toBeVisible(); const failedJobRow = page.getByRole("row", { name: /변경 사용자/ }); + await expect(failedJobRow).toContainText( + "worksmobile api failed status=400 body=invalid org", + ); await failedJobRow.getByText("payload").click(); await expect( failedJobRow.getByText('"loginEmail": "changed-user@example.com"'), diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..a1836f47 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,9 @@ +.env +.env.* +/.codex +/reports +/tmp +/logs +/server +/main +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile index e72ea159..0cf45c86 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,21 +1,49 @@ -FROM golang:1.26.2-alpine +# syntax=docker/dockerfile:1.7 + +FROM golang:1.26.2-alpine AS base WORKDIR /app -# Install git for go mod download if needed RUN apk add --no-cache git -# Pre-copy go.mod/sum to cache dependencies COPY go.mod go.sum ./ RUN go mod download -# Copy source +FROM base AS dev + COPY . . -# Build for production (optional, can just run go run for dev) -RUN go build -o main ./cmd/server +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /usr/local/bin/baron-backend-dev ./cmd/server EXPOSE 3000 -# Default command (can be overridden by compose) -CMD ["./main"] +CMD ["/usr/local/bin/baron-backend-dev"] + +FROM base AS builder + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +COPY . . + +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags="-s -w" -o /out/main ./cmd/server && \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags="-s -w" -o /out/healthcheck ./cmd/healthcheck + +FROM gcr.io/distroless/static-debian13:nonroot AS production + +WORKDIR /app + +COPY --from=builder --chown=65532:65532 /out/main ./main +COPY --from=builder --chown=65532:65532 /out/healthcheck ./healthcheck +COPY --from=builder --chown=65532:65532 /app/docs ./docs + +EXPOSE 3000 + +USER 65532:65532 + +ENTRYPOINT ["/app/main"] diff --git a/backend/cmd/adminctl/worksmobile_sync.go b/backend/cmd/adminctl/worksmobile_sync.go index b5d62565..64e33c28 100644 --- a/backend/cmd/adminctl/worksmobile_sync.go +++ b/backend/cmd/adminctl/worksmobile_sync.go @@ -6,6 +6,7 @@ import ( "baron-sso-backend/internal/service" "context" "encoding/csv" + "encoding/json" "errors" "flag" "fmt" @@ -59,6 +60,9 @@ type worksmobileSyncConfig struct { ComparisonOutput string AlignBaronFromWorksOutput string AlignBaronFromWorksExclude string + ImportFromWorksEmails string + PatchWorksUserNameEmail string + PatchWorksUserName string InspectOutput string CredentialBatchID string Process bool @@ -202,6 +206,28 @@ func runWorksmobileSync(args []string) error { return err } } + if config.ImportFromWorksEmails != "" { + kratosAdmin := service.NewKratosAdminService() + syncService.SetIdentityServices(service.NewIdentityWriteService(kratosAdmin, nil), kratosAdmin) + worksmobileUserIDs, err := resolveWorksmobileUserIDsByEmail(ctx, newWorksmobileAdminClient(), config.ImportFromWorksEmails) + if err != nil { + return err + } + result, err := syncService.ImportUsersFromWorks(ctx, root.ID, worksmobileUserIDs) + if err != nil { + return err + } + encoded, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + fmt.Println(string(encoded)) + } + if config.PatchWorksUserNameEmail != "" { + if err := patchWorksmobileUserName(ctx, newWorksmobileAdminClient(), config.PatchWorksUserNameEmail, config.PatchWorksUserName); err != nil { + return err + } + } if config.Process { return processWorksmobileOutbox(ctx, db, outboxRepo, config) } @@ -256,6 +282,9 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error) fs.StringVar(&config.ComparisonOutput, "comparison-output", "", "output CSV path for current Worksmobile user comparison rows whose status is needs_update") fs.StringVar(&config.AlignBaronFromWorksOutput, "align-baron-from-works-output", "", "output CSV path for one-time Baron user updates from current Worksmobile needs_update rows") fs.StringVar(&config.AlignBaronFromWorksExclude, "align-baron-from-works-exclude", "", "comma-separated emails or local-parts to exclude from --align-baron-from-works-output") + fs.StringVar(&config.ImportFromWorksEmails, "import-from-works-emails", "", "comma-separated Worksmobile emails to import into Baron and patch Worksmobile externalKey") + fs.StringVar(&config.PatchWorksUserNameEmail, "patch-works-user-name-email", "", "Worksmobile email to patch userName by PATCH-only") + fs.StringVar(&config.PatchWorksUserName, "patch-works-user-name", "", "display name for --patch-works-user-name-email") fs.StringVar(&config.InspectOutput, "inspect-output", "", "output CSV path for inspect/undelete commands") fs.StringVar(&config.CredentialBatchID, "credential-batch-id", "", "credential batch id for regenerated user password rows") fs.BoolVar(&config.Process, "process", false, "process ready Worksmobile outbox jobs") @@ -267,8 +296,11 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error) if err := fs.Parse(args); err != nil { return config, err } - if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.UpdateUserLevelsCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && !config.Process { - return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --update-user-levels-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, or --process") + if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.UpdateUserLevelsCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && config.ImportFromWorksEmails == "" && config.PatchWorksUserNameEmail == "" && !config.Process { + return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --update-user-levels-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, --import-from-works-emails, --patch-works-user-name-email, or --process") + } + if config.PatchWorksUserNameEmail != "" && strings.TrimSpace(config.PatchWorksUserName) == "" { + return config, fmt.Errorf("--patch-works-user-name is required with --patch-works-user-name-email") } if config.ResetPendingUsersPassword != "" && config.ResetPendingUsersResultOutput == "" { return config, fmt.Errorf("--reset-pending-users-result-output is required with --reset-pending-users-password") @@ -306,6 +338,119 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error) return config, nil } +func resolveWorksmobileUserIDsByEmail(ctx context.Context, client service.WorksmobileDirectoryClient, rawEmails string) ([]string, error) { + if client == nil { + return nil, errors.New("worksmobile client is not configured") + } + targetEmails := splitCommaSeparatedValues(rawEmails) + if len(targetEmails) == 0 { + return nil, errors.New("--import-from-works-emails requires at least one email") + } + remoteUsers, err := client.ListUsers(ctx) + if err != nil { + return nil, err + } + remoteByEmail := make(map[string]service.WorksmobileRemoteUser, len(remoteUsers)) + for _, remote := range remoteUsers { + email := strings.ToLower(strings.TrimSpace(remote.Email)) + if email == "" { + continue + } + remoteByEmail[email] = remote + } + userIDs := make([]string, 0, len(targetEmails)) + for _, targetEmail := range targetEmails { + remote, ok := remoteByEmail[strings.ToLower(targetEmail)] + if !ok { + return nil, fmt.Errorf("worksmobile user not found by email: %s", targetEmail) + } + if id := strings.TrimSpace(remote.ID); id != "" { + userIDs = append(userIDs, id) + continue + } + return nil, fmt.Errorf("worksmobile user id is empty for email: %s", targetEmail) + } + return userIDs, nil +} + +func splitCommaSeparatedValues(raw string) []string { + parts := strings.Split(raw, ",") + values := make([]string, 0, len(parts)) + seen := map[string]bool{} + for _, part := range parts { + value := strings.TrimSpace(part) + if value == "" { + continue + } + key := strings.ToLower(value) + if seen[key] { + continue + } + seen[key] = true + values = append(values, value) + } + return values +} + +func patchWorksmobileUserName(ctx context.Context, client service.WorksmobileDirectoryClient, email string, displayName string) error { + if client == nil { + return errors.New("worksmobile client is not configured") + } + email = strings.ToLower(strings.TrimSpace(email)) + displayName = strings.TrimSpace(displayName) + if email == "" || displayName == "" { + return errors.New("email and display name are required") + } + remoteUsers, err := client.ListUsers(ctx) + if err != nil { + return err + } + var target *service.WorksmobileRemoteUser + for i := range remoteUsers { + if strings.EqualFold(strings.TrimSpace(remoteUsers[i].Email), email) { + target = &remoteUsers[i] + break + } + } + if target == nil { + return fmt.Errorf("worksmobile user not found by email: %s", email) + } + if err := client.UpdateUserOnly(ctx, service.WorksmobileUserPayload{ + DomainID: target.DomainID, + Email: strings.TrimSpace(target.Email), + UserExternalKey: strings.TrimSpace(target.ExternalID), + UserName: adminctlWorksmobileUserNameFromDisplayName(displayName), + CellPhone: strings.TrimSpace(target.CellPhone), + EmployeeNumber: strings.TrimSpace(target.EmployeeNumber), + Locale: "ko_KR", + Task: strings.TrimSpace(target.Task), + }); err != nil { + return err + } + fmt.Printf("worksmobile user name patched: email=%s display_name=%s\n", email, displayName) + return nil +} + +func adminctlWorksmobileUserNameFromDisplayName(name string) service.WorksmobileUserName { + name = strings.TrimSpace(name) + if name == "" || strings.ContainsAny(name, " \t\r\n") { + return service.WorksmobileUserName{LastName: name} + } + runes := []rune(name) + if len(runes) < 2 || len(runes) > 4 { + return service.WorksmobileUserName{LastName: name} + } + for _, r := range runes { + if r < '가' || r > '힣' { + return service.WorksmobileUserName{LastName: name} + } + } + return service.WorksmobileUserName{ + LastName: string(runes[:1]), + FirstName: string(runes[1:]), + } +} + func enqueueWorksmobileOrgUnits(ctx context.Context, db *gorm.DB, syncService service.WorksmobileAdminService, rootID string) (int, int, int, error) { tenantIDs, err := activeTenantSubtreeIDs(ctx, db, rootID) if err != nil { diff --git a/backend/cmd/healthcheck/main.go b/backend/cmd/healthcheck/main.go new file mode 100644 index 00000000..5dbcc1a5 --- /dev/null +++ b/backend/cmd/healthcheck/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "bufio" + "net" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +func main() { + url := strings.TrimSpace(os.Getenv("BACKEND_HEALTHCHECK_URL")) + if url == "" { + port := strings.TrimSpace(os.Getenv("BACKEND_PORT")) + if port == "" { + port = strings.TrimSpace(os.Getenv("PORT")) + } + if port == "" { + port = "3000" + } + url = "http://127.0.0.1:" + port + "/health" + } + + statusCode, err := checkHTTP(url, 3*time.Second) + if err != nil { + _, _ = os.Stderr.WriteString("healthcheck request failed: " + err.Error() + "\n") + os.Exit(1) + } + if statusCode < 200 || statusCode >= 400 { + _, _ = os.Stderr.WriteString("healthcheck returned HTTP " + strconv.Itoa(statusCode) + "\n") + os.Exit(1) + } +} + +func checkHTTP(rawURL string, timeout time.Duration) (int, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return 0, err + } + host := parsed.Host + if !strings.Contains(host, ":") { + host += ":80" + } + path := parsed.RequestURI() + if path == "" { + path = "/" + } + + conn, err := net.DialTimeout("tcp", host, timeout) + if err != nil { + return 0, err + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + request := "GET " + path + " HTTP/1.1\r\nHost: " + parsed.Host + "\r\nConnection: close\r\n\r\n" + if _, err := conn.Write([]byte(request)); err != nil { + return 0, err + } + + line, err := bufio.NewReader(conn).ReadString('\n') + if err != nil { + return 0, err + } + parts := strings.Fields(line) + if len(parts) < 2 { + return 0, nil + } + return strconv.Atoi(parts[1]) +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 17490c51..e14b5d02 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -329,6 +329,7 @@ func main() { configureWorksmobileClientFromEnv(worksmobileClient) worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient) worksmobileService.SetIdentityMirror(redisService) + worksmobileService.SetIdentityServices(service.NewIdentityWriteService(kratosAdminService, redisService), kratosAdminService) worksmobileRelayClient := *worksmobileClient worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute) worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient) @@ -781,6 +782,7 @@ func main() { admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit) admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit) admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser) + admin.Post("/tenants/:tenantId/worksmobile/users/import-from-works", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ImportUsersFromWorks) admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword) admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob) admin.Delete("/tenants/:tenantId/worksmobile/jobs/pending", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeletePendingJobs) diff --git a/backend/internal/handler/worksmobile_handler.go b/backend/internal/handler/worksmobile_handler.go index f99edd60..0bf31d0f 100644 --- a/backend/internal/handler/worksmobile_handler.go +++ b/backend/internal/handler/worksmobile_handler.go @@ -89,6 +89,26 @@ func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error { return c.Status(fiber.StatusAccepted).JSON(job) } +func (h *WorksmobileHandler) ImportUsersFromWorks(c *fiber.Ctx) error { + var req struct { + WorksmobileUserIDs []string `json:"worksmobileUserIds"` + } + if len(c.Body()) > 0 { + if err := c.BodyParser(&req); err != nil { + return errorJSON(c, fiber.StatusBadRequest, err.Error()) + } + } + result, err := h.Service.ImportUsersFromWorks( + c.Context(), + strings.TrimSpace(c.Params("tenantId")), + req.WorksmobileUserIDs, + ) + if err != nil { + return worksmobileGuardError(c, err, "import_users_from_works") + } + return c.JSON(result) +} + func (h *WorksmobileHandler) ResetUserPassword(c *fiber.Ctx) error { userID := strings.TrimSpace(c.Params("userId")) credentialBatchID, err := parseWorksmobileCredentialBatchID(c) diff --git a/backend/internal/handler/worksmobile_handler_test.go b/backend/internal/handler/worksmobile_handler_test.go index 67b015b5..393682f2 100644 --- a/backend/internal/handler/worksmobile_handler_test.go +++ b/backend/internal/handler/worksmobile_handler_test.go @@ -230,6 +230,10 @@ func (f *fakeWorksmobileAdminService) GetComparison(ctx context.Context, tenantI return service.WorksmobileComparison{}, nil } +func (f *fakeWorksmobileAdminService) ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (service.WorksmobileImportUsersResult, error) { + return service.WorksmobileImportUsersResult{UpdatedCount: len(worksmobileUserIDs)}, nil +} + func (f *fakeWorksmobileAdminService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (service.WorksmobileBackfillDryRun, error) { return service.WorksmobileBackfillDryRun{}, nil } diff --git a/backend/internal/repository/worksmobile_outbox_repository.go b/backend/internal/repository/worksmobile_outbox_repository.go index 8fe665c7..b969044c 100644 --- a/backend/internal/repository/worksmobile_outbox_repository.go +++ b/backend/internal/repository/worksmobile_outbox_repository.go @@ -12,6 +12,7 @@ import ( type WorksmobileOutboxRepository interface { Create(ctx context.Context, item *domain.WorksmobileOutbox) error ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) + ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error) @@ -59,6 +60,20 @@ func (r *worksmobileOutboxRepository) ListRecent(ctx context.Context, limit int) return rows, err } +func (r *worksmobileOutboxRepository) ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error) { + if limit <= 0 || limit > 1000 { + limit = 50 + } + query := r.db.WithContext(ctx).Where("payload ->> 'tenantRootId' = ?", tenantRootID) + if len(resourceIDs) > 0 { + query = query.Or("resource_id IN ?", resourceIDs) + } + + var rows []domain.WorksmobileOutbox + err := query.Order("created_at desc").Limit(limit).Find(&rows).Error + return rows, err +} + func (r *worksmobileOutboxRepository) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) { query := r.db.WithContext(ctx). Where("resource_type = ? AND payload ->> 'tenantRootId' = ? AND coalesce(payload ->> 'credentialBatchId', '') <> ?", domain.WorksmobileResourceUser, tenantRootID, "") diff --git a/backend/internal/repository/worksmobile_outbox_repository_test.go b/backend/internal/repository/worksmobile_outbox_repository_test.go index 73e8d5a9..6afa72f0 100644 --- a/backend/internal/repository/worksmobile_outbox_repository_test.go +++ b/backend/internal/repository/worksmobile_outbox_repository_test.go @@ -69,6 +69,56 @@ func TestWorksmobileOutboxRepositoryDeletePendingByTenantRoot(t *testing.T) { require.Equal(t, "00000000-0000-0000-0000-000000000104", remaining[2].ID) } +func TestWorksmobileOutboxRepositoryListRecentByTenantRoot(t *testing.T) { + repo := NewWorksmobileOutboxRepository(testDB) + ctx := context.Background() + + require.NoError(t, testDB.Exec("DELETE FROM worksmobile_outboxes").Error) + + rows := []domain.WorksmobileOutbox{ + { + ID: "00000000-0000-0000-0000-000000000151", + ResourceType: domain.WorksmobileResourceUser, + ResourceID: "user-root", + Action: domain.WorksmobileActionUpsert, + Status: domain.WorksmobileOutboxStatusFailed, + DedupeKey: "recent-root-user", + Payload: domain.JSONMap{"tenantRootId": "root-1"}, + CreatedAt: time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC), + }, + { + ID: "00000000-0000-0000-0000-000000000152", + ResourceType: domain.WorksmobileResourceOrgUnit, + ResourceID: "child-tenant", + Action: domain.WorksmobileActionUpsert, + Status: domain.WorksmobileOutboxStatusFailed, + DedupeKey: "recent-root-org-legacy", + Payload: domain.JSONMap{}, + CreatedAt: time.Date(2026, 6, 1, 11, 0, 0, 0, time.UTC), + }, + { + ID: "00000000-0000-0000-0000-000000000153", + ResourceType: domain.WorksmobileResourceUser, + ResourceID: "user-other", + Action: domain.WorksmobileActionUpsert, + Status: domain.WorksmobileOutboxStatusFailed, + DedupeKey: "recent-other-root", + Payload: domain.JSONMap{"tenantRootId": "root-2"}, + CreatedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), + }, + } + for i := range rows { + require.NoError(t, testDB.Create(&rows[i]).Error) + } + + recent, err := repo.ListRecentByTenantRoot(ctx, "root-1", []string{"child-tenant"}, 50) + + require.NoError(t, err) + require.Len(t, recent, 2) + require.Equal(t, "00000000-0000-0000-0000-000000000152", recent[0].ID) + require.Equal(t, "00000000-0000-0000-0000-000000000151", recent[1].ID) +} + func TestWorksmobileOutboxRepositoryListReadyWaitsForPendingOrgUnitParent(t *testing.T) { repo := NewWorksmobileOutboxRepository(testDB) ctx := context.Background() diff --git a/backend/internal/service/worksmobile_client_test.go b/backend/internal/service/worksmobile_client_test.go index c9632bcd..b98c1c19 100644 --- a/backend/internal/service/worksmobile_client_test.go +++ b/backend/internal/service/worksmobile_client_test.go @@ -1924,6 +1924,20 @@ func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) ( return f.recent, nil } +func (f *fakeWorksmobileOutboxRepo) ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error) { + resourceIDSet := map[string]bool{} + for _, id := range resourceIDs { + resourceIDSet[id] = true + } + rows := make([]domain.WorksmobileOutbox, 0) + for _, row := range f.recent { + if stringValue(row.Payload["tenantRootId"]) == tenantRootID || resourceIDSet[row.ResourceID] { + rows = append(rows, row) + } + } + return rows, nil +} + func (f *fakeWorksmobileOutboxRepo) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) { rows := make([]domain.WorksmobileOutbox, 0) for _, row := range f.credentialBatchJobs { diff --git a/backend/internal/service/worksmobile_mapper.go b/backend/internal/service/worksmobile_mapper.go index 05e654a1..2a0ec20a 100644 --- a/backend/internal/service/worksmobile_mapper.go +++ b/backend/internal/service/worksmobile_mapper.go @@ -46,7 +46,8 @@ type WorksmobileUserPayload struct { } type WorksmobileUserName struct { - LastName string `json:"lastName,omitempty"` + LastName string `json:"lastName,omitempty"` + FirstName string `json:"firstName,omitempty"` } type WorksmobilePasswordConfig struct { @@ -61,6 +62,26 @@ func (c WorksmobilePasswordConfig) IsZero() bool { c.ChangePasswordAtNextLogin == nil } +func worksmobileUserNameFromDisplayName(name string) WorksmobileUserName { + name = strings.TrimSpace(name) + if name == "" || strings.ContainsAny(name, " \t\r\n") { + return WorksmobileUserName{LastName: name} + } + runes := []rune(name) + if len(runes) < 2 || len(runes) > 4 { + return WorksmobileUserName{LastName: name} + } + for _, r := range runes { + if r < '가' || r > '힣' { + return WorksmobileUserName{LastName: name} + } + } + return WorksmobileUserName{ + LastName: string(runes[:1]), + FirstName: string(runes[1:]), + } +} + func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) { type payloadJSON struct { DomainID int64 `json:"domainId"` @@ -299,7 +320,7 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain DomainID: domainID, Email: strings.TrimSpace(user.Email), UserExternalKey: user.ID, - UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)}, + UserName: worksmobileUserNameFromDisplayName(user.Name), CellPhone: domain.NormalizePhoneNumber(user.Phone), EmployeeNumber: employeeNumber, Locale: "ko_KR", diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go index 3827fd95..a3eaca48 100644 --- a/backend/internal/service/worksmobile_sync_service.go +++ b/backend/internal/service/worksmobile_sync_service.go @@ -34,6 +34,7 @@ type WorksmobileSyncer interface { type WorksmobileAdminService interface { GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error) + ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (WorksmobileImportUsersResult, error) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) @@ -68,6 +69,27 @@ type WorksmobilePendingJobDeleteResult struct { DeletedCount int `json:"deletedCount"` } +type WorksmobileImportUsersResult struct { + UpdatedCount int `json:"updatedCount"` + CreatedCount int `json:"createdCount"` + ExternalKeyUpdates int `json:"externalKeyUpdates"` + Failures []WorksmobileImportUsersFailure `json:"failures,omitempty"` + Items []WorksmobileImportUsersResultItem `json:"items,omitempty"` +} + +type WorksmobileImportUsersFailure struct { + WorksmobileID string `json:"worksmobileId,omitempty"` + Email string `json:"email,omitempty"` + Error string `json:"error"` +} + +type WorksmobileImportUsersResultItem struct { + WorksmobileID string `json:"worksmobileId,omitempty"` + BaronID string `json:"baronId,omitempty"` + Email string `json:"email,omitempty"` + Action string `json:"action"` +} + type WorksmobileInitialPasswordCredential struct { Email string `json:"email"` Name string `json:"name,omitempty"` @@ -178,6 +200,8 @@ type worksmobileSyncService struct { outboxRepo repository.WorksmobileOutboxRepository client WorksmobileDirectoryClient identityMirror WorksmobileIdentityMirror + identityWriter IdentityWriteService + kratos KratosAdminService } type WorksmobileIdentityMirror interface { @@ -201,18 +225,30 @@ func (s *worksmobileSyncService) SetIdentityMirror(source WorksmobileIdentityMir s.identityMirror = source } +func (s *worksmobileSyncService) SetIdentityServices(writer IdentityWriteService, kratos KratosAdminService) { + if s == nil { + return + } + s.identityWriter = writer + s.kratos = kratos +} + func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) { - tenant, err := s.tenantService.GetTenant(ctx, tenantID) + root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return WorksmobileTenantOverview{}, err } - jobs, _ := s.outboxRepo.ListRecent(ctx, 50) + scopeTenants, err := s.hanmacSubtree(ctx, root.ID) + if err != nil { + return WorksmobileTenantOverview{}, err + } + jobs, _ := s.outboxRepo.ListRecentByTenantRoot(ctx, root.ID, worksmobileRecentResourceIDs(root.ID, scopeTenants), 50) jobs = redactWorksmobileOutboxPayloads(jobs) return WorksmobileTenantOverview{ - Tenant: *tenant, + Tenant: *root, Config: WorksmobileConfigSummary{ - Enabled: WorksmobileEnabled(tenant.Config), - DomainMappings: WorksmobileDomainMappings(tenant.Config), + Enabled: WorksmobileEnabled(root.Config), + DomainMappings: WorksmobileDomainMappings(root.Config), TokenConfigured: worksmobileDirectoryAuthConfigured(), AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")), }, @@ -231,6 +267,15 @@ func worksmobileDirectoryAuthConfigured() bool { strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "") } +func worksmobileRecentResourceIDs(rootID string, tenants []domain.Tenant) []string { + ids := make([]string, 0, len(tenants)+1) + ids = append(ids, rootID) + for _, tenant := range tenants { + ids = append(ids, tenant.ID) + } + return ids +} + func WorksmobileExcluded(config domain.JSONMap) bool { rawValue, ok := config[worksmobileExcludedConfigKey] if !ok { @@ -403,6 +448,273 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str }, nil } +func (s *worksmobileSyncService) ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (WorksmobileImportUsersResult, error) { + root, err := s.hanmacRoot(ctx, tenantID) + if err != nil { + return WorksmobileImportUsersResult{}, err + } + if s.client == nil { + return WorksmobileImportUsersResult{}, errors.New("worksmobile client is not configured") + } + if len(worksmobileUserIDs) == 0 { + return WorksmobileImportUsersResult{}, errors.New("worksmobile user ids are required") + } + scopeTenants, err := s.hanmacSubtree(ctx, root.ID) + if err != nil { + return WorksmobileImportUsersResult{}, err + } + tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) + remoteUsers, err := s.client.ListUsers(ctx) + if err != nil { + return WorksmobileImportUsersResult{}, err + } + remoteGroups, err := s.client.ListGroups(ctx) + if err != nil { + return WorksmobileImportUsersResult{}, err + } + remoteByID := make(map[string]WorksmobileRemoteUser, len(remoteUsers)) + for _, remote := range remoteUsers { + if id := strings.TrimSpace(remote.ID); id != "" { + remoteByID[id] = remote + } + } + groupByID := make(map[string]WorksmobileRemoteGroup, len(remoteGroups)) + for _, group := range remoteGroups { + if id := strings.TrimSpace(group.ID); id != "" { + groupByID[id] = group + } + } + + result := WorksmobileImportUsersResult{} + seen := map[string]bool{} + for _, rawID := range worksmobileUserIDs { + worksmobileID := strings.TrimSpace(rawID) + if worksmobileID == "" || seen[worksmobileID] { + continue + } + seen[worksmobileID] = true + remote, ok := remoteByID[worksmobileID] + if !ok { + result.Failures = append(result.Failures, WorksmobileImportUsersFailure{WorksmobileID: worksmobileID, Error: "worksmobile user not found"}) + continue + } + user, created, externalKeyUpdated, err := s.importSingleWorksmobileUser(ctx, root.ID, remote, tenantByID, groupByID) + if err != nil { + result.Failures = append(result.Failures, WorksmobileImportUsersFailure{WorksmobileID: worksmobileID, Email: remote.Email, Error: err.Error()}) + continue + } + action := "updated" + if created { + action = "created" + result.CreatedCount++ + } else { + result.UpdatedCount++ + } + if externalKeyUpdated { + result.ExternalKeyUpdates++ + } + result.Items = append(result.Items, WorksmobileImportUsersResultItem{ + WorksmobileID: worksmobileID, + BaronID: user.ID, + Email: user.Email, + Action: action, + }) + } + return result, nil +} + +func (s *worksmobileSyncService) importSingleWorksmobileUser(ctx context.Context, rootID string, remote WorksmobileRemoteUser, tenantByID map[string]domain.Tenant, groupByID map[string]WorksmobileRemoteGroup) (domain.User, bool, bool, error) { + email := strings.ToLower(strings.TrimSpace(remote.Email)) + if email == "" { + return domain.User{}, false, false, errors.New("worksmobile user email is required") + } + tenantID := worksmobileTenantIDForRemoteUser(remote, groupByID) + tenant, ok := tenantByID[tenantID] + if !ok || !isWorksmobileUserScopeTenant(tenant) { + return domain.User{}, false, false, fmt.Errorf("worksmobile primary org is outside import scope: %s", tenantID) + } + + var existing *domain.User + if externalKey := strings.TrimSpace(remote.ExternalID); externalKey != "" { + if user, err := s.userRepo.FindByID(ctx, externalKey); err == nil { + existing = user + } else { + return domain.User{}, false, false, fmt.Errorf("worksmobile external key does not match a Baron user: %s", externalKey) + } + } else if user, err := s.userRepo.FindByEmail(ctx, email); err == nil { + existing = user + } + + if existing != nil { + user := *existing + applyWorksmobileRemoteToUser(&user, remote, tenant.ID) + if err := s.updateImportedWorksmobileUserIdentity(ctx, user); err != nil { + return domain.User{}, false, false, err + } + if err := s.userRepo.Update(ctx, &user); err != nil { + return domain.User{}, false, false, err + } + updatedExternalKey := false + if strings.TrimSpace(remote.ExternalID) == "" { + if err := s.patchWorksmobileUserExternalKey(ctx, remote, user.ID); err != nil { + return domain.User{}, false, false, err + } + updatedExternalKey = true + } + return user, false, updatedExternalKey, nil + } + + if strings.TrimSpace(remote.ExternalID) != "" { + return domain.User{}, false, false, errors.New("creating Baron user from non-empty unmatched worksmobile external key is not supported") + } + if s.kratos == nil { + return domain.User{}, false, false, errors.New("kratos admin service is required") + } + identityID, err := s.kratos.CreateUser(ctx, &domain.BrokerUser{ + Email: email, + Name: strings.TrimSpace(remote.DisplayName), + PhoneNumber: strings.TrimSpace(remote.CellPhone), + Attributes: map[string]any{ + "tenant_id": tenant.ID, + "role": domain.RoleUser, + "status": domain.UserStatusActive, + "grade": strings.TrimSpace(remote.LevelName), + "jobTitle": strings.TrimSpace(remote.Task), + }, + }, GenerateWorksmobileInitialPassword()) + if err != nil { + return domain.User{}, false, false, err + } + now := time.Now().UTC() + user := domain.User{ + ID: identityID, + Email: email, + Name: strings.TrimSpace(remote.DisplayName), + Phone: strings.TrimSpace(remote.CellPhone), + Role: domain.RoleUser, + Status: domain.UserStatusActive, + TenantID: &tenant.ID, + Grade: strings.TrimSpace(remote.LevelName), + JobTitle: strings.TrimSpace(remote.Task), + Metadata: worksmobileImportedUserMetadata(remote, tenant), + CreatedAt: now, + UpdatedAt: now, + } + if err := s.userRepo.Update(ctx, &user); err != nil { + return domain.User{}, false, false, err + } + if err := s.patchWorksmobileUserExternalKey(ctx, remote, user.ID); err != nil { + return domain.User{}, false, false, err + } + return user, true, true, nil +} + +func (s *worksmobileSyncService) updateImportedWorksmobileUserIdentity(ctx context.Context, user domain.User) error { + if s.identityWriter == nil { + return nil + } + identity, err := s.identityWriter.GetIdentity(ctx, user.ID) + if err != nil { + return err + } + traits := map[string]any{} + for key, value := range identity.Traits { + traits[key] = value + } + traits["email"] = user.Email + traits["name"] = user.Name + if phone := strings.TrimSpace(user.Phone); phone != "" { + traits["phone_number"] = phone + } + traits["tenant_id"] = strings.TrimSpace(stringPtrValue(user.TenantID)) + traits["role"] = user.Role + traits["status"] = user.Status + traits["grade"] = user.Grade + traits["jobTitle"] = user.JobTitle + _, err = s.identityWriter.UpdateIdentity(ctx, IdentityUpdateRequest{ + IdentityID: user.ID, + Traits: traits, + State: strings.TrimSpace(identity.State), + Reason: "worksmobile_import_from_works", + Source: "admin_worksmobile", + }) + return err +} + +func (s *worksmobileSyncService) patchWorksmobileUserExternalKey(ctx context.Context, remote WorksmobileRemoteUser, userID string) error { + return s.client.UpdateUserOnly(ctx, WorksmobileUserPayload{ + DomainID: remote.DomainID, + Email: strings.TrimSpace(remote.Email), + UserExternalKey: strings.TrimSpace(userID), + CellPhone: strings.TrimSpace(remote.CellPhone), + EmployeeNumber: strings.TrimSpace(remote.EmployeeNumber), + Locale: "ko_KR", + Task: strings.TrimSpace(remote.Task), + }) +} + +func applyWorksmobileRemoteToUser(user *domain.User, remote WorksmobileRemoteUser, tenantID string) { + now := time.Now().UTC() + user.Email = strings.ToLower(strings.TrimSpace(remote.Email)) + user.Name = strings.TrimSpace(remote.DisplayName) + user.Phone = strings.TrimSpace(remote.CellPhone) + user.Role = domain.NormalizeRole(user.Role) + user.Status = domain.UserStatusActive + user.TenantID = &tenantID + user.Grade = strings.TrimSpace(remote.LevelName) + user.JobTitle = strings.TrimSpace(remote.Task) + user.Metadata = mergeWorksmobileImportedUserMetadata(user.Metadata, remote, tenantID) + user.UpdatedAt = now +} + +func worksmobileImportedUserMetadata(remote WorksmobileRemoteUser, tenant domain.Tenant) domain.JSONMap { + return mergeWorksmobileImportedUserMetadata(domain.JSONMap{}, remote, tenant.ID) +} + +func mergeWorksmobileImportedUserMetadata(metadata domain.JSONMap, remote WorksmobileRemoteUser, tenantID string) domain.JSONMap { + if metadata == nil { + metadata = domain.JSONMap{} + } + if value := strings.TrimSpace(remote.EmployeeNumber); value != "" { + metadata["employeeNumber"] = value + metadata["employee_id"] = value + } + if value := strings.TrimSpace(remote.LevelName); value != "" { + metadata["grade"] = value + } + if value := strings.TrimSpace(remote.PrimaryOrgUnitName); value != "" { + metadata["department"] = value + } + metadata["worksmobileImportedAt"] = time.Now().UTC().Format(time.RFC3339Nano) + metadata["worksmobileId"] = strings.TrimSpace(remote.ID) + metadata["worksmobileDomainId"] = remote.DomainID + metadata["worksmobilePrimaryOrgUnitId"] = strings.TrimSpace(remote.PrimaryOrgUnitID) + metadata["additionalAppointments"] = []domain.JSONMap{{ + "tenantId": tenantID, + "isPrimary": true, + "grade": strings.TrimSpace(remote.LevelName), + }} + return metadata +} + +func worksmobileTenantIDForRemoteUser(remote WorksmobileRemoteUser, groupByID map[string]WorksmobileRemoteGroup) string { + primaryOrgUnitID := strings.TrimSpace(remote.PrimaryOrgUnitID) + if tenantID, ok := strings.CutPrefix(primaryOrgUnitID, "externalKey:"); ok { + return strings.TrimSpace(tenantID) + } + if group, ok := groupByID[primaryOrgUnitID]; ok { + return strings.TrimSpace(group.ExternalID) + } + return "" +} + +func stringPtrValue(value *string) string { + if value == nil { + return "" + } + return *value +} + func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string, tenantByID map[string]domain.Tenant) ([]domain.User, error) { if s.identityMirror != nil { status, err := s.identityMirror.GetIdentityCacheStatus(ctx) @@ -586,8 +898,9 @@ func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tena Action: domain.WorksmobileActionDryRun, DedupeKey: "backfill:dry-run:" + root.ID, Payload: domain.JSONMap{ - "tenantIds": orgUnitTenantIDs, - "userCount": len(users), + "tenantRootId": root.ID, + "tenantIds": orgUnitTenantIDs, + "userCount": len(users), }, }) return WorksmobileBackfillDryRun{OrgUnitCount: len(orgUnitTenantIDs), UserCount: len(users)}, nil @@ -604,10 +917,17 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI } tenantRoot, ok, err := s.rootForTenant(ctx, *tenant) if err != nil { + if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil { + return nil, errors.Join(err, recordErr) + } return nil, err } if !ok || tenantRoot.ID != root.ID { - return nil, errors.New("target orgunit is outside hanmac-family subtree") + err := errors.New("target orgunit is outside hanmac-family subtree") + if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil { + return nil, errors.Join(err, recordErr) + } + return nil, err } scopeTenants, err := s.hanmacSubtree(ctx, root.ID) if err != nil { @@ -615,10 +935,18 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI } tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) if _, ok := tenantByID[tenant.ID]; !ok { - return nil, errors.New("target tenant is excluded from Worksmobile sync") + err := errors.New("target tenant is excluded from Worksmobile sync") + if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil { + return nil, errors.Join(err, recordErr) + } + return nil, err } if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) { - return nil, errors.New("target tenant is not a worksmobile orgunit tenant") + err := errors.New("target tenant is not a worksmobile orgunit tenant") + if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil { + return nil, errors.Join(err, recordErr) + } + return nil, err } return s.enqueueOrgUnitUpsert(ctx, root, *tenant, scopeTenants) } @@ -632,6 +960,9 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root 0, ) if err != nil { + if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, tenant, err); recordErr != nil { + return nil, errors.Join(err, recordErr) + } return nil, err } payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID) @@ -641,6 +972,7 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root Action: domain.WorksmobileActionUpsert, DedupeKey: "orgunit:upsert:" + tenant.ID, Payload: domain.JSONMap{ + "tenantRootId": root.ID, "request": payload, "matchLocalPart": tenant.Slug, }, @@ -651,6 +983,36 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root return item, nil } +func (s *worksmobileSyncService) recordRejectedOrgUnitSync(ctx context.Context, rootID string, tenant domain.Tenant, reason error) error { + if s.outboxRepo == nil { + return nil + } + payload := domain.JSONMap{ + "tenantRootId": rootID, + "displayName": strings.TrimSpace(tenant.Name), + "matchLocalPart": strings.TrimSpace(tenant.Slug), + "tenantSlug": strings.TrimSpace(tenant.Slug), + "requestSummary": domain.JSONMap{ + "orgUnitName": strings.TrimSpace(tenant.Name), + "orgUnitExternalKey": tenant.ID, + "tenantSlug": strings.TrimSpace(tenant.Slug), + }, + } + if tenant.ParentID != nil { + payload["parentTenantId"] = strings.TrimSpace(*tenant.ParentID) + } + item := &domain.WorksmobileOutbox{ + ResourceType: domain.WorksmobileResourceOrgUnit, + ResourceID: tenant.ID, + Action: domain.WorksmobileActionUpsert, + DedupeKey: "orgunit:rejected:" + tenant.ID + ":" + uuid.NewString(), + Payload: payload, + Status: domain.WorksmobileOutboxStatusFailed, + LastError: reason.Error(), + } + return s.outboxRepo.Create(ctx, item) +} + func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { @@ -692,6 +1054,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan Action: domain.WorksmobileActionDelete, DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID, Payload: domain.JSONMap{ + "tenantRootId": root.ID, "worksmobileId": worksmobileOrgUnitID, "externalKey": target.ExternalID, "domainId": target.DomainID, @@ -756,7 +1119,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, return nil, err } action := WorksmobileUserStatusAction(user.Status) - if action == domain.WorksmobileActionUpsert { + if action == domain.WorksmobileActionUpsert && strings.TrimSpace(initialPassword) != "" { payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword) } item := &domain.WorksmobileOutbox{ @@ -768,7 +1131,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, } item.Payload["displayName"] = strings.TrimSpace(user.Name) item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID) - if batchID := strings.TrimSpace(credentialBatchID); batchID != "" { + if batchID := strings.TrimSpace(credentialBatchID); batchID != "" && strings.TrimSpace(payload.PasswordConfig.Password) != "" { item.Payload["credentialBatchId"] = batchID item.Payload["credentialOperation"] = "worksmobile_user_sync" item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano) @@ -783,7 +1146,7 @@ func (s *worksmobileSyncService) recordRejectedUserSync(ctx context.Context, roo payload := WorksmobileUserPayload{ Email: strings.TrimSpace(user.Email), UserExternalKey: user.ID, - UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)}, + UserName: worksmobileUserNameFromDisplayName(user.Name), CellPhone: domain.NormalizePhoneNumber(user.Phone), EmployeeNumber: metadataEmployeeNumber(user.Metadata), Locale: "ko_KR", diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go index f1ff8aba..ea5ab2b0 100644 --- a/backend/internal/service/worksmobile_sync_service_test.go +++ b/backend/internal/service/worksmobile_sync_service_test.go @@ -164,7 +164,7 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) { require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin) } -func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) { +func TestWorksmobileSyncServiceSkipsAdminInitialPasswordWhenEmpty(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" tenantID := "saman-tenant" @@ -201,13 +201,13 @@ func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) { require.NoError(t, err) require.NotNil(t, item) initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"]) - require.NotEmpty(t, initialPassword) + require.Empty(t, initialPassword) request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload) require.True(t, ok) - require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType) - require.Equal(t, initialPassword, request.PasswordConfig.Password) - require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin) - require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin) + require.Empty(t, request.PasswordConfig.PasswordCreationType) + require.Empty(t, request.PasswordConfig.Password) + require.Nil(t, request.PasswordConfig.ChangePasswordAtNextLogin) + require.Empty(t, stringValue(outboxRepo.created[0].Payload["credentialBatchId"])) } func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) { @@ -661,6 +661,7 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes Action: domain.WorksmobileActionUpsert, Status: domain.WorksmobileOutboxStatusProcessed, Payload: domain.JSONMap{ + "tenantRootId": root.ID, "loginEmail": "changed@example.com", "displayName": "변경 사용자", "primaryLeafOrgName": "인재성장", @@ -680,6 +681,7 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes Action: domain.WorksmobileActionUpsert, Status: domain.WorksmobileOutboxStatusProcessed, Payload: domain.JSONMap{ + "tenantRootId": root.ID, "matchLocalPart": "people-growth", "request": WorksmobileOrgUnitPayload{ OrgUnitName: "인재성장", @@ -725,6 +727,67 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes }, orgPayload["requestSummary"]) } +func TestWorksmobileSyncServiceOverviewScopesRecentJobsToTenantRoot(t *testing.T) { + rootID := "root-tenant" + childID := "child-org" + otherRootID := "other-root" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "한맥가족", + } + child := domain.Tenant{ + ID: childID, + Slug: "structure-planning", + Name: "구조물계획", + Type: domain.TenantTypeUserGroup, + ParentID: &rootID, + } + outboxRepo := &fakeWorksmobileOutboxRepo{ + recent: []domain.WorksmobileOutbox{ + { + ID: "job-root-user-failed", + ResourceType: domain.WorksmobileResourceUser, + ResourceID: "user-1", + Status: domain.WorksmobileOutboxStatusFailed, + Payload: domain.JSONMap{"tenantRootId": rootID}, + LastError: "worksmobile api failed", + }, + { + ID: "job-child-org-legacy", + ResourceType: domain.WorksmobileResourceOrgUnit, + ResourceID: childID, + Status: domain.WorksmobileOutboxStatusFailed, + LastError: "legacy org job without tenantRootId", + }, + { + ID: "job-other-root", + ResourceType: domain.WorksmobileResourceUser, + ResourceID: "user-2", + Status: domain.WorksmobileOutboxStatusFailed, + Payload: domain.JSONMap{"tenantRootId": otherRootID}, + }, + }, + } + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{ + tenants: map[string]domain.Tenant{rootID: root, childID: child}, + list: []domain.Tenant{child}, + }, + &fakeWorksmobileUserRepo{}, + outboxRepo, + nil, + ) + + overview, err := service.GetTenantOverview(context.Background(), rootID) + + require.NoError(t, err) + require.Len(t, overview.RecentJobs, 2) + require.Equal(t, "job-root-user-failed", overview.RecentJobs[0].ID) + require.Equal(t, "job-child-org-legacy", overview.RecentJobs[1].ID) + require.Equal(t, "legacy org job without tenantRootId", overview.RecentJobs[1].LastError) +} + func TestWorksmobileSyncServiceEnqueueTenantUpsertReflectsChangedParentOrgUnit(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" @@ -1041,7 +1104,12 @@ func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) { require.Nil(t, item) require.Error(t, err) require.Contains(t, err.Error(), "worksmobile orgunit tenant") - require.Empty(t, outboxRepo.created) + require.Len(t, outboxRepo.created, 1) + require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType) + require.Equal(t, domain.WorksmobileOutboxStatusFailed, outboxRepo.created[0].Status) + require.Equal(t, companyID, outboxRepo.created[0].ResourceID) + require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"]) + require.Equal(t, "target tenant is not a worksmobile orgunit tenant", outboxRepo.created[0].LastError) } func TestWorksmobileSyncServiceEnqueuesBarongroupChildCompanyOrgUnitSync(t *testing.T) { @@ -2046,7 +2114,13 @@ func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) { require.Nil(t, item) require.ErrorContains(t, err, "excluded from Worksmobile sync") - require.Empty(t, outboxRepo.created) + require.Len(t, outboxRepo.created, 1) + require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType) + require.Equal(t, domain.WorksmobileOutboxStatusFailed, outboxRepo.created[0].Status) + require.Equal(t, excludedOrgID, outboxRepo.created[0].ResourceID) + require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"]) + require.Equal(t, "excluded-team", outboxRepo.created[0].Payload["matchLocalPart"]) + require.Equal(t, "target tenant is excluded from Worksmobile sync", outboxRepo.created[0].LastError) } func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) { diff --git a/config-restored/compose/docker-compose.yaml b/config-restored/compose/docker-compose.yaml index 49fc0262..33ad1cad 100644 --- a/config-restored/compose/docker-compose.yaml +++ b/config-restored/compose/docker-compose.yaml @@ -3,6 +3,7 @@ services: build: context: ./backend dockerfile: Dockerfile + target: dev container_name: baron_backend env_file: - .env @@ -42,14 +43,14 @@ services: - ./backend:/app - ./config:/app/config:ro - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro - command: ["go", "run", "./cmd/server"] + command: ["/usr/local/bin/baron-backend-dev"] healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] interval: 10s timeout: 5s - retries: 3 - start_period: 10s + retries: 12 + start_period: 60s adminfront: build: diff --git a/docker-compose.yaml b/docker-compose.yaml index 11881d4f..a38cc4a6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,6 +3,7 @@ services: build: context: ./backend dockerfile: Dockerfile + target: dev container_name: baron_backend env_file: - .env @@ -42,14 +43,14 @@ services: - ./backend:/app - ./config:/app/config:ro - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro - command: ["go", "run", "./cmd/server"] + command: ["/usr/local/bin/baron-backend-dev"] healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] interval: 10s timeout: 5s - retries: 3 - start_period: 10s + retries: 12 + start_period: 60s adminfront: build: diff --git a/docker/docker-compose.staging.template.yaml b/docker/docker-compose.staging.template.yaml index 4eb8c4cc..b7b07028 100644 --- a/docker/docker-compose.staging.template.yaml +++ b/docker/docker-compose.staging.template.yaml @@ -26,7 +26,7 @@ services: - baron_net - ory-net healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] + test: ["CMD", "/app/healthcheck"] interval: 10s timeout: 5s retries: 10 diff --git a/docker/docker-compose.template.yaml b/docker/docker-compose.template.yaml index 39e742d7..38619a01 100644 --- a/docker/docker-compose.template.yaml +++ b/docker/docker-compose.template.yaml @@ -26,11 +26,11 @@ services: depends_on: - infra_check healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] + test: ["CMD", "/app/healthcheck"] interval: 10s timeout: 5s - retries: 3 - start_period: 10s + retries: 12 + start_period: 60s networks: - baron_net diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml index e65c50c5..fde40acb 100644 --- a/docker/staging_pull_compose.template.yaml +++ b/docker/staging_pull_compose.template.yaml @@ -364,6 +364,7 @@ services: build: context: ./backend dockerfile: Dockerfile + target: dev container_name: baron_backend restart: unless-stopped env_file: @@ -412,13 +413,13 @@ services: volumes: - ./backend:/app - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro - command: ["go", "run", "./cmd/server"] + command: ["/usr/local/bin/baron-backend-dev"] healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] interval: 10s timeout: 5s - retries: 3 - start_period: 10s + retries: 12 + start_period: 60s adminfront: build: diff --git a/test/backend_go_version_policy_test.sh b/test/backend_go_version_policy_test.sh index 64072168..7c1fd2f5 100644 --- a/test/backend_go_version_policy_test.sh +++ b/test/backend_go_version_policy_test.sh @@ -38,8 +38,13 @@ if ! grep -Eq "^go ${TARGET_GO_VERSION}$" "$GO_MOD"; then exit 1 fi -if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine$" "$BACKEND_DOCKERFILE"; then - echo "ERROR: backend Dockerfile must use golang:${TARGET_GO_VERSION}-alpine." >&2 +if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine AS base$" "$BACKEND_DOCKERFILE"; then + echo "ERROR: backend Dockerfile base stage must use golang:${TARGET_GO_VERSION}-alpine." >&2 + exit 1 +fi + +if ! grep -Eq "^FROM gcr\\.io/distroless/static-debian13:nonroot AS production$" "$BACKEND_DOCKERFILE"; then + echo "ERROR: backend Dockerfile production stage must use distroless/static-debian13:nonroot." >&2 exit 1 fi