diff --git a/.gitignore b/.gitignore index 9cd8b8ec..1a5c7519 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ .npm-cache/ reports reports/* +config/*.pem # Docker Services Data (Volumes) postgres_data/ diff --git a/adminfront/NAVERWORKS_member_add_sample_English.csv b/adminfront/NAVERWORKS_member_add_sample_English.csv new file mode 100644 index 00000000..aaffce7d --- /dev/null +++ b/adminfront/NAVERWORKS_member_add_sample_English.csv @@ -0,0 +1,3 @@ +"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time" +"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00" +"Doe","Eric","eric.doe","eric@naver.com","eric2@company.com","Eric","Contract Employee","Manager","org.1|org.2|org.3|org.4|myteam","Manager","02-1234-0000","+1","9765412345","Japanese","General affairs","New York","Facebook","Eric","lunar","19840704","20240704","AB002","20240704 14:00" diff --git a/adminfront/seed-tenant.csv b/adminfront/seed-tenant.csv index 7df8c628..f774259a 100644 --- a/adminfront/seed-tenant.csv +++ b/adminfront/seed-tenant.csv @@ -1,3 +1,11 @@ -name,type,parent_tenant_slug,slug,memo,email_domain -한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트, -Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트, +id,name,type,parent_tenant_slug,slug,memo,email_domain +038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트, +9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID, samaneng.com +369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID, hanmaceng.co.kr +5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr +96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID, +c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com +b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr +5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,baron-group,hanlla,,hanllasanup.co.kr +e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr +9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트, diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 55e19ab8..335a6fde 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -13,6 +13,7 @@ import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantListPage from "../features/tenants/routes/TenantListPage"; import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage"; import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage"; +import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage"; import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab"; import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage"; import UserCreatePage from "../features/users/UserCreatePage"; @@ -49,6 +50,7 @@ export const router = createBrowserRouter( { path: "permissions", element: }, { path: "organization", element: }, { path: "schema", element: }, + { path: "worksmobile", element: }, ], }, { diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx index bddfa350..b95fc44a 100644 --- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -18,8 +18,8 @@ import { createTenant, fetchTenants } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { DomainTagInput } from "../components/DomainTagInput"; import { - formatDomainConflictMessage, type ServerDomainConflict, + formatDomainConflictMessage, } from "../utils/domainTags"; function TenantCreatePage() { @@ -151,6 +151,12 @@ function TenantCreatePage() { "COMPANY_GROUP (그룹사/지주사)", )} + + {t( + "domain.tenant_type.organization", + "ORGANIZATION (정규 조직)", + )} + {t( "domain.tenant_type.user_group", diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.test.ts b/adminfront/src/features/tenants/routes/TenantDetailPage.test.ts new file mode 100644 index 00000000..4ee15345 --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { canShowWorksmobileEntry } from "./TenantDetailPage"; + +describe("TenantDetailPage Worksmobile entry visibility", () => { + it("shows Worksmobile entry only for hanmac-family root tenant", () => { + expect( + canShowWorksmobileEntry({ + id: "hanmac-family-id", + slug: "hanmac-family", + parentId: undefined, + }), + ).toBe(true); + + expect( + canShowWorksmobileEntry({ + id: "hanmac-child-id", + slug: "hanmac-family", + parentId: "root-id", + }), + ).toBe(false); + + expect( + canShowWorksmobileEntry({ + id: "other-id", + slug: "other", + parentId: undefined, + }), + ).toBe(false); + }); +}); diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index 97714c8c..1099bc84 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -1,10 +1,17 @@ import { useQuery } from "@tanstack/react-query"; -import { ArrowLeft } from "lucide-react"; import { Link, Outlet, useLocation, useParams } from "react-router-dom"; import { Badge } from "../../../components/ui/badge"; import { fetchMe, fetchTenant } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +export function canShowWorksmobileEntry(tenant?: { + id?: string; + slug?: string; + parentId?: string | null; +}) { + return tenant?.slug === "hanmac-family" && !tenant.parentId; +} + function TenantDetailPage() { const params = useParams<{ tenantId: string }>(); const tenantId = params.tenantId ?? ""; @@ -23,9 +30,11 @@ function TenantDetailPage() { const canAccessSchema = profile?.role === "super_admin" || profile?.role === "tenant_admin"; + const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data); const isPermissionsTab = location.pathname.includes("/permissions"); const isOrganizationTab = location.pathname.includes("/organization"); + const isWorksmobileTab = location.pathname.includes("/worksmobile"); return ( @@ -51,6 +60,7 @@ function TenantDetailPage() { className={`px-6 py-3 text-sm font-medium transition-colors relative ${ !isPermissionsTab && !location.pathname.includes("/schema") && + !isWorksmobileTab && !isOrganizationTab ? "text-primary border-b-2 border-primary" : "text-muted-foreground hover:text-foreground" @@ -90,6 +100,18 @@ function TenantDetailPage() { {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")} )} + {showWorksmobileEntry && ( + + {t("ui.admin.tenants.detail.tab_worksmobile", "Worksmobile")} + + )} {/* Outlet for nested routes */} diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.worksmobile.test.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.worksmobile.test.tsx new file mode 100644 index 00000000..8bff6fa2 --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.worksmobile.test.tsx @@ -0,0 +1,57 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import TenantDetailPage from "./TenantDetailPage"; + +vi.mock("../../../lib/adminApi", () => ({ + fetchMe: vi.fn(async () => ({ role: "super_admin" })), + fetchTenant: vi.fn(async () => ({ + id: "hanmac-family-id", + name: "한맥 가족", + slug: "hanmac-family", + parentId: null, + })), +})); + +function renderTenantDetailPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return render( + + + + }> + profile} /> + worksmobile} /> + + + + , + ); +} + +describe("TenantDetailPage Worksmobile navigation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("opens Worksmobile management in the current admin route", async () => { + renderTenantDetailPage(); + + const link = await screen.findByRole("link", { name: /Worksmobile/i }); + + expect(link).toHaveAttribute( + "href", + "/tenants/hanmac-family-id/worksmobile", + ); + expect(link).not.toHaveAttribute("target"); + expect(link).not.toHaveAttribute("rel"); + }); +}); diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index 9bcfbe15..d67c3214 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -25,8 +25,8 @@ import { import { t } from "../../../lib/i18n"; import { DomainTagInput } from "../components/DomainTagInput"; import { - formatDomainConflictMessage, type ServerDomainConflict, + formatDomainConflictMessage, } from "../utils/domainTags"; import { isSeedTenant } from "../utils/protectedTenants"; @@ -230,6 +230,12 @@ export function TenantProfilePage() { "COMPANY_GROUP (그룹사/지주사)", )} + + {t( + "domain.tenant_type.organization", + "ORGANIZATION (정규 조직)", + )} + {t( "domain.tenant_type.user_group", diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts new file mode 100644 index 00000000..31a8c590 --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { + canCreateWorksmobileRow, + filterWorksmobileComparisonRows, + formatWorksmobileOrgDetails, + formatWorksmobilePersonName, + getWorksmobileComparisonStatusLabel, + summarizeWorksmobileComparison, + userFilterOptions, +} from "./TenantWorksmobilePage"; + +describe("TenantWorksmobilePage comparison helpers", () => { + it("summarizes comparison rows by status", () => { + const summary = summarizeWorksmobileComparison([ + { resourceType: "USER", status: "matched" }, + { resourceType: "USER", status: "missing_in_worksmobile" }, + { resourceType: "USER", status: "missing_in_baron" }, + { resourceType: "USER", status: "missing_external_key" }, + { resourceType: "USER", status: "missing_in_baron" }, + ]); + + expect(summary).toEqual({ + total: 5, + matched: 1, + missingInWorksmobile: 1, + missingInBaron: 2, + missingExternalKey: 1, + }); + }); + + it("returns Korean labels for known comparison statuses", () => { + expect(getWorksmobileComparisonStatusLabel("matched")).toBe("일치"); + expect(getWorksmobileComparisonStatusLabel("missing_in_worksmobile")).toBe( + "WORKS 없음", + ); + expect(getWorksmobileComparisonStatusLabel("missing_in_baron")).toBe( + "Baron 없음", + ); + expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe( + "ex_key 없음", + ); + expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe( + "unknown_status", + ); + }); + + it("allows WORKS creation only for Baron rows missing in WORKS", () => { + expect( + canCreateWorksmobileRow({ + resourceType: "USER", + status: "missing_in_worksmobile", + baronId: "user-1", + }), + ).toBe(true); + + expect( + canCreateWorksmobileRow({ + resourceType: "USER", + status: "missing_in_worksmobile", + }), + ).toBe(false); + + expect( + canCreateWorksmobileRow({ + resourceType: "USER", + status: "missing_in_baron", + worksmobileId: "works-user-1", + }), + ).toBe(false); + }); + + it("filters user comparison rows by selected relationship", () => { + const rows = [ + { + resourceType: "USER", + status: "missing_in_worksmobile", + baronId: "baron-only", + baronName: "Baron only", + }, + { + resourceType: "USER", + status: "missing_in_baron", + worksmobileId: "works-only", + worksmobileName: "WORKS only", + }, + { + resourceType: "USER", + status: "matched", + baronId: "matched", + worksmobileId: "works-matched", + }, + { + resourceType: "USER", + status: "missing_external_key", + worksmobileId: "missing-external-key", + }, + ]; + + expect(filterWorksmobileComparisonRows(rows, ["baron_only"])).toEqual([ + rows[0], + ]); + expect(filterWorksmobileComparisonRows(rows, ["works_only"])).toEqual([ + rows[1], + rows[3], + ]); + expect(filterWorksmobileComparisonRows(rows, ["matched"])).toEqual([ + rows[2], + ]); + expect( + filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]), + ).toEqual([rows[0], rows[1], rows[3]]); + expect(filterWorksmobileComparisonRows(rows, [])).toEqual(rows); + expect( + filterWorksmobileComparisonRows(rows, [ + "baron_only", + "works_only", + "matched", + ]), + ).toEqual(rows); + }); + + it("orders user comparison filter options from Baron-only first", () => { + expect(userFilterOptions.map((option) => option.value)).toEqual([ + "baron_only", + "works_only", + "matched", + ]); + }); + + it("formats WORKS account name with level on one line", () => { + expect( + formatWorksmobilePersonName({ + resourceType: "USER", + status: "matched", + worksmobileName: "홍길동", + worksmobileLevelName: "책임", + }), + ).toBe("홍길동 책임"); + }); + + it("formats WORKS organization details with task and manager status", () => { + expect( + formatWorksmobileOrgDetails({ + resourceType: "USER", + status: "matched", + worksmobileTask: "기술검토", + worksmobilePrimaryOrgPositionName: "팀장", + worksmobilePrimaryOrgIsManager: true, + }), + ).toEqual(["직책 팀장", "직무 기술검토", "조직장"]); + }); +}); diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx new file mode 100644 index 00000000..9710e14d --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx @@ -0,0 +1,854 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Download, RefreshCw, RotateCcw } from "lucide-react"; +import * as React from "react"; +import { useParams } from "react-router-dom"; +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { Checkbox } from "../../../components/ui/checkbox"; +import { Input } from "../../../components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { toast } from "../../../components/ui/use-toast"; +import { + type WorksmobileComparisonItem, + downloadWorksmobileInitialPasswordsCSV, + enqueueWorksmobileBackfillDryRun, + enqueueWorksmobileOrgUnitSync, + enqueueWorksmobileUserSync, + fetchWorksmobileComparison, + fetchWorksmobileOverview, + retryWorksmobileJob, +} from "../../../lib/adminApi"; +import { t } from "../../../lib/i18n"; + +export type WorksmobileComparisonFilter = + | "works_only" + | "baron_only" + | "matched"; + +export function TenantWorksmobilePage() { + const params = useParams<{ tenantId: string }>(); + const tenantId = params.tenantId ?? ""; + const [orgUnitId, setOrgUnitId] = React.useState(""); + const [userId, setUserId] = React.useState(""); + const [userFilters, setUserFilters] = React.useState< + WorksmobileComparisonFilter[] + >(["baron_only", "works_only"]); + const [selectedUserIds, setSelectedUserIds] = React.useState([]); + const [selectedGroupIds, setSelectedGroupIds] = React.useState([]); + + const overviewQuery = useQuery({ + queryKey: ["worksmobile", tenantId], + queryFn: () => fetchWorksmobileOverview(tenantId), + enabled: tenantId.length > 0, + }); + + const comparisonQuery = useQuery({ + queryKey: ["worksmobile-comparison", tenantId], + queryFn: () => fetchWorksmobileComparison(tenantId, true), + enabled: tenantId.length > 0, + }); + + const dryRunMutation = useMutation({ + mutationFn: () => enqueueWorksmobileBackfillDryRun(tenantId), + onSuccess: () => { + toast.success("Backfill Dry-run 작업을 등록했습니다."); + overviewQuery.refetch(); + }, + onError: (error) => { + toast.error("Backfill Dry-run 실패", { + description: getErrorMessage(error), + }); + }, + }); + + const retryMutation = useMutation({ + mutationFn: (jobId: string) => retryWorksmobileJob(tenantId, jobId), + onSuccess: () => { + toast.success("재시도 작업을 등록했습니다."); + overviewQuery.refetch(); + }, + onError: (error) => { + toast.error("재시도 작업 등록 실패", { + description: getErrorMessage(error), + }); + }, + }); + + const initialPasswordDownloadMutation = useMutation({ + mutationFn: () => downloadWorksmobileInitialPasswordsCSV(tenantId), + onSuccess: ({ blob, filename }) => { + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }, + onError: (error) => { + toast.error("초기 비밀번호 CSV 다운로드 실패", { + description: getErrorMessage(error), + }); + }, + }); + + const orgUnitSyncMutation = useMutation({ + mutationFn: () => enqueueWorksmobileOrgUnitSync(tenantId, orgUnitId.trim()), + onSuccess: () => { + toast.success("조직 Sync 작업을 등록했습니다."); + overviewQuery.refetch(); + }, + onError: (error) => { + toast.error("조직 Sync 작업 등록 실패", { + description: getErrorMessage(error), + }); + }, + }); + + const userSyncMutation = useMutation({ + mutationFn: () => enqueueWorksmobileUserSync(tenantId, userId.trim()), + onSuccess: () => { + toast.success("구성원 Sync 작업을 등록했습니다."); + overviewQuery.refetch(); + }, + onError: (error) => { + toast.error("구성원 Sync 작업 등록 실패", { + description: getErrorMessage(error), + }); + }, + }); + + const createSelectedMutation = useMutation({ + mutationFn: async ({ + resourceKind, + ids, + }: { + resourceKind: "users" | "groups"; + ids: string[]; + }) => { + for (const id of ids) { + if (resourceKind === "users") { + await enqueueWorksmobileUserSync(tenantId, id); + } else { + await enqueueWorksmobileOrgUnitSync(tenantId, id); + } + } + return { resourceKind, count: ids.length }; + }, + onSuccess: ({ resourceKind, count }) => { + if (resourceKind === "users") { + setSelectedUserIds([]); + } else { + setSelectedGroupIds([]); + } + toast.success("WORKS 생성 작업을 등록했습니다.", { + description: `${count}건`, + }); + overviewQuery.refetch(); + comparisonQuery.refetch(); + }, + onError: (error) => { + toast.error("WORKS 생성 작업 등록 실패", { + description: getErrorMessage(error), + }); + }, + }); + + if (overviewQuery.isError) { + return ( + + {t( + "ui.admin.tenants.worksmobile.forbidden", + "한맥가족 테넌트에서만 Worksmobile 연동을 관리할 수 있습니다.", + )} + + ); + } + + const overview = overviewQuery.data; + const comparisonUsers = comparisonQuery.data?.users ?? []; + const comparisonGroups = comparisonQuery.data?.groups ?? []; + const filteredComparisonUsers = filterWorksmobileComparisonRows( + comparisonUsers, + userFilters, + ); + const userSummary = summarizeWorksmobileComparison(comparisonUsers); + const groupSummary = summarizeWorksmobileComparison(comparisonGroups); + const isCreatingUsers = + createSelectedMutation.isPending && + createSelectedMutation.variables?.resourceKind === "users"; + const isCreatingGroups = + createSelectedMutation.isPending && + createSelectedMutation.variables?.resourceKind === "groups"; + const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching; + + return ( + + + + + {t("ui.admin.tenants.worksmobile.title", "Worksmobile 연동")} + + + {t( + "ui.admin.tenants.worksmobile.subtitle", + "한맥가족 Directory 조직/구성원 동기화 상태를 확인하고 실패 작업을 재시도합니다.", + )} + + + + initialPasswordDownloadMutation.mutate()} + disabled={initialPasswordDownloadMutation.isPending} + > + + {t( + "ui.admin.tenants.worksmobile.initial_password_csv", + "초기 비밀번호 CSV", + )} + + { + overviewQuery.refetch(); + comparisonQuery.refetch(); + }} + disabled={isRefreshing} + > + + {t("ui.admin.tenants.worksmobile.refresh", "새로고침")} + + dryRunMutation.mutate()} + disabled={dryRunMutation.isPending} + > + + {t("ui.admin.tenants.worksmobile.dry_run", "Backfill Dry-run")} + + + + + + + + + {t("ui.admin.tenants.worksmobile.compare", "Baron / Works 비교")} + + + {t( + "ui.admin.tenants.worksmobile.compare_description", + "구성원은 기본적으로 Baron 또는 WORKS 한쪽에만 있는 항목을 보여줍니다.", + )} + + + + + + + + + + {userFilterOptions.map((option) => ( + { + setUserFilters((current) => + current.includes(option.value) + ? current.filter((value) => value !== option.value) + : [...current, option.value], + ); + setSelectedUserIds([]); + }} + > + {option.label} + + ))} + + + createSelectedMutation.mutate({ + resourceKind: "users", + ids: selectedUserIds, + }) + } + /> + + createSelectedMutation.mutate({ + resourceKind: "groups", + ids: selectedGroupIds, + }) + } + /> + + + + + + + {t("ui.admin.tenants.worksmobile.single_sync", "단건 동기화")} + + + {t( + "ui.admin.tenants.worksmobile.single_sync_description", + "Baron UUID 기준으로 조직 또는 구성원 sync 작업을 생성합니다.", + )} + + + + + setOrgUnitId(event.target.value)} + placeholder="orgUnit tenant UUID" + /> + orgUnitSyncMutation.mutate()} + disabled={!orgUnitId.trim() || orgUnitSyncMutation.isPending} + > + {t("ui.admin.tenants.worksmobile.sync_orgunit", "조직 Sync")} + + + + setUserId(event.target.value)} + placeholder="Kratos user UUID" + /> + userSyncMutation.mutate()} + disabled={!userId.trim() || userSyncMutation.isPending} + > + {t("ui.admin.tenants.worksmobile.sync_user", "구성원 Sync")} + + + + + + + + + {t("ui.admin.tenants.worksmobile.recent_jobs", "최근 작업")} + + + + + + + resource + action + status + retry + + + + + {(overview?.recentJobs ?? []).map((job) => ( + + + {job.resourceType}:{job.resourceId} + + {job.action} + {job.status} + {job.retryCount} + + retryMutation.mutate(job.id)} + disabled={retryMutation.isPending} + > + + + + + ))} + + + + + + ); +} + +export type WorksmobileComparisonSummary = { + total: number; + matched: number; + missingInWorksmobile: number; + missingInBaron: number; + missingExternalKey: number; +}; + +export function summarizeWorksmobileComparison( + rows: WorksmobileComparisonItem[], +): WorksmobileComparisonSummary { + return rows.reduce( + (summary, row) => { + if (row.status === "matched") { + summary.matched += 1; + } else if (row.status === "missing_in_worksmobile") { + summary.missingInWorksmobile += 1; + } else if (row.status === "missing_in_baron") { + summary.missingInBaron += 1; + } else if (row.status === "missing_external_key") { + summary.missingExternalKey += 1; + } + return summary; + }, + { + total: rows.length, + matched: 0, + missingInWorksmobile: 0, + missingInBaron: 0, + missingExternalKey: 0, + }, + ); +} + +export function getWorksmobileComparisonStatusLabel(status: string) { + switch (status) { + case "matched": + return "일치"; + case "missing_in_worksmobile": + return "WORKS 없음"; + case "missing_in_baron": + return "Baron 없음"; + case "missing_external_key": + return "ex_key 없음"; + default: + return status; + } +} + +export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) { + return row.status === "missing_in_worksmobile" && Boolean(row.baronId); +} + +export function filterWorksmobileComparisonRows( + rows: WorksmobileComparisonItem[], + filters: WorksmobileComparisonFilter[], +) { + if (filters.length === 0 || filters.length === userFilterOptions.length) { + return rows; + } + const allowedStatuses = new Set( + filters.flatMap((filter) => worksmobileFilterStatuses[filter]), + ); + return rows.filter((row) => allowedStatuses.has(row.status)); +} + +export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) { + return [ + row.worksmobileName, + row.worksmobileLevelName ?? row.worksmobileLevelId, + ] + .filter(Boolean) + .join(" "); +} + +export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) { + const details: string[] = []; + const position = + row.worksmobilePrimaryOrgPositionName ?? + row.worksmobilePrimaryOrgPositionId; + if (position) { + details.push(`직책 ${position}`); + } + if (row.worksmobileTask) { + details.push(`직무 ${row.worksmobileTask}`); + } + if (typeof row.worksmobilePrimaryOrgIsManager === "boolean") { + details.push(row.worksmobilePrimaryOrgIsManager ? "조직장" : "조직장 아님"); + } + return details; +} + +export const userFilterOptions: Array<{ + value: WorksmobileComparisonFilter; + label: string; +}> = [ + { value: "baron_only", label: "Baron에만 있음" }, + { value: "works_only", label: "WORKS에만 있음" }, + { value: "matched", label: "양쪽에 다 있음" }, +]; + +const worksmobileFilterStatuses: Record = + { + baron_only: ["missing_in_worksmobile"], + works_only: ["missing_in_baron", "missing_external_key"], + matched: ["matched"], + }; + +function getErrorMessage(error: unknown) { + const responseData = (error as { response?: { data?: unknown } })?.response + ?.data; + if (typeof responseData === "string") { + return responseData; + } + if (responseData && typeof responseData === "object") { + const data = responseData as { error?: unknown; message?: unknown }; + if (typeof data.error === "string") { + return data.error; + } + if (typeof data.message === "string") { + return data.message; + } + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function getWorksmobileComparisonStatusVariant(status: string) { + if (status === "matched") { + return "success"; + } + if (status === "missing_external_key") { + return "warning"; + } + return "secondary"; +} + +function ComparisonSummary({ + title, + summary, +}: { + title: string; + summary: WorksmobileComparisonSummary; +}) { + return ( + + + {title} + {summary.total} + + + + WORKS 없음 + {summary.missingInWorksmobile} + + + Baron 없음 + {summary.missingInBaron} + + + ex_key 없음 + {summary.missingExternalKey} + + + 일치 + {summary.matched} + + + + ); +} + +function ComparisonTable({ + title, + rows, + loading, + selectedIds, + onSelectedIdsChange, + actionLabel, + actionDisabled, + onCreateSelected, +}: { + title: string; + rows: WorksmobileComparisonItem[]; + loading: boolean; + selectedIds: string[]; + onSelectedIdsChange: (ids: string[]) => void; + actionLabel: string; + actionDisabled: boolean; + onCreateSelected: () => void; +}) { + const creatableIds = rows + .filter(canCreateWorksmobileRow) + .map((row) => row.baronId) + .filter((id): id is string => Boolean(id)); + const allCreatableSelected = + creatableIds.length > 0 && + creatableIds.every((id) => selectedIds.includes(id)); + + const toggleAll = (checked: boolean | "indeterminate") => { + onSelectedIdsChange(checked === true ? creatableIds : []); + }; + + const toggleRow = ( + id: string | undefined, + checked: boolean | "indeterminate", + ) => { + if (!id) { + return; + } + if (checked === true) { + onSelectedIdsChange([...new Set([...selectedIds, id])]); + return; + } + onSelectedIdsChange(selectedIds.filter((selectedId) => selectedId !== id)); + }; + + return ( + + + {title} + + {actionLabel} + + + + + + + + + + 상태 + + Baron ID + + + Baron + + + Baron 조직 + + + WORKS ID + + + external_key + + + WORKS 도메인 + + + WORKS + + + WORKS 조직 + + + + + {loading && ( + + + 불러오는 중... + + + )} + {!loading && rows.length === 0 && ( + + + 표시할 차이가 없습니다. + + + )} + {rows.map((row) => ( + + + + toggleRow(row.baronId, checked) + } + /> + + + + {getWorksmobileComparisonStatusLabel(row.status)} + + + + {row.baronId ?? "-"} + + + + {row.baronName ?? "-"} + + {row.baronEmail ?? ""} + + + + + + + + {row.worksmobileId ?? "-"} + + + {row.externalKey ?? "-"} + + + + + + + {formatWorksmobilePersonName(row) || "-"} + + {row.worksmobileEmail ?? ""} + + + + + + + + ))} + + + + + ); +} + +function ComparisonDomainCell({ + name, + id, +}: { + name?: string; + id?: number; +}) { + if (!name && !id) { + return -; + } + return ( + + {name ?? "-"} + {id ?? ""} + + ); +} + +function ComparisonOrgCell({ + name, + id, + details = [], +}: { + name?: string; + id?: string; + details?: string[]; +}) { + if (!name && !id && details.length === 0) { + return -; + } + return ( + + {name ?? "-"} + {id ?? ""} + {details.length > 0 && ( + + {details.join(" · ")} + + )} + + ); +} diff --git a/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts b/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts index ea87fe3c..4cb3c3a4 100644 --- a/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts +++ b/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts @@ -120,7 +120,7 @@ describe("tenantCsvImport", () => { [ "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain", "local-parent-id,Parent Tenant,COMPANY,,parent-local,,", - "local-child-id,Child Tenant,USER_GROUP,local-parent-id,child-local,,", + "local-child-id,Child Tenant,ORGANIZATION,local-parent-id,child-local,,", ].join("\n"), ); const preview = buildTenantImportPreview(rows, tenants); @@ -141,7 +141,7 @@ describe("tenantCsvImport", () => { "staging-parent-id,Parent Tenant,COMPANY,,parent-staging,,", ); expect(csv).toContain( - "staging-child-id,Child Tenant,USER_GROUP,staging-parent-id,child-staging,,", + "staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-staging,,", ); expect(csv).not.toContain("local-parent-id"); expect(csv).not.toContain("local-child-id"); @@ -152,7 +152,7 @@ describe("tenantCsvImport", () => { [ "name,type,parent_tenant_slug,slug,memo,email_domain", "Parent Tenant,COMPANY,,parent-slug,,", - "Child Tenant,USER_GROUP,parent-slug,child-slug,,", + "Child Tenant,ORGANIZATION,parent-slug,child-slug,,", ].join("\n"), ); const preview = buildTenantImportPreview(rows, tenants); @@ -171,7 +171,7 @@ describe("tenantCsvImport", () => { expect(rows[1].parentTenantSlug).toBe("parent-slug"); expect(csv).toContain( - "staging-child-id,Child Tenant,USER_GROUP,staging-parent-id,child-slug,,", + "staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-slug,,", ); }); }); diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index e6c90858..42f88448 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -87,6 +87,7 @@ const getTenantIcon = (type?: string) => { return Briefcase; case "PERSONAL": return UserCircle; + case "ORGANIZATION": case "USER_GROUP": return Network; default: diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 958685cd..791b5f33 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -44,6 +44,13 @@ import { } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../components/ui/select"; import { Switch } from "../../components/ui/switch"; import { Tabs, @@ -78,6 +85,7 @@ import { parseOrgChartTenantSelection, } from "./orgChartPicker"; import type { UserSchemaField } from "./userSchemaFields"; +import { userStatusLabel, userStatusValues } from "./userStatus"; type UserFormValues = Omit & { metadata: Record>; @@ -123,12 +131,40 @@ function createEmptyAppointment(): AppointmentDraft { tenantId: "", tenantName: "", tenantSlug: "", + isPrimary: false, isOwner: false, jobTitle: "", position: "", }; } +function normalizePrimaryAppointments( + appointments: AppointmentDraft[], +): AppointmentDraft[] { + const leafIndexes = appointments + .map((appointment, index) => + appointment.tenantId.trim().length > 0 ? index : -1, + ) + .filter((index) => index >= 0); + if (leafIndexes.length === 1) { + const primaryIndex = leafIndexes[0]; + return appointments.map((appointment, index) => ({ + ...appointment, + isPrimary: index === primaryIndex, + })); + } + const selectedIndex = appointments.findIndex( + (appointment) => appointment.isPrimary === true, + ); + return appointments.map((appointment, index) => ({ + ...appointment, + isPrimary: + selectedIndex >= 0 && + index === selectedIndex && + appointment.tenantId.trim().length > 0, + })); +} + function validateManualPassword( password: string, policy?: PasswordPolicyResponse, @@ -485,15 +521,17 @@ function UserDetailPage() { try { const tenant = await resolveTenantSelection(selection, tenants); setAdditionalAppointments((current) => - current.map((appointment, index) => - index === target.index - ? { - ...appointment, - tenantId: tenant.id, - tenantName: tenant.name, - tenantSlug: tenant.slug, - } - : appointment, + normalizePrimaryAppointments( + current.map((appointment, index) => + index === target.index + ? { + ...appointment, + tenantId: tenant.id, + tenantName: tenant.name, + tenantSlug: tenant.slug, + } + : appointment, + ), ), ); setPickerTarget(null); @@ -536,15 +574,30 @@ function UserDetailPage() { patch: Partial, ) => { setAdditionalAppointments((current) => - current.map((appointment, currentIndex) => - currentIndex === index ? { ...appointment, ...patch } : appointment, + normalizePrimaryAppointments( + current.map((appointment, currentIndex) => + currentIndex === index ? { ...appointment, ...patch } : appointment, + ), + ), + ); + }; + + const setPrimaryAppointment = (index: number, checked: boolean) => { + setAdditionalAppointments((current) => + normalizePrimaryAppointments( + current.map((appointment, currentIndex) => ({ + ...appointment, + isPrimary: checked && currentIndex === index, + })), ), ); }; const removeAppointment = (index: number) => { setAdditionalAppointments((current) => - current.filter((_, currentIndex) => currentIndex !== index), + normalizePrimaryAppointments( + current.filter((_, currentIndex) => currentIndex !== index), + ), ); }; @@ -602,7 +655,10 @@ function UserDetailPage() { tenantSlug: user.companyCode || user.joinedTenants?.find( - (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP", + (t) => + t.type === "COMPANY" || + t.type === "COMPANY_GROUP" || + t.type === "ORGANIZATION", )?.slug || "", department: user.department || "", @@ -636,38 +692,45 @@ function UserDetailPage() { isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId), ); setAdditionalAppointments( - Array.isArray(rawAppointments) - ? (rawAppointments as UserAppointment[]).map((appointment) => ({ - ...appointment, - draftId: createDraftId(), - })) - : isUserHanmacFamily - ? familyFallbackTenants.length > 0 - ? familyFallbackTenants.map((tenant) => ({ - draftId: createDraftId(), - tenantId: tenant.id, - tenantName: tenant.name, - tenantSlug: tenant.slug, - isOwner: - metadata.primaryTenantIsOwner === true && - tenant.id === fallbackAppointment?.id, - jobTitle: user.jobTitle, - position: user.position, - })) - : fallbackAppointment - ? [ - { - draftId: createDraftId(), - tenantId: fallbackAppointment.id, - tenantName: fallbackAppointment.name, - tenantSlug: fallbackAppointment.slug, - isOwner: metadata.primaryTenantIsOwner === true, - jobTitle: user.jobTitle, - position: user.position, - }, - ] - : [] - : [], + normalizePrimaryAppointments( + Array.isArray(rawAppointments) + ? (rawAppointments as UserAppointment[]).map((appointment) => ({ + ...appointment, + isPrimary: + appointment.isPrimary === true || + appointment.tenantId === metadata.primaryTenantId, + draftId: createDraftId(), + })) + : isUserHanmacFamily + ? familyFallbackTenants.length > 0 + ? familyFallbackTenants.map((tenant) => ({ + draftId: createDraftId(), + tenantId: tenant.id, + tenantName: tenant.name, + tenantSlug: tenant.slug, + isPrimary: tenant.id === fallbackAppointment?.id, + isOwner: + metadata.primaryTenantIsOwner === true && + tenant.id === fallbackAppointment?.id, + jobTitle: user.jobTitle, + position: user.position, + })) + : fallbackAppointment + ? [ + { + draftId: createDraftId(), + tenantId: fallbackAppointment.id, + tenantName: fallbackAppointment.name, + tenantSlug: fallbackAppointment.slug, + isPrimary: true, + isOwner: metadata.primaryTenantIsOwner === true, + jobTitle: user.jobTitle, + position: user.position, + }, + ] + : [] + : [], + ), ); } }, [hanmacFamilyTenantId, tenants, user, reset]); @@ -748,19 +811,37 @@ function UserDetailPage() { tenantId: appointment.tenantId, tenantSlug: appointment.tenantSlug, tenantName: appointment.tenantName, + isPrimary: appointment.isPrimary === true, isOwner: appointment.isOwner, jobTitle: appointment.jobTitle, position: appointment.position, })); + const primaryAppointment = appointments.find( + (appointment) => appointment.isPrimary, + ); payload.tenantSlug = undefined; payload.department = undefined; payload.position = undefined; payload.jobTitle = undefined; payload.additionalAppointments = appointments; + if (primaryAppointment) { + payload.tenantSlug = primaryAppointment.tenantSlug; + payload.primaryTenantId = primaryAppointment.tenantId; + payload.primaryTenantName = primaryAppointment.tenantName; + payload.primaryTenantIsOwner = primaryAppointment.isOwner; + } payload.metadata = { ...metadata, additionalAppointments: appointments, + ...(primaryAppointment + ? { + primaryTenantId: primaryAppointment.tenantId, + primaryTenantName: primaryAppointment.tenantName, + primaryTenantSlug: primaryAppointment.tenantSlug, + primaryTenantIsOwner: primaryAppointment.isOwner, + } + : {}), }; } @@ -791,6 +872,9 @@ function UserDetailPage() { filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId), [userAffiliatedTenants, hanmacFamilyTenantId], ); + const primaryAppointmentLeafCount = additionalAppointments.filter( + (appointment) => appointment.tenantId.trim().length > 0, + ).length; if (isLoading) { return ( @@ -857,7 +941,10 @@ function UserDetailPage() { {user.tenant?.name || user.companyCode || user.joinedTenants?.find( - (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP", + (t) => + t.type === "COMPANY" || + t.type === "COMPANY_GROUP" || + t.type === "ORGANIZATION", )?.name || t("ui.admin.users.detail.form.tenant_global", "시스템 전역")} @@ -1001,21 +1088,23 @@ function UserDetailPage() { > {t("ui.admin.users.detail.form.status", "상태")} - - - setValue("status", checked ? "active" : "inactive") - } - /> - - {t( - `ui.common.status.${watchedStatus}`, - watchedStatus || "inactive", - )} - - + setValue("status", status)} + > + + + {userStatusLabel(watchedStatus || "inactive")} + + + + {userStatusValues.map((status) => ( + + {userStatusLabel(status)} + + ))} + + @@ -1160,6 +1249,26 @@ function UserDetailPage() { {appointment.tenantSlug} )} + + + setPrimaryAppointment(index, checked) + } + /> + {t( + "ui.admin.users.detail.form.appointment_primary", + "대표 조직", + )} + - + statusMutation.mutate({ userId: user.id, - status: checked ? "active" : "inactive", + status, }) } disabled={ statusMutation.isPending || user.id === profile?.id } - aria-label={t( - "ui.admin.users.list.toggle_status", - "{{name}} 활성 상태", - { name: user.name }, - )} - data-testid={`user-status-toggle-${user.id}`} - /> - - {t(`ui.common.status.${user.status}`, user.status)} - + > + + + {userStatusLabel(user.status)} + + + + {userStatusValues.map((status) => ( + + {userStatusLabel(status)} + + ))} + + {/* Dynamic Metadata Cells */} @@ -683,6 +702,24 @@ function UserListPage() { > {t("ui.common.status.inactive", "비활성화")} + handleBulkStatusChange("suspended")} + data-testid="bulk-suspended-btn" + > + {t("ui.common.status.suspended", "정지")} + + handleBulkStatusChange("leave_of_absence")} + data-testid="bulk-leave-of-absence-btn" + > + {t("ui.common.status.leave_of_absence", "휴직")} + { + const csv = `"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time" +"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00"`; + + const result = parseUserCSV(csv); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + email: "john1@company.com", + loginId: "john.doe", + name: "John Doe", + phone: "+19144812222", + department: "myteam", + position: "Manager", + jobTitle: "Sales management", + tenantImport: { + name: "myteam", + parentTenantName: "org.3", + }, + metadata: { + personal_email: "john@naver.com", + employee_id: "AB001", + naverworks_user_type: "Permanent Employee", + naverworks_level: "Manager", + naverworks_organization_path: "org.1|org.2|org.3|myteam", + naverworks_workplace: "New York", + }, + }); + }); + it("should parse tenant conflict metadata for import resolution", () => { const csv = `email,name,tenant_id,tenant_slug,tenant_name,tenant_type,parent_tenant_slug,tenant_memo,email_domain test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-slug,Imported memo,missing.example.com`; diff --git a/adminfront/src/features/users/utils/csvParser.ts b/adminfront/src/features/users/utils/csvParser.ts index 31cf2a0e..4605cad7 100644 --- a/adminfront/src/features/users/utils/csvParser.ts +++ b/adminfront/src/features/users/utils/csvParser.ts @@ -1,18 +1,17 @@ import type { BulkUserItem } from "../../../lib/adminApi"; export function parseUserCSV(text: string): BulkUserItem[] { - const lines = text.split(/\r?\n/); - if (lines.length < 2) { + const records = parseCSVRecords(text.replace(/^\uFEFF/, "")); + if (records.length < 2) { return []; } - const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); + const headers = records[0].map(normalizeHeader); const data: BulkUserItem[] = []; - for (let i = 1; i < lines.length; i++) { - if (!lines[i].trim()) continue; - - const values = lines[i].split(",").map((v) => v.trim()); + for (let i = 1; i < records.length; i++) { + const values = records[i].map((v) => v.trim()); + if (values.every((value) => value === "")) continue; const item: Partial & { metadata: Record } = { metadata: {}, }; @@ -84,11 +83,70 @@ export function parseUserCSV(text: string): BulkUserItem[] { item.position = value; } else if (header === "jobtitle") { item.jobTitle = value; + } else if (header === "lastname") { + item.metadata.naverworks_last_name = value; + } else if (header === "firstname") { + item.metadata.naverworks_first_name = value; + } else if (header === "id") { + item.loginId = value; + item.metadata.naverworks_id = value; + } else if (header === "personalemail") { + item.metadata.personal_email = value; + } else if (header === "subemail") { + item.metadata.naverworks_sub_email = value; + item.email = firstEmailToken(value) || item.email; + } else if (header === "nickname") { + item.metadata.naverworks_nickname = value; + } else if (header === "usertype") { + item.metadata.naverworks_user_type = value; + } else if (header === "level") { + item.metadata.naverworks_level = value; + } else if (header === "organization") { + item.metadata.naverworks_organization_path = value; + const parts = splitOrganizationPath(value); + const leaf = parts.at(-1) ?? ""; + const parent = parts.at(-2) ?? ""; + if (leaf) { + item.department = leaf; + item.tenantImport = { + ...(item.tenantImport ?? {}), + name: leaf, + parentTenantName: parent, + }; + } + } else if (header === "companymainphone") { + item.metadata.naverworks_company_main_phone = value; + } else if (header === "mobilecountrycode") { + item.metadata.naverworks_mobile_country_code = value; + } else if (header === "mobilenumbers") { + item.metadata.naverworks_mobile_numbers = value; + } else if (header === "language") { + item.metadata.naverworks_language = value; + } else if (header === "responsibilities") { + item.jobTitle = value; + } else if (header === "workplace") { + item.metadata.naverworks_workplace = value; + } else if (header === "sns") { + item.metadata.naverworks_sns = value; + } else if (header === "snsid") { + item.metadata.naverworks_sns_id = value; + } else if (header === "birthdaysolarlunar") { + item.metadata.naverworks_birthday_calendar = value; + } else if (header === "birthday") { + item.metadata.naverworks_birthday = value; + } else if (header === "entrydate") { + item.metadata.naverworks_entry_date = value; + } else if (header === "employeenumber") { + item.metadata.employee_id = value; + } else if (header === "accountactivationtime") { + item.metadata.naverworks_account_activation_time = value; } else { item.metadata[header] = value; } } + applyNaverWorksFallbacks(item); + if (item.email && item.name) { data.push(item as BulkUserItem); } @@ -96,3 +154,100 @@ export function parseUserCSV(text: string): BulkUserItem[] { return data; } + +function normalizeHeader(header: string) { + return header + .trim() + .toLowerCase() + .replace(/^\uFEFF/, "") + .replace(/[^a-z0-9_]/g, ""); +} + +function parseCSVRecords(text: string) { + const records: string[][] = []; + let field = ""; + let row: string[] = []; + let quoted = false; + + for (let index = 0; index < text.length; index++) { + const char = text[index]; + const next = text[index + 1]; + + if (char === '"') { + if (quoted && next === '"') { + field += '"'; + index++; + } else { + quoted = !quoted; + } + continue; + } + + if (char === "," && !quoted) { + row.push(field); + field = ""; + continue; + } + + if ((char === "\n" || char === "\r") && !quoted) { + if (char === "\r" && next === "\n") index++; + row.push(field); + records.push(row); + field = ""; + row = []; + continue; + } + + field += char; + } + + if (field !== "" || row.length > 0) { + row.push(field); + records.push(row); + } + + return records; +} + +function firstEmailToken(value: string) { + return ( + value + .split(/[;,]/) + .map((token) => token.trim()) + .find((token) => token.includes("@")) ?? "" + ); +} + +function splitOrganizationPath(value: string) { + return value + .split("|") + .map((part) => part.trim()) + .filter(Boolean); +} + +function applyNaverWorksFallbacks( + item: Partial & { metadata: Record }, +) { + if (!item.name) { + const firstName = item.metadata.naverworks_first_name ?? ""; + const lastName = item.metadata.naverworks_last_name ?? ""; + item.name = [firstName, lastName].filter(Boolean).join(" ").trim(); + if (!item.name && item.metadata.naverworks_nickname) { + item.name = item.metadata.naverworks_nickname; + } + } + + if (!item.email) { + item.email = item.metadata.personal_email; + } + + if (!item.phone) { + const countryCode = item.metadata.naverworks_mobile_country_code ?? ""; + const number = item.metadata.naverworks_mobile_numbers ?? ""; + item.phone = `${countryCode}${number}`.replace(/\s/g, ""); + } + + if (!item.position && item.metadata.naverworks_level) { + item.position = item.metadata.naverworks_level; + } +} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index ee54796d..8f0ee7a1 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -21,7 +21,7 @@ export type AuditLogListResponse = { export type TenantSummary = { id: string; - type: string; // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP + type: string; // 허용 타입: PERSONAL, COMPANY, COMPANY_GROUP, ORGANIZATION, USER_GROUP name: string; slug: string; description: string; @@ -447,6 +447,7 @@ export type UserAppointment = { tenantId: string; tenantSlug?: string; tenantName: string; + isPrimary?: boolean; isOwner: boolean; jobTitle?: string; position?: string; @@ -491,6 +492,60 @@ export type BulkUserResponse = { results: BulkUserResult[]; }; +export type WorksmobileOutboxItem = { + id: string; + resourceType: string; + resourceId: string; + action: string; + status: string; + retryCount: number; + lastError?: string; + createdAt: string; + updatedAt: string; +}; + +export type WorksmobileOverview = { + tenant: TenantSummary; + config: { + enabled: boolean; + tokenConfigured: boolean; + }; + recentJobs: WorksmobileOutboxItem[]; +}; + +export type WorksmobileComparisonItem = { + resourceType: string; + baronId?: string; + baronName?: string; + baronEmail?: string; + baronPrimaryOrgId?: string; + baronPrimaryOrgName?: string; + baronParentId?: string; + baronParentName?: string; + worksmobileId?: string; + externalKey?: string; + worksmobileName?: string; + worksmobileEmail?: string; + worksmobileLevelId?: string; + worksmobileLevelName?: string; + worksmobileTask?: string; + worksmobileDomainId?: number; + worksmobileDomainName?: string; + worksmobilePrimaryOrgId?: string; + worksmobilePrimaryOrgName?: string; + worksmobilePrimaryOrgPositionId?: string; + worksmobilePrimaryOrgPositionName?: string; + worksmobilePrimaryOrgIsManager?: boolean; + worksmobileParentId?: string; + worksmobileParentName?: string; + status: string; +}; + +export type WorksmobileComparison = { + users: WorksmobileComparisonItem[]; + groups: WorksmobileComparisonItem[]; +}; + export async function fetchUsers( limit = 50, offset = 0, @@ -561,12 +616,86 @@ export async function bulkCreateUsers(users: BulkUserItem[]) { return data; } +export async function fetchWorksmobileOverview(tenantId: string) { + const { data } = await apiClient.get( + `/v1/admin/tenants/${tenantId}/worksmobile`, + ); + return data; +} + +export async function fetchWorksmobileComparison( + tenantId: string, + includeMatched = false, +) { + const { data } = await apiClient.get( + `/v1/admin/tenants/${tenantId}/worksmobile/comparison`, + { + params: { includeMatched }, + }, + ); + return data; +} + +export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) { + const response = await apiClient.get( + `/v1/admin/tenants/${tenantId}/worksmobile/initial-passwords.csv`, + { + responseType: "blob", + }, + ); + const dispositionHeader = response.headers["content-disposition"]; + const disposition = Array.isArray(dispositionHeader) + ? dispositionHeader[0] + : String(dispositionHeader ?? ""); + const filenameMatch = disposition?.match(/filename="?([^"]+)"?/i); + return { + blob: response.data, + filename: filenameMatch?.[1] ?? "worksmobile_initial_passwords.csv", + }; +} + +export async function enqueueWorksmobileBackfillDryRun(tenantId: string) { + const { data } = await apiClient.post( + `/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`, + ); + return data; +} + +export async function enqueueWorksmobileOrgUnitSync( + tenantId: string, + orgUnitId: string, +) { + const { data } = await apiClient.post( + `/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${orgUnitId}/sync`, + ); + return data; +} + +export async function enqueueWorksmobileUserSync( + tenantId: string, + userId: string, +) { + const { data } = await apiClient.post( + `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`, + ); + return data; +} + +export async function retryWorksmobileJob(tenantId: string, jobId: string) { + const { data } = await apiClient.post( + `/v1/admin/tenants/${tenantId}/worksmobile/jobs/${jobId}/retry`, + ); + return data; +} + export async function bulkUpdateUsers(payload: { userIds: string[]; status?: string; role?: string; tenantSlug?: string; department?: string; + position?: string; + jobTitle?: string; }) { const requestPayload: typeof payload & { companyCode?: string } = { ...payload, diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index f54dafe3..4eb742fd 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -16,6 +16,7 @@ saman = "Saman" [domain.tenant_type] company = "Company" company_group = "Company Group" +organization = "Organization" personal = "Personal" user_group = "User Group" @@ -1282,9 +1283,12 @@ active = "Active" blocked = "Blocked" failure = "Failure" inactive = "Inactive" +leave_of_absence = "Leave of absence" ok = "Ok" pending = "Pending" +status = "Status" success = "Success" +suspended = "Suspended" [test] key = "Test" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index b9cc6a4e..63469a8f 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -16,6 +16,7 @@ saman = "삼안" [domain.tenant_type] company = "COMPANY (일반 기업)" company_group = "COMPANY_GROUP (그룹사/지주사)" +organization = "ORGANIZATION (정규 조직)" personal = "PERSONAL (개인 워크스페이스)" user_group = "USER_GROUP (내부 부서/팀)" @@ -1284,9 +1285,12 @@ active = "활성" blocked = "차단됨" failure = "실패" inactive = "비활성" +leave_of_absence = "휴직" ok = "정상" pending = "준비 중" +status = "상태" success = "성공" +suspended = "정지" [test] key = "테스트" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index c22f8ff8..79844871 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -16,6 +16,7 @@ saman = "" [domain.tenant_type] company = "" company_group = "" +organization = "" personal = "" user_group = "" @@ -1284,9 +1285,12 @@ active = "" blocked = "" failure = "" inactive = "" +leave_of_absence = "" ok = "" pending = "" +status = "" success = "" +suspended = "" [test] key = "" diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts index 5e6c7d3a..bd293614 100644 --- a/adminfront/tests/users.spec.ts +++ b/adminfront/tests/users.spec.ts @@ -703,6 +703,7 @@ test.describe("User Management", () => { tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35", tenantSlug: "tech-planning", tenantName: "기술기획", + isPrimary: true, isOwner: true, jobTitle: "플랫폼 운영", position: "책임", @@ -724,12 +725,115 @@ test.describe("User Management", () => { await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible(); await expect( page.getByTestId("detail-appointment-tenant-owner-line-0"), - ).toContainText(/기술기획|조직장/); + ).toContainText(/기술기획|대표 조직|조직장/); + await expect( + page.getByTestId("detail-appointment-row-0").getByRole("switch", { + name: /대표 조직/i, + }), + ).toBeChecked(); + await expect( + page.getByTestId("detail-appointment-row-0").getByRole("switch", { + name: /대표 조직/i, + }), + ).toBeDisabled(); await expect( page.getByTestId("detail-appointment-position-line-0"), ).toBeVisible(); }); + test("should save selected Hanmac representative appointment from user detail", async ({ + page, + }) => { + let updatePayload: Record | undefined; + + await page.route(/\/admin\/users\/u-1$/, async (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + json: { + id: "u-1", + name: "Family User", + email: "family@test.com", + phone: "010-1111-2222", + loginId: "familyuser", + role: "user", + status: "active", + createdAt: "2026-04-01T00:00:00Z", + updatedAt: "2026-04-01T00:00:00Z", + metadata: { + hanmacFamily: true, + additionalAppointments: [ + { + tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35", + tenantSlug: "tech-planning", + tenantName: "기술기획", + isOwner: false, + jobTitle: "플랫폼 운영", + position: "책임", + }, + { + tenantId: "hanmac-team-id", + tenantSlug: "hanmac-team", + tenantName: "한맥팀", + isOwner: true, + jobTitle: "개발", + position: "선임", + }, + ], + }, + }, + }); + } + if (route.request().method() === "PUT") { + updatePayload = route.request().postDataJSON(); + return route.fulfill({ + json: { + id: "u-1", + name: "Family User", + email: "family@test.com", + status: "active", + }, + }); + } + return route.fallback(); + }); + + await page.goto("/users/u-1"); + await page + .getByTestId("detail-appointment-row-1") + .getByRole("switch", { name: /대표 조직/i }) + .click(); + await expect( + page.getByTestId("detail-appointment-row-0").getByRole("switch", { + name: /대표 조직/i, + }), + ).not.toBeChecked(); + await expect( + page.getByTestId("detail-appointment-row-1").getByRole("switch", { + name: /대표 조직/i, + }), + ).toBeChecked(); + await page.locator("form").evaluate((form) => { + (form as HTMLFormElement).requestSubmit(); + }); + + await expect.poll(() => updatePayload).toMatchObject({ + tenantSlug: "hanmac-team", + primaryTenantId: "hanmac-team-id", + primaryTenantName: "한맥팀", + primaryTenantIsOwner: true, + metadata: { + primaryTenantId: "hanmac-team-id", + primaryTenantName: "한맥팀", + primaryTenantSlug: "hanmac-team", + primaryTenantIsOwner: true, + additionalAppointments: [ + { tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35", isPrimary: false }, + { tenantId: "hanmac-team-id", isPrimary: true }, + ], + }, + }); + }); + test("should show conflict error when creating with an existing Login ID", async ({ page, }) => { diff --git a/adminfront/tests/worksmobile.spec.ts b/adminfront/tests/worksmobile.spec.ts new file mode 100644 index 00000000..0068391a --- /dev/null +++ b/adminfront/tests/worksmobile.spec.ts @@ -0,0 +1,363 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Worksmobile tenant management", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem("locale", "ko"); + window.localStorage.setItem("admin_session", "fake-token"); + window.localStorage.setItem("RoleSwitcher-Collapsed", "true"); + ( + window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } + )._IS_TEST_MODE = true; + + const authority = "http://localhost:5000/oidc"; + const client_id = "adminfront"; + const key = `oidc.user:${authority}:${client_id}`; + const authData = { + access_token: "fake-token", + token_type: "Bearer", + profile: { sub: "admin-user", name: "Admin", role: "super_admin" }, + expires_at: Math.floor(Date.now() / 1000) + 36000, + }; + window.localStorage.setItem(key, JSON.stringify(authData)); + }); + + await page.route("**/oidc/**", async (route) => { + await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + }); + }); + + test("opens Worksmobile in the current tab and filters comparison rows", async ({ + page, + }) => { + const comparisonRequests: boolean[] = []; + const syncRequests: string[] = []; + + await page.route("**/api/v1/**", async (route) => { + const url = new URL(route.request().url()); + const method = route.request().method(); + const headers = { "Access-Control-Allow-Origin": "*" }; + + if (url.pathname.endsWith("/user/me")) { + return route.fulfill({ + json: { + id: "admin-user", + name: "Admin", + role: "super_admin", + manageableTenants: [], + }, + headers, + }); + } + + if ( + url.pathname.endsWith("/admin/tenants/hanmac-family-id") && + method === "GET" + ) { + return route.fulfill({ + json: { + id: "hanmac-family-id", + name: "한맥 가족", + slug: "hanmac-family", + type: "COMPANY_GROUP", + status: "active", + parentId: null, + }, + headers, + }); + } + + if ( + url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") && + method === "GET" + ) { + return route.fulfill({ + json: { + tenant: { + id: "hanmac-family-id", + name: "한맥 가족", + slug: "hanmac-family", + type: "COMPANY_GROUP", + status: "active", + memberCount: 0, + createdAt: "2026-05-04T00:00:00Z", + updatedAt: "2026-05-04T00:00:00Z", + }, + config: { + enabled: true, + tokenConfigured: true, + }, + recentJobs: [], + }, + headers, + }); + } + + if ( + url.pathname.endsWith( + "/admin/tenants/hanmac-family-id/worksmobile/comparison", + ) && + method === "GET" + ) { + const includeMatched = + url.searchParams.get("includeMatched") === "true"; + comparisonRequests.push(includeMatched); + + return route.fulfill({ + json: { + users: includeMatched + ? [ + { + resourceType: "USER", + baronId: "user-matched", + baronName: "홍길동", + baronPrimaryOrgId: "team-tech", + baronPrimaryOrgName: "기술기획", + worksmobileId: "works-user-matched", + externalKey: "user-matched", + worksmobileName: "홍길동", + status: "matched", + worksmobilePrimaryOrgId: "works-team-tech", + worksmobilePrimaryOrgName: "WORKS 기술기획", + }, + { + resourceType: "USER", + baronId: "user-missing", + baronName: "김누락", + status: "missing_in_worksmobile", + }, + { + resourceType: "USER", + worksmobileId: "works-user-only", + externalKey: "works-user-only", + worksmobileName: "박웍스", + status: "missing_in_baron", + }, + ] + : [ + { + resourceType: "USER", + baronId: "user-missing", + baronName: "김누락", + status: "missing_in_worksmobile", + }, + { + resourceType: "USER", + worksmobileId: "works-user-only", + externalKey: "works-user-only", + worksmobileName: "박웍스", + status: "missing_in_baron", + }, + ], + groups: [ + { + resourceType: "GROUP", + baronId: "group-missing", + baronName: "Baron 전용 조직", + baronParentId: "parent-tech", + baronParentName: "기술본부", + status: "missing_in_worksmobile", + }, + { + resourceType: "GROUP", + worksmobileId: "works-group-only", + externalKey: "works-group-only", + worksmobileName: "WORKS 전용 조직", + worksmobileParentId: "works-parent-tech", + worksmobileParentName: "WORKS 기술본부", + status: "missing_in_baron", + }, + ], + }, + headers, + }); + } + + if ( + url.pathname.endsWith( + "/admin/tenants/hanmac-family-id/worksmobile/users/user-missing/sync", + ) && + method === "POST" + ) { + syncRequests.push("user-missing"); + return route.fulfill({ + json: { id: "job-user-missing", resourceId: "user-missing" }, + headers, + }); + } + + return route.fulfill({ json: { items: [], total: 0 }, headers }); + }); + + await page.goto("/tenants/hanmac-family-id"); + await page.getByRole("link", { name: "Worksmobile" }).click(); + + await expect(page).toHaveURL(/\/tenants\/hanmac-family-id\/worksmobile$/); + await expect(page.getByText("Baron / Works 비교")).toBeVisible(); + await expect(page.getByText("domainMappings")).not.toBeVisible(); + await expect(page.getByText("SCIM token")).not.toBeVisible(); + await expect(page.getByText("김누락")).toBeVisible(); + await expect(page.getByText("박웍스")).not.toBeVisible(); + await expect(page.getByText("WORKS 전용 조직")).toBeVisible(); + await expect(page.getByText("기술본부", { exact: true })).toBeVisible(); + await expect(page.getByText("parent-tech", { exact: true })).toBeVisible(); + await expect(page.getByText("WORKS 기술본부")).toBeVisible(); + await expect(page.getByText("works-parent-tech")).toBeVisible(); + await expect(page.getByText("홍길동")).not.toBeVisible(); + expect(comparisonRequests[0]).toBe(true); + + const filterButtons = page + .getByRole("button", { + name: /Baron에만 있음|WORKS에만 있음|양쪽에 다 있음/, + }) + .allTextContents(); + await expect.poll(() => filterButtons).toEqual([ + "Baron에만 있음", + "WORKS에만 있음", + "양쪽에 다 있음", + ]); + + await page.getByRole("button", { name: "WORKS에만 있음" }).click(); + await expect(page.getByText("박웍스")).toBeVisible(); + await expect(page.getByText("김누락")).toBeVisible(); + await expect(page.getByText("홍길동")).not.toBeVisible(); + + await page.getByRole("button", { name: "양쪽에 다 있음" }).click(); + await expect(page.getByText("홍길동")).toHaveCount(2); + await expect(page.getByText("기술기획", { exact: true })).toBeVisible(); + await expect(page.getByText("team-tech", { exact: true })).toBeVisible(); + await expect(page.getByText("WORKS 기술기획")).toBeVisible(); + await expect(page.getByText("works-team-tech")).toBeVisible(); + await expect(page.getByText("김누락")).toBeVisible(); + await expect(page.getByText("박웍스")).toBeVisible(); + + await page.getByRole("button", { name: "Baron에만 있음" }).click(); + await expect(page.getByText("홍길동")).toHaveCount(2); + await expect(page.getByText("김누락")).not.toBeVisible(); + await expect(page.getByText("박웍스")).toBeVisible(); + + await page.getByRole("button", { name: "WORKS에만 있음" }).click(); + await expect(page.getByText("홍길동")).toHaveCount(2); + await expect(page.getByText("김누락")).not.toBeVisible(); + await expect(page.getByText("박웍스")).not.toBeVisible(); + + await page.getByRole("button", { name: "양쪽에 다 있음" }).click(); + await expect(page.getByText("김누락")).toBeVisible(); + await expect(page.getByText("박웍스")).toBeVisible(); + await expect(page.getByText("홍길동")).toHaveCount(2); + + await page.getByRole("button", { name: "Baron에만 있음" }).click(); + await expect(page.getByText("김누락")).toBeVisible(); + await expect(page.getByText("박웍스")).not.toBeVisible(); + await expect(page.getByText("홍길동")).not.toBeVisible(); + + await page + .getByRole("row", { name: /김누락/ }) + .getByRole("checkbox") + .check(); + await page + .getByRole("button", { name: "선택 구성원 WORKS에 생성" }) + .click(); + await expect.poll(() => syncRequests).toEqual(["user-missing"]); + }); + + test("shows a toast when selected WORKS creation fails", async ({ page }) => { + await page.route("**/api/v1/**", async (route) => { + const url = new URL(route.request().url()); + const method = route.request().method(); + const headers = { "Access-Control-Allow-Origin": "*" }; + + if (url.pathname.endsWith("/user/me")) { + return route.fulfill({ + json: { id: "admin-user", name: "Admin", role: "super_admin" }, + headers, + }); + } + + if ( + url.pathname.endsWith("/admin/tenants/hanmac-family-id") && + method === "GET" + ) { + return route.fulfill({ + json: { + id: "hanmac-family-id", + name: "한맥 가족", + slug: "hanmac-family", + parentId: null, + }, + headers, + }); + } + + if ( + url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") && + method === "GET" + ) { + return route.fulfill({ + json: { + tenant: { + id: "hanmac-family-id", + name: "한맥 가족", + slug: "hanmac-family", + parentId: null, + }, + config: {}, + recentJobs: [], + }, + headers, + }); + } + + if ( + url.pathname.endsWith( + "/admin/tenants/hanmac-family-id/worksmobile/comparison", + ) && + method === "GET" + ) { + return route.fulfill({ + json: { + users: [ + { + resourceType: "USER", + baronId: "user-fail", + baronName: "실패 사용자", + status: "missing_in_worksmobile", + }, + ], + groups: [], + }, + headers, + }); + } + + if ( + url.pathname.endsWith( + "/admin/tenants/hanmac-family-id/worksmobile/users/user-fail/sync", + ) && + method === "POST" + ) { + return route.fulfill({ + status: 500, + json: { error: "WORKS API rejected user creation" }, + headers, + }); + } + + return route.fulfill({ json: { items: [], total: 0 }, headers }); + }); + + await page.goto("/tenants/hanmac-family-id/worksmobile"); + await page + .getByRole("row", { name: /실패 사용자/ }) + .getByRole("checkbox") + .check(); + await page + .getByRole("button", { name: "선택 구성원 WORKS에 생성" }) + .click(); + + await expect(page.getByText("WORKS 생성 작업 등록 실패")).toBeVisible(); + await expect( + page.getByText(/WORKS API rejected user creation/), + ).toBeVisible(); + }); +}); diff --git a/backend/cmd/adminctl/main.go b/backend/cmd/adminctl/main.go new file mode 100644 index 00000000..020d4e51 --- /dev/null +++ b/backend/cmd/adminctl/main.go @@ -0,0 +1,176 @@ +package main + +import ( + "baron-sso-backend/internal/bootstrap" + "baron-sso-backend/internal/idp" + "baron-sso-backend/internal/logger" + "baron-sso-backend/internal/repository" + "baron-sso-backend/internal/service" + "context" + "flag" + "fmt" + "log" + "log/slog" + "os" + "strings" + "time" + + "github.com/joho/godotenv" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormLogger "gorm.io/gorm/logger" +) + +type createSuperAdminConfig struct { + Email string + Password string + Name string + UpdatePassword bool +} + +func main() { + loadEnv() + logger.Init(logger.Config{ + ServiceName: "baron-sso-adminctl", + Environment: getenv("APP_ENV", getenv("GO_ENV", "dev")), + LevelOverride: getenv("BACKEND_LOG_LEVEL", ""), + }) + + if len(os.Args) < 2 { + printUsage() + os.Exit(2) + } + + switch os.Args[1] { + case "create-super-admin": + if err := runCreateSuperAdmin(os.Args[2:]); err != nil { + slog.Error("create-super-admin failed", "error", err) + os.Exit(1) + } + default: + printUsage() + os.Exit(2) + } +} + +func runCreateSuperAdmin(args []string) error { + config, err := resolveCreateSuperAdminConfig(args) + if err != nil { + return err + } + + db, err := openDB() + if err != nil { + return err + } + if err := bootstrap.Run(db); err != nil { + return err + } + + provider, err := idp.InitializeProvider() + if err != nil { + return err + } + if provider == nil { + return fmt.Errorf("idp provider is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := bootstrap.EnsureSuperAdmin( + ctx, + service.NewKratosAdminService(), + bootstrap.NewGormSuperAdminStore(db, repository.NewKetoOutboxRepository(db)), + bootstrap.EnsureSuperAdminOptions{ + Email: config.Email, + Password: config.Password, + Name: config.Name, + Source: "adminctl", + UpdatePassword: config.UpdatePassword, + }, + ) + if err != nil { + return err + } + + fmt.Printf("super admin ensured: email=%s identity_id=%s user_id=%s identity_created=%t local_created=%t local_updated=%t password_updated=%t keto_relation_queued=%t\n", + result.Email, + result.IdentityID, + result.LocalUserID, + result.IdentityCreated, + result.LocalUserCreated, + result.LocalUserUpdated, + result.PasswordUpdated, + result.KetoRelationQueued, + ) + return nil +} + +func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error) { + fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + config := createSuperAdminConfig{} + fs.StringVar(&config.Email, "email", getenv("ADMIN_EMAIL", ""), "admin email") + fs.StringVar(&config.Password, "password", getenv("ADMIN_PASSWORD", ""), "admin password") + fs.StringVar(&config.Name, "name", getenv("ADMIN_NAME", "System Admin"), "admin display name") + fs.BoolVar(&config.UpdatePassword, "update-password", false, "update password when identity already exists") + + if err := fs.Parse(args); err != nil { + return config, err + } + + config.Email = strings.TrimSpace(config.Email) + config.Name = strings.TrimSpace(config.Name) + if config.Email == "" { + return config, fmt.Errorf("admin email is required; pass --email or set ADMIN_EMAIL") + } + if strings.TrimSpace(config.Password) == "" { + return config, fmt.Errorf("admin password is required; pass --password or set ADMIN_PASSWORD") + } + if config.Name == "" { + config.Name = "System Admin" + } + return config, nil +} + +func openDB() (*gorm.DB, error) { + dsn := fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul", + getenv("DB_HOST", "localhost"), + getenv("DB_USER", "baron"), + getenv("DB_PASSWORD", "password"), + getenv("DB_NAME", "baron_sso"), + getenv("DB_PORT", "5432"), + ) + return gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: gormLogger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + gormLogger.Config{ + SlowThreshold: time.Second, + LogLevel: gormLogger.Warn, + IgnoreRecordNotFoundError: true, + Colorful: true, + }, + ), + }) +} + +func loadEnv() { + _ = godotenv.Load(".env") + _ = godotenv.Load("../.env") + _ = godotenv.Load("../../.env") +} + +func getenv(key string, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} + +func printUsage() { + fmt.Fprintln(os.Stderr, "usage:") + fmt.Fprintln(os.Stderr, " adminctl create-super-admin [--email EMAIL] [--password PASSWORD] [--name NAME] [--update-password]") +} diff --git a/backend/cmd/adminctl/main_test.go b/backend/cmd/adminctl/main_test.go new file mode 100644 index 00000000..0eb4c5f7 --- /dev/null +++ b/backend/cmd/adminctl/main_test.go @@ -0,0 +1,62 @@ +package main + +import "testing" + +func TestResolveCreateSuperAdminConfigUsesEnvDefaults(t *testing.T) { + t.Setenv("ADMIN_EMAIL", "admin@example.com") + t.Setenv("ADMIN_PASSWORD", "Password!123") + t.Setenv("ADMIN_NAME", "Env Admin") + + config, err := resolveCreateSuperAdminConfig([]string{}) + if err != nil { + t.Fatalf("resolveCreateSuperAdminConfig returned error: %v", err) + } + + if config.Email != "admin@example.com" { + t.Fatalf("email = %q", config.Email) + } + if config.Password != "Password!123" { + t.Fatal("password was not read from ADMIN_PASSWORD") + } + if config.Name != "Env Admin" { + t.Fatalf("name = %q", config.Name) + } +} + +func TestResolveCreateSuperAdminConfigAllowsFlagOverrides(t *testing.T) { + t.Setenv("ADMIN_EMAIL", "admin@example.com") + t.Setenv("ADMIN_PASSWORD", "Password!123") + t.Setenv("ADMIN_NAME", "Env Admin") + + config, err := resolveCreateSuperAdminConfig([]string{ + "--email", "flag@example.com", + "--password", "FlagPassword!123", + "--name", "Flag Admin", + "--update-password", + }) + if err != nil { + t.Fatalf("resolveCreateSuperAdminConfig returned error: %v", err) + } + + if config.Email != "flag@example.com" { + t.Fatalf("email = %q", config.Email) + } + if config.Password != "FlagPassword!123" { + t.Fatal("password flag was not used") + } + if config.Name != "Flag Admin" { + t.Fatalf("name = %q", config.Name) + } + if !config.UpdatePassword { + t.Fatal("update password flag was not set") + } +} + +func TestResolveCreateSuperAdminConfigRequiresEmailAndPassword(t *testing.T) { + t.Setenv("ADMIN_EMAIL", "") + t.Setenv("ADMIN_PASSWORD", "") + + if _, err := resolveCreateSuperAdminConfig([]string{}); err == nil { + t.Fatal("expected error") + } +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e8bb208c..39a99db8 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -16,6 +16,7 @@ import ( "log/slog" "net/url" "os" + "path/filepath" "strconv" "strings" "time" @@ -39,6 +40,34 @@ func getEnv(key, fallback string) string { return fallback } +func getEnvFileOrValue(fileKey string, valueKey string, fallback string) (string, error) { + if path := strings.TrimSpace(getEnv(fileKey, "")); path != "" { + value, err := readEnvFileValue(path) + if err != nil { + return "", err + } + return value, nil + } + return getEnv(valueKey, fallback), nil +} + +func readEnvFileValue(path string) (string, error) { + candidates := []string{path} + if !filepath.IsAbs(path) { + candidates = append(candidates, filepath.Join("..", path), filepath.Join("..", "..", path)) + } + + var lastErr error + for _, candidate := range candidates { + data, err := os.ReadFile(candidate) + if err == nil { + return string(data), nil + } + lastErr = err + } + return "", fmt.Errorf("read secret file %q: %w", path, lastErr) +} + func normalizeDocsPrefix(prefix string) string { trimmed := strings.TrimSpace(prefix) if trimmed == "" || trimmed == "/" { @@ -268,11 +297,32 @@ func main() { userGroupRepo := repository.NewUserGroupRepository(db) userRepo := repository.NewUserRepository(db) ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init + worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db) sharedLinkRepo := repository.NewSharedLinkRepository(db) kratosAdminService := service.NewKratosAdminService() oryAdminProvider := service.NewOryProvider() tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo) + worksmobilePrivateKey, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "") + if err != nil { + slog.Error("Worksmobile private key file could not be loaded", "error", err) + os.Exit(1) + } + worksmobileClient := service.NewWorksmobileHTTPClientWithAuth( + getEnv("WORKS_ADMIN_ACCESS_TOKEN", getEnv("WORKS_ADMIN_OAUTH_ACCESS_TOKEN", "")), + getEnv("SAMAN_SCIM_LONGLIVE_TOKEN", ""), + service.WorksmobileOAuthConfig{ + ClientID: getEnv("WORKS_ADMIN_OAUTH_CLIENT_ID", ""), + ClientSecret: getEnv("WORKS_ADMIN_OAUTH_CLIENT_SECRET", ""), + ServiceAccount: getEnv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT", ""), + PrivateKey: worksmobilePrivateKey, + Scope: getEnv("WORKS_ADMIN_OAUTH_SCOPE", "directory"), + }, + ) + worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient) + worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient) + go worksmobileRelayWorker.Start(context.Background()) + slog.Info("✅ Worksmobile Relay Worker started") sharedLinkService := service.NewSharedLinkService(sharedLinkRepo) userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService) tenantService.SetKetoService(ketoService) // Keto 주입 @@ -301,6 +351,9 @@ func main() { userGroupHandler := handler.NewUserGroupHandler(userGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo) + tenantHandler.SetWorksmobileSyncer(worksmobileService) + userHandler.SetWorksmobileSyncer(worksmobileService) + worksmobileHandler := handler.NewWorksmobileHandler(worksmobileService) apiKeyHandler := handler.NewApiKeyHandler(db) // 3. Initialize Fiber @@ -532,6 +585,7 @@ func main() { // Public Tenant Registration api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic) + api.Get("/admin/worksmobile/oauth/callback", worksmobileHandler.OAuthCallback) // Tenant Context Middleware (identifies tenant from Host header) api.Use(middleware.TenantContextMiddleware(middleware.TenantContextConfig{ @@ -644,6 +698,14 @@ func main() { admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner) admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner) + admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview) + admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison) + admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV) + admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun) + 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/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser) + admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob) + // Organization & Org-Chart Management (Tenant Admin/Super Admin) org := admin.Group("/tenants/:tenantId/organization") org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List) diff --git a/backend/cmd/server/worksmobile_config_test.go b/backend/cmd/server/worksmobile_config_test.go new file mode 100644 index 00000000..9dccc1c9 --- /dev/null +++ b/backend/cmd/server/worksmobile_config_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGetEnvFileOrValueReadsSecretFile(t *testing.T) { + t.Setenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "inline-value") + + secretPath := filepath.Join(t.TempDir(), "worksmobile-private-key.pem") + want := "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----\n" + if err := os.WriteFile(secretPath, []byte(want), 0o600); err != nil { + t.Fatalf("failed to write secret file: %v", err) + } + t.Setenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", secretPath) + + got, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "") + if err != nil { + t.Fatalf("getEnvFileOrValue returned error: %v", err) + } + if got != want { + t.Fatalf("secret value = %q, want file content", got) + } +} + +func TestGetEnvFileOrValueFallsBackToRawEnv(t *testing.T) { + t.Setenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "inline-value") + t.Setenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "") + + got, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "") + if err != nil { + t.Fatalf("getEnvFileOrValue returned error: %v", err) + } + if got != "inline-value" { + t.Fatalf("secret value = %q, want raw env value", got) + } +} diff --git a/backend/internal/bootstrap/admin_account.go b/backend/internal/bootstrap/admin_account.go new file mode 100644 index 00000000..52b58fd4 --- /dev/null +++ b/backend/internal/bootstrap/admin_account.go @@ -0,0 +1,204 @@ +package bootstrap + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" + "context" + "errors" + "fmt" + "net/mail" + "strings" + "time" + + "gorm.io/gorm" +) + +type SuperAdminIdentityAdmin interface { + FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) + CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) + UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error +} + +type SuperAdminStore interface { + FindUserByEmail(ctx context.Context, email string) (*domain.User, error) + CreateUser(ctx context.Context, user *domain.User) error + UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) + EnqueueSuperAdminRelation(ctx context.Context, userID string) error +} + +type EnsureSuperAdminOptions struct { + Email string + Password string + Name string + Source string + UpdatePassword bool +} + +type EnsureSuperAdminResult struct { + Email string + IdentityID string + LocalUserID string + IdentityCreated bool + PasswordUpdated bool + LocalUserCreated bool + LocalUserUpdated bool + KetoRelationQueued bool +} + +func EnsureSuperAdmin(ctx context.Context, identityAdmin SuperAdminIdentityAdmin, store SuperAdminStore, opts EnsureSuperAdminOptions) (EnsureSuperAdminResult, error) { + email := strings.ToLower(strings.TrimSpace(opts.Email)) + name := strings.TrimSpace(opts.Name) + if name == "" { + name = "System Admin" + } + source := strings.TrimSpace(opts.Source) + if source == "" { + source = "admin_cli" + } + result := EnsureSuperAdminResult{Email: email} + + if _, err := mail.ParseAddress(email); err != nil { + return result, fmt.Errorf("invalid admin email: %w", err) + } + if identityAdmin == nil { + return result, errors.New("identity admin is required") + } + if store == nil { + return result, errors.New("super admin store is required") + } + + identityID, err := identityAdmin.FindIdentityIDByIdentifier(ctx, email) + if err != nil { + return result, fmt.Errorf("find admin identity: %w", err) + } + if identityID == "" { + if strings.TrimSpace(opts.Password) == "" { + return result, errors.New("admin password is required to create identity") + } + identityID, err = identityAdmin.CreateUser(ctx, buildSuperAdminBrokerUser(email, name), opts.Password) + if err != nil { + return result, fmt.Errorf("create admin identity: %w", err) + } + result.IdentityCreated = true + } else if opts.UpdatePassword { + if strings.TrimSpace(opts.Password) == "" { + return result, errors.New("admin password is required to update identity password") + } + if err := identityAdmin.UpdateIdentityPassword(ctx, identityID, opts.Password); err != nil { + return result, fmt.Errorf("update admin identity password: %w", err) + } + result.PasswordUpdated = true + } + result.IdentityID = identityID + + user, err := store.FindUserByEmail(ctx, email) + if err != nil { + return result, fmt.Errorf("find local admin user: %w", err) + } + if user == nil { + if identityID == "" { + return result, errors.New("identity id is required to create local admin user") + } + user = &domain.User{ + ID: identityID, + Email: email, + Name: name, + Role: domain.RoleSuperAdmin, + Status: domain.UserStatusActive, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Metadata: domain.JSONMap{ + "source": source, + }, + } + if err := store.CreateUser(ctx, user); err != nil { + return result, fmt.Errorf("create local admin user: %w", err) + } + result.LocalUserCreated = true + } else if domain.NormalizeRole(user.Role) != domain.RoleSuperAdmin || user.Status != domain.UserStatusActive || (name != "" && user.Name != name) { + user, err = store.UpdateUserSuperAdmin(ctx, user.ID, name) + if err != nil { + return result, fmt.Errorf("update local admin user: %w", err) + } + result.LocalUserUpdated = true + } + result.LocalUserID = user.ID + + if err := store.EnqueueSuperAdminRelation(ctx, user.ID); err != nil { + return result, fmt.Errorf("enqueue super admin keto relation: %w", err) + } + result.KetoRelationQueued = true + return result, nil +} + +func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser { + return &domain.BrokerUser{ + Email: email, + Name: name, + PhoneNumber: "", + Attributes: map[string]interface{}{ + "department": "Admin", + "affiliationType": "internal", + "companyCode": "", + "grade": "admin", + "role": domain.RoleSuperAdmin, + }, + } +} + +type gormSuperAdminStore struct { + db *gorm.DB + outbox repository.KetoOutboxRepository +} + +func NewGormSuperAdminStore(db *gorm.DB, outbox repository.KetoOutboxRepository) SuperAdminStore { + return &gormSuperAdminStore{db: db, outbox: outbox} +} + +func (s *gormSuperAdminStore) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) { + var user domain.User + if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &user, nil +} + +func (s *gormSuperAdminStore) CreateUser(ctx context.Context, user *domain.User) error { + return s.db.WithContext(ctx).Create(user).Error +} + +func (s *gormSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) { + updates := map[string]interface{}{ + "role": domain.RoleSuperAdmin, + "status": domain.UserStatusActive, + "updated_at": time.Now(), + } + if strings.TrimSpace(name) != "" { + updates["name"] = strings.TrimSpace(name) + } + if err := s.db.WithContext(ctx).Model(&domain.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil { + return nil, err + } + + var user domain.User + if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (s *gormSuperAdminStore) EnqueueSuperAdminRelation(ctx context.Context, userID string) error { + if s.outbox == nil { + return nil + } + return s.outbox.Create(ctx, &domain.KetoOutbox{ + Namespace: "System", + Object: "global", + Relation: "super_admins", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionCreate, + }) +} diff --git a/backend/internal/bootstrap/admin_account_test.go b/backend/internal/bootstrap/admin_account_test.go new file mode 100644 index 00000000..b1b0b4dc --- /dev/null +++ b/backend/internal/bootstrap/admin_account_test.go @@ -0,0 +1,159 @@ +package bootstrap + +import ( + "baron-sso-backend/internal/domain" + "context" + "errors" + "testing" +) + +func TestEnsureSuperAdminCreatesIdentityLocalUserAndKetoRelation(t *testing.T) { + ctx := context.Background() + identityAdmin := &fakeSuperAdminIdentityAdmin{createdID: "identity-1"} + store := &fakeSuperAdminStore{} + + result, err := EnsureSuperAdmin(ctx, identityAdmin, store, EnsureSuperAdminOptions{ + Email: "new-admin@example.com", + Password: "Password!123", + Name: "New Admin", + Source: "test", + }) + + if err != nil { + t.Fatalf("EnsureSuperAdmin returned error: %v", err) + } + if !result.IdentityCreated { + t.Fatal("identity must be created") + } + if !result.LocalUserCreated { + t.Fatal("local user must be created") + } + if result.IdentityID != "identity-1" { + t.Fatalf("identity ID = %q, want identity-1", result.IdentityID) + } + if store.user == nil { + t.Fatal("local user was not stored") + } + if store.user.Email != "new-admin@example.com" { + t.Fatalf("local user email = %q", store.user.Email) + } + if store.user.Role != domain.RoleSuperAdmin { + t.Fatalf("local user role = %q, want %q", store.user.Role, domain.RoleSuperAdmin) + } + if len(store.ketoSubjects) != 1 || store.ketoSubjects[0] != "User:identity-1" { + t.Fatalf("keto subjects = %#v, want User:identity-1", store.ketoSubjects) + } + if identityAdmin.createdUser == nil || identityAdmin.createdUser.Attributes["role"] != domain.RoleSuperAdmin { + t.Fatalf("created identity attributes = %#v", identityAdmin.createdUser) + } +} + +func TestEnsureSuperAdminPromotesExistingLocalUser(t *testing.T) { + ctx := context.Background() + identityAdmin := &fakeSuperAdminIdentityAdmin{existingID: "identity-1"} + store := &fakeSuperAdminStore{ + user: &domain.User{ + ID: "local-user-1", + Email: "existing@example.com", + Name: "Existing", + Role: domain.RoleUser, + Status: domain.UserStatusInactive, + }, + } + + result, err := EnsureSuperAdmin(ctx, identityAdmin, store, EnsureSuperAdminOptions{ + Email: "existing@example.com", + Password: "Password!123", + Name: "Existing Admin", + Source: "test", + }) + + if err != nil { + t.Fatalf("EnsureSuperAdmin returned error: %v", err) + } + if result.IdentityCreated { + t.Fatal("existing identity must not be recreated") + } + if !result.LocalUserUpdated { + t.Fatal("local user must be promoted") + } + if store.user.Role != domain.RoleSuperAdmin { + t.Fatalf("local user role = %q, want %q", store.user.Role, domain.RoleSuperAdmin) + } + if store.user.Status != domain.UserStatusActive { + t.Fatalf("local user status = %q, want %q", store.user.Status, domain.UserStatusActive) + } + if len(store.ketoSubjects) != 1 || store.ketoSubjects[0] != "User:local-user-1" { + t.Fatalf("keto subjects = %#v, want User:local-user-1", store.ketoSubjects) + } +} + +func TestEnsureSuperAdminRequiresPasswordForNewIdentity(t *testing.T) { + _, err := EnsureSuperAdmin(context.Background(), &fakeSuperAdminIdentityAdmin{}, &fakeSuperAdminStore{}, EnsureSuperAdminOptions{ + Email: "new-admin@example.com", + Name: "New Admin", + }) + + if err == nil { + t.Fatal("expected error") + } +} + +type fakeSuperAdminIdentityAdmin struct { + existingID string + createdID string + createdUser *domain.BrokerUser + createdSecret string +} + +func (f *fakeSuperAdminIdentityAdmin) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { + return f.existingID, nil +} + +func (f *fakeSuperAdminIdentityAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { + if f.createdID == "" { + return "", errors.New("created id is not configured") + } + f.createdUser = user + f.createdSecret = password + return f.createdID, nil +} + +func (f *fakeSuperAdminIdentityAdmin) UpdateIdentityPassword(ctx context.Context, identityID string, newPassword string) error { + return nil +} + +type fakeSuperAdminStore struct { + user *domain.User + ketoSubjects []string +} + +func (f *fakeSuperAdminStore) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) { + if f.user == nil { + return nil, nil + } + return f.user, nil +} + +func (f *fakeSuperAdminStore) CreateUser(ctx context.Context, user *domain.User) error { + copied := *user + f.user = &copied + return nil +} + +func (f *fakeSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) { + if f.user == nil { + return nil, errors.New("user not found") + } + f.user.Role = domain.RoleSuperAdmin + f.user.Status = domain.UserStatusActive + if name != "" { + f.user.Name = name + } + return f.user, nil +} + +func (f *fakeSuperAdminStore) EnqueueSuperAdminRelation(ctx context.Context, userID string) error { + f.ketoSubjects = append(f.ketoSubjects, "User:"+userID) + return nil +} diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index f5229922..ddb4bfe5 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -45,6 +45,8 @@ func migrateSchemas(db *gorm.DB) error { &domain.ClientSecret{}, &domain.ClientConsent{}, &domain.KetoOutbox{}, + &domain.WorksmobileOutbox{}, + &domain.WorksmobileResourceMapping{}, &domain.SharedLink{}, &domain.DeveloperRequest{}, &domain.RPUserMetadata{}, diff --git a/backend/internal/bootstrap/tenant_seed.go b/backend/internal/bootstrap/tenant_seed.go index 9f57d874..84fc3575 100644 --- a/backend/internal/bootstrap/tenant_seed.go +++ b/backend/internal/bootstrap/tenant_seed.go @@ -288,6 +288,8 @@ func normalizeSeedTenantType(value string) string { return domain.TenantTypeCompany case domain.TenantTypeCompanyGroup: return domain.TenantTypeCompanyGroup + case domain.TenantTypeOrganization: + return domain.TenantTypeOrganization case domain.TenantTypeUserGroup: return domain.TenantTypeUserGroup default: diff --git a/backend/internal/bootstrap/tenant_seed_test.go b/backend/internal/bootstrap/tenant_seed_test.go index a29be402..06a87fff 100644 --- a/backend/internal/bootstrap/tenant_seed_test.go +++ b/backend/internal/bootstrap/tenant_seed_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -func TestSeedTenantCSVDefinesOnlyRequiredRootTenants(t *testing.T) { +func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) { configs, err := loadSeedTenantConfigs() if err != nil { t.Fatalf("loadSeedTenantConfigs returned error: %v", err) @@ -17,12 +17,69 @@ func TestSeedTenantCSVDefinesOnlyRequiredRootTenants(t *testing.T) { name string slug string tenantType string + parentSlug string + domains []string }{ { name: "한맥가족", slug: "hanmac-family", tenantType: domain.TenantTypeCompanyGroup, }, + { + name: "삼안", + slug: "saman", + tenantType: domain.TenantTypeCompany, + parentSlug: "hanmac-family", + domains: []string{"samaneng.com"}, + }, + { + name: "한맥기술", + slug: "hanmac", + tenantType: domain.TenantTypeCompany, + parentSlug: "hanmac-family", + domains: []string{"hanmaceng.co.kr"}, + }, + { + name: "총괄기획&기술개발센터", + slug: "gpdtdc", + tenantType: domain.TenantTypeCompany, + parentSlug: "hanmac-family", + domains: []string{"baroncs.co.kr"}, + }, + { + name: "바론그룹", + slug: "baron-group", + tenantType: domain.TenantTypeCompanyGroup, + parentSlug: "hanmac-family", + }, + { + name: "(주)장헌", + slug: "jangheon", + tenantType: domain.TenantTypeCompany, + parentSlug: "baron-group", + domains: []string{"jangheon.com"}, + }, + { + name: "장헌산업", + slug: "jangheon-sanup", + tenantType: domain.TenantTypeCompany, + parentSlug: "baron-group", + domains: []string{"jangheon.co.kr"}, + }, + { + name: "한라산업개발", + slug: "hanlla", + tenantType: domain.TenantTypeCompany, + parentSlug: "baron-group", + domains: []string{"hanllasanup.co.kr"}, + }, + { + name: "(주)피티씨", + slug: "ptc", + tenantType: domain.TenantTypeCompany, + parentSlug: "baron-group", + domains: []string{"pre-cast.co.kr"}, + }, { name: "Personal", slug: "personal", @@ -45,15 +102,23 @@ func TestSeedTenantCSVDefinesOnlyRequiredRootTenants(t *testing.T) { if got.Type != want.tenantType { t.Fatalf("tenant[%d] type = %q, want %q", i, got.Type, want.tenantType) } - if got.ParentSlug != "" { - t.Fatalf("tenant[%d] parent slug = %q, want empty root tenant", i, got.ParentSlug) + if got.ParentSlug != want.parentSlug { + t.Fatalf("tenant[%d] parent slug = %q, want %q", i, got.ParentSlug, want.parentSlug) + } + if len(got.Domains) != len(want.domains) { + t.Fatalf("tenant[%d] domains = %#v, want %#v", i, got.Domains, want.domains) + } + for j, wantDomain := range want.domains { + if got.Domains[j] != wantDomain { + t.Fatalf("tenant[%d] domain[%d] = %q, want %q", i, j, got.Domains[j], wantDomain) + } } } +} - for _, tenant := range configs { - if tenant.Slug == "system" || tenant.Slug == "hanmac" || tenant.Slug == "saman" { - t.Fatalf("tenant %q must be configured by import, not seed CSV", tenant.Slug) - } +func TestNormalizeSeedTenantTypeAllowsOrganization(t *testing.T) { + if got := normalizeSeedTenantType("organization"); got != domain.TenantTypeOrganization { + t.Fatalf("normalizeSeedTenantType(organization) = %q, want %q", got, domain.TenantTypeOrganization) } } diff --git a/backend/internal/domain/tenant.go b/backend/internal/domain/tenant.go index efd747d6..ec2ef9f5 100644 --- a/backend/internal/domain/tenant.go +++ b/backend/internal/domain/tenant.go @@ -20,13 +20,14 @@ const ( TenantTypePersonal = "PERSONAL" TenantTypeCompany = "COMPANY" TenantTypeCompanyGroup = "COMPANY_GROUP" + TenantTypeOrganization = "ORGANIZATION" TenantTypeUserGroup = "USER_GROUP" ) // Tenant represents a tenant model stored in PostgreSQL. type Tenant struct { ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` - Type string `gorm:"not null;default:'PERSONAL'" json:"type"` // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP + Type string `gorm:"not null;default:'PERSONAL'" json:"type"` // PERSONAL, COMPANY, COMPANY_GROUP, ORGANIZATION, USER_GROUP ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID Name string `gorm:"not null" json:"name"` Slug string `gorm:"uniqueIndex;not null" json:"slug"` diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index ccfac8eb..bc9eec70 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -17,6 +17,14 @@ const ( RoleUser = "user" // 일반 사용자 ) +// User statuses +const ( + UserStatusActive = "active" + UserStatusInactive = "inactive" + UserStatusSuspended = "suspended" + UserStatusLeaveOfAbsence = "leave_of_absence" +) + // NormalizeRole maps legacy/synonym role values to canonical role keys. func NormalizeRole(role string) string { normalized := strings.ToLower(strings.TrimSpace(role)) diff --git a/backend/internal/domain/worksmobile.go b/backend/internal/domain/worksmobile.go new file mode 100644 index 00000000..a21f17d8 --- /dev/null +++ b/backend/internal/domain/worksmobile.go @@ -0,0 +1,72 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + WorksmobileOutboxStatusPending = "pending" + WorksmobileOutboxStatusProcessing = "processing" + WorksmobileOutboxStatusProcessed = "processed" + WorksmobileOutboxStatusFailed = "failed" +) + +const ( + WorksmobileResourceOrgUnit = "ORGUNIT" + WorksmobileResourceUser = "USER" +) + +const ( + WorksmobileActionUpsert = "UPSERT" + WorksmobileActionDelete = "DELETE" + WorksmobileActionDryRun = "DRY_RUN" + WorksmobileActionSuspend = "SUSPEND" +) + +type WorksmobileOutbox struct { + ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` + ResourceType string `gorm:"not null;index:idx_worksmobile_outbox_resource" json:"resourceType"` + ResourceID string `gorm:"not null;index:idx_worksmobile_outbox_resource" json:"resourceId"` + Action string `gorm:"not null" json:"action"` + Payload JSONMap `gorm:"type:jsonb" json:"payload,omitempty"` + DedupeKey string `gorm:"uniqueIndex" json:"dedupeKey"` + Status string `gorm:"default:'pending';index" json:"status"` + RetryCount int `gorm:"default:0" json:"retryCount"` + LastError string `json:"lastError,omitempty"` + NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"` + ProcessedAt *time.Time `json:"processedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (w *WorksmobileOutbox) BeforeCreate(tx *gorm.DB) error { + if w.ID == "" { + w.ID = uuid.NewString() + } + if w.Status == "" { + w.Status = WorksmobileOutboxStatusPending + } + return nil +} + +type WorksmobileResourceMapping struct { + ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` + BaronResourceType string `gorm:"not null;uniqueIndex:idx_worksmobile_mapping_baron" json:"baronResourceType"` + BaronResourceID string `gorm:"not null;uniqueIndex:idx_worksmobile_mapping_baron" json:"baronResourceId"` + ExternalKey string `gorm:"not null;uniqueIndex" json:"externalKey"` + WorksmobileResourceID string `json:"worksmobileResourceId,omitempty"` + DomainID int64 `json:"domainId"` + LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (w *WorksmobileResourceMapping) BeforeCreate(tx *gorm.DB) error { + if w.ID == "" { + w.ID = uuid.NewString() + } + return nil +} diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 4dad86dc..71de9bf4 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -27,6 +27,7 @@ type TenantHandler struct { KetoOutbox repository.KetoOutboxRepository KratosAdmin service.KratosAdminService SharedLink service.SharedLinkService + Worksmobile service.WorksmobileSyncer } func seedTenantDeleteError(c *fiber.Ctx) error { @@ -58,6 +59,10 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repositor } } +func (h *TenantHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) { + h.Worksmobile = syncer +} + type tenantSummary struct { ID string `json:"id"` Type string `json:"type"` @@ -393,6 +398,9 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error { if updated { tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID result.Updated++ + if h.Worksmobile != nil { + _ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant) + } continue } } @@ -410,6 +418,9 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error { } tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID result.Created++ + if h.Worksmobile != nil { + _ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant) + } } return c.JSON(result) @@ -1042,6 +1053,13 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { if len(normalizedDomains) > 0 { summary.Domains = normalizedDomains } + if h.Worksmobile != nil { + if refreshed := h.DB.Preload("Domains").First(tenant, "id = ?", tenant.ID); refreshed.Error == nil { + if err := h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant); err != nil { + fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant sync: %v\n", err) + } + } + } return c.Status(fiber.StatusCreated).JSON(summary) } @@ -1188,6 +1206,11 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { // Refetch to get updated relations h.DB.Preload("Domains").First(&tenant, "id = ?", tenant.ID) + if h.Worksmobile != nil { + if err := h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), tenant); err != nil { + fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant update sync: %v\n", err) + } + } return c.JSON(mapTenantSummary(tenant)) } @@ -1222,6 +1245,11 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error { if err := h.DB.Delete(&tenant).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if h.Worksmobile != nil { + if err := h.Worksmobile.EnqueueTenantDeleteIfInScope(c.Context(), tenant); err != nil { + fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant delete sync: %v\n", err) + } + } return c.SendStatus(fiber.StatusNoContent) } @@ -1581,7 +1609,7 @@ func normalizeTenantStatus(value string) string { func normalizeTenantType(value string) string { value = strings.ToUpper(strings.TrimSpace(value)) switch value { - case domain.TenantTypePersonal, domain.TenantTypeCompany, domain.TenantTypeCompanyGroup, domain.TenantTypeUserGroup: + case domain.TenantTypePersonal, domain.TenantTypeCompany, domain.TenantTypeCompanyGroup, domain.TenantTypeOrganization, domain.TenantTypeUserGroup: return value default: return "" diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 96282ef5..be6725e0 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -383,7 +383,7 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) { writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "tenants.csv") assert.NoError(t, err) - _, err = part.Write([]byte("name,type,parent_tenant_slug,slug,memo,email_domain\nParent Tenant,COMPANY,,parent-slug,,\nChild Tenant,USER_GROUP,parent-slug,child-slug,,\n")) + _, err = part.Write([]byte("name,type,parent_tenant_slug,slug,memo,email_domain\nParent Tenant,COMPANY,,parent-slug,,\nChild Tenant,ORGANIZATION,parent-slug,child-slug,,\n")) assert.NoError(t, err) assert.NoError(t, writer.Close()) @@ -405,7 +405,7 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) { mock.Anything, "Child Tenant", "child-slug", - domain.TenantTypeUserGroup, + domain.TenantTypeOrganization, "", []string{}, mock.MatchedBy(func(got *string) bool { @@ -426,6 +426,10 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) { mockSvc.AssertExpectations(t) } +func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) { + assert.Equal(t, domain.TenantTypeOrganization, normalizeTenantType("organization")) +} + func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) { records, err := parseTenantCSVRecords(strings.NewReader( "name,type,parent_tenant_slug,slug,memo,email_domain\n" + diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index a4b1a885..f609c850 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -36,6 +36,7 @@ type UserHandler struct { UserRepo repository.UserRepository UserGroupRepo repository.UserGroupRepository AuditRepo domain.AuditRepository + Worksmobile service.WorksmobileSyncer } func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository, auditRepo domain.AuditRepository) *UserHandler { @@ -51,6 +52,97 @@ func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProvi } } +func (h *UserHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) { + h.Worksmobile = syncer +} + +func mergeUserAppointmentMetadata(metadata map[string]any, appointments []map[string]any, primaryTenantID string, primaryTenantName string, primaryTenantIsOwner *bool) map[string]any { + if metadata == nil { + metadata = map[string]any{} + } + if len(appointments) > 0 { + values := make([]any, 0, len(appointments)) + for _, appointment := range appointments { + values = append(values, appointment) + } + metadata["additionalAppointments"] = values + } + if strings.TrimSpace(primaryTenantID) != "" { + metadata["primaryTenantId"] = strings.TrimSpace(primaryTenantID) + } + if strings.TrimSpace(primaryTenantName) != "" { + metadata["primaryTenantName"] = strings.TrimSpace(primaryTenantName) + } + if primaryTenantIsOwner != nil { + metadata["primaryTenantIsOwner"] = *primaryTenantIsOwner + } + return metadata +} + +func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string { + if value := strings.TrimSpace(primaryTenantID); value != "" { + return value + } + if value := normalizeMetadataString(metadata["primaryTenantId"]); value != "" { + return value + } + for _, appointment := range appointments { + if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary"); ok && isPrimary { + if value := normalizeMetadataString(appointment["tenantId"]); value != "" { + return value + } + } + } + if len(appointments) > 0 { + return normalizeMetadataString(appointments[0]["tenantId"]) + } + if raw, ok := metadata["additionalAppointments"].([]any); ok { + for _, item := range raw { + appointment, ok := item.(map[string]any) + if !ok { + continue + } + if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary"); ok && isPrimary { + if value := normalizeMetadataString(appointment["tenantId"]); value != "" { + return value + } + } + } + if len(raw) > 0 { + if appointment, ok := raw[0].(map[string]any); ok { + return normalizeMetadataString(appointment["tenantId"]) + } + } + } + return "" +} + +func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) { + for _, key := range keys { + value, ok := metadata[key] + if !ok { + continue + } + switch v := value.(type) { + case bool: + return v, true + case string: + normalized := strings.ToLower(strings.TrimSpace(v)) + if normalized == "true" || normalized == "1" || normalized == "yes" { + return true, true + } + if normalized == "false" || normalized == "0" || normalized == "no" { + return false, true + } + case float64: + return v != 0, true + case int: + return v != 0, true + } + } + return false, false +} + type userSummary struct { ID string `json:"id"` Email string `json:"email"` @@ -331,21 +423,26 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { } var req struct { - Email string `json:"email"` - LoginID string `json:"loginId"` - Password string `json:"password"` - Name string `json:"name"` - Phone string `json:"phone"` - Role string `json:"role"` - CompanyCode string `json:"companyCode"` - Department string `json:"department"` - Position string `json:"position"` - JobTitle string `json:"jobTitle"` - Metadata map[string]any `json:"metadata"` + Email string `json:"email"` + LoginID string `json:"loginId"` + Password string `json:"password"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` + CompanyCode string `json:"companyCode"` + Department string `json:"department"` + Position string `json:"position"` + JobTitle string `json:"jobTitle"` + PrimaryTenantID string `json:"primaryTenantId"` + PrimaryTenantName string `json:"primaryTenantName"` + PrimaryTenantIsOwner *bool `json:"primaryTenantIsOwner"` + AdditionalAppointments []map[string]any `json:"additionalAppointments"` + Metadata map[string]any `json:"metadata"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } + req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner) email := strings.TrimSpace(req.Email) if email == "" { @@ -411,6 +508,14 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { // [Resolve TenantID and Custom Login IDs before Kratos creation] var tenantID string + if req.CompanyCode == "" && h.TenantService != nil { + if primaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, req.AdditionalAppointments); primaryTenantID != "" { + if tenant, err := h.TenantService.GetTenant(c.Context(), primaryTenantID); err == nil && tenant != nil { + tenantID = tenant.ID + req.CompanyCode = tenant.Slug + } + } + } if req.CompanyCode != "" && h.TenantService != nil { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil { tenantID = tenant.ID @@ -421,6 +526,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "") attributes["role"] = role + attributes["companyCode"] = req.CompanyCode if tenantID != "" { attributes["tenant_id"] = tenantID } @@ -495,6 +601,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { if err := h.UserRepo.Update(c.Context(), localUser); err != nil { slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err) } + if h.Worksmobile != nil { + if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil { + slog.Warn("[UserHandler] Failed to enqueue Worksmobile user sync", "userID", localUser.ID, "error", err) + } + } // Update User Login IDs in local DB for i := range loginIDRecords { @@ -796,6 +907,11 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { if err := h.UserRepo.Update(c.Context(), localUser); err != nil { slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err) } + if h.Worksmobile != nil { + if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil { + slog.Warn("Failed to enqueue Worksmobile bulk user sync", "userID", localUser.ID, "error", err) + } + } // Update User Login IDs in local DB for i := range loginIDRecords { @@ -1170,6 +1286,11 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { } _ = h.UserRepo.Update(c.Context(), localUser) + if h.Worksmobile != nil { + if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil { + slog.Warn("Failed to enqueue Worksmobile bulk user update sync", "userID", localUser.ID, "error", err) + } + } // [Keto Sync] if h.KetoOutboxRepo != nil { @@ -1244,6 +1365,12 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error { results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()}) continue } + if h.Worksmobile != nil { + localUser := h.mapToLocalUser(*identity) + if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil { + slog.Warn("Failed to enqueue Worksmobile bulk user delete", "userID", id, "error", err) + } + } // Local DB Sync if h.UserRepo != nil { @@ -1298,21 +1425,26 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { } var req struct { - LoginID *string `json:"loginId"` - Password *string `json:"password"` - Name *string `json:"name"` - Phone *string `json:"phone"` - Role *string `json:"role"` - Status *string `json:"status"` - CompanyCode *string `json:"companyCode"` - Department *string `json:"department"` - Position *string `json:"position"` - JobTitle *string `json:"jobTitle"` - Metadata map[string]any `json:"metadata"` + LoginID *string `json:"loginId"` + Password *string `json:"password"` + Name *string `json:"name"` + Phone *string `json:"phone"` + Role *string `json:"role"` + Status *string `json:"status"` + CompanyCode *string `json:"companyCode"` + Department *string `json:"department"` + Position *string `json:"position"` + JobTitle *string `json:"jobTitle"` + PrimaryTenantID string `json:"primaryTenantId"` + PrimaryTenantName string `json:"primaryTenantName"` + PrimaryTenantIsOwner *bool `json:"primaryTenantIsOwner"` + AdditionalAppointments []map[string]any `json:"additionalAppointments"` + Metadata map[string]any `json:"metadata"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } + req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner) // [New] Tenant Admin restriction: Cannot change companyCode if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin { @@ -1510,11 +1642,19 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { // [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller if h.UserRepo != nil { updatedLocalUser := h.mapToLocalUser(*updated) + if req.Status != nil { + updatedLocalUser.Status = normalizeStatus(*req.Status) + } ctx := context.Background() // Use request context if appropriate, but sync must finish if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil { slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err) } + if h.Worksmobile != nil { + if err := h.Worksmobile.EnqueueUserUpsertIfInScope(ctx, *updatedLocalUser); err != nil { + slog.Warn("[UserHandler] Failed to enqueue Worksmobile updated user sync", "userID", updatedLocalUser.ID, "error", err) + } + } // Update User Login IDs in local DB if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil { @@ -1628,9 +1768,15 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "cannot delete your own account for safety") } + var identity *service.KratosIdentity + if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin || h.Worksmobile != nil { + found, err := h.KratosAdmin.GetIdentity(c.Context(), userID) + if err == nil { + identity = found + } + } if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin { - identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) - if err == nil && identity != nil { + if identity != nil { compCode := extractTraitString(identity.Traits, "companyCode") if requester.CompanyCode == "" || compCode != requester.CompanyCode { return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot delete user in another tenant") @@ -1641,6 +1787,12 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if h.Worksmobile != nil && identity != nil { + localUser := h.mapToLocalUser(*identity) + if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil { + slog.Warn("[UserHandler] Failed to enqueue Worksmobile user delete", "userID", userID, "error", err) + } + } // [Keto] Cleanup relations via Outbox if h.KetoOutboxRepo != nil { @@ -2041,11 +2193,17 @@ func formatTime(value time.Time) string { func normalizeStatus(state string) string { state = strings.ToLower(strings.TrimSpace(state)) - if state == "inactive" || state == "blocked" || state == "active" { + if state == "blocked" { + return domain.UserStatusInactive + } + if state == domain.UserStatusInactive || + state == domain.UserStatusSuspended || + state == domain.UserStatusLeaveOfAbsence || + state == domain.UserStatusActive { return state } if state == "" { - return "active" + return domain.UserStatusActive } return state } @@ -2056,10 +2214,15 @@ func normalizeKratosState(status *string) string { } value := strings.ToLower(strings.TrimSpace(*status)) if value == "blocked" { - return "inactive" + return domain.UserStatusInactive } - if value == "active" || value == "inactive" { - return value + if value == domain.UserStatusActive { + return domain.UserStatusActive + } + if value == domain.UserStatusInactive || + value == domain.UserStatusSuspended || + value == domain.UserStatusLeaveOfAbsence { + return domain.UserStatusInactive } return "" } diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index beb3ec50..e8a08967 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -97,6 +97,27 @@ func (m *MockOryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { return args.Get(0).(*domain.PasswordPolicy), args.Error(1) } +type fakeUserHandlerWorksmobileSyncer struct { + upserts []domain.User +} + +func (f *fakeUserHandlerWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error { + return nil +} + +func (f *fakeUserHandlerWorksmobileSyncer) EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error { + return nil +} + +func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error { + f.upserts = append(f.upserts, user) + return nil +} + +func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error { + return nil +} + type MockTenantServiceForUser struct { mock.Mock service.TenantService @@ -576,7 +597,9 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes func TestUserHandler_BulkUpdateUsers(t *testing.T) { app := fiber.New() mockKratos := new(MockKratosAdmin) - h := &UserHandler{KratosAdmin: mockKratos} + mockRepo := new(MockUserRepoForHandler) + worksmobile := &fakeUserHandlerWorksmobileSyncer{} + h := &UserHandler{KratosAdmin: mockKratos, UserRepo: mockRepo, Worksmobile: worksmobile} app.Put("/users/bulk", func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin}) @@ -585,10 +608,18 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) { t.Run("Success - Update Role and Status", func(t *testing.T) { mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ - ID: "u-1", Traits: map[string]interface{}{"email": "u1@test.com"}, State: "active", + ID: "u-1", Traits: map[string]interface{}{"email": "u1@test.com", "tenant_id": "tenant-1"}, State: "active", }, nil).Once() - mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, "inactive").Return(&service.KratosIdentity{}, nil).Once() + mockKratos.On("UpdateIdentity", mock.Anything, "u-1", mock.Anything, "inactive").Return(&service.KratosIdentity{ + ID: "u-1", + Traits: map[string]interface{}{ + "email": "u1@test.com", + "name": "Bulk User", + "tenant_id": "tenant-1", + }, + State: "inactive", + }, nil).Once() status := "inactive" payload := map[string]interface{}{ @@ -606,6 +637,9 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) { json.NewDecoder(resp.Body).Decode(&result) results := result["results"].([]interface{}) assert.True(t, results[0].(map[string]interface{})["success"].(bool)) + assert.Len(t, worksmobile.upserts, 1) + assert.Equal(t, "u-1", worksmobile.upserts[0].ID) + assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status) }) } @@ -1033,6 +1067,74 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) { }) } +func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + mockOry := new(MockOryProvider) + mockTenant := new(MockTenantServiceForUser) + mockRepo := new(MockUserRepoForHandler) + worksmobile := &fakeUserHandlerWorksmobileSyncer{} + h := &UserHandler{ + KratosAdmin: mockKratos, + OryProvider: mockOry, + TenantService: mockTenant, + UserRepo: mockRepo, + Worksmobile: worksmobile, + } + app.Post("/users", h.CreateUser) + + tenantID := "33333333-3333-3333-3333-333333333333" + mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{ + ID: tenantID, + Slug: "saman", + }, nil) + mockTenant.On("GetTenantBySlug", mock.Anything, "saman").Return(&domain.Tenant{ + ID: tenantID, + Slug: "saman", + }, nil) + mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil) + mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) + mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool { + return user.Attributes["tenant_id"] == tenantID && + user.Attributes["companyCode"] == "saman" && + user.Attributes["additionalAppointments"] != nil + }), mock.Anything).Return("u-appointment", nil).Once() + mockKratos.On("GetIdentity", mock.Anything, "u-appointment").Return(&service.KratosIdentity{ + ID: "u-appointment", + Traits: map[string]interface{}{ + "email": "new@samaneng.com", + "name": "Appointment User", + "companyCode": "saman", + "tenant_id": tenantID, + "additionalAppointments": []interface{}{ + map[string]interface{}{"tenantId": tenantID, "tenantSlug": "saman"}, + }, + }, + State: "active", + }, nil).Once() + + payload := map[string]interface{}{ + "email": "new@samaneng.com", + "name": "Appointment User", + "additionalAppointments": []map[string]interface{}{ + {"tenantId": tenantID, "tenantSlug": "saman", "tenantName": "삼안"}, + }, + "metadata": map[string]interface{}{ + "userType": "hanmac", + }, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/users", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req) + + assert.Equal(t, 201, resp.StatusCode) + assert.Len(t, worksmobile.upserts, 1) + assert.Equal(t, tenantID, *worksmobile.upserts[0].TenantID) + mockOry.AssertExpectations(t) +} + func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { return "", nil } diff --git a/backend/internal/handler/worksmobile_handler.go b/backend/internal/handler/worksmobile_handler.go new file mode 100644 index 00000000..c019d95c --- /dev/null +++ b/backend/internal/handler/worksmobile_handler.go @@ -0,0 +1,132 @@ +package handler + +import ( + "baron-sso-backend/internal/service" + "bytes" + "context" + "encoding/csv" + "errors" + "log/slog" + "strings" + + "github.com/gofiber/fiber/v2" +) + +type WorksmobileHandler struct { + Service service.WorksmobileAdminService +} + +func NewWorksmobileHandler(svc service.WorksmobileAdminService) *WorksmobileHandler { + return &WorksmobileHandler{Service: svc} +} + +func (h *WorksmobileHandler) GetOverview(c *fiber.Ctx) error { + overview, err := h.Service.GetTenantOverview(c.Context(), strings.TrimSpace(c.Params("tenantId"))) + if err != nil { + return worksmobileGuardError(c, err, "get_overview") + } + if !worksmobileOverviewAllowed(overview) { + return errorJSON(c, fiber.StatusNotFound, "worksmobile is only available for hanmac-family root tenant") + } + return c.JSON(overview) +} + +func (h *WorksmobileHandler) GetComparison(c *fiber.Ctx) error { + includeMatched := strings.EqualFold(strings.TrimSpace(c.Query("includeMatched")), "true") + comparison, err := h.Service.GetComparison(c.Context(), strings.TrimSpace(c.Params("tenantId")), includeMatched) + if err != nil { + return worksmobileGuardError(c, err, "get_comparison") + } + return c.JSON(comparison) +} + +func (h *WorksmobileHandler) OAuthCallback(c *fiber.Ctx) error { + return c.Type("html").SendString("Worksmobile OAuth callback reachable") +} + +func (h *WorksmobileHandler) BackfillDryRun(c *fiber.Ctx) error { + result, err := h.Service.EnqueueBackfillDryRun(c.Context(), strings.TrimSpace(c.Params("tenantId"))) + if err != nil { + return worksmobileGuardError(c, err, "backfill_dry_run") + } + return c.JSON(result) +} + +func (h *WorksmobileHandler) SyncOrgUnit(c *fiber.Ctx) error { + orgUnitID := strings.TrimSpace(c.Params("orgUnitId")) + job, err := h.Service.EnqueueOrgUnitSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), orgUnitID) + if err != nil { + return worksmobileGuardError(c, err, "sync_orgunit", "org_unit_id", orgUnitID) + } + return c.Status(fiber.StatusAccepted).JSON(job) +} + +func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error { + userID := strings.TrimSpace(c.Params("userId")) + job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID) + if err != nil { + return worksmobileGuardError(c, err, "sync_user", "user_id", userID) + } + return c.Status(fiber.StatusAccepted).JSON(job) +} + +func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error { + jobID := strings.TrimSpace(c.Params("jobId")) + job, err := h.Service.RetryJob(c.Context(), strings.TrimSpace(c.Params("tenantId")), jobID) + if err != nil { + return worksmobileGuardError(c, err, "retry_job", "job_id", jobID) + } + return c.JSON(job) +} + +func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error { + credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId"))) + if err != nil { + return worksmobileGuardError(c, err, "download_initial_passwords") + } + + var buf bytes.Buffer + writer := csv.NewWriter(&buf) + if err := writer.Write([]string{"email", "initialPassword", "status", "lastError"}); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + for _, credential := range credentials { + if err := writer.Write([]string{credential.Email, credential.InitialPassword, credential.Status, credential.LastError}); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + } + writer.Flush() + if err := writer.Error(); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + c.Set(fiber.HeaderContentType, "text/csv; charset=utf-8") + c.Set(fiber.HeaderContentDisposition, `attachment; filename="worksmobile_initial_passwords.csv"`) + return c.Send(buf.Bytes()) +} + +func worksmobileOverviewAllowed(overview service.WorksmobileTenantOverview) bool { + return overview.Tenant.Slug == service.HanmacFamilyTenantSlug && overview.Tenant.ParentID == nil +} + +func worksmobileGuardError(c *fiber.Ctx, err error, operation string, attrs ...any) error { + if err == nil { + return nil + } + logAttrs := []any{ + "operation", operation, + "tenant_id", strings.TrimSpace(c.Params("tenantId")), + "path", c.Path(), + "error", err, + } + logAttrs = append(logAttrs, attrs...) + if errors.Is(err, context.Canceled) { + slog.Warn("worksmobile admin operation failed", logAttrs...) + return errorJSON(c, fiber.StatusRequestTimeout, err.Error()) + } + slog.Error("worksmobile admin operation failed", logAttrs...) + if strings.Contains(err.Error(), "hanmac-family root") { + return errorJSON(c, fiber.StatusNotFound, err.Error()) + } + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) +} diff --git a/backend/internal/handler/worksmobile_handler_test.go b/backend/internal/handler/worksmobile_handler_test.go new file mode 100644 index 00000000..80de4f19 --- /dev/null +++ b/backend/internal/handler/worksmobile_handler_test.go @@ -0,0 +1,128 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "bytes" + "context" + "errors" + "io" + "log/slog" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/require" +) + +func TestWorksmobileHandlerRejectsNonHanmacTenant(t *testing.T) { + h := NewWorksmobileHandler(&fakeWorksmobileAdminService{ + overview: service.WorksmobileTenantOverview{ + Tenant: domain.Tenant{ID: "tenant-1", Slug: "other"}, + }, + }) + app := fiber.New() + app.Get("/tenants/:tenantId/worksmobile", h.GetOverview) + + resp, err := app.Test(httptest.NewRequest("GET", "/tenants/tenant-1/worksmobile", nil)) + + require.NoError(t, err) + require.Equal(t, fiber.StatusNotFound, resp.StatusCode) +} + +func TestWorksmobileHandlerReturnsOverviewForHanmacTenant(t *testing.T) { + h := NewWorksmobileHandler(&fakeWorksmobileAdminService{ + overview: service.WorksmobileTenantOverview{ + Tenant: domain.Tenant{ID: "hanmac-id", Slug: "hanmac-family"}, + Config: service.WorksmobileConfigSummary{ + Enabled: true, + }, + }, + }) + app := fiber.New() + app.Get("/tenants/:tenantId/worksmobile", h.GetOverview) + + resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile", nil)) + + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) +} + +func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) { + h := NewWorksmobileHandler(&fakeWorksmobileAdminService{ + credentials: []service.WorksmobileInitialPasswordCredential{ + {Email: "user@hanmaceng.co.kr", InitialPassword: "Aa1!Aa1!Aa1!Aa1!", Status: "processed"}, + }, + }) + app := fiber.New() + app.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", h.DownloadInitialPasswordsCSV) + + resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile/initial-passwords.csv", nil)) + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + require.Contains(t, resp.Header.Get("Content-Disposition"), "worksmobile_initial_passwords.csv") + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(body), "email,initialPassword,status,lastError") + require.Contains(t, string(body), "user@hanmaceng.co.kr,Aa1!Aa1!Aa1!Aa1!,processed,") +} + +func TestWorksmobileHandlerLogsActionFailures(t *testing.T) { + var logs bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, nil))) + t.Cleanup(func() { + slog.SetDefault(previous) + }) + + h := NewWorksmobileHandler(&fakeWorksmobileAdminService{ + syncUserErr: errors.New("works user sync failed"), + }) + 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.StatusInternalServerError, resp.StatusCode) + require.Contains(t, logs.String(), "worksmobile admin operation failed") + require.Contains(t, logs.String(), "sync_user") + require.Contains(t, logs.String(), "works user sync failed") +} + +type fakeWorksmobileAdminService struct { + overview service.WorksmobileTenantOverview + credentials []service.WorksmobileInitialPasswordCredential + syncUserErr error +} + +func (f *fakeWorksmobileAdminService) GetTenantOverview(ctx context.Context, tenantID string) (service.WorksmobileTenantOverview, error) { + return f.overview, nil +} + +func (f *fakeWorksmobileAdminService) GetComparison(ctx context.Context, tenantID string, includeMatched bool) (service.WorksmobileComparison, error) { + return service.WorksmobileComparison{}, nil +} + +func (f *fakeWorksmobileAdminService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (service.WorksmobileBackfillDryRun, error) { + return service.WorksmobileBackfillDryRun{}, nil +} + +func (f *fakeWorksmobileAdminService) EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) { + return &domain.WorksmobileOutbox{ID: "job-orgunit", ResourceID: orgUnitID}, nil +} + +func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) { + if f.syncUserErr != nil { + return nil, f.syncUserErr + } + return &domain.WorksmobileOutbox{ID: "job-user", ResourceID: userID}, nil +} + +func (f *fakeWorksmobileAdminService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) { + return &domain.WorksmobileOutbox{ID: jobID}, nil +} + +func (f *fakeWorksmobileAdminService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]service.WorksmobileInitialPasswordCredential, error) { + return f.credentials, nil +} diff --git a/backend/internal/repository/worksmobile_outbox_repository.go b/backend/internal/repository/worksmobile_outbox_repository.go new file mode 100644 index 00000000..8da5f5a8 --- /dev/null +++ b/backend/internal/repository/worksmobile_outbox_repository.go @@ -0,0 +1,114 @@ +package repository + +import ( + "baron-sso-backend/internal/domain" + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type WorksmobileOutboxRepository interface { + Create(ctx context.Context, item *domain.WorksmobileOutbox) error + ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) + ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) + FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error) + MarkRetry(ctx context.Context, id string) error + MarkProcessing(ctx context.Context, id string) error + MarkProcessed(ctx context.Context, id string) error + MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error +} + +type worksmobileOutboxRepository struct { + db *gorm.DB +} + +func NewWorksmobileOutboxRepository(db *gorm.DB) WorksmobileOutboxRepository { + return &worksmobileOutboxRepository{db: db} +} + +func (r *worksmobileOutboxRepository) Create(ctx context.Context, item *domain.WorksmobileOutbox) error { + if item.Payload == nil { + item.Payload = domain.JSONMap{} + } + if item.Status == "" { + item.Status = domain.WorksmobileOutboxStatusPending + } + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "dedupe_key"}}, + DoUpdates: clause.Assignments(map[string]any{ + "payload": item.Payload, + "status": domain.WorksmobileOutboxStatusPending, + "last_error": "", + "next_attempt_at": nil, + "updated_at": time.Now(), + }), + }).Create(item).Error +} + +func (r *worksmobileOutboxRepository) ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) { + if limit <= 0 || limit > 1000 { + limit = 50 + } + var rows []domain.WorksmobileOutbox + err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Find(&rows).Error + return rows, err +} + +func (r *worksmobileOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + var rows []domain.WorksmobileOutbox + err := r.db.WithContext(ctx). + Where("status = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)", domain.WorksmobileOutboxStatusPending, time.Now()). + Order("created_at asc"). + Limit(limit). + Find(&rows).Error + return rows, err +} + +func (r *worksmobileOutboxRepository) FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error) { + var row domain.WorksmobileOutbox + if err := r.db.WithContext(ctx).First(&row, "id = ?", id).Error; err != nil { + return nil, err + } + return &row, nil +} + +func (r *worksmobileOutboxRepository) MarkRetry(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ?", id).Updates(map[string]any{ + "status": domain.WorksmobileOutboxStatusPending, + "last_error": "", + "next_attempt_at": nil, + "updated_at": time.Now(), + }).Error +} + +func (r *worksmobileOutboxRepository) MarkProcessing(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ? AND status = ?", id, domain.WorksmobileOutboxStatusPending).Updates(map[string]any{ + "status": domain.WorksmobileOutboxStatusProcessing, + "updated_at": time.Now(), + }).Error +} + +func (r *worksmobileOutboxRepository) MarkProcessed(ctx context.Context, id string) error { + now := time.Now() + return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ?", id).Updates(map[string]any{ + "status": domain.WorksmobileOutboxStatusProcessed, + "last_error": "", + "processed_at": &now, + "updated_at": now, + }).Error +} + +func (r *worksmobileOutboxRepository) MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error { + return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ?", id).Updates(map[string]any{ + "status": domain.WorksmobileOutboxStatusFailed, + "retry_count": gorm.Expr("retry_count + 1"), + "last_error": message, + "next_attempt_at": &nextAttemptAt, + "updated_at": time.Now(), + }).Error +} diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index f4fbe806..563e1247 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -68,10 +68,10 @@ func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID unitID := uuid.NewString() - // 1. Create Tenant (Type: USER_GROUP) + // 1. Create Tenant (Type: ORGANIZATION) groupTenant := &domain.Tenant{ ID: unitID, - Type: domain.TenantTypeUserGroup, + Type: domain.TenantTypeOrganization, ParentID: actualParentID, Name: name, Slug: fmt.Sprintf("ug-%s", unitID[:8]), diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index 209b58d9..1f6e4f05 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -202,7 +202,7 @@ func TestUserGroupService_Create(t *testing.T) { // Mock Tenant creation (Polymorphic) mockTenantRepo.On("Create", mock.Anything, mock.MatchedBy(func(ten *domain.Tenant) bool { - return ten.Type == domain.TenantTypeUserGroup && ten.Name == name && *ten.ParentID == parentID + return ten.Type == domain.TenantTypeOrganization && ten.Name == name && *ten.ParentID == parentID })).Return(nil) // Mock UserGroup creation diff --git a/backend/internal/service/worksmobile_client.go b/backend/internal/service/worksmobile_client.go new file mode 100644 index 00000000..f467c49f --- /dev/null +++ b/backend/internal/service/worksmobile_client.go @@ -0,0 +1,969 @@ +package service + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const defaultWorksmobileAPIBaseURL = "https://www.worksapis.com" +const defaultWorksmobileOAuthTokenURL = "https://auth.worksmobile.com/oauth2/v2.0/token" +const defaultWorksmobileOAuthScope = "directory" + +type WorksmobileDirectoryClient interface { + CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error + CreateUser(ctx context.Context, payload WorksmobileUserPayload) error + UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error + DeleteUser(ctx context.Context, userID string) error + ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) + ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) +} + +type WorksmobileHTTPClient struct { + BaseURL string + DirectoryToken string + SCIMToken string + HTTPClient *http.Client + OAuthConfig WorksmobileOAuthConfig + DomainIDs []int64 + tokenCache worksmobileAccessTokenCache + now func() time.Time +} + +type WorksmobileOAuthConfig struct { + ClientID string + ClientSecret string + ServiceAccount string + PrivateKey string + Scope string + TokenURL string +} + +type worksmobileAccessTokenCache struct { + Token string + ExpiresAt time.Time +} + +func (c WorksmobileOAuthConfig) normalized() WorksmobileOAuthConfig { + c.ClientID = strings.Trim(strings.TrimSpace(c.ClientID), `"`) + c.ClientSecret = strings.Trim(strings.TrimSpace(c.ClientSecret), `"`) + c.ServiceAccount = strings.Trim(strings.TrimSpace(c.ServiceAccount), `"`) + c.PrivateKey = normalizeWorksmobilePrivateKey(c.PrivateKey) + c.Scope = strings.TrimSpace(c.Scope) + if c.Scope == "" { + c.Scope = defaultWorksmobileOAuthScope + } + c.TokenURL = strings.TrimSpace(c.TokenURL) + if c.TokenURL == "" { + c.TokenURL = defaultWorksmobileOAuthTokenURL + } + return c +} + +func (c WorksmobileOAuthConfig) validate() error { + if strings.TrimSpace(c.ClientID) == "" || strings.TrimSpace(c.ClientSecret) == "" { + return fmt.Errorf("worksmobile directory token is not configured") + } + if strings.TrimSpace(c.ServiceAccount) == "" || strings.TrimSpace(c.PrivateKey) == "" { + return fmt.Errorf("worksmobile oauth service account is not configured") + } + return nil +} + +func normalizeWorksmobilePrivateKey(value string) string { + value = strings.Trim(strings.TrimSpace(value), `"`) + value = strings.ReplaceAll(value, `\n`, "\n") + return value +} + +func buildWorksmobileJWTAssertion(config WorksmobileOAuthConfig, now time.Time) (string, error) { + privateKey, err := parseWorksmobilePrivateKey(config.PrivateKey) + if err != nil { + return "", err + } + header := map[string]string{"alg": "RS256", "typ": "JWT"} + payload := map[string]any{ + "iss": config.ClientID, + "sub": config.ServiceAccount, + "iat": now.Unix(), + "exp": now.Add(time.Hour).Unix(), + } + encodedHeader, err := encodeWorksmobileJWTPart(header) + if err != nil { + return "", err + } + encodedPayload, err := encodeWorksmobileJWTPart(payload) + if err != nil { + return "", err + } + signingInput := encodedHeader + "." + encodedPayload + sum := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, sum[:]) + if err != nil { + return "", err + } + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil +} + +func encodeWorksmobileJWTPart(value any) (string, error) { + data, err := json.Marshal(value) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(data), nil +} + +func parseWorksmobilePrivateKey(value string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(normalizeWorksmobilePrivateKey(value))) + if block == nil { + return nil, fmt.Errorf("worksmobile private key is not a valid PEM block") + } + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("worksmobile private key is not RSA") + } + return key, nil +} + +type WorksmobileHTTPError struct { + StatusCode int + Body string +} + +func (e WorksmobileHTTPError) Error() string { + return fmt.Sprintf("worksmobile api failed status=%d body=%s", e.StatusCode, e.Body) +} + +func NewWorksmobileHTTPClient(scimToken string) *WorksmobileHTTPClient { + return &WorksmobileHTTPClient{ + BaseURL: defaultWorksmobileAPIBaseURL, + SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`), + } +} + +func NewWorksmobileHTTPClientWithTokens(directoryToken string, scimToken string) *WorksmobileHTTPClient { + return &WorksmobileHTTPClient{ + BaseURL: defaultWorksmobileAPIBaseURL, + DirectoryToken: strings.Trim(strings.TrimSpace(directoryToken), `"`), + SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`), + } +} + +func NewWorksmobileHTTPClientWithAuth(directoryToken string, scimToken string, oauthConfig WorksmobileOAuthConfig) *WorksmobileHTTPClient { + return &WorksmobileHTTPClient{ + BaseURL: defaultWorksmobileAPIBaseURL, + DirectoryToken: strings.Trim(strings.TrimSpace(directoryToken), `"`), + SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`), + OAuthConfig: oauthConfig.normalized(), + DomainIDs: WorksmobileDomainIDsFromEnv(), + } +} + +func (c *WorksmobileHTTPClient) CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error { + return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/orgunits", payload) +} + +func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error { + return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload) +} + +func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error { + err := c.CreateUser(ctx, payload) + if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict { + identifier := strings.TrimSpace(payload.Email) + if identifier == "" { + identifier = strings.TrimSpace(payload.UserExternalKey) + } + return c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload)) + } + return err +} + +func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return fmt.Errorf("worksmobile user identifier is required") + } + return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), payload) +} + +func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) error { + userID = strings.TrimSpace(userID) + if userID == "" { + return fmt.Errorf("worksmobile user id is required") + } + remote, err := c.FindUser(ctx, userID) + if err != nil { + return err + } + if remote == nil { + return nil + } + if c.directoryAuthConfigured() && remote.Email != "" { + err := c.sendDirectoryJSON(ctx, http.MethodDelete, "/v1.0/users/"+url.PathEscape(remote.Email), nil) + if err == nil || strings.TrimSpace(c.SCIMToken) == "" { + return err + } + } + return c.sendJSON(ctx, http.MethodDelete, "/scim/v2/Users/"+url.PathEscape(remote.ID), nil) +} + +func (c *WorksmobileHTTPClient) FindUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) { + users, err := c.ListUsers(ctx) + if err != nil { + return nil, err + } + identifier = strings.TrimSpace(identifier) + for _, user := range users { + if strings.EqualFold(user.UserName, identifier) || user.ExternalID == identifier || strings.EqualFold(user.Email, identifier) { + return &user, nil + } + } + return nil, nil +} + +func (c *WorksmobileHTTPClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) { + if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 { + users, err := c.listDirectoryUsers(ctx, c.DomainIDs) + if err == nil { + return users, nil + } + if strings.TrimSpace(c.SCIMToken) == "" { + return nil, err + } + } + var users []WorksmobileRemoteUser + err := c.listSCIM(ctx, "/scim/v2/Users", func(resource map[string]any) { + users = append(users, parseWorksmobileRemoteUser(resource)) + }) + return users, err +} + +func (c *WorksmobileHTTPClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) { + if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 { + groups, err := c.listDirectoryGroups(ctx, c.DomainIDs) + if err == nil { + return groups, nil + } + if strings.TrimSpace(c.SCIMToken) == "" { + return nil, err + } + } + var groups []WorksmobileRemoteGroup + err := c.listSCIM(ctx, "/scim/v2/Groups", func(resource map[string]any) { + groups = append(groups, parseWorksmobileRemoteGroup(resource)) + }) + return groups, err +} + +func (c *WorksmobileHTTPClient) listSCIM(ctx context.Context, path string, consume func(map[string]any)) error { + startIndex := 1 + count := 100 + for { + var response struct { + TotalResults int `json:"totalResults"` + ItemsPerPage int `json:"itemsPerPage"` + Resources []map[string]any `json:"Resources"` + } + if err := c.getJSON(ctx, fmt.Sprintf("%s?startIndex=%d&count=%d", path, startIndex, count), &response); err != nil { + return err + } + for _, resource := range response.Resources { + consume(resource) + } + if len(response.Resources) == 0 || startIndex+len(response.Resources) > response.TotalResults { + return nil + } + startIndex += len(response.Resources) + } +} + +func (c *WorksmobileHTTPClient) getJSON(ctx context.Context, path string, target any) error { + token := strings.TrimSpace(c.SCIMToken) + if token == "" { + token = strings.TrimSpace(c.DirectoryToken) + } + if token == "" { + return fmt.Errorf("worksmobile read token is not configured") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.baseURL(), "/")+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)} + } + return json.NewDecoder(resp.Body).Decode(target) +} + +func (c *WorksmobileHTTPClient) getDirectoryJSON(ctx context.Context, path string, target any) error { + token, err := c.directoryAccessToken(ctx) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.baseURL(), "/")+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)} + } + return json.NewDecoder(resp.Body).Decode(target) +} + +func (c *WorksmobileHTTPClient) listDirectoryUsers(ctx context.Context, domainIDs []int64) ([]WorksmobileRemoteUser, error) { + users := make([]WorksmobileRemoteUser, 0) + for _, domainID := range uniqueWorksmobileDomainIDs(domainIDs) { + cursor := "" + for { + path := fmt.Sprintf("/v1.0/users?domainId=%d&count=100", domainID) + if cursor != "" { + path += "&cursor=" + url.QueryEscape(cursor) + } + var response struct { + Users []map[string]any `json:"users"` + ResponseMetaData struct { + NextCursor string `json:"nextCursor"` + } `json:"responseMetaData"` + } + if err := c.getDirectoryJSON(ctx, path, &response); err != nil { + return nil, err + } + for _, raw := range response.Users { + user := parseWorksmobileDirectoryUser(raw) + user.DomainID = domainID + user.DomainName = WorksmobileDomainLabelForID(domainID) + users = append(users, user) + } + cursor = strings.TrimSpace(response.ResponseMetaData.NextCursor) + if cursor == "" { + break + } + } + } + return users, nil +} + +func (c *WorksmobileHTTPClient) listDirectoryGroups(ctx context.Context, domainIDs []int64) ([]WorksmobileRemoteGroup, error) { + groups := make([]WorksmobileRemoteGroup, 0) + for _, domainID := range uniqueWorksmobileDomainIDs(domainIDs) { + cursor := "" + for { + path := fmt.Sprintf("/v1.0/orgunits?domainId=%d&count=100", domainID) + if cursor != "" { + path += "&cursor=" + url.QueryEscape(cursor) + } + var response struct { + OrgUnits []map[string]any `json:"orgUnits"` + ResponseMetaData struct { + NextCursor string `json:"nextCursor"` + } `json:"responseMetaData"` + } + if err := c.getDirectoryJSON(ctx, path, &response); err != nil { + return nil, err + } + for _, raw := range response.OrgUnits { + group := parseWorksmobileDirectoryGroup(raw) + group.DomainID = domainID + group.DomainName = WorksmobileDomainLabelForID(domainID) + groups = append(groups, group) + } + cursor = strings.TrimSpace(response.ResponseMetaData.NextCursor) + if cursor == "" { + break + } + } + } + return groups, nil +} + +func uniqueWorksmobileDomainIDs(domainIDs []int64) []int64 { + result := make([]int64, 0, len(domainIDs)) + seen := map[int64]bool{} + for _, id := range domainIDs { + if id <= 0 || seen[id] { + continue + } + seen[id] = true + result = append(result, id) + } + return result +} + +func (c *WorksmobileHTTPClient) sendJSON(ctx context.Context, method string, path string, payload any) error { + token := strings.TrimSpace(c.SCIMToken) + if token == "" { + return fmt.Errorf("worksmobile scim token is not configured") + } + return c.sendJSONWithToken(ctx, method, path, payload, token) +} + +func (c *WorksmobileHTTPClient) sendDirectoryJSON(ctx context.Context, method string, path string, payload any) error { + token, err := c.directoryAccessToken(ctx) + if err != nil { + return err + } + return c.sendJSONWithToken(ctx, method, path, payload, token) +} + +func (c *WorksmobileHTTPClient) directoryAccessToken(ctx context.Context) (string, error) { + if token := strings.TrimSpace(c.DirectoryToken); token != "" { + return token, nil + } + now := c.currentTime() + if c.tokenCache.Token != "" && now.Before(c.tokenCache.ExpiresAt) { + return c.tokenCache.Token, nil + } + token, expiresAt, err := c.requestDirectoryAccessToken(ctx, now) + if err != nil { + return "", err + } + c.tokenCache = worksmobileAccessTokenCache{Token: token, ExpiresAt: expiresAt} + return token, nil +} + +func (c *WorksmobileHTTPClient) directoryAuthConfigured() bool { + if strings.TrimSpace(c.DirectoryToken) != "" { + return true + } + return c.OAuthConfig.normalized().validate() == nil +} + +func (c *WorksmobileHTTPClient) requestDirectoryAccessToken(ctx context.Context, now time.Time) (string, time.Time, error) { + config := c.OAuthConfig.normalized() + if err := config.validate(); err != nil { + return "", time.Time{}, err + } + assertion, err := buildWorksmobileJWTAssertion(config, now) + if err != nil { + return "", time.Time{}, err + } + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + form.Set("assertion", assertion) + form.Set("client_id", config.ClientID) + form.Set("client_secret", config.ClientSecret) + form.Set("scope", config.Scope) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", time.Time{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient().Do(req) + if err != nil { + return "", time.Time{}, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", time.Time{}, WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)} + } + var tokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn any `json:"expires_in"` + TokenType string `json:"token_type"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil { + return "", time.Time{}, err + } + if strings.TrimSpace(tokenResponse.AccessToken) == "" { + return "", time.Time{}, fmt.Errorf("worksmobile token response is missing access_token") + } + expiresIn := worksmobileTokenExpiresIn(tokenResponse.ExpiresIn) + if expiresIn <= 0 { + expiresIn = 3600 + } + return strings.TrimSpace(tokenResponse.AccessToken), now.Add(time.Duration(expiresIn-60) * time.Second), nil +} + +func worksmobileTokenExpiresIn(raw any) int64 { + switch value := raw.(type) { + case float64: + return int64(value) + case int64: + return value + case string: + parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + return parsed + default: + return 0 + } +} + +func (c *WorksmobileHTTPClient) sendJSONWithToken(ctx context.Context, method string, path string, payload any, token string) error { + var body io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return err + } + body = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.baseURL(), "/")+path, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)} +} + +const worksmobileSCIMUserExtensionSchema = "urn:ietf:params:scim:schemas:extension:works:2.0:User" + +type WorksmobileSCIMUserPayload struct { + Schemas []string `json:"schemas"` + UserName string `json:"userName"` + ExternalID string `json:"externalId"` + DisplayName string `json:"displayName"` + Name WorksmobileSCIMName `json:"name"` + Emails []WorksmobileSCIMEmail `json:"emails"` + PhoneNumbers []WorksmobileSCIMPhoneNumber `json:"phoneNumbers,omitempty"` + Password string `json:"password,omitempty"` + Active bool `json:"active"` + PreferredLanguage string `json:"preferredLanguage,omitempty"` + WorksExtension map[string]any `json:"urn:ietf:params:scim:schemas:extension:works:2.0:User,omitempty"` +} + +type WorksmobileSCIMName struct { + FamilyName string `json:"familyName"` +} + +type WorksmobileSCIMEmail struct { + Value string `json:"value"` + Primary bool `json:"primary"` + Type string `json:"type,omitempty"` +} + +type WorksmobileSCIMPhoneNumber struct { + Value string `json:"value"` + Primary bool `json:"primary"` + Type string `json:"type,omitempty"` +} + +type WorksmobileUserPatchPayload struct { + DomainID int64 `json:"domainId"` + Email string `json:"email,omitempty"` + UserExternalKey string `json:"userExternalKey,omitempty"` + UserName WorksmobileUserName `json:"userName,omitempty"` + CellPhone string `json:"cellPhone,omitempty"` + EmployeeNumber string `json:"employeeNumber,omitempty"` + AliasEmails []string `json:"aliasEmails,omitempty"` + Locale string `json:"locale,omitempty"` + Task string `json:"task,omitempty"` + Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"` +} + +type WorksmobileRemoteUser struct { + ID string `json:"id"` + ExternalID string `json:"externalId"` + UserName string `json:"userName"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + LevelID string `json:"levelId"` + LevelName string `json:"levelName"` + Task string `json:"task"` + DomainID int64 `json:"domainId"` + DomainName string `json:"domainName"` + PrimaryOrgUnitID string `json:"primaryOrgUnitId"` + PrimaryOrgUnitName string `json:"primaryOrgUnitName"` + PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"` + PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"` + PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"` + Active bool `json:"active"` +} + +type WorksmobileRemoteGroup struct { + ID string `json:"id"` + ExternalID string `json:"externalId"` + DisplayName string `json:"displayName"` + DomainID int64 `json:"domainId"` + DomainName string `json:"domainName"` + ParentID string `json:"parentId"` + ParentName string `json:"parentName"` +} + +func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSCIMUserPayload { + name := strings.TrimSpace(payload.UserName.LastName) + if name == "" { + name = strings.TrimSpace(payload.Email) + } + result := WorksmobileSCIMUserPayload{ + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User", worksmobileSCIMUserExtensionSchema}, + UserName: strings.TrimSpace(payload.Email), + ExternalID: strings.TrimSpace(payload.UserExternalKey), + DisplayName: name, + Name: WorksmobileSCIMName{FamilyName: name}, + Emails: []WorksmobileSCIMEmail{{Value: strings.TrimSpace(payload.Email), Primary: true, Type: "other"}}, + Password: payload.PasswordConfig.Password, + Active: true, + PreferredLanguage: worksmobileSCIMPreferredLanguage(payload.Locale), + WorksExtension: map[string]any{ + "employeeNumber": payload.EmployeeNumber, + "task": payload.Task, + }, + } + if strings.TrimSpace(payload.CellPhone) != "" { + result.PhoneNumbers = []WorksmobileSCIMPhoneNumber{{Value: strings.TrimSpace(payload.CellPhone), Primary: true, Type: "mobile"}} + } + return result +} + +func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileUserPatchPayload { + return WorksmobileUserPatchPayload{ + DomainID: payload.DomainID, + Email: strings.TrimSpace(payload.Email), + UserExternalKey: strings.TrimSpace(payload.UserExternalKey), + UserName: payload.UserName, + CellPhone: strings.TrimSpace(payload.CellPhone), + EmployeeNumber: strings.TrimSpace(payload.EmployeeNumber), + AliasEmails: payload.AliasEmails, + Locale: strings.TrimSpace(payload.Locale), + Task: strings.TrimSpace(payload.Task), + Organizations: payload.Organizations, + } +} + +func worksmobileSCIMPreferredLanguage(locale string) string { + locale = strings.TrimSpace(locale) + if locale == "" { + return "" + } + return strings.ReplaceAll(locale, "_", "-") +} + +func parseWorksmobileRemoteUser(resource map[string]any) WorksmobileRemoteUser { + user := WorksmobileRemoteUser{ + ID: stringFromMap(resource, "id"), + ExternalID: stringFromMap(resource, "externalId"), + UserName: stringFromMap(resource, "userName"), + DisplayName: stringFromMap(resource, "displayName"), + Active: boolFromMap(resource, "active"), + } + if emails, ok := resource["emails"].([]any); ok { + for _, raw := range emails { + email, ok := raw.(map[string]any) + if !ok { + continue + } + if user.Email == "" || boolFromMap(email, "primary") { + user.Email = stringFromMap(email, "value") + } + } + } + if user.Email == "" && strings.Contains(user.UserName, "@") { + user.Email = user.UserName + } + user.PrimaryOrgUnitID, user.PrimaryOrgUnitName = parseWorksmobilePrimaryOrgUnit(resource) + return user +} + +func parseWorksmobileRemoteGroup(resource map[string]any) WorksmobileRemoteGroup { + group := WorksmobileRemoteGroup{ + ID: stringFromMap(resource, "id"), + ExternalID: stringFromMap(resource, "externalId"), + DisplayName: stringFromMap(resource, "displayName"), + } + group.ParentID, group.ParentName = parseWorksmobileParentOrgUnit(resource) + return group +} + +func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUser { + email := firstStringFromMap(resource, "email", "loginId", "userName") + user := WorksmobileRemoteUser{ + ID: firstStringFromMap(resource, "userId", "id"), + ExternalID: firstStringFromMap(resource, "userExternalKey", "externalKey", "externalId"), + UserName: email, + Email: email, + DisplayName: parseWorksmobileDirectoryUserName(resource), + LevelID: parseWorksmobileUserLevelID(resource), + LevelName: parseWorksmobileUserLevelName(resource), + Task: firstStringFromMap(resource, "task", "job", "jobDescription"), + Active: true, + } + if active, ok := resource["active"].(bool); ok { + user.Active = active + } + primaryOrgUnit := parseWorksmobilePrimaryOrgUnitDetail(resource) + user.PrimaryOrgUnitID = primaryOrgUnit.ID + user.PrimaryOrgUnitName = primaryOrgUnit.Name + user.PrimaryOrgUnitPositionID = primaryOrgUnit.PositionID + user.PrimaryOrgUnitPositionName = primaryOrgUnit.PositionName + user.PrimaryOrgUnitIsManager = primaryOrgUnit.IsManager + return user +} + +func parseWorksmobileDirectoryGroup(resource map[string]any) WorksmobileRemoteGroup { + return WorksmobileRemoteGroup{ + ID: firstStringFromMap(resource, "orgUnitId", "id"), + ExternalID: firstStringFromMap(resource, "orgUnitExternalKey", "externalKey", "externalId"), + DisplayName: firstStringFromMap(resource, "orgUnitName", "displayName", "name"), + ParentID: firstStringFromMap(resource, "parentOrgUnitId", "parentId"), + ParentName: firstStringFromMap(resource, "parentOrgUnitName", "parentName"), + } +} + +func parseWorksmobileDirectoryUserName(resource map[string]any) string { + if value := firstStringFromMap(resource, "displayName", "name"); value != "" { + return value + } + if name, ok := resource["userName"].(map[string]any); ok { + if value := firstStringFromMap(name, "fullName", "displayName", "name"); value != "" { + return value + } + if value := joinWorksmobileNameParts(firstStringFromMap(name, "lastName", "familyName"), firstStringFromMap(name, "firstName", "givenName")); value != "" { + return value + } + } + if name, ok := resource["name"].(map[string]any); ok { + if value := firstStringFromMap(name, "fullName", "displayName", "name"); value != "" { + return value + } + if value := joinWorksmobileNameParts(firstStringFromMap(name, "lastName", "familyName"), firstStringFromMap(name, "firstName", "givenName")); value != "" { + return value + } + } + return "" +} + +func joinWorksmobileNameParts(lastName, firstName string) string { + lastName = strings.TrimSpace(lastName) + firstName = strings.TrimSpace(firstName) + if lastName == "" { + return firstName + } + if firstName == "" { + return lastName + } + return lastName + firstName +} + +func parseWorksmobileUserLevelID(resource map[string]any) string { + if value := firstStringFromMap(resource, "levelId"); value != "" { + return value + } + if level, ok := resource["level"].(map[string]any); ok { + return firstStringFromMap(level, "levelId", "id", "value") + } + return "" +} + +func parseWorksmobileUserLevelName(resource map[string]any) string { + if value := firstStringFromMap(resource, "levelName"); value != "" { + return value + } + if level, ok := resource["level"].(map[string]any); ok { + return firstStringFromMap(level, "levelName", "displayName", "name") + } + return "" +} + +type worksmobileOrgUnitDetail struct { + ID string + Name string + PositionID string + PositionName string + IsManager *bool +} + +func (d worksmobileOrgUnitDetail) empty() bool { + return d.ID == "" && d.Name == "" && d.PositionID == "" && d.PositionName == "" && d.IsManager == nil +} + +func parseWorksmobilePrimaryOrgUnit(resource map[string]any) (string, string) { + detail := parseWorksmobilePrimaryOrgUnitDetail(resource) + return detail.ID, detail.Name +} + +func parseWorksmobilePrimaryOrgUnitDetail(resource map[string]any) worksmobileOrgUnitDetail { + if detail := parseWorksmobileOrgUnitDetailList(resource["organizations"], true); !detail.empty() { + return detail + } + if detail := parseWorksmobileOrgUnitDetailList(resource["orgUnits"], true); !detail.empty() { + return detail + } + for key, raw := range resource { + if !strings.Contains(strings.ToLower(key), "works") { + continue + } + if values, ok := raw.(map[string]any); ok { + if detail := parseWorksmobileOrgUnitDetailList(values["organizations"], true); !detail.empty() { + return detail + } + if detail := parseWorksmobileOrgUnitDetailList(values["orgUnits"], true); !detail.empty() { + return detail + } + } + } + return worksmobileOrgUnitDetail{} +} + +func parseWorksmobileParentOrgUnit(resource map[string]any) (string, string) { + id := firstStringFromMap(resource, "parentOrgUnitId", "parentId") + name := firstStringFromMap(resource, "parentOrgUnitName", "parentName") + if id != "" || name != "" { + return id, name + } + for _, key := range []string{"parent", "parentOrgUnit"} { + if values, ok := resource[key].(map[string]any); ok { + id = firstStringFromMap(values, "id", "orgUnitId", "value") + name = firstStringFromMap(values, "displayName", "orgUnitName", "name") + if id != "" || name != "" { + return id, name + } + } + } + for key, raw := range resource { + if !strings.Contains(strings.ToLower(key), "works") { + continue + } + if values, ok := raw.(map[string]any); ok { + if id, name := parseWorksmobileParentOrgUnit(values); id != "" || name != "" { + return id, name + } + } + } + return "", "" +} + +func parseWorksmobileOrgUnitList(raw any, preferPrimary bool) (string, string) { + detail := parseWorksmobileOrgUnitDetailList(raw, preferPrimary) + return detail.ID, detail.Name +} + +func parseWorksmobileOrgUnitDetailList(raw any, preferPrimary bool) worksmobileOrgUnitDetail { + values, ok := raw.([]any) + if !ok { + return worksmobileOrgUnitDetail{} + } + var fallback worksmobileOrgUnitDetail + for _, item := range values { + orgUnit, ok := item.(map[string]any) + if !ok { + continue + } + detail := worksmobileOrgUnitDetail{ + ID: firstStringFromMap(orgUnit, "orgUnitId", "id", "value"), + Name: firstStringFromMap(orgUnit, "orgUnitName", "displayName", "name"), + PositionID: firstStringFromMap(orgUnit, "positionId"), + PositionName: firstStringFromMap(orgUnit, "positionName"), + IsManager: boolPointerFromMap(orgUnit, "isManager", "manager"), + } + if detail.empty() { + if nested := parseWorksmobileOrgUnitDetailList(orgUnit["orgUnits"], preferPrimary); !nested.empty() { + detail = nested + } + } + if fallback.empty() { + fallback = detail + } + if !preferPrimary || boolFromMap(orgUnit, "primary") { + return detail + } + } + return fallback +} + +func stringFromMap(values map[string]any, key string) string { + value, _ := values[key].(string) + return strings.TrimSpace(value) +} + +func firstStringFromMap(values map[string]any, keys ...string) string { + for _, key := range keys { + if value := stringFromMap(values, key); value != "" { + return value + } + } + return "" +} + +func boolFromMap(values map[string]any, key string) bool { + value, _ := values[key].(bool) + return value +} + +func boolPointerFromMap(values map[string]any, keys ...string) *bool { + for _, key := range keys { + if value, ok := values[key].(bool); ok { + return &value + } + } + return nil +} + +func (c *WorksmobileHTTPClient) baseURL() string { + if strings.TrimSpace(c.BaseURL) == "" { + return defaultWorksmobileAPIBaseURL + } + return c.BaseURL +} + +func (c *WorksmobileHTTPClient) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return &http.Client{Timeout: 15 * time.Second} +} + +func (c *WorksmobileHTTPClient) currentTime() time.Time { + if c.now != nil { + return c.now() + } + return time.Now() +} diff --git a/backend/internal/service/worksmobile_client_test.go b/backend/internal/service/worksmobile_client_test.go new file mode 100644 index 00000000..087e346d --- /dev/null +++ b/backend/internal/service/worksmobile_client_test.go @@ -0,0 +1,703 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *testing.T) { + transport := &captureRoundTripper{ + statusCode: http.StatusCreated, + body: `{}`, + } + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + DirectoryToken: "directory-token-1", + SCIMToken: "scim-token-1", + HTTPClient: &http.Client{Transport: transport}, + } + err := client.CreateUser(context.Background(), WorksmobileUserPayload{ + DomainID: 300285955, + Email: "tester@samaneng.com", + UserExternalKey: "user-1", + UserName: WorksmobileUserName{LastName: "Tester"}, + AliasEmails: []string{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"}, + Locale: "ko_KR", + PasswordConfig: WorksmobilePasswordConfig{ + PasswordCreationType: "ADMIN", + Password: GenerateWorksmobileInitialPassword(), + }, + Organizations: []WorksmobileUserOrganization{ + {DomainID: 300285955, Primary: true, OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:tenant-saman"}}}, + }, + }) + + require.NoError(t, err) + require.NotNil(t, transport.request) + require.Equal(t, "/v1.0/users", transport.request.URL.Path) + require.Equal(t, http.MethodPost, transport.request.Method) + require.Equal(t, "Bearer directory-token-1", transport.request.Header.Get("Authorization")) + + var payload map[string]any + require.NoError(t, json.Unmarshal(transport.requestBody, &payload)) + require.Equal(t, "tester@samaneng.com", payload["email"]) + require.Equal(t, "user-1", payload["userExternalKey"]) + require.NotContains(t, payload, "privateEmail") + require.Equal(t, []any{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"}, payload["aliasEmails"]) + passwordConfig := payload["passwordConfig"].(map[string]any) + require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"]) + require.Len(t, passwordConfig["password"], 16) +} + +func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOrPrivateEmail(t *testing.T) { + transport := &captureRoundTripper{ + responses: []captureResponse{ + {statusCode: http.StatusConflict, body: `{"code":"ALREADY_EXISTS"}`}, + {statusCode: http.StatusOK, body: `{}`}, + }, + } + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + DirectoryToken: "directory-token-1", + HTTPClient: &http.Client{Transport: transport}, + } + + err := client.UpsertUser(context.Background(), WorksmobileUserPayload{ + DomainID: 300285955, + Email: "tester@samaneng.com", + UserExternalKey: "user-1", + UserName: WorksmobileUserName{LastName: "Tester"}, + PrivateEmail: "private@example.com", + PasswordConfig: WorksmobilePasswordConfig{ + PasswordCreationType: "ADMIN", + Password: GenerateWorksmobileInitialPassword(), + }, + Organizations: []WorksmobileUserOrganization{ + { + DomainID: 300285955, + Primary: true, + OrgUnits: []WorksmobileUserOrgUnit{ + {OrgUnitID: "externalKey:tenant-saman", Primary: true}, + }, + }, + }, + }) + + require.NoError(t, err) + require.Len(t, transport.requests, 2) + require.Equal(t, http.MethodPost, transport.requests[0].Method) + require.Equal(t, "/v1.0/users", transport.requests[0].URL.Path) + require.Equal(t, http.MethodPatch, transport.requests[1].Method) + require.Equal(t, "/v1.0/users/tester@samaneng.com", transport.requests[1].URL.Path) + + var patchPayload map[string]any + require.NoError(t, json.Unmarshal(transport.requestBodies[1], &patchPayload)) + require.NotContains(t, patchPayload, "passwordConfig") + require.NotContains(t, patchPayload, "privateEmail") + require.Equal(t, "tester@samaneng.com", patchPayload["email"]) + require.Equal(t, "user-1", patchPayload["userExternalKey"]) +} + +func TestWorksmobileHTTPClientCreateUserRequiresDirectoryToken(t *testing.T) { + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + SCIMToken: "scim-token-1", + HTTPClient: &http.Client{Transport: &captureRoundTripper{statusCode: http.StatusCreated, body: `{}`}}, + } + + err := client.CreateUser(context.Background(), WorksmobileUserPayload{Email: "tester@samaneng.com"}) + + require.Error(t, err) + require.Contains(t, err.Error(), "worksmobile directory token is not configured") +} + +func TestWorksmobileHTTPClientRequestsJWTBearerAccessToken(t *testing.T) { + privateKey := testRSAPrivateKeyPEM(t) + transport := &captureRoundTripper{ + responses: []captureResponse{ + {statusCode: http.StatusOK, body: `{"access_token":"directory-token-from-jwt","token_type":"Bearer","expires_in":3600}`}, + {statusCode: http.StatusCreated, body: `{}`}, + }, + } + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + HTTPClient: &http.Client{Transport: transport}, + now: func() time.Time { return time.Unix(1710000000, 0) }, + OAuthConfig: WorksmobileOAuthConfig{ + ClientID: "client-id-1", + ClientSecret: "client-secret-1", + ServiceAccount: "service-account-1", + PrivateKey: privateKey, + Scope: "directory", + TokenURL: "https://auth.example.test/token", + }, + } + + err := client.CreateUser(context.Background(), WorksmobileUserPayload{ + DomainID: 300285955, + Email: "tester@samaneng.com", + UserExternalKey: "user-1", + UserName: WorksmobileUserName{LastName: "Tester"}, + PasswordConfig: WorksmobilePasswordConfig{PasswordCreationType: "ADMIN", Password: "Aa1!Aa1!Aa1!Aa1!"}, + }) + + require.NoError(t, err) + require.Len(t, transport.requests, 2) + require.Equal(t, "https://auth.example.test/token", transport.requests[0].URL.String()) + require.Equal(t, "/v1.0/users", transport.requests[1].URL.Path) + require.Equal(t, "Bearer directory-token-from-jwt", transport.requests[1].Header.Get("Authorization")) + + form, err := url.ParseQuery(string(transport.requestBodies[0])) + require.NoError(t, err) + require.Equal(t, "urn:ietf:params:oauth:grant-type:jwt-bearer", form.Get("grant_type")) + require.Equal(t, "client-id-1", form.Get("client_id")) + require.Equal(t, "client-secret-1", form.Get("client_secret")) + require.Equal(t, "directory", form.Get("scope")) + + parts := strings.Split(form.Get("assertion"), ".") + require.Len(t, parts, 3) + payloadData, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(payloadData, &payload)) + require.Equal(t, "client-id-1", payload["iss"]) + require.Equal(t, "service-account-1", payload["sub"]) + require.Equal(t, float64(1710000000), payload["iat"]) + require.Equal(t, float64(1710003600), payload["exp"]) +} + +func TestWorksmobileHTTPClientListUsersUsesDirectoryAPIFirst(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "300285955") + transport := &captureRoundTripper{ + responses: []captureResponse{ + {statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"tester@samaneng.com","userName":{"lastName":"Tester"},"organizations":[{"primary":true,"orgUnits":[{"orgUnitId":"works-org-1","orgUnitName":"삼안"}]}]}],"responseMetaData":{}}`}, + }, + } + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + DirectoryToken: "directory-token-1", + SCIMToken: "scim-token-1", + DomainIDs: []int64{300285955}, + HTTPClient: &http.Client{Transport: transport}, + } + + users, err := client.ListUsers(context.Background()) + + require.NoError(t, err) + require.Len(t, users, 1) + require.Equal(t, "user-1", users[0].ExternalID) + require.Equal(t, "tester@samaneng.com", users[0].Email) + require.Equal(t, int64(300285955), users[0].DomainID) + require.Equal(t, "삼안", users[0].DomainName) + require.Equal(t, "works-org-1", users[0].PrimaryOrgUnitID) + require.Len(t, transport.requests, 1) + require.Equal(t, "/v1.0/users", transport.requests[0].URL.Path) + require.Equal(t, "300285955", transport.requests[0].URL.Query().Get("domainId")) +} + +func TestWorksmobileHTTPClientListUsersFallsBackToSCIMWhenDirectoryFails(t *testing.T) { + transport := &captureRoundTripper{ + responses: []captureResponse{ + {statusCode: http.StatusForbidden, body: `{"code":"FORBIDDEN"}`}, + {statusCode: http.StatusOK, body: `{"totalResults":1,"Resources":[{"id":"scim-user-1","userName":"tester@samaneng.com","displayName":"Tester","emails":[]}]} `}, + }, + } + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + DirectoryToken: "directory-token-1", + SCIMToken: "scim-token-1", + DomainIDs: []int64{300285955}, + HTTPClient: &http.Client{Transport: transport}, + } + + users, err := client.ListUsers(context.Background()) + + require.NoError(t, err) + require.Len(t, users, 1) + require.Equal(t, "scim-user-1", users[0].ID) + require.Equal(t, "tester@samaneng.com", users[0].Email) + require.Equal(t, "/v1.0/users", transport.requests[0].URL.Path) + require.Equal(t, "/scim/v2/Users", transport.requests[1].URL.Path) +} + +func TestWorksmobileHTTPClientListGroupsUsesDirectoryAPIFirst(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "300285955") + transport := &captureRoundTripper{ + responses: []captureResponse{ + {statusCode: http.StatusOK, body: `{"orgUnits":[{"orgUnitId":"works-org-1","orgUnitExternalKey":"tenant-1","orgUnitName":"삼안","parentOrgUnitId":"parent-1","parentOrgUnitName":"상위"}],"responseMetaData":{}}`}, + }, + } + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + DirectoryToken: "directory-token-1", + SCIMToken: "scim-token-1", + DomainIDs: []int64{300285955}, + HTTPClient: &http.Client{Transport: transport}, + } + + groups, err := client.ListGroups(context.Background()) + + require.NoError(t, err) + require.Len(t, groups, 1) + require.Equal(t, "tenant-1", groups[0].ExternalID) + require.Equal(t, "삼안", groups[0].DisplayName) + require.Equal(t, int64(300285955), groups[0].DomainID) + require.Equal(t, "삼안", groups[0].DomainName) + require.Equal(t, "parent-1", groups[0].ParentID) + require.Equal(t, "/v1.0/orgunits", transport.requests[0].URL.Path) +} + +func TestWorksmobileLiveJWTTokenExchange(t *testing.T) { + if os.Getenv("WORKSMOBILE_LIVE_JWT_TOKEN_EXCHANGE") != "1" { + t.Skip("live Worksmobile token exchange is disabled") + } + client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ + ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), + ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), + PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), + Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), + }) + + token, err := client.directoryAccessToken(context.Background()) + + require.NoError(t, err) + require.NotEmpty(t, token) +} + +func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T) { + repo := &fakeWorksmobileOutboxRepo{ + ready: []domain.WorksmobileOutbox{ + { + ID: "job-1", + ResourceType: domain.WorksmobileResourceUser, + ResourceID: "user-1", + Action: domain.WorksmobileActionUpsert, + Status: domain.WorksmobileOutboxStatusPending, + Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{ + Email: "tester@samaneng.com", + UserExternalKey: "user-1", + PasswordConfig: WorksmobilePasswordConfig{ + PasswordCreationType: "ADMIN", + Password: "Aa1!Aa1!Aa1!Aa1!", + }, + }), + }, + }, + } + client := &fakeWorksmobileDirectoryClient{} + worker := NewWorksmobileRelayWorker(repo, client) + + err := worker.ProcessOnce(context.Background()) + + require.NoError(t, err) + require.Equal(t, []string{"job-1"}, repo.processingIDs) + require.Equal(t, []string{"job-1"}, repo.processedIDs) + require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email) +} + +func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) { + jobs := []domain.WorksmobileOutbox{ + { + ID: "job-1", + Payload: domain.JSONMap{ + "loginEmail": "tester@samaneng.com", + "initialPassword": "Aa1!Aa1!Aa1!Aa1!", + }, + }, + } + + redacted := redactWorksmobileOutboxPayloads(jobs) + + require.Nil(t, redacted[0].Payload) +} + +func TestCompareWorksmobileUsersHidesMatchedByDefault(t *testing.T) { + localUsers := []domain.User{ + {ID: "user-1", Email: "matched@samaneng.com", Name: "Matched"}, + {ID: "user-2", Email: "missing@samaneng.com", Name: "Missing"}, + } + remoteUsers := []WorksmobileRemoteUser{ + {ID: "works-1", ExternalID: "user-1", Email: "matched@samaneng.com", DisplayName: "Matched"}, + } + + diffOnly := compareWorksmobileUsers(localUsers, remoteUsers, false, nil) + all := compareWorksmobileUsers(localUsers, remoteUsers, true, nil) + + require.Len(t, diffOnly, 1) + require.Equal(t, "missing_in_worksmobile", diffOnly[0].Status) + require.Len(t, all, 2) + require.Equal(t, "matched", all[0].Status) +} + +func TestCompareWorksmobileUsersIncludesBaronAndWorksPrimaryOrg(t *testing.T) { + tenantID := "tenant-primary" + localUsers := []domain.User{ + {ID: "user-1", Email: "matched@samaneng.com", Name: "Matched", TenantID: &tenantID}, + } + localTenants := map[string]domain.Tenant{ + tenantID: {ID: tenantID, Name: "기술기획", Slug: "tech-planning"}, + } + remoteUsers := []WorksmobileRemoteUser{ + { + ID: "works-1", + ExternalID: "user-1", + Email: "matched@samaneng.com", + DisplayName: "Matched", + DomainID: 300285955, + DomainName: "삼안", + PrimaryOrgUnitID: "works-org-1", + PrimaryOrgUnitName: "WORKS 기술기획", + }, + } + + items := compareWorksmobileUsers(localUsers, remoteUsers, true, localTenants) + + require.Len(t, items, 1) + require.Equal(t, tenantID, items[0].BaronPrimaryOrgID) + require.Equal(t, "기술기획", items[0].BaronPrimaryOrgName) + require.Equal(t, int64(300285955), items[0].WorksmobileDomainID) + require.Equal(t, "삼안", items[0].WorksmobileDomainName) + require.Equal(t, "works-org-1", items[0].WorksmobilePrimaryOrgID) + require.Equal(t, "WORKS 기술기획", items[0].WorksmobilePrimaryOrgName) +} + +func TestCompareWorksmobileUsersMatchesByEmailWhenDirectoryAPIOmitsExternalID(t *testing.T) { + localUsers := []domain.User{ + {ID: "user-1", Email: "tester@samaneng.com", Name: "Tester"}, + } + remoteUsers := []WorksmobileRemoteUser{ + {ID: "works-1", ExternalID: "", Email: "tester@samaneng.com", DisplayName: "Tester"}, + } + + diffOnly := compareWorksmobileUsers(localUsers, remoteUsers, false, nil) + all := compareWorksmobileUsers(localUsers, remoteUsers, true, nil) + + require.Empty(t, diffOnly) + require.Len(t, all, 1) + require.Equal(t, "matched", all[0].Status) + require.Equal(t, "works-1", all[0].WorksmobileID) + require.Empty(t, all[0].ExternalKey) +} + +func TestCompareWorksmobileUsersIncludesWorksOnlyRowsWithoutExternalIDWhenIncludingMatched(t *testing.T) { + remoteUsers := []WorksmobileRemoteUser{ + {ID: "works-1", ExternalID: "", Email: "works-only@samaneng.com", DisplayName: "Works Only"}, + } + + items := compareWorksmobileUsers(nil, remoteUsers, true, nil) + + require.Len(t, items, 1) + require.Equal(t, "missing_external_key", items[0].Status) + require.Equal(t, "works-1", items[0].WorksmobileID) + require.Equal(t, "works-only@samaneng.com", items[0].WorksmobileEmail) +} + +func TestCompareWorksmobileGroupsIncludesBaronAndWorksParentOrg(t *testing.T) { + parentID := "tenant-parent" + childID := "tenant-child" + localTenants := []domain.Tenant{ + {ID: parentID, Name: "기술본부", Type: domain.TenantTypeOrganization}, + {ID: childID, Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &parentID}, + } + remoteGroups := []WorksmobileRemoteGroup{ + { + ID: "works-parent", + ExternalID: parentID, + DisplayName: "WORKS 기술본부", + DomainID: 300286337, + DomainName: "총괄기획&기술개발센터", + }, + { + ID: "works-child", + ExternalID: childID, + DisplayName: "WORKS 기술기획", + DomainID: 300286337, + DomainName: "총괄기획&기술개발센터", + ParentID: "works-parent", + ParentName: "WORKS 기술본부", + }, + } + + items := compareWorksmobileGroups(localTenants, remoteGroups, true) + + require.Len(t, items, 2) + require.Equal(t, parentID, items[1].BaronParentID) + require.Equal(t, "기술본부", items[1].BaronParentName) + require.Equal(t, int64(300286337), items[1].WorksmobileDomainID) + require.Equal(t, "총괄기획&기술개발센터", items[1].WorksmobileDomainName) + require.Equal(t, "works-parent", items[1].WorksmobileParentID) + require.Equal(t, "WORKS 기술본부", items[1].WorksmobileParentName) +} + +func TestCompareWorksmobileGroupsDoesNotMatchDomainCompanyByDomainID(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + parentID := "root-tenant" + localTenants := []domain.Tenant{ + { + ID: "company-saman", + Name: "삼안", + Type: domain.TenantTypeCompany, + ParentID: &parentID, + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + }, + } + remoteGroups := []WorksmobileRemoteGroup{ + { + ID: "works-org-1", + DisplayName: "WORKS 전용 조직", + DomainID: 1001, + DomainName: "삼안", + }, + } + + items := compareWorksmobileGroups(localTenants, remoteGroups, true) + + require.Len(t, items, 1) + require.Empty(t, items[0].BaronID) + require.Equal(t, "missing_external_key", items[0].Status) +} + +func TestCompareWorksmobileGroupsIncludesWorksOnlyRowsWithoutExternalIDWhenIncludingMatched(t *testing.T) { + remoteGroups := []WorksmobileRemoteGroup{ + {ID: "works-group-1", ExternalID: "", DisplayName: "WORKS 전용 조직"}, + } + + items := compareWorksmobileGroups(nil, remoteGroups, true) + + require.Len(t, items, 1) + require.Equal(t, "missing_external_key", items[0].Status) + require.Equal(t, "works-group-1", items[0].WorksmobileID) + require.Equal(t, "WORKS 전용 조직", items[0].WorksmobileName) +} + +func TestParseWorksmobileRemoteUserUsesUserNameEmailWhenEmailsAreEmpty(t *testing.T) { + user := parseWorksmobileRemoteUser(map[string]any{ + "id": "works-1", + "userName": "tester@samaneng.com", + "displayName": "Tester", + "emails": []any{}, + }) + + require.Equal(t, "tester@samaneng.com", user.UserName) + require.Equal(t, "tester@samaneng.com", user.Email) +} + +func TestParseWorksmobileRemoteResourcesExtractsOrgFields(t *testing.T) { + user := parseWorksmobileRemoteUser(map[string]any{ + "id": "works-user", + "externalId": "user-1", + "organizations": []any{ + map[string]any{ + "primary": true, + "orgUnitId": "works-org-1", + "orgUnitName": "WORKS 기술기획", + }, + }, + }) + group := parseWorksmobileRemoteGroup(map[string]any{ + "id": "works-group", + "externalId": "group-1", + "parent": map[string]any{ + "id": "works-parent", + "displayName": "WORKS 기술본부", + }, + }) + + require.Equal(t, "works-org-1", user.PrimaryOrgUnitID) + require.Equal(t, "WORKS 기술기획", user.PrimaryOrgUnitName) + require.Equal(t, "works-parent", group.ParentID) + require.Equal(t, "WORKS 기술본부", group.ParentName) +} + +func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing.T) { + user := parseWorksmobileDirectoryUser(map[string]any{ + "userId": "works-user", + "email": "tester@samaneng.com", + "userName": map[string]any{ + "lastName": "홍", + "firstName": "길동", + }, + "levelId": "level-1", + "levelName": "책임", + "task": "기술검토", + "organizations": []any{ + map[string]any{ + "primary": true, + "orgUnits": []any{ + map[string]any{ + "orgUnitId": "works-org-1", + "orgUnitName": "기술기획", + "positionId": "position-1", + "positionName": "팀장", + "isManager": true, + }, + }, + }, + }, + }) + + require.Equal(t, "홍길동", user.DisplayName) + require.Equal(t, "level-1", user.LevelID) + require.Equal(t, "책임", user.LevelName) + require.Equal(t, "기술검토", user.Task) + require.Equal(t, "works-org-1", user.PrimaryOrgUnitID) + require.Equal(t, "기술기획", user.PrimaryOrgUnitName) + require.Equal(t, "position-1", user.PrimaryOrgUnitPositionID) + require.Equal(t, "팀장", user.PrimaryOrgUnitPositionName) + require.NotNil(t, user.PrimaryOrgUnitIsManager) + require.True(t, *user.PrimaryOrgUnitIsManager) +} + +type fakeWorksmobileOutboxRepo struct { + ready []domain.WorksmobileOutbox + created []domain.WorksmobileOutbox + processingIDs []string + processedIDs []string + failedIDs []string +} + +func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error { + f.created = append(f.created, *item) + return nil +} + +func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) { + return nil, nil +} + +func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) { + return f.ready, nil +} + +func (f *fakeWorksmobileOutboxRepo) FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error) { + return nil, nil +} + +func (f *fakeWorksmobileOutboxRepo) MarkRetry(ctx context.Context, id string) error { + return nil +} + +func (f *fakeWorksmobileOutboxRepo) MarkProcessing(ctx context.Context, id string) error { + f.processingIDs = append(f.processingIDs, id) + return nil +} + +func (f *fakeWorksmobileOutboxRepo) MarkProcessed(ctx context.Context, id string) error { + f.processedIDs = append(f.processedIDs, id) + return nil +} + +func (f *fakeWorksmobileOutboxRepo) MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error { + f.failedIDs = append(f.failedIDs, id) + return nil +} + +type fakeWorksmobileDirectoryClient struct { + createdOrgUnits []WorksmobileOrgUnitPayload + createdUsers []WorksmobileUserPayload + deletedUsers []string +} + +type captureRoundTripper struct { + request *http.Request + requestBody []byte + statusCode int + body string + responses []captureResponse + requests []*http.Request + requestBodies [][]byte +} + +type captureResponse struct { + statusCode int + body string +} + +func (t *captureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + t.request = req + t.requests = append(t.requests, req) + if req.Body != nil { + data, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + t.requestBody = data + t.requestBodies = append(t.requestBodies, data) + } + statusCode := t.statusCode + body := t.body + if len(t.responses) > 0 { + response := t.responses[0] + t.responses = t.responses[1:] + statusCode = response.statusCode + body = response.body + } + if statusCode == 0 { + statusCode = http.StatusOK + } + return &http.Response{ + StatusCode: statusCode, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil +} + +func testRSAPrivateKeyPEM(t *testing.T) string { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + data := x509.MarshalPKCS1PrivateKey(key) + return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: data})) +} + +func getenvDefault(key string, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func (f *fakeWorksmobileDirectoryClient) CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error { + f.createdOrgUnits = append(f.createdOrgUnits, payload) + return nil +} + +func (f *fakeWorksmobileDirectoryClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error { + f.createdUsers = append(f.createdUsers, payload) + return nil +} + +func (f *fakeWorksmobileDirectoryClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error { + f.createdUsers = append(f.createdUsers, payload) + return nil +} + +func (f *fakeWorksmobileDirectoryClient) DeleteUser(ctx context.Context, userID string) error { + f.deletedUsers = append(f.deletedUsers, userID) + return nil +} + +func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) { + return nil, nil +} + +func (f *fakeWorksmobileDirectoryClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) { + return nil, nil +} diff --git a/backend/internal/service/worksmobile_live_flow_test.go b/backend/internal/service/worksmobile_live_flow_test.go new file mode 100644 index 00000000..ff2a9cc5 --- /dev/null +++ b/backend/internal/service/worksmobile_live_flow_test.go @@ -0,0 +1,145 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) { + if os.Getenv("WORKSMOBILE_LIVE_SAMAN_PROVISIONING") != "1" { + t.Skip("live Worksmobile Saman provisioning is disabled") + } + ctx := context.Background() + db, err := gorm.Open(postgres.Open(worksmobileLiveDSN()), &gorm.Config{}) + require.NoError(t, err) + + tenantRepo := repository.NewTenantRepository(db) + userRepo := repository.NewUserRepository(db) + userGroupRepo := repository.NewUserGroupRepository(db) + outboxRepo := repository.NewWorksmobileOutboxRepository(db) + tenantService := NewTenantService(tenantRepo, userRepo, userGroupRepo, nil) + client := NewWorksmobileHTTPClientWithAuth("", os.Getenv("SAMAN_SCIM_LONGLIVE_TOKEN"), WorksmobileOAuthConfig{ + ClientID: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET"), + ServiceAccount: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT"), + PrivateKey: os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY"), + Scope: getenvDefault("WORKS_ADMIN_OAUTH_SCOPE", "directory"), + }) + syncService := NewWorksmobileSyncService(tenantService, userRepo, outboxRepo, client) + worker := NewWorksmobileRelayWorker(outboxRepo, client) + + root, err := tenantService.GetTenantBySlug(ctx, HanmacFamilyTenantSlug) + require.NoError(t, err) + samanTenant, err := tenantService.GetTenantBySlug(ctx, "saman") + require.NoError(t, err) + createWorksmobileLiveOrgUnitIfMissing(t, ctx, client, *samanTenant) + + targetEmails := []string{"tester@samaneng.com", "orgadmin@samaneng.com"} + for _, email := range targetEmails { + user, err := userRepo.FindByEmail(ctx, email) + require.NoError(t, err) + dedupeKey := "user:" + strings.ToLower(WorksmobileUserStatusAction(user.Status)) + ":" + user.ID + job := findWorksmobileLiveOutboxByDedupe(t, db, dedupeKey) + if job.Status != domain.WorksmobileOutboxStatusProcessed { + remote, err := client.FindUser(ctx, user.Email) + require.NoError(t, err) + if remote != nil { + require.NoError(t, outboxRepo.MarkProcessed(ctx, job.ID)) + continue + } + item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID) + require.NoError(t, err) + require.NotEmpty(t, item) + require.NoError(t, outboxRepo.MarkRetry(ctx, job.ID)) + job = findWorksmobileLiveOutboxByDedupe(t, db, dedupeKey) + err = worker.processJob(ctx, job) + require.NoError(t, err) + } + + processed, err := outboxRepo.FindByID(ctx, job.ID) + require.NoError(t, err) + require.Equal(t, domain.WorksmobileOutboxStatusProcessed, processed.Status) + } + + credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID) + require.NoError(t, err) + seen := map[string]bool{} + for _, credential := range credentials { + if credential.Email == "tester@samaneng.com" || credential.Email == "orgadmin@samaneng.com" { + require.Equal(t, domain.WorksmobileOutboxStatusProcessed, credential.Status) + require.Len(t, credential.InitialPassword, 16) + seen[credential.Email] = true + } + } + require.True(t, seen["tester@samaneng.com"]) + require.True(t, seen["orgadmin@samaneng.com"]) + + remoteUsers, err := client.ListUsers(ctx) + require.NoError(t, err) + remoteByEmail := map[string]WorksmobileRemoteUser{} + for _, user := range remoteUsers { + remoteByEmail[user.Email] = user + } + require.NotEmpty(t, remoteByEmail["tester@samaneng.com"].ID) + require.NotEmpty(t, remoteByEmail["orgadmin@samaneng.com"].ID) + + remoteGroups, err := client.ListGroups(ctx) + require.NoError(t, err) + foundSamanOrgUnit := false + for _, group := range remoteGroups { + if group.ExternalID == samanTenant.ID { + foundSamanOrgUnit = true + require.Equal(t, "삼안", group.DisplayName) + } + } + require.True(t, foundSamanOrgUnit) +} + +func createWorksmobileLiveOrgUnitIfMissing(t *testing.T, ctx context.Context, client *WorksmobileHTTPClient, tenant domain.Tenant) { + t.Helper() + payload, err := BuildWorksmobileOrgUnitPayload(tenant, nil, 1) + require.NoError(t, err) + if tenant.ParentID != nil { + payload.ParentOrgUnitID = "" + } + err = client.CreateOrgUnit(ctx, payload) + if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == 409 { + return + } + require.NoError(t, err) +} + +func worksmobileLiveDSN() string { + host := getenvDefault("DB_HOST", "localhost") + port := getenvDefault("DB_PORT", "5432") + user := getenvDefault("DB_USER", "baron") + password := os.Getenv("DB_PASSWORD") + name := getenvDefault("DB_NAME", "baron_sso") + return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul", host, user, password, name, port) +} + +func findWorksmobileLiveOutboxByDedupe(t *testing.T, db *gorm.DB, dedupeKey string) domain.WorksmobileOutbox { + t.Helper() + var job domain.WorksmobileOutbox + deadline := time.Now().Add(3 * time.Second) + for { + err := db.Where("dedupe_key = ?", dedupeKey).First(&job).Error + if err == nil { + return job + } + if time.Now().After(deadline) { + require.NoError(t, err) + } + time.Sleep(100 * time.Millisecond) + } +} diff --git a/backend/internal/service/worksmobile_mapper.go b/backend/internal/service/worksmobile_mapper.go new file mode 100644 index 00000000..9799fe44 --- /dev/null +++ b/backend/internal/service/worksmobile_mapper.go @@ -0,0 +1,604 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "crypto/rand" + "errors" + "fmt" + "math/big" + "net/mail" + "os" + "sort" + "strconv" + "strings" +) + +const ( + WorksmobileUserActionUpsert = "UPSERT" + WorksmobileUserActionSuspend = "SUSPEND" +) + +type WorksmobileOrgUnitPayload struct { + DomainID int64 `json:"domainId"` + OrgUnitName string `json:"orgUnitName"` + OrgUnitExternalKey string `json:"orgUnitExternalKey"` + ParentOrgUnitID string `json:"parentOrgUnitId,omitempty"` + DisplayOrder int `json:"displayOrder"` +} + +type WorksmobileUserPayload struct { + DomainID int64 `json:"domainId"` + Email string `json:"email"` + UserExternalKey string `json:"userExternalKey"` + UserName WorksmobileUserName `json:"userName"` + CellPhone string `json:"cellPhone,omitempty"` + EmployeeNumber string `json:"employeeNumber,omitempty"` + PrivateEmail string `json:"privateEmail,omitempty"` + AliasEmails []string `json:"aliasEmails,omitempty"` + Locale string `json:"locale,omitempty"` + PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"` + Task string `json:"task,omitempty"` + Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"` +} + +type WorksmobileUserName struct { + LastName string `json:"lastName,omitempty"` +} + +type WorksmobilePasswordConfig struct { + PasswordCreationType string `json:"passwordCreationType"` + Password string `json:"password"` +} + +type WorksmobileUserOrganization struct { + DomainID int64 `json:"domainId,omitempty"` + Primary bool `json:"primary,omitempty"` + OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"` +} + +type WorksmobileUserOrgUnit struct { + OrgUnitID string `json:"orgUnitId"` + Primary bool `json:"primary,omitempty"` + PositionID string `json:"positionId,omitempty"` + IsManager *bool `json:"isManager,omitempty"` +} + +func BuildWorksmobileOrgUnitPayload(tenant domain.Tenant, rootConfig domain.JSONMap, displayOrder int) (WorksmobileOrgUnitPayload, error) { + return BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, tenant, rootConfig, displayOrder) +} + +func BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant domain.Tenant, domainTenant domain.Tenant, rootConfig domain.JSONMap, displayOrder int) (WorksmobileOrgUnitPayload, error) { + if err := ValidateWorksmobileExternalKey(tenant.ID); err != nil { + return WorksmobileOrgUnitPayload{}, err + } + domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig) + if err != nil { + return WorksmobileOrgUnitPayload{}, err + } + payload := WorksmobileOrgUnitPayload{ + DomainID: domainID, + OrgUnitName: strings.TrimSpace(tenant.Name), + OrgUnitExternalKey: tenant.ID, + DisplayOrder: displayOrder, + } + if tenant.ParentID != nil && *tenant.ParentID != "" { + if err := ValidateWorksmobileExternalKey(*tenant.ParentID); err != nil { + return WorksmobileOrgUnitPayload{}, err + } + payload.ParentOrgUnitID = "externalKey:" + *tenant.ParentID + } + return payload, nil +} + +func BuildWorksmobileUserPayload(user domain.User, tenant domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) { + return BuildWorksmobileUserPayloadForDomainTenant(user, tenant, tenant, rootConfig) +} + +func BuildWorksmobileUserPayloadForDomainTenant(user domain.User, tenant domain.Tenant, _ domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) { + return BuildWorksmobileUserPayloadForDomainTenants(user, tenant, map[string]domain.Tenant{tenant.ID: tenant}, rootConfig) +} + +func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) { + if err := ValidateWorksmobileExternalKey(user.ID); err != nil { + return WorksmobileUserPayload{}, err + } + if tenant.ID == "" { + return WorksmobileUserPayload{}, errors.New("tenant is required") + } + if tenantByID == nil { + tenantByID = map[string]domain.Tenant{} + } + tenantByID[tenant.ID] = tenant + domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID) + domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig) + if err != nil { + return WorksmobileUserPayload{}, err + } + employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number") + organizations, task, err := buildWorksmobileUserOrganizations(user, tenant, tenantByID, rootConfig) + if err != nil { + return WorksmobileUserPayload{}, err + } + if task == "" { + task = strings.TrimSpace(user.JobTitle) + } + payload := WorksmobileUserPayload{ + DomainID: domainID, + Email: strings.TrimSpace(user.Email), + UserExternalKey: user.ID, + UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)}, + CellPhone: strings.TrimSpace(user.Phone), + EmployeeNumber: employeeNumber, + Locale: "ko_KR", + PasswordConfig: WorksmobilePasswordConfig{ + PasswordCreationType: "ADMIN", + Password: GenerateWorksmobileInitialPassword(), + }, + Task: task, + Organizations: organizations, + } + payload.AliasEmails = BuildWorksmobileAliasEmails(user, tenant) + return payload, nil +} + +type worksmobileAppointment struct { + TenantID string + IsPrimary bool + IsOwner bool + HasOwner bool + JobTitle string + PositionID string +} + +func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) ([]WorksmobileUserOrganization, string, error) { + appointments := worksmobileAppointmentsFromMetadata(user.Metadata) + if len(appointments) == 0 { + appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}} + } + primaryTenantID := metadataString(user.Metadata, "primaryTenantId", "primary_tenant_id") + if primaryTenantID == "" && user.TenantID != nil { + primaryTenantID = *user.TenantID + } + hasPrimary := false + for i := range appointments { + if appointments[i].TenantID == primaryTenantID || appointments[i].IsPrimary { + appointments[i].IsPrimary = true + hasPrimary = true + break + } + } + if !hasPrimary { + for i := range appointments { + if appointments[i].TenantID == tenant.ID { + appointments[i].IsPrimary = true + break + } + } + } + + organizations := make([]WorksmobileUserOrganization, 0, len(appointments)) + seen := map[string]bool{} + task := "" + for _, appointment := range appointments { + if appointment.TenantID == "" || seen[appointment.TenantID] { + continue + } + appointmentTenant, ok := tenantByID[appointment.TenantID] + if !ok { + continue + } + if err := ValidateWorksmobileExternalKey(appointmentTenant.ID); err != nil { + return nil, "", err + } + domainTenant := worksmobileDomainClassificationTenant(appointmentTenant, tenantByID) + domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig) + if err != nil { + return nil, "", err + } + orgUnit := WorksmobileUserOrgUnit{ + OrgUnitID: "externalKey:" + appointmentTenant.ID, + Primary: appointment.IsPrimary, + PositionID: appointment.PositionID, + } + if appointment.HasOwner { + isManager := appointment.IsOwner + orgUnit.IsManager = &isManager + } + organizations = append(organizations, WorksmobileUserOrganization{ + DomainID: domainID, + Primary: appointment.IsPrimary, + OrgUnits: []WorksmobileUserOrgUnit{orgUnit}, + }) + if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" { + task = strings.TrimSpace(appointment.JobTitle) + } + seen[appointment.TenantID] = true + } + if len(organizations) == 0 { + return nil, "", errors.New("no valid worksmobile organization") + } + sortWorksmobileOrganizations(organizations) + return organizations, task, nil +} + +func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileAppointment { + rawAppointments, ok := metadata["additionalAppointments"].([]any) + if !ok { + return nil + } + appointments := make([]worksmobileAppointment, 0, len(rawAppointments)) + for _, raw := range rawAppointments { + item, ok := raw.(map[string]any) + if !ok { + continue + } + appointment := worksmobileAppointment{ + TenantID: metadataString(domain.JSONMap(item), "tenantId", "tenant_id"), + IsPrimary: metadataBool(domain.JSONMap(item), "isPrimary", "primary"), + JobTitle: metadataString(domain.JSONMap(item), "jobTitle", "job_title", "task"), + PositionID: metadataString(domain.JSONMap(item), "worksmobilePositionId", "positionId", "position_id"), + } + if isOwner, ok := metadataOptionalBool(domain.JSONMap(item), "isOwner", "isManager"); ok { + appointment.IsOwner = isOwner + appointment.HasOwner = true + } + appointments = append(appointments, appointment) + } + return appointments +} + +func sortWorksmobileOrganizations(organizations []WorksmobileUserOrganization) { + sort.SliceStable(organizations, func(i, j int) bool { + if organizations[i].Primary != organizations[j].Primary { + return organizations[i].Primary + } + left := "" + right := "" + if len(organizations[i].OrgUnits) > 0 { + left = organizations[i].OrgUnits[0].OrgUnitID + } + if len(organizations[j].OrgUnits) > 0 { + right = organizations[j].OrgUnits[0].OrgUnitID + } + return left < right + }) +} + +func BuildWorksmobileAliasEmails(user domain.User, tenant domain.Tenant) []string { + candidates := metadataStringList(user.Metadata, "aliasEmails", "alias_emails", "worksmobileAliasEmails") + employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number") + if isHanmacWorksmobileTenant(tenant) && employeeNumber != "" { + candidates = append(candidates, employeeNumber+"@hanmaceng.co.kr") + } + return normalizeWorksmobileAliasEmails(user.Email, candidates) +} + +func normalizeWorksmobileAliasEmails(primaryEmail string, candidates []string) []string { + result := make([]string, 0, len(candidates)) + seen := map[string]bool{} + primary := strings.ToLower(strings.TrimSpace(primaryEmail)) + for _, candidate := range candidates { + normalized := strings.ToLower(strings.TrimSpace(candidate)) + if normalized == "" || normalized == primary || seen[normalized] { + continue + } + if _, err := mail.ParseAddress(normalized); err != nil { + continue + } + seen[normalized] = true + result = append(result, normalized) + } + return result +} + +func ValidateWorksmobileAliasLocalParts(primaryEmail string, aliasEmails []string, existingLocalParts map[string]string) error { + seen := map[string]string{} + primaryLocalPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail) + if err != nil { + return err + } + seen[primaryLocalPart] = primaryEmail + + for _, aliasEmail := range aliasEmails { + localPart, err := domain.ExtractNormalizedEmailLocalPart(aliasEmail) + if err != nil { + return err + } + if previous, ok := seen[localPart]; ok { + return fmt.Errorf("worksmobile alias local-part duplicates %s: %s and %s", localPart, previous, aliasEmail) + } + if owner, ok := existingLocalParts[localPart]; ok { + return fmt.Errorf("worksmobile alias local-part %s는 이미 사용 중입니다: %s", localPart, owner) + } + seen[localPart] = aliasEmail + } + return nil +} + +func GenerateWorksmobileInitialPassword() string { + digits := "0123456789" + letters := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + symbols := "!@#$%^&*()-_=+[]{}" + all := digits + letters + symbols + + password := []byte{ + randomChar(digits), + randomChar(letters), + randomChar(symbols), + } + for len(password) < 16 { + password = append(password, randomChar(all)) + } + shuffleBytes(password) + return string(password) +} + +func randomChar(chars string) byte { + if chars == "" { + return 'x' + } + index, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) + if err != nil { + return chars[0] + } + return chars[index.Int64()] +} + +func shuffleBytes(values []byte) { + for i := len(values) - 1; i > 0; i-- { + j, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + if err != nil { + continue + } + values[i], values[j.Int64()] = values[j.Int64()], values[i] + } +} + +func WorksmobileUserStatusAction(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case domain.UserStatusInactive, domain.UserStatusSuspended, domain.UserStatusLeaveOfAbsence: + return WorksmobileUserActionSuspend + default: + return WorksmobileUserActionUpsert + } +} + +func ValidateWorksmobileExternalKey(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return errors.New("external key is required") + } + if strings.ContainsAny(value, `%\#/?`) { + return fmt.Errorf("external key contains unsupported character: %s", value) + } + return nil +} + +func ResolveWorksmobileDomainIDFromTenant(tenant domain.Tenant, _ domain.JSONMap) (int64, error) { + envKey := worksmobileTenantDomainIDEnvKey(tenant) + if domainID, ok := worksmobileDomainIDFromEnv(envKey); ok { + return domainID, nil + } + return 0, fmt.Errorf("worksmobile domain id env is missing for tenant: %s", envKey) +} + +func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string { + if tenantHasDomain(tenant, "samaneng.com") || tenantMatchesAny(tenant, "saman", "삼안") { + return "SAMAN_DOMAIN_ID" + } + if isHanmacWorksmobileTenant(tenant) { + return "HANMAC_DOMAIN_ID" + } + if tenantMatchesAny(tenant, "gpdtdc", "총괄", "기술개발센터", "기술개발") { + return "GPDTDC_DOMAIN_ID" + } + return "BARONGROUP_DOMAIN_ID" +} + +func worksmobileDomainIDFromEnv(key string) (int64, bool) { + if key == "" { + return 0, false + } + id, ok := parseDomainID(os.Getenv(key)) + return id, ok +} + +type worksmobileDomainEnvMapping struct { + Key string + Label string +} + +func worksmobileDomainEnvMappings() []worksmobileDomainEnvMapping { + return []worksmobileDomainEnvMapping{ + {Key: "SAMAN_DOMAIN_ID", Label: "삼안"}, + {Key: "HANMAC_DOMAIN_ID", Label: "한맥기술"}, + {Key: "GPDTDC_DOMAIN_ID", Label: "총괄기획&기술개발센터"}, + {Key: "BARONGROUP_DOMAIN_ID", Label: "바론그룹"}, + } +} + +func WorksmobileDomainIDsFromEnv() []int64 { + mappings := worksmobileDomainEnvMappings() + result := make([]int64, 0, len(mappings)) + seen := map[int64]bool{} + for _, mapping := range mappings { + if id, ok := worksmobileDomainIDFromEnv(mapping.Key); ok && !seen[id] { + seen[id] = true + result = append(result, id) + } + } + return result +} + +func WorksmobileDomainLabelForID(domainID int64) string { + for _, mapping := range worksmobileDomainEnvMappings() { + if id, ok := worksmobileDomainIDFromEnv(mapping.Key); ok && id == domainID { + return mapping.Label + } + } + return "" +} + +func isHanmacWorksmobileTenant(tenant domain.Tenant) bool { + return tenantHasDomain(tenant, "hanmaceng.co.kr") || tenantMatchesAny(tenant, "hanmac", "한맥") +} + +func tenantHasDomain(tenant domain.Tenant, domainName string) bool { + domainName = strings.ToLower(strings.TrimSpace(domainName)) + for _, d := range tenant.Domains { + if strings.EqualFold(strings.TrimSpace(d.Domain), domainName) { + return true + } + } + return false +} + +func tenantMatchesAny(tenant domain.Tenant, needles ...string) bool { + haystack := strings.ToLower(strings.TrimSpace(tenant.Slug + " " + tenant.Name)) + for _, needle := range needles { + if strings.Contains(haystack, strings.ToLower(strings.TrimSpace(needle))) { + return true + } + } + return false +} + +func WorksmobileEnabled(rootConfig domain.JSONMap) bool { + rawWorksmobile, ok := rootConfig["worksmobile"].(map[string]any) + if !ok { + if raw, ok := rootConfig["worksmobile"].(domain.JSONMap); ok { + rawWorksmobile = map[string]any(raw) + } else { + return false + } + } + enabled, _ := rawWorksmobile["enabled"].(bool) + return enabled +} + +func WorksmobileDomainMappings(rootConfig domain.JSONMap) map[string]int64 { + result := map[string]int64{} + rawWorksmobile, ok := rootConfig["worksmobile"].(map[string]any) + if !ok { + if raw, ok := rootConfig["worksmobile"].(domain.JSONMap); ok { + rawWorksmobile = map[string]any(raw) + } else { + return result + } + } + rawMappings, ok := rawWorksmobile["domainMappings"].(map[string]any) + if !ok { + if raw, ok := rawWorksmobile["domainMappings"].(domain.JSONMap); ok { + rawMappings = map[string]any(raw) + } else { + return result + } + } + for key, raw := range rawMappings { + if id, ok := parseDomainID(raw); ok { + result[strings.ToLower(strings.TrimSpace(key))] = id + } + } + return result +} + +func parseDomainID(raw any) (int64, bool) { + switch value := raw.(type) { + case int: + return int64(value), value > 0 + case int64: + return value, value > 0 + case float64: + id := int64(value) + return id, id > 0 + case string: + id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + return id, err == nil && id > 0 + default: + return 0, false + } +} + +func metadataString(metadata domain.JSONMap, keys ...string) string { + for _, key := range keys { + if value, ok := metadata[key]; ok { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + default: + return strings.TrimSpace(fmt.Sprint(v)) + } + } + } + return "" +} + +func metadataBool(metadata domain.JSONMap, keys ...string) bool { + value, _ := metadataOptionalBool(metadata, keys...) + return value +} + +func metadataOptionalBool(metadata domain.JSONMap, keys ...string) (bool, bool) { + for _, key := range keys { + value, ok := metadata[key] + if !ok { + continue + } + switch v := value.(type) { + case bool: + return v, true + case string: + normalized := strings.ToLower(strings.TrimSpace(v)) + if normalized == "true" || normalized == "1" || normalized == "yes" { + return true, true + } + if normalized == "false" || normalized == "0" || normalized == "no" { + return false, true + } + case int: + return v != 0, true + case float64: + return v != 0, true + } + } + return false, false +} + +func metadataStringList(metadata domain.JSONMap, keys ...string) []string { + for _, key := range keys { + value, ok := metadata[key] + if !ok { + continue + } + switch v := value.(type) { + case []string: + return splitWorksmobileAliasValues(v) + case []any: + values := make([]string, 0, len(v)) + for _, item := range v { + values = append(values, strings.TrimSpace(fmt.Sprint(item))) + } + return splitWorksmobileAliasValues(values) + case string: + return splitWorksmobileAliasValues([]string{v}) + default: + return splitWorksmobileAliasValues([]string{fmt.Sprint(v)}) + } + } + return nil +} + +func splitWorksmobileAliasValues(values []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + fields := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' + }) + for _, field := range fields { + if trimmed := strings.TrimSpace(field); trimmed != "" { + result = append(result, trimmed) + } + } + } + return result +} diff --git a/backend/internal/service/worksmobile_mapper_test.go b/backend/internal/service/worksmobile_mapper_test.go new file mode 100644 index 00000000..5f74c2f8 --- /dev/null +++ b/backend/internal/service/worksmobile_mapper_test.go @@ -0,0 +1,347 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildWorksmobileOrgUnitPayloadUsesTenantExternalKeyAndEnvDomainClassification(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + parentID := "11111111-1111-1111-1111-111111111111" + tenant := domain.Tenant{ + ID: "22222222-2222-2222-2222-222222222222", + Name: "Saman Engineering", + ParentID: &parentID, + Domains: []domain.TenantDomain{ + {Domain: "samaneng.com"}, + }, + } + rootConfig := domain.JSONMap{ + "worksmobile": map[string]any{ + "domainMappings": map[string]any{ + "samaneng.com": float64(9999), + }, + }, + } + + payload, err := BuildWorksmobileOrgUnitPayload(tenant, rootConfig, 7) + + require.NoError(t, err) + require.Equal(t, int64(1001), payload.DomainID) + require.Equal(t, "Saman Engineering", payload.OrgUnitName) + require.Equal(t, tenant.ID, payload.OrgUnitExternalKey) + require.Equal(t, "externalKey:"+parentID, payload.ParentOrgUnitID) + require.Equal(t, 7, payload.DisplayOrder) +} + +func TestNormalizeRootChildWorksmobileOrgUnitParentClearsCrossDomainParent(t *testing.T) { + rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" + payload := WorksmobileOrgUnitPayload{ParentOrgUnitID: "externalKey:" + rootID} + tenant := domain.Tenant{ParentID: &rootID} + + normalized := normalizeWorksmobileOrgUnitParent(payload, tenant, nil, rootID) + + require.Empty(t, normalized.ParentOrgUnitID) +} + +func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + tenantID := "33333333-3333-3333-3333-333333333333" + user := domain.User{ + ID: "44444444-4444-4444-4444-444444444444", + Email: "john1@samaneng.com", + Name: "John Doe", + Phone: "+19144812222", + Position: "Manager", + JobTitle: "Sales management", + TenantID: &tenantID, + Metadata: domain.JSONMap{ + "employee_id": "AB001", + }, + } + tenant := domain.Tenant{ + ID: tenantID, + Slug: "saman", + Name: "Saman", + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + } + rootConfig := domain.JSONMap{ + "worksmobile": map[string]any{ + "domainMappings": map[string]any{ + "samaneng.com": int64(9999), + }, + }, + } + + payload, err := BuildWorksmobileUserPayload(user, tenant, rootConfig) + + require.NoError(t, err) + require.Equal(t, int64(1001), payload.DomainID) + require.Equal(t, "john1@samaneng.com", payload.Email) + require.Equal(t, user.ID, payload.UserExternalKey) + require.Equal(t, "John Doe", payload.UserName.LastName) + require.Equal(t, "+19144812222", payload.CellPhone) + require.Equal(t, "AB001", payload.EmployeeNumber) + require.Equal(t, "Sales management", payload.Task) + require.Empty(t, payload.PrivateEmail) + require.Empty(t, payload.AliasEmails) + require.Equal(t, "ko_KR", payload.Locale) + require.Equal(t, "ADMIN", payload.PasswordConfig.PasswordCreationType) + require.Len(t, payload.PasswordConfig.Password, 16) + require.True(t, containsAny(payload.PasswordConfig.Password, "0123456789")) + require.True(t, containsAny(payload.PasswordConfig.Password, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")) + require.True(t, containsAny(payload.PasswordConfig.Password, "!@#$%^&*()-_=+[]{}")) + require.Len(t, payload.Organizations, 1) + require.Equal(t, int64(1001), payload.Organizations[0].DomainID) + require.True(t, payload.Organizations[0].Primary) + require.Equal(t, "externalKey:"+tenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID) +} + +func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + t.Setenv("HANMAC_DOMAIN_ID", "1002") + primaryTenantID := "33333333-3333-3333-3333-333333333333" + secondaryTenantID := "55555555-5555-5555-5555-555555555555" + user := domain.User{ + ID: "44444444-4444-4444-4444-444444444444", + Email: "john1@samaneng.com", + Name: "John Doe", + Phone: "+19144812222", + TenantID: &primaryTenantID, + Metadata: domain.JSONMap{ + "additionalAppointments": []any{ + map[string]any{ + "tenantId": secondaryTenantID, + "isPrimary": false, + "isOwner": true, + "jobTitle": "PM", + "position": "팀장", + }, + map[string]any{ + "tenantId": primaryTenantID, + "isPrimary": true, + "isOwner": false, + "jobTitle": "Engineering", + "position": "책임", + }, + }, + }, + } + primaryTenant := domain.Tenant{ + ID: primaryTenantID, + Slug: "saman", + Name: "Saman", + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + } + secondaryTenant := domain.Tenant{ + ID: secondaryTenantID, + Slug: "hanmac", + Name: "Hanmac", + Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}}, + } + + payload, err := BuildWorksmobileUserPayloadForDomainTenants( + user, + primaryTenant, + map[string]domain.Tenant{ + primaryTenantID: primaryTenant, + secondaryTenantID: secondaryTenant, + }, + nil, + ) + + require.NoError(t, err) + require.Equal(t, "Engineering", payload.Task) + require.Len(t, payload.Organizations, 2) + require.Equal(t, int64(1001), payload.Organizations[0].DomainID) + require.True(t, payload.Organizations[0].Primary) + require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID) + require.True(t, payload.Organizations[0].OrgUnits[0].Primary) + require.NotNil(t, payload.Organizations[0].OrgUnits[0].IsManager) + require.False(t, *payload.Organizations[0].OrgUnits[0].IsManager) + require.Equal(t, int64(1002), payload.Organizations[1].DomainID) + require.False(t, payload.Organizations[1].Primary) + require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID) + require.False(t, payload.Organizations[1].OrgUnits[0].Primary) + require.NotNil(t, payload.Organizations[1].OrgUnits[0].IsManager) + require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager) +} + +func TestResolveWorksmobileDomainIDFromTenantIgnoresRootDomainMappings(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + rootConfig := domain.JSONMap{ + "worksmobile": map[string]any{ + "domainMappings": map[string]any{ + "samaneng.com": int64(9999), + }, + }, + } + + got, err := ResolveWorksmobileDomainIDFromTenant( + domain.Tenant{ + Slug: "saman", + Name: "삼안", + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + }, + rootConfig, + ) + + require.NoError(t, err) + require.Equal(t, int64(1001), got) +} + +func TestResolveWorksmobileDomainIDFromTenantRequiresFamilyDomainEnv(t *testing.T) { + rootConfig := domain.JSONMap{ + "worksmobile": map[string]any{ + "domainMappings": map[string]any{ + "samaneng.com": int64(9999), + }, + }, + } + + _, err := ResolveWorksmobileDomainIDFromTenant( + domain.Tenant{ + Slug: "saman", + Name: "삼안", + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + }, + rootConfig, + ) + + require.Error(t, err) + require.Contains(t, err.Error(), "SAMAN_DOMAIN_ID") +} + +func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + t.Setenv("HANMAC_DOMAIN_ID", "1002") + t.Setenv("GPDTDC_DOMAIN_ID", "1003") + t.Setenv("BARONGROUP_DOMAIN_ID", "1004") + + tests := []struct { + name string + tenant domain.Tenant + want int64 + }{ + { + name: "saman", + tenant: domain.Tenant{Slug: "saman", Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}}, + want: 1001, + }, + { + name: "hanmac", + tenant: domain.Tenant{Slug: "hanmac", Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}}}, + want: 1002, + }, + { + name: "gpdtdc", + tenant: domain.Tenant{Slug: "gpdtdc", Name: "총괄기획&기술개발센터"}, + want: 1003, + }, + { + name: "barongroup fallback", + tenant: domain.Tenant{Slug: "family-company", Name: "기타 가족사"}, + want: 1004, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveWorksmobileDomainIDFromTenant(tt.tenant, nil) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestBuildWorksmobileUserPayloadAddsHanmacEmployeeAlias(t *testing.T) { + t.Setenv("HANMAC_DOMAIN_ID", "1002") + tenantID := "33333333-3333-3333-3333-333333333333" + user := domain.User{ + ID: "44444444-4444-4444-4444-444444444444", + Email: "main@hanmaceng.co.kr", + Name: "Hanmac User", + TenantID: &tenantID, + Metadata: domain.JSONMap{ + "employee_id": "HM001", + "personal_email": "private@example.com", + }, + } + tenant := domain.Tenant{ + ID: tenantID, + Slug: "hanmac", + Name: "한맥", + Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}}, + } + + payload, err := BuildWorksmobileUserPayload(user, tenant, nil) + + require.NoError(t, err) + require.Equal(t, int64(1002), payload.DomainID) + require.Equal(t, []string{"hm001@hanmaceng.co.kr"}, payload.AliasEmails) + require.Empty(t, payload.PrivateEmail) + require.Equal(t, "ko_KR", payload.Locale) +} + +func TestBuildWorksmobileUserPayloadAddsMultipleMetadataAliases(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + tenantID := "33333333-3333-3333-3333-333333333333" + user := domain.User{ + ID: "44444444-4444-4444-4444-444444444444", + Email: "main@samaneng.com", + Name: "Saman User", + TenantID: &tenantID, + Metadata: domain.JSONMap{ + "aliasEmails": []any{"alias1@samaneng.com", "alias2@samaneng.com", "main@samaneng.com"}, + }, + } + tenant := domain.Tenant{ + ID: tenantID, + Slug: "saman", + Name: "삼안", + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + } + + payload, err := BuildWorksmobileUserPayload(user, tenant, nil) + + require.NoError(t, err) + require.Equal(t, []string{"alias1@samaneng.com", "alias2@samaneng.com"}, payload.AliasEmails) +} + +func TestValidateWorksmobileAliasLocalPartsRejectsPrimaryAndAliasCollisions(t *testing.T) { + err := ValidateWorksmobileAliasLocalParts( + "main@samaneng.com", + []string{"main@hanmaceng.co.kr"}, + map[string]string{}, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "local-part") + + err = ValidateWorksmobileAliasLocalParts( + "main@samaneng.com", + []string{"alias@hanmaceng.co.kr"}, + map[string]string{"alias": "existing-user"}, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "이미 사용 중") +} + +func containsAny(value string, candidates string) bool { + return strings.ContainsAny(value, candidates) +} + +func TestWorksmobileUserStatusAction(t *testing.T) { + require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusActive)) + require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusInactive)) + require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusSuspended)) + require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusLeaveOfAbsence)) +} + +func TestValidateWorksmobileExternalKeyRejectsUnsupportedCharacters(t *testing.T) { + require.NoError(t, ValidateWorksmobileExternalKey("44444444-4444-4444-4444-444444444444")) + require.Error(t, ValidateWorksmobileExternalKey("user/with/slash")) + require.Error(t, ValidateWorksmobileExternalKey("user#with-hash")) +} diff --git a/backend/internal/service/worksmobile_relay_worker.go b/backend/internal/service/worksmobile_relay_worker.go new file mode 100644 index 00000000..1391cd8b --- /dev/null +++ b/backend/internal/service/worksmobile_relay_worker.go @@ -0,0 +1,141 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" + "context" + "encoding/json" + "errors" + "log/slog" + "strings" + "time" +) + +type WorksmobileRelayWorker struct { + repo repository.WorksmobileOutboxRepository + client WorksmobileDirectoryClient + interval time.Duration + batchLimit int +} + +func NewWorksmobileRelayWorker(repo repository.WorksmobileOutboxRepository, client WorksmobileDirectoryClient) *WorksmobileRelayWorker { + return &WorksmobileRelayWorker{ + repo: repo, + client: client, + interval: 3 * time.Second, + batchLimit: 10, + } +} + +func (w *WorksmobileRelayWorker) Start(ctx context.Context) { + if w.repo == nil || w.client == nil { + slog.Warn("Worksmobile relay worker disabled") + return + } + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + for { + if err := w.ProcessOnce(ctx); err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("Worksmobile relay tick failed", "error", err) + } + + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) error { + jobs, err := w.repo.ListReady(ctx, w.batchLimit) + if err != nil { + return err + } + for _, job := range jobs { + if err := w.processJob(ctx, job); err != nil { + slog.Warn("Worksmobile relay job failed", "jobID", job.ID, "resourceType", job.ResourceType, "resourceID", job.ResourceID, "error", err) + } + } + return nil +} + +func (w *WorksmobileRelayWorker) processJob(ctx context.Context, job domain.WorksmobileOutbox) error { + if err := w.repo.MarkProcessing(ctx, job.ID); err != nil { + return err + } + + err := w.dispatch(ctx, job) + if err != nil { + nextAttempt := time.Now().Add(worksmobileRetryDelay(job.RetryCount)) + _ = w.repo.MarkFailed(ctx, job.ID, err.Error(), nextAttempt) + return err + } + return w.repo.MarkProcessed(ctx, job.ID) +} + +func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.WorksmobileOutbox) error { + if job.Action == domain.WorksmobileActionDryRun { + return nil + } + + switch job.ResourceType { + case domain.WorksmobileResourceOrgUnit: + if job.Action != domain.WorksmobileActionUpsert { + return nil + } + var payload WorksmobileOrgUnitPayload + if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil { + return err + } + err := w.client.CreateOrgUnit(ctx, payload) + if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == 409 { + return nil + } + return err + case domain.WorksmobileResourceUser: + switch job.Action { + case domain.WorksmobileActionUpsert: + var payload WorksmobileUserPayload + if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil { + return err + } + return w.client.UpsertUser(ctx, payload) + case domain.WorksmobileActionDelete: + userID := stringValue(job.Payload["loginEmail"]) + if userID == "" { + userID = stringValue(job.Payload["userExternalKey"]) + } + return w.client.DeleteUser(ctx, userID) + default: + return nil + } + default: + return nil + } +} + +func decodeWorksmobileRequest(payload domain.JSONMap, target any) error { + raw := payload["request"] + if raw == nil { + return errors.New("worksmobile request payload is missing") + } + data, err := json.Marshal(raw) + if err != nil { + return err + } + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} + +func worksmobileRetryDelay(retryCount int) time.Duration { + if retryCount < 0 { + retryCount = 0 + } + if retryCount > 5 { + retryCount = 5 + } + return time.Duration(1</dev/null 2>&1 || true hydra delete oauth2-client --endpoint http://hydra:4445 devfront >/dev/null 2>&1 || true hydra delete oauth2-client --endpoint http://hydra:4445 orgfront >/dev/null 2>&1 || true diff --git a/config/.gitkeep b/config/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/config/.gitkeep @@ -0,0 +1 @@ + diff --git a/docker-compose.yaml b/docker-compose.yaml index 4e491829..03bc7874 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -36,6 +36,7 @@ services: - ory-net volumes: - ./backend:/app + - ./config:/app/config:ro - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro command: ["go", "run", "./cmd/server"] diff --git a/docker/compose.ory.yaml b/docker/compose.ory.yaml index aae3dca6..a360fb9c 100644 --- a/docker/compose.ory.yaml +++ b/docker/compose.ory.yaml @@ -88,6 +88,33 @@ services: - ory-net - hydranet + keto-migrate: + image: oryd/keto:${KETO_VERSION:-v25.4.0} + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 + volumes: + - ./docker/ory/keto:/etc/config/keto + command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"] + depends_on: + postgres_ory: + condition: service_healthy + networks: + - ory-net + + keto: + image: oryd/keto:${KETO_VERSION:-v25.4.0} + container_name: ory_keto + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 + volumes: + - ./docker/ory/keto:/etc/config/keto + command: serve -c /etc/config/keto/keto.yml + depends_on: + keto-migrate: + condition: service_completed_successfully + networks: + - ory-net + oathkeeper: image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v0.40.6} container_name: oathkeeper @@ -123,20 +150,32 @@ services: echo 'Wait for services...'; until curl -s http://kratos:4433/health/ready; do sleep 1; done; until curl -s http://hydra:4444/health/ready; do sleep 1; done; + until curl -s http://keto:4466/health/ready; do sleep 1; done; echo 'Ory Stack is fully operational!';" depends_on: - kratos - hydra + - keto networks: - ory-net init-rp: - image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} + image: alpine:latest container_name: init-rp - entrypoint: ["/bin/sh"] + env_file: + - ../.env 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 + echo "Creating/Updating OAuth2 Clients..." hydra create oauth2-client \ diff --git a/docker/ory/kratos/kratos.yml b/docker/ory/kratos/kratos.yml index 583dd96e..9ccd01f2 100644 --- a/docker/ory/kratos/kratos.yml +++ b/docker/ory/kratos/kratos.yml @@ -1,4 +1,4 @@ -version: v25.4.0 +version: v26.2.0 dsn: ${DSN} diff --git a/docker/ory/oathkeeper/entrypoint.sh b/docker/ory/oathkeeper/entrypoint.sh index d3736b85..d152847e 100755 --- a/docker/ory/oathkeeper/entrypoint.sh +++ b/docker/ory/oathkeeper/entrypoint.sh @@ -19,17 +19,15 @@ export RULES_FILE echo "[oathkeeper] APP_ENV=$APP_ENV_VALUE rules=$RULES_FILE" -RULES_ACTIVE="/etc/config/oathkeeper/rules.active.json" +RUNTIME_DIR="/tmp/oathkeeper" +RULES_ACTIVE="${RUNTIME_DIR}/rules.active.json" if [ ! -f "$RULES_FILE" ]; then echo "[oathkeeper] rules file not found: $RULES_FILE" exit 1 fi -# Remove existing active rules file to prevent overwrite issues (File exists/Permission denied) -if [ -f "$RULES_ACTIVE" ]; then - rm -f "$RULES_ACTIVE" || echo "[oathkeeper] Warning: Failed to remove existing rules.active.json" -fi -cp -f "$RULES_FILE" "$RULES_ACTIVE" || echo "[oathkeeper] Warning: Failed to copy rules file. Using existing if present." +mkdir -p "$RUNTIME_DIR" +cp -f "$RULES_FILE" "$RULES_ACTIVE" LOG_DIR="/var/log/oathkeeper" LOG_FILE="${LOG_DIR}/access.log" @@ -41,7 +39,7 @@ if ! touch "$LOG_FILE" 2>/dev/null; then fi if [ -n "$LOG_FILE" ]; then - exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml 2>&1 | tee \"$LOG_FILE\"" + exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml 2>&1 | tee -a \"$LOG_FILE\"" fi exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml" diff --git a/docker/ory/oathkeeper/oathkeeper.yml b/docker/ory/oathkeeper/oathkeeper.yml index ed78a337..c3a385cb 100755 --- a/docker/ory/oathkeeper/oathkeeper.yml +++ b/docker/ory/oathkeeper/oathkeeper.yml @@ -14,7 +14,7 @@ errors: access_rules: repositories: - - file:///etc/config/oathkeeper/rules.active.json + - file:///tmp/oathkeeper/rules.active.json authenticators: noop: diff --git a/docker/ory/vector/vector.toml b/docker/ory/vector/vector.toml index 35dd4f50..83fd6a90 100644 --- a/docker/ory/vector/vector.toml +++ b/docker/ory/vector/vector.toml @@ -7,56 +7,60 @@ type = "remap" inputs = ["oathkeeper_file"] source = ''' - .raw = .message - parsed = parse_json(.message) ?? {} - - .timestamp = to_timestamp(.timestamp) ?? now() - .request_id = parsed.request_id ?? parsed.req_id ?? "" - request_method = get(parsed, ["request", "method"]) ?? "" - request_path = get(parsed, ["request", "path"]) ?? "" - request_url = get(parsed, ["request", "url"]) ?? "" - request_host = get(parsed, ["request", "host"]) ?? "" - request_scheme = get(parsed, ["request", "scheme"]) ?? "" - request_query = get(parsed, ["request", "query"]) ?? "" - .method = parsed.method ?? parsed.http_method ?? request_method ?? "" - .path = parsed.path ?? parsed.http_path ?? request_path ?? request_url ?? "" + raw = to_string(.message) ?? "" + parsed = parse_json(raw) ?? {} + request_method = to_string(get(parsed, ["request", "method"]) ?? "") ?? "" + request_path = to_string(get(parsed, ["request", "path"]) ?? "") ?? "" + request_url = to_string(get(parsed, ["request", "url"]) ?? "") ?? "" + request_host = to_string(get(parsed, ["request", "host"]) ?? "") ?? "" + request_scheme = to_string(get(parsed, ["request", "scheme"]) ?? "") ?? "" + request_query = to_string(get(parsed, ["request", "query"]) ?? "") ?? "" response_status = get(parsed, ["response", "status"]) ?? 0 - .status = to_int(parsed.status ?? parsed.status_code ?? response_status ?? 0) ?? 0 - .latency_ms = to_int(parsed.latency_ms ?? parsed.duration_ms ?? parsed.took ?? 0) ?? 0 - identity_id = get(parsed, ["identity", "id"]) ?? "" - .subject = parsed.subject ?? identity_id ?? "" - .client_ip = parsed.client_ip ?? parsed.remote_ip ?? parsed.ip ?? "" + identity_id = to_string(get(parsed, ["identity", "id"]) ?? "") ?? "" headers = get(parsed, ["headers"]) ?? {} - .user_agent = parsed.user_agent - if is_null(.user_agent) { .user_agent = get(headers, ["User-Agent"]) } - if is_null(.user_agent) { .user_agent = "" } - .referer = get(headers, ["Referer"]) ?? "" - - .decision = parsed.decision - if is_null(.decision) { .decision = parsed.result } - if is_null(.decision) { .decision = "" } - - .trace_id = parsed.trace_id - if is_null(.trace_id) { .trace_id = "" } - - .span_id = parsed.span_id - if is_null(.span_id) { .span_id = "" } - - .rp = parsed.rp ?? "" - .action = parsed.action ?? "" - .target = parsed.target ?? "" - .rule_id = parsed.rule_id ?? get(parsed, ["rule", "id"]) ?? "" - parsed_url = {} - if request_url != "" { parsed_url = parse_url(request_url) ?? {} } + user_agent = to_string(get(headers, ["User-Agent"]) ?? "") ?? "" + referer = to_string(get(headers, ["Referer"]) ?? "") ?? "" + rule_id = to_string(get(parsed, ["rule", "id"]) ?? "") ?? "" + upstream_url = to_string(get(parsed, ["upstream", "url"]) ?? "") ?? "" + client_id = to_string(get(parsed, ["client", "id"]) ?? "") ?? "" + parent_session_id = to_string(get(parsed, ["extra", "parent_session_id"]) ?? "") ?? "" + parsed_url = parse_url(request_url) ?? {} query_params = get(parsed_url, ["query"]) ?? {} - .client_id = parsed.client_id ?? get(parsed, ["client", "id"]) ?? get(query_params, ["client_id"]) ?? get(query_params, ["clientId"]) ?? "" - .parent_session_id = parsed.parent_session_id ?? get(parsed, ["extra", "parent_session_id"]) ?? "" - .host = parsed.host ?? request_host ?? "" - .scheme = parsed.scheme ?? request_scheme ?? "" - .query = parsed.query ?? request_query ?? "" - .upstream_url = parsed.upstream_url ?? get(parsed, ["upstream", "url"]) ?? "" - .bytes_in = to_int(parsed.bytes_in ?? parsed.request_bytes ?? 0) ?? 0 - .bytes_out = to_int(parsed.bytes_out ?? parsed.response_bytes ?? 0) ?? 0 + event_path = to_string(parsed.path) ?? to_string(parsed.http_path) ?? "" + if event_path == "" { event_path = request_path } + if event_path == "" { event_path = request_url } + event_client_id = to_string(parsed.client_id) ?? "" + if event_client_id == "" { event_client_id = client_id } + if event_client_id == "" { event_client_id = to_string(get(query_params, ["client_id"]) ?? "") ?? "" } + if event_client_id == "" { event_client_id = to_string(get(query_params, ["clientId"]) ?? "") ?? "" } + + . = { + "request_id": to_string(parsed.request_id) ?? to_string(parsed.req_id) ?? "", + "method": to_string(parsed.method) ?? to_string(parsed.http_method) ?? request_method, + "path": event_path, + "status": to_int(parsed.status) ?? to_int(parsed.status_code) ?? to_int(response_status) ?? 0, + "latency_ms": to_int(parsed.latency_ms) ?? to_int(parsed.duration_ms) ?? to_int(parsed.took) ?? 0, + "client_id": event_client_id, + "rp": to_string(parsed.rp) ?? "", + "action": to_string(parsed.action) ?? "", + "target": to_string(parsed.target) ?? "", + "rule_id": to_string(parsed.rule_id) ?? rule_id, + "host": to_string(parsed.host) ?? request_host, + "scheme": to_string(parsed.scheme) ?? request_scheme, + "query": to_string(parsed.query) ?? request_query, + "upstream_url": to_string(parsed.upstream_url) ?? upstream_url, + "subject": to_string(parsed.subject) ?? identity_id, + "parent_session_id": to_string(parsed.parent_session_id) ?? parent_session_id, + "client_ip": to_string(parsed.client_ip) ?? to_string(parsed.remote_ip) ?? to_string(parsed.ip) ?? "", + "user_agent": to_string(parsed.user_agent) ?? user_agent, + "referer": referer, + "decision": to_string(parsed.decision) ?? to_string(parsed.result) ?? "", + "bytes_in": to_int(parsed.bytes_in) ?? to_int(parsed.request_bytes) ?? 0, + "bytes_out": to_int(parsed.bytes_out) ?? to_int(parsed.response_bytes) ?? 0, + "trace_id": to_string(parsed.trace_id) ?? "", + "span_id": to_string(parsed.span_id) ?? "", + "raw": raw + } ''' [sinks.clickhouse] @@ -66,3 +70,6 @@ database = "ory" table = "oathkeeper_access_logs" compression = "gzip" + auth.strategy = "basic" + auth.user = "${ORY_CLICKHOUSE_USER}" + auth.password = "${ORY_CLICKHOUSE_PASSWORD}" diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml index d7d82967..15195a52 100644 --- a/docker/staging_pull_compose.template.yaml +++ b/docker/staging_pull_compose.template.yaml @@ -271,7 +271,10 @@ services: - -ec - | apk add --no-cache curl tar - curl -sLo /tmp/hydra.tar.gz https://github.com/ory/hydra/releases/download/v25.4.0/hydra_25.4.0-linux_64bit.tar.gz + 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 diff --git a/docs/worksmobile-directory-sync-technical-review.md b/docs/worksmobile-directory-sync-technical-review.md new file mode 100644 index 00000000..07679c5a --- /dev/null +++ b/docs/worksmobile-directory-sync-technical-review.md @@ -0,0 +1,421 @@ +# 웍스모바일 Directory 연동 기술 검토 + +## 개요 + +- 대상 Epic: `orgfront`와 웍스모바일 Directory API 간 한맥가족 사용자/조직 연동 +- 관련 이슈: #668 한맥가족 이메일 local-part unique 정책 +- 대상 마일스톤: `한맥가족사 조직도 반영 및 웍스모바일 연동` (`id=42`) +- 기준 SoT: `hanmac-family` 테넌트 subtree 하위 Kratos identity +- 작성일: 2026-05-04 + +## 현재 Baron SSO 구조 요약 + +Baron SSO는 Ory Stack을 SoT로 두고, PostgreSQL은 read-model 및 비즈니스 메타데이터 저장소로 사용합니다. `docs/SoT_Architecture_Policy.md`와 `docs/tenant-usergroup-policy.md` 기준으로 Identity는 Kratos, 권한/멤버십은 Keto, 테넌트/조직 메타데이터는 PostgreSQL이 담당합니다. + +현재 사용자 생성 흐름은 다음과 같습니다. + +- `backend/internal/handler/user_handler.go` + - `CreateUser`: `companyCode`로 tenant slug를 찾아 `traits["tenant_id"]`에 tenant UUID를 저장합니다. + - `BulkCreateUsers`: CSV/import row별로 tenant slug를 tenant UUID로 변환하고 Kratos identity를 생성합니다. + - Kratos identity id는 `users.id`로 그대로 저장됩니다. 이 값이 웍스모바일 `userExternalKey` 후보입니다. + - `mapToLocalUser`는 `traits["tenant_id"]`를 `users.tenant_id`로 저장합니다. +- `backend/internal/handler/tenant_handler.go` + - `CreateTenant`: `TenantService.RegisterTenant` 호출 후 domains/config를 별도로 저장합니다. + - `UpdateTenant`: tenant 필드와 parent relation을 갱신합니다. +- `backend/internal/service/keto_relay_worker.go` + - `keto_outbox`를 polling하여 Keto relation을 비동기로 반영합니다. + +한맥가족 이메일 정책은 이미 #668에서 다음 방향으로 구현되어 있습니다. + +- `hanmac-family` root tenant와 descendant subtree에서 email local-part를 unique로 강제합니다. +- 단건 생성은 중복 시 `409 Conflict`로 차단합니다. +- bulk import는 `@domain` 입력 시 이름 기반 local-part를 제안하고, 생성 직전 재검증합니다. + +## 웍스모바일 Directory API 확인 사항 + +공식 문서 기준 Directory API는 구성원, 조직, 그룹, 직급, 직책, 사용자 유형 등을 관리합니다. + +확인한 주요 엔드포인트와 제약은 다음과 같습니다. + +- 인증 + - API 호출에는 OAuth 2.0 Access Token이 필요합니다. + - 시스템 연동에는 서비스 계정 인증(JWT) 방식이 적합합니다. + - 필요한 scope는 최소 `directory`이며, 구성원만 다룰 경우 `user`, 조직만 다룰 경우 `orgunit`도 사용 가능합니다. +- 구성원 + - `POST https://www.worksapis.com/v1.0/users` + - 필수 주요 필드: `domainId`, `email`, `userName` + - SSO 사용 시 `userExternalKey`가 필요합니다. + - `userExternalKey`는 테넌트 내 unique이며 `%`, `\`, `#`, `/`, `?`를 사용할 수 없습니다. + - `organizations[].orgUnits[].orgUnitId`는 resource id 또는 `externalKey:{orgUnitExternalKey}` 형태를 사용할 수 있습니다. +- 조직 + - `POST https://www.worksapis.com/v1.0/orgunits` + - 필수 주요 필드: `domainId`, `orgUnitName`, `displayOrder` + - `orgUnitExternalKey`는 테넌트 내 unique이며 `%`, `\`, `#`, `/`, `?`를 사용할 수 없습니다. + - `parentOrgUnitId`는 resource id 또는 `externalKey:{orgUnitExternalKey}` 형태를 사용할 수 있습니다. +- External Key Mapping + - `POST /users/external-keys` + - `POST /orgunits/external-keys` + - 기존 웍스모바일 리소스에 External Key가 없는 경우 초기 bulk mapping에 사용합니다. +- 호출 제한/운영 주의 + - 조직 추가/수정/부분 수정/이동 API는 도메인당 단일 스레드로 1초에 1회, 순서대로 호출해야 합니다. + - 동일 구성원에 대한 추가/수정/부분수정/전배 API는 동시에 호출하지 않아야 합니다. + - Directory API 조직 연동 배치는 직급/직책/사용자 유형 -> 조직 -> 구성원 -> 그룹 순서를 권장합니다. + - API 동시 호출은 5회 이상 하지 않도록 관리해야 하며, 특히 조직 API는 단일 스레드가 필요합니다. + +## AdminFront bulk 생성과 NAVERWORKS bulk 생성 비교 + +구현 전에 `adminfront`의 기존 조직/사용자 bulk 생성 방식과 `adminfront/NAVERWORKS_member_add_sample_English.csv`의 구성원 bulk 필드를 비교했습니다. + +### Baron/AdminFront 기존 방식 + +- 조직 bulk + - `adminfront/src/features/tenants/utils/tenantCsvImport.ts` + - 주요 컬럼: `tenant_id`, `name`, `type`, `parent_tenant_id`, `parent_tenant_slug`, `slug`, `memo`, `email_domain` + - Baron tenant tree를 직접 생성/갱신합니다. + - parent는 Baron tenant UUID 또는 slug 기준으로 해석합니다. +- 사용자 bulk + - `adminfront/src/features/users/components/UserBulkUploadModal.tsx` + - `parseUserCSV`가 `BulkUserItem`으로 변환한 뒤 `/api/v1/admin/users/bulk`로 전송합니다. + - 주요 컬럼: `email`, `name`, `phone`, `role`, `tenant_slug`, `department`, `position`, `jobTitle`, `employee_id` + - 사용자 import 중 없는 tenant를 미리 생성할 수 있고, #668 한맥가족 email local-part unique preview를 거칩니다. + +### NAVERWORKS sample 방식 + +- 구성원 bulk sample + - 파일: `adminfront/NAVERWORKS_member_add_sample_English.csv` + - 주요 컬럼: `LastName`, `FirstName`, `ID`, `Personal email`, `Sub email`, `User type`, `Level`, `Organization`, `Position`, `Mobile/Country code`, `Mobile/Numbers`, `Responsibilities`, `Workplace`, `Entry Date`, `Employee number`, `Account activation time` + - `ID`는 Baron loginId 및 Worksmobile userExternalKey와는 다른 계정 local-part 성격입니다. + - `Organization`은 `org.1|org.2|org.3|myteam`처럼 path 문자열로 제공됩니다. + - `Employee number`는 Baron metadata의 `employee_id`로 보존합니다. + +### 구현 반영 + +- `parseUserCSV`를 quoted CSV/BOM에 대응하도록 보강했습니다. +- NAVERWORKS 구성원 sample 필드를 Baron bulk user field로 흡수합니다. + - `Sub email`의 첫 이메일 -> Baron `email` + - `ID` -> Baron `loginId`, metadata `naverworks_id` + - `FirstName` + `LastName` -> Baron `name` + - `Mobile/Country code` + `Mobile/Numbers` -> Baron `phone` + - `Organization` path leaf -> Baron `department`, tenant import name + - `Position` -> Baron `position` + - `Responsibilities` -> Baron `jobTitle` + - `Employee number` -> metadata `employee_id` +- Worksmobile API payload 생성 시에는 request body의 external key/domainId를 사용하지 않고 Baron UUID와 `tenant.config.worksmobile.domainMappings` 및 `.env`의 domainId 값을 server-side 계산합니다. + +## 매핑 설계 + +### External Key + +Baron 내부 UUID는 웍스모바일 External Key 제한 문자와 충돌하지 않으므로 그대로 사용할 수 있습니다. + +- 구성원 `userExternalKey`: Kratos identity UUID, 즉 `users.id` +- 조직 `orgUnitExternalKey`: Baron `tenants.id` +- 조직 지정: `externalKey:{tenant.ID}` +- 구성원 지정: `externalKey:{user.ID}` 또는 email/resource id + +이 선택은 "Kratos account가 사용자 SoT"라는 정책과 맞습니다. 사용자 생성 후 Worksmobile resource id가 생기더라도 Baron의 primary mapping은 Kratos UUID를 유지하고, Worksmobile resource id는 캐시/응답 추적용으로만 보관하는 것이 좋습니다. + +### 조직 + +Baron tenant를 Worksmobile orgunit으로 보냅니다. + +- 대상 tenant: `hanmac-family` root 하위 subtree 중 `COMPANY`, `USER_GROUP` +- 제외 후보: `PERSONAL`, system/global 성격 tenant +- `orgUnitName`: `tenant.name` +- `orgUnitExternalKey`: `tenant.id` +- `parentOrgUnitId`: parent가 Worksmobile 동기화 대상이면 `externalKey:{parentTenant.ID}` +- `domainId`: tenant domain 또는 root integration config에서 email domain별로 해석 +- `displayOrder`: 동일 parent 내 deterministic order 필요. 1차 구현은 `name asc`, `created_at asc`, 또는 별도 `config.worksmobile.displayOrder` 정책 중 하나를 선택해야 합니다. + +주의할 점은 Worksmobile `orgunits`가 `domainId`를 필수로 요구한다는 점입니다. Baron은 한맥가족 root 아래에 여러 이메일 도메인과 법인/조직 subtree를 둘 수 있으므로, 우선 `tenant.config.worksmobile.domainMappings`를 지원하되 운영 domainId는 `.env`의 다음 값을 fallback으로 사용합니다. + +- `SAMAN_DOMAIN_ID`: 삼안 계열 +- `HANMAC_DOMAIN_ID`: 한맥 계열 +- `GPDTDC_DOMAIN_ID`: 총괄기획&기술개발센터 +- `BARONGROUP_DOMAIN_ID`: 위 세 가지에 속하지 않는 모든 한맥가족사 + +분류 순서는 config mapping -> 삼안 -> 한맥 -> GPDTDC -> BARONGROUP fallback입니다. + +개발/스테이징에서 테스트한 값은 프로덕션에도 동일하게 반영해야 합니다. 네이버웍스 쪽 backend가 동일 환경이므로 이 mapping은 env-only 값으로 숨기지 않고 seed/config migration 등 코드 레벨에서 추적 가능한 값으로 남깁니다. + +```json +{ + "worksmobile": { + "enabled": true, + "tenantId": "hanmac-family", + "domainMappings": { + "hanmaceng.co.kr": 10000001, + "samaneng.com": 10000002 + } + } +} +``` + +### 구성원 + +Baron Kratos identity를 Worksmobile user로 보냅니다. + +- `userExternalKey`: Kratos UUID (`users.id`) +- `email`: #668 정책으로 확정된 email +- `userName.lastName`: `traits["name"]` 또는 `users.name` 전체를 우선 입력합니다. 성/이름 분리가 확정되기 전까지는 API 필수 조건 충족을 위해 전체 표시명을 `lastName`에 둡니다. +- `cellPhone`: normalized phone +- `employeeNumber`: metadata의 `employee_id` 또는 schema login ID 값이 있으면 사용 +- `privateEmail`: 기본 매핑하지 않습니다. NAVERWORKS sample의 `Personal email`은 Baron metadata에는 보존하지만 Worksmobile payload에는 기본 전송하지 않습니다. +- `aliasEmails`: 한맥 tenant에 속하고 `employee_id`가 있으면 `employee_id@hanmaceng.co.kr`을 추가합니다. +- `locale`: 별도 지정이 없으면 `ko_KR` +- `passwordConfig.passwordCreationType`: `ADMIN` 값을 구성원 생성 시에만 사용합니다. +- `passwordConfig.password`: 구성원 생성 시 숫자, 영문, 기호를 모두 포함한 16자리 난수 초기 비밀번호를 생성합니다. +- `task`: Baron `jobTitle`을 우선 사용 +- `organizations` + - 원직: 대표 tenant 또는 `additionalAppointments` 중 primary로 선택된 tenant + - 겸직: `metadata.additionalAppointments` 또는 Keto `joinedTenants` + - `orgUnits[].orgUnitId`: `externalKey:{tenant.ID}` + - `levelId`, `positionId`, `userTypeId`: 이번 scope에서는 External Key mapping을 사용하지 않고 사용자 정보 업데이트 필드로 최대한 커버 + - `isManager`: `additionalAppointments[].isOwner == true` 또는 Keto owners/admins relation을 기준으로 변환 + +초기 비밀번호는 Worksmobile user upsert outbox payload에 `loginEmail`, `initialPassword` 형태로 함께 보관하고, adminfront의 한맥가족 Worksmobile 관리 화면에서 `email,initialPassword,status,lastError` CSV로 다운로드할 수 있게 합니다. 생성 성공/실패 판정은 outbox 작업 상태(`processed`, `failed`)와 함께 확인할 수 있으며, 운영상 평문 초기 비밀번호가 포함되므로 다운로드 권한은 `hanmac-family` tenant manage 권한으로 제한하고 보존 기간 정책을 별도 확정해야 합니다. + +현재 backend `CreateUser`와 `UpdateUser`는 adminfront가 보내는 top-level `additionalAppointments` 및 `metadata.additionalAppointments`를 수용합니다. 한맥가족 단건 생성에서 대표 `tenantSlug` 없이 appointment만 오는 경우에는 first/primary appointment tenant를 대표 tenant로 해석해 Kratos traits, local read-model, Worksmobile enqueue가 누락되지 않게 합니다. + +### 구성원 수정과 비밀번호 정책 + +Worksmobile 구성원 수정 API에는 PUT(`user-update-put`)과 PATCH(`user-update-patch`)가 있지만, 비밀번호 변경 경로로 사용하지 않습니다. + +- Worksmobile Directory API에서 관리자 지정 초기 비밀번호 값은 별도 top-level `password`가 아니라 `passwordConfig.password`입니다. +- `passwordConfig.passwordCreationType`은 생성 방식이고, `passwordConfig.password`가 실제 초기 비밀번호 값입니다. +- 공식 문서와 개발자 포럼 확인 결과, `passwordConfig.passwordCreationType`과 `passwordConfig.password`는 모두 구성원 등록 시에만 실제 반영됩니다. +- PUT/PATCH request body에 `passwordConfig.password`를 포함해도 기존 구성원의 비밀번호 변경에는 반영되지 않습니다. +- 따라서 Baron SSO는 WORKS Mobile 구성원 생성 시에만 초기 비밀번호를 설정하고, 생성 이후 Baron 사용자 비밀번호 변경을 WORKS Mobile PUT/PATCH로 전파하지 않습니다. +- 생성 이후 WORKS Mobile 비밀번호 변경은 WORKS Mobile 관리자 페이지 또는 WORKS Mobile이 제공하는 별도 운영 절차에서 직접 처리합니다. +- PATCH/PUT payload에는 `passwordConfig`를 포함하지 않습니다. +- `privateEmail`도 기존 정책대로 기본 전송하지 않습니다. +- 기존 WORKS Mobile 구성원에 대한 일반 속성/조직/겸직 동기화는 생성 효율을 위해 먼저 `POST /v1.0/users`를 시도하고, `409 Conflict`일 때 `PATCH /v1.0/users/{email}`로 전환합니다. +- PUT은 전체 교체 성격이 강하고 누락 필드 초기화 위험이 있으므로 현 scope에서는 사용하지 않습니다. 모든 Baron -> WORKS 변경 반영은 부분 수정 PATCH를 우선합니다. + +## 비동기 아키텍처 권장안 + +Worksmobile API를 handler에서 직접 호출하지 않고, 별도 outbox와 relay worker를 둡니다. + +권장 신규 테이블: + +- `worksmobile_outbox` + - `id` + - `resource_type`: `ORGUNIT`, `USER` + - `resource_id`: Baron tenant/user UUID + - `action`: `UPSERT`, `DELETE`, `EXTERNAL_KEY_MAP` + - `payload`: JSONB + - `dedupe_key` + - `status`: `pending`, `processing`, `processed`, `failed` + - `retry_count`, `last_error` + - `next_attempt_at`, `processed_at`, `created_at`, `updated_at` +- `worksmobile_resource_mappings` + - `baron_resource_type` + - `baron_resource_id` + - `external_key` + - `worksmobile_resource_id` + - `domain_id` + - `last_synced_at` + +권장 신규 service/client: + +- `backend/internal/service/worksmobile_client.go` +- `backend/internal/service/worksmobile_sync_service.go` +- `backend/internal/service/worksmobile_relay_worker.go` +- `backend/internal/repository/worksmobile_outbox_repository.go` + +현재 구현은 `WORKS_ADMIN_*` OAuth 또는 directory token을 사용하는 `WorksmobileHTTPClient`와 `WorksmobileRelayWorker`를 통해 `worksmobile_outbox`의 pending 사용자 작업을 Directory API로 전달합니다. 사용자 생성은 `POST https://www.worksapis.com/v1.0/users`를 먼저 호출하고, 이미 존재하는 구성원으로 `409 Conflict`가 발생하면 `PATCH /v1.0/users/{email}`로 전환합니다. + +SCIM은 주경로로 사용하지 않습니다. 기존 검토에서 SCIM은 다음 이유로 보류했습니다. + +- Directory API의 `passwordConfig.passwordCreationType = ADMIN` 생성 정책을 그대로 표현하기 어렵습니다. +- SCIM 경로는 검증 조건 때문에 private mail 성격의 email 값을 강제해야 하는 문제가 있었습니다. +- Baron 정책은 `privateEmail` 기본 미전송이므로 SCIM을 주경로로 삼지 않습니다. + +`WORKS_ADMIN_OAUTH_CLIENT_ID`, `WORKS_ADMIN_OAUTH_CLIENT_SECRET`, service account private key는 Directory API 호출용 토큰 발급에 사용합니다. OAuth redirect URI 등록이 필요한 경우 다음 경로를 사용합니다. + +```text +http://localhost:5000/api/v1/admin/worksmobile/oauth/callback +``` + +로컬 Playwright 검증에서는 위 callback 경로가 브라우저에서 도달 가능함을 확인했습니다. + +Worker 정책: + +- orgunit 작업은 `domainId`별 단일 worker lane, 최소 1초 간격 +- user 작업은 같은 `userExternalKey` 단위로 순차 처리 +- 전체 동시성은 5 미만 +- 409는 idempotent conflict로 보고 user PATCH 전환 +- 404 parent orgunit은 parent job 선처리 후 retry +- 429/5xx는 exponential backoff + +## AdminFront 운영 화면 배치와 권한 정책 + +Worksmobile 운영 화면은 `orgfront`가 아니라 `adminfront`의 tenant detail 하위에 둡니다. 데이터 성격상 tenant 관리자 도구이며, 실제 사용자 동선은 `hanmac-family` tenant detail에서 바로 확인할 수 있게 만드는 쪽이 맞습니다. + +화면 노출 정책: + +- 전역 메뉴에는 Worksmobile 메뉴를 추가하지 않습니다. +- `adminfront` tenant list에서 한맥가족 root tenant detail에 들어갔을 때만 Worksmobile 탭 또는 버튼을 표시합니다. +- 노출 조건은 tenant detail API 응답의 `slug == "hanmac-family"` 또는 동일한 canonical 식별자로 판단합니다. +- 별도 운영 URL을 새 탭으로 여는 방식은 허용합니다. 단, 새 탭 화면도 tenant-scoped route여야 하며 URL을 직접 입력해도 같은 권한 검사를 통과해야 합니다. +- `orgfront`는 필요 시 read-only sync badge나 adminfront deep link 정도만 담당하고, 생성/재시도/삭제 같은 운영 액션은 제공하지 않습니다. + +권한 강제 정책: + +- frontend의 탭 숨김은 UX 보조일 뿐이며 보안 경계로 보지 않습니다. +- backend endpoint는 `/api/v1/admin/tenants/:tenantId/worksmobile/*`처럼 tenant-scoped 형태로 둡니다. +- handler 또는 middleware에서 `tenantId`가 존재하는지, 해당 tenant가 정확히 `hanmac-family` root인지 먼저 확인합니다. +- 요청자는 `super_admin`이거나 `Tenant:{tenantId}`에 대한 관리 권한을 Keto로 통과해야 합니다. 기존 `RequireKetoPermission(..., "Tenant", "manage")` 또는 이에 준하는 relation을 사용합니다. +- user/orgunit 개별 동작은 대상 resource가 `hanmac-family` subtree 안에 있는지 추가로 확인합니다. +- Worksmobile `domainId`, `userExternalKey`, `orgUnitExternalKey`는 request body의 임의 입력을 신뢰하지 않고 server-side tenant config와 Baron UUID에서 계산합니다. +- tenant가 `hanmac-family`가 아니거나, 사용자가 해당 tenant를 관리할 수 없거나, 대상 resource가 subtree 밖이면 작업을 생성하지 않고 `403 Forbidden` 또는 존재 노출을 줄이는 `404 Not Found`로 차단합니다. + +관리 화면에서 필요한 최소 기능: + +- Worksmobile config/domain mapping 조회 +- 조직/구성원별 최근 sync 상태와 마지막 오류 조회 +- 단건 조직/구성원 sync enqueue +- 실패 작업 retry +- backfill dry-run 결과 조회 및 제한된 실행 버튼 + +## 삽입 지점 + +### 기존 계정 bulk/backfill + +1. `hanmac-family` subtree tenant 전체를 읽습니다. +2. Worksmobile에 이미 존재하는 조직/구성원의 External Key Mapping을 먼저 수집하거나 Developer Console CSV mapping으로 보정합니다. +3. Baron tenant를 depth asc로 정렬해 `ORGUNIT UPSERT` outbox를 생성합니다. +4. Baron user를 `users.id` 기준으로 읽고 `USER UPSERT` outbox를 생성합니다. +5. `USER UPSERT`는 해당 사용자의 orgunit mapping이 processed인 뒤 실행합니다. + +### 신규 tenant 생성 + +- 후보 위치: `TenantHandler.CreateTenant`에서 `replaceTenantDomains` 성공 후 +- 더 좋은 위치: `TenantService.RegisterTenant`가 tenant/domain/config/outbox를 하나의 transaction으로 저장하도록 정리한 뒤 같은 transaction 안에서 `worksmobile_outbox`를 생성 +- 조건: 생성된 tenant가 `hanmac-family` subtree 하위이고 type이 `COMPANY` 또는 `USER_GROUP` + +### tenant 수정 + +- 후보 위치: `TenantHandler.UpdateTenant`에서 `h.DB.Save(&tenant)` 및 domain update 성공 후 +- parent 변경 시 Worksmobile orgunit move 또는 patch가 필요합니다. +- 현재 `UpdateTenant`는 Keto parent outbox를 tenant 저장 전에 생성하므로, Worksmobile 이전에 outbox transaction 정합성 개선을 권장합니다. + +### 신규 사용자 단건 생성 + +- 후보 위치: `UserHandler.CreateUser`에서 Kratos 생성, local DB sync, login ID sync, Keto outbox enqueue 후 +- payload에는 `identityID`, email, name, phone, tenantID, metadata/additionalAppointments를 포함합니다. +- `hanmac-family` scope가 아니면 enqueue하지 않습니다. + +### 신규 사용자 bulk 생성 + +- 후보 위치: `UserHandler.BulkCreateUsers`에서 row별 local DB sync와 Keto outbox enqueue 후 +- row별 partial success를 유지하고, Worksmobile enqueue 실패는 사용자 생성 실패와 분리하는 것이 좋습니다. +- 단, enqueue 실패는 audit/error로 남기고 운영자가 재시도할 수 있어야 합니다. + +### 사용자 수정/소속 변경 + +- 후보 위치: `UserHandler.UpdateUser`에서 Kratos update와 local DB sync 후 +- `email`, `name`, `phone`, `companyCode`, `tenant_id`, `metadata.additionalAppointments` 변경 시 `USER UPSERT` enqueue +- `inactive`는 Worksmobile suspend로 동기화합니다. +- Baron user delete는 Worksmobile delete로 동기화합니다. +- `leave-of-absence`는 필요하지만 orgfront/Baron user status model 확장이 선행되어야 하므로 별도 scope로 분리합니다. + +## 테스트 전략 + +기능 추가이므로 테스트를 먼저 작성합니다. + +### Backend unit + +- Worksmobile request mapper + - tenant UUID -> `orgUnitExternalKey` + - parent tenant -> `parentOrgUnitId = externalKey:{parentID}` + - Kratos UUID -> `userExternalKey` + - `additionalAppointments` -> `organizations[].orgUnits[]` + - #668 email final value 사용 +- External Key validator + - `%`, `\`, `#`, `/`, `?` 포함 시 reject +- Domain mapping resolver + - email domain -> `domainId` + - missing mapping -> blocking error 또는 skipped job + +### Backend repository/service + +- `worksmobile_outbox` enqueue idempotency +- orgunit depth order enqueue +- user job이 orgunit processed 전에는 보류되는지 확인 +- retry/backoff/status transition +- 409 conflict 시 get/patch 전환 + +### Handler + +- `CreateTenant`가 한맥가족 subtree tenant 생성 시 `ORGUNIT UPSERT` job을 생성 +- `CreateUser`가 한맥가족 사용자 생성 시 `USER UPSERT` job을 생성 +- 한맥가족 외부 tenant는 job을 만들지 않음 +- `BulkCreateUsers`에서 row별 성공 결과와 Worksmobile enqueue 결과가 분리됨 + +### Frontend unit + +- CSV alias가 Worksmobile/Baron 컬럼을 정상 매핑하는지 확인 +- `additionalAppointments`를 유지한 채 사용자 생성/수정 payload가 만들어지는지 확인 +- tenant detail이 `hanmac-family` root일 때만 Worksmobile 탭/버튼을 표시하는지 확인 +- non-hanmac tenant detail이나 직접 URL 접근에서 Worksmobile 운영 화면이 차단되는지 확인 + +### E2E/manual + +- adminfront에서 `hanmac-family` 하위 조직 생성 -> outbox 생성 -> mock Worksmobile orgunit create 확인 +- bulk import로 기존 계정 생성 -> #668 email preview -> Worksmobile user create mock 확인 +- 신규 구성원 단건 생성 -> Worksmobile user create mock 확인 +- orgfront 조직도 표시가 기존 `joinedTenants`/`additionalAppointments` 기준과 충돌하지 않는지 확인 +- adminfront tenant list -> 한맥가족 detail -> Worksmobile 운영 화면 새 탭 -> 단건 sync 결과 확인 +- non-hanmac tenant detail에서는 Worksmobile UI가 보이지 않고 직접 URL 접근도 backend에서 차단되는지 확인 + +## 주요 리스크와 선행 결정 + +1. `domainId` mapping 관리 + - Worksmobile API는 `domainId`가 필수입니다. + - mapping 위치는 `tenant.config.worksmobile.domainMappings`로 결정했습니다. + - 개발/스테이징에서 검증한 값이 프로덕션에도 적용되어야 하므로 코드 레벨 seed/config로 추적 가능해야 합니다. + +2. `additionalAppointments` backend 정규화 + - 현재 frontend는 값을 보내지만 backend 단건 생성 path는 구조적으로 처리하지 않습니다. + - Worksmobile 연동 전 Baron 내부 membership과 metadata 정합성을 먼저 맞춰야 합니다. + +3. transaction 정합성 + - 현재 tenant create/update와 Keto outbox는 완전한 transactional outbox가 아닙니다. + - Worksmobile outbox는 신규 구현 시 DB 변경과 같은 transaction으로 enqueue되도록 설계하는 것이 좋습니다. + +4. Worksmobile orgunit rate limit + - 조직 API는 도메인당 단일 스레드/1초 1회 제약이 있으므로 worker lane 설계가 필수입니다. + +5. 기존 Worksmobile 데이터 backfill + - Developer Console External Key Mapping이 비어 있는 기존 조직/구성원은 CSV mapping 또는 API external-key update가 선행되어야 합니다. + +6. 상태 정책 + - Baron `inactive`는 Worksmobile suspend로 동기화합니다. + - Baron delete는 Worksmobile delete로 동기화합니다. + - leave-of-absence는 별도 user 상태 확장 이슈로 분리합니다. + - 직급/직책/사용자 유형 External Key sync는 이번 scope에서 제외합니다. + +7. adminfront 권한 경계 + - Worksmobile 운영 화면은 `hanmac-family` root tenant detail에서만 보이게 합니다. + - 별도 URL/새 탭은 허용하지만 backend는 tenant slug, Keto 관리 권한, subtree membership을 모두 확인해야 합니다. + - UI hidden state만으로 접근 제어를 대신하지 않습니다. + +## 권장 구현 순서 + +1. Worksmobile integration config와 `tenant.config.worksmobile.domainMappings` seed/config 정책 확정 +2. `additionalAppointments` backend DTO/parser/Keto membership sync 정리 +3. Worksmobile mapper unit test 작성 후 RED 확인 +4. `worksmobile_outbox` repository/service/worker 구현 +5. tenant orgunit enqueue 및 mock client GREEN +6. user enqueue 및 mock client GREEN +7. backfill command 또는 admin API dry-run 구현 +8. adminfront 상태/재시도 UI 또는 최소 운영 조회 API 추가 +9. E2E/mock integration 검증 + +## 문서 업데이트 후보 + +- `README.md`: 관리 데이터 import 정책에 Worksmobile sync 상태와 External Key 원칙 추가 +- `docs/organization-chart-policy.md`: Worksmobile orgunit mapping 정책 추가 +- `docs/tenant-usergroup-policy.md`: 외부 Directory sync outbox 정책 추가 +- `docs/worksmobile-directory-sync-technical-review.md`: 본 문서를 위키 반영 전 검토본으로 유지 diff --git a/orgfront/src/features/orgchart/pickerTree.ts b/orgfront/src/features/orgchart/pickerTree.ts index fc71bc7a..e321d1f4 100644 --- a/orgfront/src/features/orgchart/pickerTree.ts +++ b/orgfront/src/features/orgchart/pickerTree.ts @@ -9,6 +9,12 @@ function getUserTenantSlug(user: UserSummary) { ); } +function isOrgFrontTenantType(tenant: TenantSummary) { + return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION"].includes( + tenant.type.toUpperCase(), + ); +} + function getCompanyGroupId(node: TenantNode, allTenants: TenantSummary[]) { let cursor: TenantSummary | undefined = node; const byId = new Map(allTenants.map((tenant) => [tenant.id, tenant])); @@ -73,6 +79,7 @@ export function buildOrgPickerTree({ rootTenantId?: string; tenantId?: string; }) { + const visibleTenants = tenants.filter(isOrgFrontTenantType); const usersBySlug = new Map(); for (const user of users) { if (user.status !== "active") continue; @@ -84,16 +91,16 @@ export function buildOrgPickerTree({ } const companyGroup = - tenants.find((tenant) => tenant.id === rootTenantId) ?? - tenants.find((tenant) => tenant.type === "COMPANY_GROUP") ?? - tenants.find((tenant) => !tenant.parentId); + visibleTenants.find((tenant) => tenant.id === rootTenantId) ?? + visibleTenants.find((tenant) => tenant.type === "COMPANY_GROUP") ?? + visibleTenants.find((tenant) => !tenant.parentId); if (!companyGroup) return { roots: [], companies: [], companyGroupId: "" }; - const { currentBase } = buildTenantFullTree(tenants, companyGroup.id); + const { currentBase } = buildTenantFullTree(visibleTenants, companyGroup.id); const groupNode = currentBase ?? - buildTenantFullTree(tenants).subTree.find( + buildTenantFullTree(visibleTenants).subTree.find( (node) => node.id === companyGroup.id, ); diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx index b5f920d9..98c0b7fd 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx @@ -297,6 +297,12 @@ function isVisibleOrgChartUser(user: UserSummary) { ); } +function isOrgFrontTenantType(tenant: TenantSummary) { + return ["COMPANY_GROUP", "COMPANY", "ORGANIZATION"].includes( + tenant.type.toUpperCase(), + ); +} + function isSystemGlobalTenant( tenant?: Pick, ) { @@ -363,7 +369,9 @@ function filterSystemGlobalTenants(tenants: TenantSummary[]) { } } - return tenants.filter((tenant) => !excludedIds.has(tenant.id)); + return tenants.filter( + (tenant) => !excludedIds.has(tenant.id) && isOrgFrontTenantType(tenant), + ); } type TenantIndexes = { @@ -567,7 +575,7 @@ export function TenantOrgChartPage() { const companyFilters = React.useMemo(() => { return (familyRoot?.children ?? []) - .filter((node) => node.type === "COMPANY") + .filter((node) => node.type === "COMPANY" || node.type === "ORGANIZATION") .map((node) => ({ id: node.id, label: node.name })) .sort((a, b) => a.label.localeCompare(b.label)); }, [familyRoot]); @@ -588,7 +596,9 @@ export function TenantOrgChartPage() { node.slug.toLowerCase() === tenantId.toLowerCase() || node.name === tenantId, ); - if (match?.type === "COMPANY") setSelectedTenantFilter(match.id); + if (match?.type === "COMPANY" || match?.type === "ORGANIZATION") { + setSelectedTenantFilter(match.id); + } }, [familyRoot, rootNodes, tenantId]); const targetNodes = React.useMemo(() => { diff --git a/orgfront/src/lib/adminApi.ts b/orgfront/src/lib/adminApi.ts index 1a176b90..daf962d1 100644 --- a/orgfront/src/lib/adminApi.ts +++ b/orgfront/src/lib/adminApi.ts @@ -21,7 +21,7 @@ export type AuditLogListResponse = { export type TenantSummary = { id: string; - type: string; // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP + type: string; // 허용 타입: PERSONAL, COMPANY, COMPANY_GROUP, ORGANIZATION, USER_GROUP name: string; slug: string; description: string; diff --git a/orgfront/src/locales/en.toml b/orgfront/src/locales/en.toml index 87256821..1794ae04 100644 --- a/orgfront/src/locales/en.toml +++ b/orgfront/src/locales/en.toml @@ -16,6 +16,7 @@ saman = "Saman" [domain.tenant_type] company = "Company" company_group = "Company Group" +organization = "Organization" personal = "Personal" user_group = "User Group" diff --git a/orgfront/src/locales/ko.toml b/orgfront/src/locales/ko.toml index 2ac3c74a..0df67fd2 100644 --- a/orgfront/src/locales/ko.toml +++ b/orgfront/src/locales/ko.toml @@ -16,6 +16,7 @@ saman = "삼안" [domain.tenant_type] company = "COMPANY (일반 기업)" company_group = "COMPANY_GROUP (그룹사/지주사)" +organization = "ORGANIZATION (정규 조직)" personal = "PERSONAL (개인 워크스페이스)" user_group = "USER_GROUP (내부 부서/팀)" diff --git a/orgfront/src/locales/template.toml b/orgfront/src/locales/template.toml index 7bff5ee6..8b3a86fd 100644 --- a/orgfront/src/locales/template.toml +++ b/orgfront/src/locales/template.toml @@ -16,6 +16,7 @@ saman = "" [domain.tenant_type] company = "" company_group = "" +organization = "" personal = "" user_group = "" diff --git a/test/env_secret_file_policy_test.sh b/test/env_secret_file_policy_test.sh new file mode 100644 index 00000000..10c09762 --- /dev/null +++ b/test/env_secret_file_policy_test.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +env_file="$repo_root/.env" +gitignore_file="$repo_root/.gitignore" + +if [[ -f "$env_file" ]] && grep -q -- "-----BEGIN PRIVATE KEY-----" "$env_file"; then + echo "ERROR: .env must not contain a multi-line PEM private key; put it under config/ and reference WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE." >&2 + exit 1 +fi + +if [[ -f "$env_file" ]] && ! grep -q '^WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE=' "$env_file"; then + echo "ERROR: .env must reference WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE." >&2 + exit 1 +fi + +if ! grep -Eq '(^|/)config/\*\.pem$' "$gitignore_file"; then + echo "ERROR: .gitignore must ignore config/*.pem secret files." >&2 + exit 1 +fi + +make --dry-run --always-make -C "$repo_root" dev DEV_SERVICES="backend adminfront" >/dev/null diff --git a/test/oathkeeper_access_log_e2e_test.sh b/test/oathkeeper_access_log_e2e_test.sh new file mode 100755 index 00000000..14482b7c --- /dev/null +++ b/test/oathkeeper_access_log_e2e_test.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +require_container() { + local name="$1" + if ! docker inspect "$name" >/dev/null 2>&1; then + echo "ERROR: required container is missing: $name" >&2 + exit 1 + fi +} + +for container in ory_oathkeeper ory_vector ory_clickhouse baron_backend; do + require_container "$container" +done + +vector_state="$(docker inspect -f '{{.State.Status}}' ory_vector)" +if [[ "$vector_state" != "running" ]]; then + echo "ERROR: ory_vector must be running, got: $vector_state" >&2 + docker logs --tail 100 ory_vector >&2 || true + exit 1 +fi + +before_lines="$(docker exec ory_oathkeeper sh -lc 'test -f /var/log/oathkeeper/access.log && wc -l < /var/log/oathkeeper/access.log || echo 0')" +before_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "SELECT count() FROM ory.oathkeeper_access_logs")" + +docker run --rm --network public_net curlimages/curl:8.10.1 \ + -fsS http://ory_oathkeeper:4455/health >/dev/null + +deadline=$((SECONDS + 20)) +after_lines="$before_lines" +while (( SECONDS < deadline )); do + after_lines="$(docker exec ory_oathkeeper sh -lc 'test -f /var/log/oathkeeper/access.log && wc -l < /var/log/oathkeeper/access.log || echo 0')" + if (( after_lines > before_lines )); then + break + fi + sleep 1 +done + +if (( after_lines <= before_lines )); then + echo "ERROR: Oathkeeper access log did not grow after a proxied request." >&2 + docker exec ory_oathkeeper sh -lc 'ls -l /var/log/oathkeeper && tail -n 50 /var/log/oathkeeper/access.log 2>/dev/null || true' >&2 + exit 1 +fi + +deadline=$((SECONDS + 30)) +after_rows="$before_rows" +while (( SECONDS < deadline )); do + after_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "SELECT count() FROM ory.oathkeeper_access_logs")" + if (( after_rows > before_rows )); then + break + fi + sleep 2 +done + +if (( after_rows <= before_rows )); then + echo "ERROR: Vector did not insert the new Oathkeeper access log into ClickHouse." >&2 + echo "before_rows=$before_rows after_rows=$after_rows" >&2 + docker logs --tail 100 ory_vector >&2 || true + exit 1 +fi diff --git a/test/ory_log_pipeline_policy_test.sh b/test/ory_log_pipeline_policy_test.sh new file mode 100755 index 00000000..18c2b5ed --- /dev/null +++ b/test/ory_log_pipeline_policy_test.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +docker run --rm \ + -e ORY_CLICKHOUSE_USER=ory \ + -e ORY_CLICKHOUSE_PASSWORD=orypass \ + -v "$repo_root/docker/ory/vector:/etc/vector:ro" \ + timberio/vector:0.36.0-alpine validate --no-environment /etc/vector/vector.toml >/dev/null + +if grep -q '/etc/config/oathkeeper/rules.active.json' "$repo_root/docker/ory/oathkeeper/entrypoint.sh"; then + echo "ERROR: Oathkeeper entrypoint must not write active rules into the bind-mounted config directory." >&2 + exit 1 +fi + +if ! grep -q 'file:///tmp/oathkeeper/rules.active.json' "$repo_root/docker/ory/oathkeeper/oathkeeper.yml"; then + echo "ERROR: Oathkeeper config must load active rules from writable runtime storage." >&2 + exit 1 +fi + +if ! grep -q '^version: v26.2.0$' "$repo_root/docker/ory/kratos/kratos.yml"; then + echo "ERROR: Kratos config version must match the v26.2.0 runtime." >&2 + exit 1 +fi + +cookie_secret="$(grep -E '^COOKIE_SECRET=' "$repo_root/.env" | cut -d= -f2-)" +if [[ ${#cookie_secret} -ne 32 ]]; then + echo "ERROR: COOKIE_SECRET must be exactly 32 bytes/chars for backend encryptcookie." >&2 + exit 1 +fi + +root_config="$( + docker compose --env-file "$repo_root/.env" -f "$repo_root/compose.ory.yaml" config +)" +if ! grep -q "oathkeeper_logs_init:" <<<"$root_config"; then + echo "ERROR: compose.ory.yaml must initialize the Oathkeeper log volume permissions." >&2 + exit 1 +fi diff --git a/test/ory_v26_compose_policy_test.sh b/test/ory_v26_compose_policy_test.sh new file mode 100644 index 00000000..5c1d3a29 --- /dev/null +++ b/test/ory_v26_compose_policy_test.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +root_config="$( + docker compose --env-file "$repo_root/.env" -f "$repo_root/compose.ory.yaml" config +)" +docker_config="$( + docker compose --env-file "$repo_root/.env" -f "$repo_root/docker/compose.ory.yaml" config +)" + +for service in kratos hydra keto oathkeeper; do + version_key="$(tr '[:lower:]' '[:upper:]' <<<"$service")_VERSION" + expected_version="$(grep -E "^${version_key}=" "$repo_root/.env" | cut -d= -f2-)" + if [[ -z "$expected_version" ]]; then + echo "ERROR: $version_key must be set in .env" >&2 + exit 1 + fi + if ! grep -q "image: oryd/${service}:${expected_version}" <<<"$root_config"; then + echo "ERROR: compose.ory.yaml must render oryd/${service}:${expected_version}" >&2 + exit 1 + fi +done + +if grep -q "oryd/hydra:v25.4.0" <<<"$root_config"; then + echo "ERROR: compose.ory.yaml must not hard-code init-rp to hydra v25.4.0." >&2 + exit 1 +fi + +root_init_rp="$( + awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^ init-rp:/ { in_block=1 } in_block { print }' "$repo_root/compose.ory.yaml" +)" +docker_init_rp="$( + awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^ init-rp:/ { in_block=1 } in_block { print }' "$repo_root/docker/compose.ory.yaml" +)" +if grep -q "image: oryd/hydra" <<<"$root_init_rp$docker_init_rp"; then + echo "ERROR: init-rp must not use the Hydra service image because distroless tags do not provide /bin/sh." >&2 + exit 1 +fi + +if ! grep -q "migrate sql up" "$repo_root/compose.ory.yaml"; then + echo "ERROR: compose.ory.yaml Kratos migration must use migrate sql up." >&2 + exit 1 +fi + +if ! grep -q "keto-migrate:" <<<"$docker_config"; then + echo "ERROR: docker/compose.ory.yaml must include keto-migrate for clean Ory installs." >&2 + exit 1 +fi + +if grep -q "releases/download/v25.4.0" "$repo_root/docker/staging_pull_compose.template.yaml"; then + echo "ERROR: staging pull compose must not download a hard-coded Hydra v25.4.0 CLI." >&2 + exit 1 +fi
+ {t( + "ui.admin.tenants.worksmobile.subtitle", + "한맥가족 Directory 조직/구성원 동기화 상태를 확인하고 실패 작업을 재시도합니다.", + )} +