1
0
forked from baron/baron-sso

worksmobile 연동 & ory stack 26.2.0으로 업그레이드

This commit is contained in:
2026-05-06 09:30:00 +09:00
parent 3dcdd97882
commit 2495fcb13d
74 changed files with 8698 additions and 212 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@
.npm-cache/
reports
reports/*
config/*.pem
# Docker Services Data (Volumes)
postgres_data/

View File

@@ -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"
1 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
2 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
3 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

View File

@@ -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,개인 사용자 기본 루트 테넌트,
1 id name type parent_tenant_slug slug memo email_domain
2 038326b6-954a-48a7-a85f-efd83f62b82a 한맥가족 COMPANY_GROUP hanmac-family 한맥가족 기본 루트 테넌트
3 9caf62e1-297d-4e8f-870b-61780998bbeb Personal 삼안 PERSONAL COMPANY hanmac-family personal saman 개인 사용자 기본 루트 테넌트 네이버웍스 삼안 SAMAN_DOMAIN_ID samaneng.com
4 369c1843-56af-4344-9c21-0e01197ab861 한맥기술 COMPANY hanmac-family hanmac 네이버웍스 한맥 HANMAC_DOMAIN_ID hanmaceng.co.kr
5 5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee 총괄기획&기술개발센터 COMPANY hanmac-family gpdtdc 네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID baroncs.co.kr
6 96369f12-6b66-4b2a-a916-d1c99d326f02 바론그룹 COMPANY_GROUP hanmac-family baron-group 네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID
7 c18a8284-0008-48aa-9cdf-9f47ab79a2a9 (주)장헌 COMPANY baron-group jangheon jangheon.com
8 b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 장헌산업 COMPANY baron-group jangheon-sanup jangheon.co.kr
9 5a03efd2-e62f-4243-800d-58334bf48b2f 한라산업개발 COMPANY baron-group hanlla hanllasanup.co.kr
10 e57cb22c-383e-4489-8c2f-0c5431917e86 (주)피티씨 COMPANY baron-group ptc pre-cast.co.kr
11 9607eb7b-04d2-42ab-80fe-780fe21c7e8f Personal PERSONAL personal 개인 사용자 기본 루트 테넌트

View File

@@ -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: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
],
},
{

View File

@@ -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 (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",

View File

@@ -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);
});
});

View File

@@ -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 (
<div className="space-y-8">
@@ -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", "사용자 스키마")}
</Link>
)}
{showWorksmobileEntry && (
<Link
to={`/tenants/${tenantId}/worksmobile`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isWorksmobileTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_worksmobile", "Worksmobile")}
</Link>
)}
</div>
{/* Outlet for nested routes */}

View File

@@ -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(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/tenants/hanmac-family-id"]}>
<Routes>
<Route path="/tenants/:tenantId/*" element={<TenantDetailPage />}>
<Route index element={<div>profile</div>} />
<Route path="worksmobile" element={<div>worksmobile</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
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");
});
});

View File

@@ -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 (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",

View File

@@ -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(["직책 팀장", "직무 기술검토", "조직장"]);
});
});

View File

@@ -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<string[]>([]);
const [selectedGroupIds, setSelectedGroupIds] = React.useState<string[]>([]);
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 (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{t(
"ui.admin.tenants.worksmobile.forbidden",
"한맥가족 테넌트에서만 Worksmobile 연동을 관리할 수 있습니다.",
)}
</div>
);
}
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 (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-xl font-semibold">
{t("ui.admin.tenants.worksmobile.title", "Worksmobile 연동")}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.tenants.worksmobile.subtitle",
"한맥가족 Directory 조직/구성원 동기화 상태를 확인하고 실패 작업을 재시도합니다.",
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => initialPasswordDownloadMutation.mutate()}
disabled={initialPasswordDownloadMutation.isPending}
>
<Download size={16} />
{t(
"ui.admin.tenants.worksmobile.initial_password_csv",
"초기 비밀번호 CSV",
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
overviewQuery.refetch();
comparisonQuery.refetch();
}}
disabled={isRefreshing}
>
<RefreshCw size={16} />
{t("ui.admin.tenants.worksmobile.refresh", "새로고침")}
</Button>
<Button
type="button"
onClick={() => dryRunMutation.mutate()}
disabled={dryRunMutation.isPending}
>
<RefreshCw size={16} />
{t("ui.admin.tenants.worksmobile.dry_run", "Backfill Dry-run")}
</Button>
</div>
</header>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
<CardTitle className="text-base">
{t("ui.admin.tenants.worksmobile.compare", "Baron / Works 비교")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.tenants.worksmobile.compare_description",
"구성원은 기본적으로 Baron 또는 WORKS 한쪽에만 있는 항목을 보여줍니다.",
)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<ComparisonSummary
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
summary={userSummary}
/>
<ComparisonSummary
title={t(
"ui.admin.tenants.worksmobile.compare_groups",
"조직/그룹",
)}
summary={groupSummary}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{userFilterOptions.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={
userFilters.includes(option.value) ? "default" : "outline"
}
aria-pressed={userFilters.includes(option.value)}
onClick={() => {
setUserFilters((current) =>
current.includes(option.value)
? current.filter((value) => value !== option.value)
: [...current, option.value],
);
setSelectedUserIds([]);
}}
>
{option.label}
</Button>
))}
</div>
<ComparisonTable
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
rows={filteredComparisonUsers}
loading={comparisonQuery.isLoading}
selectedIds={selectedUserIds}
onSelectedIdsChange={setSelectedUserIds}
actionLabel="선택 구성원 WORKS에 생성"
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
onCreateSelected={() =>
createSelectedMutation.mutate({
resourceKind: "users",
ids: selectedUserIds,
})
}
/>
<ComparisonTable
title={t(
"ui.admin.tenants.worksmobile.compare_groups",
"조직/그룹",
)}
rows={comparisonGroups}
loading={comparisonQuery.isLoading}
selectedIds={selectedGroupIds}
onSelectedIdsChange={setSelectedGroupIds}
actionLabel="선택 조직 WORKS에 생성"
actionDisabled={
isCreatingGroups || createSelectedMutation.isPending
}
onCreateSelected={() =>
createSelectedMutation.mutate({
resourceKind: "groups",
ids: selectedGroupIds,
})
}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("ui.admin.tenants.worksmobile.single_sync", "단건 동기화")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.tenants.worksmobile.single_sync_description",
"Baron UUID 기준으로 조직 또는 구성원 sync 작업을 생성합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2">
<div className="flex gap-2">
<Input
value={orgUnitId}
onChange={(event) => setOrgUnitId(event.target.value)}
placeholder="orgUnit tenant UUID"
/>
<Button
type="button"
onClick={() => orgUnitSyncMutation.mutate()}
disabled={!orgUnitId.trim() || orgUnitSyncMutation.isPending}
>
{t("ui.admin.tenants.worksmobile.sync_orgunit", "조직 Sync")}
</Button>
</div>
<div className="flex gap-2">
<Input
value={userId}
onChange={(event) => setUserId(event.target.value)}
placeholder="Kratos user UUID"
/>
<Button
type="button"
onClick={() => userSyncMutation.mutate()}
disabled={!userId.trim() || userSyncMutation.isPending}
>
{t("ui.admin.tenants.worksmobile.sync_user", "구성원 Sync")}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("ui.admin.tenants.worksmobile.recent_jobs", "최근 작업")}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>resource</TableHead>
<TableHead>action</TableHead>
<TableHead>status</TableHead>
<TableHead>retry</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{(overview?.recentJobs ?? []).map((job) => (
<TableRow key={job.id}>
<TableCell>
{job.resourceType}:{job.resourceId}
</TableCell>
<TableCell>{job.action}</TableCell>
<TableCell>{job.status}</TableCell>
<TableCell>{job.retryCount}</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => retryMutation.mutate(job.id)}
disabled={retryMutation.isPending}
>
<RotateCcw size={16} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export type WorksmobileComparisonSummary = {
total: number;
matched: number;
missingInWorksmobile: number;
missingInBaron: number;
missingExternalKey: number;
};
export function summarizeWorksmobileComparison(
rows: WorksmobileComparisonItem[],
): WorksmobileComparisonSummary {
return rows.reduce<WorksmobileComparisonSummary>(
(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<WorksmobileComparisonFilter, string[]> =
{
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 (
<div className="rounded-md border p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-sm font-medium">{title}</span>
<Badge variant="outline">{summary.total}</Badge>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">WORKS </span>
<span className="font-mono">{summary.missingInWorksmobile}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Baron </span>
<span className="font-mono">{summary.missingInBaron}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">ex_key </span>
<span className="font-mono">{summary.missingExternalKey}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span className="font-mono">{summary.matched}</span>
</div>
</div>
</div>
);
}
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 (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-sm font-medium">{title}</h4>
<Button
type="button"
size="sm"
onClick={onCreateSelected}
disabled={selectedIds.length === 0 || actionDisabled}
>
{actionLabel}
</Button>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 whitespace-nowrap">
<Checkbox
aria-label={`${title} 전체 선택`}
checked={allCreatableSelected}
disabled={creatableIds.length === 0}
onCheckedChange={toggleAll}
/>
</TableHead>
<TableHead className="w-24 whitespace-nowrap"></TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
Baron ID
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
Baron
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
WORKS ID
</TableHead>
<TableHead className="min-w-40 whitespace-nowrap">
external_key
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
</TableHead>
<TableHead className="min-w-52 whitespace-nowrap">
WORKS
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
...
</TableCell>
</TableRow>
)}
{!loading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{rows.map((row) => (
<TableRow
key={`${row.status}:${row.baronId ?? row.worksmobileId ?? row.externalKey}`}
>
<TableCell className="whitespace-nowrap">
<Checkbox
aria-label={`${row.baronName ?? row.baronId ?? row.worksmobileName ?? row.worksmobileId ?? "row"} 선택`}
checked={Boolean(
row.baronId && selectedIds.includes(row.baronId),
)}
disabled={!canCreateWorksmobileRow(row)}
onCheckedChange={(checked) =>
toggleRow(row.baronId, checked)
}
/>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
className="whitespace-nowrap"
variant={getWorksmobileComparisonStatusVariant(row.status)}
>
{getWorksmobileComparisonStatusLabel(row.status)}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{row.baronId ?? "-"}
</TableCell>
<TableCell>
<div className="space-y-1">
<div>{row.baronName ?? "-"}</div>
<div className="text-xs text-muted-foreground">
{row.baronEmail ?? ""}
</div>
</div>
</TableCell>
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.baronParentName
: row.baronPrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.baronParentId
: row.baronPrimaryOrgId
}
/>
</TableCell>
<TableCell className="font-mono text-xs">
{row.worksmobileId ?? "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{row.externalKey ?? "-"}
</TableCell>
<TableCell>
<ComparisonDomainCell
name={row.worksmobileDomainName}
id={row.worksmobileDomainId}
/>
</TableCell>
<TableCell>
<div className="space-y-1">
<div>{formatWorksmobilePersonName(row) || "-"}</div>
<div className="text-xs text-muted-foreground">
{row.worksmobileEmail ?? ""}
</div>
</div>
</TableCell>
<TableCell>
<ComparisonOrgCell
name={
row.resourceType === "GROUP"
? row.worksmobileParentName
: row.worksmobilePrimaryOrgName
}
id={
row.resourceType === "GROUP"
? row.worksmobileParentId
: row.worksmobilePrimaryOrgId
}
details={
row.resourceType === "GROUP"
? []
: formatWorksmobileOrgDetails(row)
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
function ComparisonDomainCell({
name,
id,
}: {
name?: string;
id?: number;
}) {
if (!name && !id) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="space-y-1">
<div>{name ?? "-"}</div>
<div className="font-mono text-xs text-muted-foreground">{id ?? ""}</div>
</div>
);
}
function ComparisonOrgCell({
name,
id,
details = [],
}: {
name?: string;
id?: string;
details?: string[];
}) {
if (!name && !id && details.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="space-y-1">
<div>{name ?? "-"}</div>
<div className="font-mono text-xs text-muted-foreground">{id ?? ""}</div>
{details.length > 0 && (
<div className="text-xs text-muted-foreground">
{details.join(" · ")}
</div>
)}
</div>
);
}

View File

@@ -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,,",
);
});
});

View File

@@ -87,6 +87,7 @@ const getTenantIcon = (type?: string) => {
return Briefcase;
case "PERSONAL":
return UserCircle;
case "ORGANIZATION":
case "USER_GROUP":
return Network;
default:

View File

@@ -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<UserUpdateRequest, "metadata"> & {
metadata: Record<string, Record<string, string | number | boolean>>;
@@ -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<UserAppointment>,
) => {
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", "시스템 전역")}
</Badge>
@@ -1001,21 +1088,23 @@ function UserDetailPage() {
>
{t("ui.admin.users.detail.form.status", "상태")}
</Label>
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
<Switch
id="status"
checked={watchedStatus === "active"}
onCheckedChange={(checked) =>
setValue("status", checked ? "active" : "inactive")
}
/>
<span className="text-sm text-muted-foreground">
{t(
`ui.common.status.${watchedStatus}`,
watchedStatus || "inactive",
)}
</span>
</div>
<Select
value={watchedStatus || "inactive"}
onValueChange={(status) => setValue("status", status)}
>
<SelectTrigger id="status" className="h-11">
<SelectValue>
{userStatusLabel(watchedStatus || "inactive")}
</SelectValue>
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
@@ -1160,6 +1249,26 @@ function UserDetailPage() {
{appointment.tenantSlug}
</span>
)}
<label className="flex items-center gap-3 text-sm">
<Switch
aria-label={t(
"ui.admin.users.detail.form.appointment_primary",
"대표 조직",
)}
checked={appointment.isPrimary === true}
disabled={
!appointment.tenantId ||
primaryAppointmentLeafCount <= 1
}
onCheckedChange={(checked) =>
setPrimaryAppointment(index, checked)
}
/>
{t(
"ui.admin.users.detail.form.appointment_primary",
"대표 조직",
)}
</label>
<label className="flex items-center gap-3 text-sm">
<Checkbox
checked={appointment.isOwner}

View File

@@ -32,7 +32,13 @@ import {
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Switch } from "../../components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import {
Table,
TableBody,
@@ -55,6 +61,7 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
import { userStatusLabel, userStatusValues } from "./userStatus";
type UserSchemaField = {
key: string;
@@ -579,28 +586,40 @@ function UserListPage() {
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) =>
<Select
value={user.status}
onValueChange={(status) =>
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}`}
/>
<span className="text-sm text-muted-foreground">
{t(`ui.common.status.${user.status}`, user.status)}
</span>
>
<SelectTrigger
className="h-8 w-36"
aria-label={t(
"ui.admin.users.list.status_select",
"{{name}} 상태",
{ name: user.name },
)}
data-testid={`user-status-select-${user.id}`}
>
<SelectValue>
{userStatusLabel(user.status)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</TableCell>
{/* Dynamic Metadata Cells */}
@@ -683,6 +702,24 @@ function UserListPage() {
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("suspended")}
data-testid="bulk-suspended-btn"
>
{t("ui.common.status.suspended", "정지")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("leave_of_absence")}
data-testid="bulk-leave-of-absence-btn"
>
{t("ui.common.status.leave_of_absence", "휴직")}
</Button>
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"

View File

@@ -0,0 +1,14 @@
import { t } from "../../lib/i18n";
export const userStatusValues = [
"active",
"inactive",
"suspended",
"leave_of_absence",
] as const;
export type UserStatusValue = (typeof userStatusValues)[number];
export function userStatusLabel(status: string) {
return t(`ui.common.status.${status}`, status);
}

View File

@@ -44,6 +44,36 @@ test@test.com,Test,baron`;
expect(result[0].tenantSlug).toBe("baron");
});
it("should parse NAVERWORKS member CSV sample into Baron bulk user fields", () => {
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`;

View File

@@ -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<BulkUserItem> & { metadata: Record<string, string> } = {
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<BulkUserItem> & { metadata: Record<string, string> },
) {
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;
}
}

View File

@@ -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<WorksmobileOverview>(
`/v1/admin/tenants/${tenantId}/worksmobile`,
);
return data;
}
export async function fetchWorksmobileComparison(
tenantId: string,
includeMatched = false,
) {
const { data } = await apiClient.get<WorksmobileComparison>(
`/v1/admin/tenants/${tenantId}/worksmobile/comparison`,
{
params: { includeMatched },
},
);
return data;
}
export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) {
const response = await apiClient.get<Blob>(
`/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<WorksmobileOutboxItem>(
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${orgUnitId}/sync`,
);
return data;
}
export async function enqueueWorksmobileUserSync(
tenantId: string,
userId: string,
) {
const { data } = await apiClient.post<WorksmobileOutboxItem>(
`/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,

View File

@@ -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"

View File

@@ -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 = "테스트"

View File

@@ -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 = ""

View File

@@ -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<string, unknown> | 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,
}) => {

View File

@@ -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();
});
});

View File

@@ -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]")
}

View File

@@ -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")
}
}

View File

@@ -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{
@@ -643,6 +697,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)

View File

@@ -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)
}
}

View File

@@ -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,
})
}

View File

@@ -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
}

View File

@@ -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{},

View File

@@ -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:

View File

@@ -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)
}
}

View File

@@ -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"`

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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 ""

View File

@@ -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" +

View File

@@ -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 ""
}

View File

@@ -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
}

View File

@@ -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("<!doctype html><html><body>Worksmobile OAuth callback reachable</body></html>")
}
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())
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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]),

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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"))
}

View File

@@ -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<<retryCount) * time.Minute
}

View File

@@ -0,0 +1,881 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"errors"
"os"
"sort"
"strings"
)
const HanmacFamilyTenantSlug = "hanmac-family"
type WorksmobileSyncer interface {
EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error
EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error
EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error
EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error
}
type WorksmobileAdminService interface {
GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error)
GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error)
EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error)
RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error)
ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error)
}
type WorksmobileConfigSummary struct {
Enabled bool `json:"enabled"`
DomainMappings map[string]int64 `json:"domainMappings"`
TokenConfigured bool `json:"tokenConfigured"`
}
type WorksmobileTenantOverview struct {
Tenant domain.Tenant `json:"tenant"`
Config WorksmobileConfigSummary `json:"config"`
RecentJobs []domain.WorksmobileOutbox `json:"recentJobs"`
}
type WorksmobileBackfillDryRun struct {
OrgUnitCount int `json:"orgUnitCount"`
UserCount int `json:"userCount"`
}
type WorksmobileInitialPasswordCredential struct {
Email string `json:"email"`
InitialPassword string `json:"initialPassword"`
Status string `json:"status"`
LastError string `json:"lastError,omitempty"`
}
type WorksmobileComparison struct {
Users []WorksmobileComparisonItem `json:"users"`
Groups []WorksmobileComparisonItem `json:"groups"`
}
type WorksmobileComparisonItem struct {
ResourceType string `json:"resourceType"`
BaronID string `json:"baronId,omitempty"`
BaronName string `json:"baronName,omitempty"`
BaronEmail string `json:"baronEmail,omitempty"`
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
BaronParentID string `json:"baronParentId,omitempty"`
BaronParentName string `json:"baronParentName,omitempty"`
WorksmobileID string `json:"worksmobileId,omitempty"`
ExternalKey string `json:"externalKey,omitempty"`
WorksmobileName string `json:"worksmobileName,omitempty"`
WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
WorksmobileTask string `json:"worksmobileTask,omitempty"`
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
Status string `json:"status"`
}
type worksmobileSyncService struct {
tenantService TenantService
userRepo repository.UserRepository
outboxRepo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient
}
func NewWorksmobileSyncService(tenantService TenantService, userRepo repository.UserRepository, outboxRepo repository.WorksmobileOutboxRepository, client WorksmobileDirectoryClient) *worksmobileSyncService {
return &worksmobileSyncService{
tenantService: tenantService,
userRepo: userRepo,
outboxRepo: outboxRepo,
client: client,
}
}
func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) {
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
if err != nil {
return WorksmobileTenantOverview{}, err
}
jobs, _ := s.outboxRepo.ListRecent(ctx, 50)
jobs = redactWorksmobileOutboxPayloads(jobs)
return WorksmobileTenantOverview{
Tenant: *tenant,
Config: WorksmobileConfigSummary{
Enabled: WorksmobileEnabled(tenant.Config),
DomainMappings: WorksmobileDomainMappings(tenant.Config),
TokenConfigured: worksmobileDirectoryAuthConfigured(),
},
RecentJobs: jobs,
}, nil
}
func worksmobileDirectoryAuthConfigured() bool {
if strings.TrimSpace(os.Getenv("WORKS_ADMIN_ACCESS_TOKEN")) != "" || strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_ACCESS_TOKEN")) != "" {
return true
}
return strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID")) != "" &&
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET")) != "" &&
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT")) != "" &&
(strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY")) != "" ||
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "")
}
func redactWorksmobileOutboxPayloads(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
for i := range jobs {
if jobs[i].Payload != nil {
jobs[i].Payload = nil
}
}
return jobs
}
func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return WorksmobileComparison{}, err
}
if s.client == nil {
return WorksmobileComparison{}, errors.New("worksmobile client is not configured")
}
tenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return WorksmobileComparison{}, err
}
tenantByID := worksmobileTenantByID(tenants)
tenantByID[root.ID] = *root
tenantIDs := make([]string, 0, len(tenants))
for _, tenant := range tenants {
if isWorksmobileUserScopeTenant(tenant) {
tenantIDs = append(tenantIDs, tenant.ID)
}
}
users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs)
if err != nil {
return WorksmobileComparison{}, err
}
remoteUsers, err := s.client.ListUsers(ctx)
if err != nil {
return WorksmobileComparison{}, err
}
remoteGroups, err := s.client.ListGroups(ctx)
if err != nil {
return WorksmobileComparison{}, err
}
return WorksmobileComparison{
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID),
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
}, nil
}
func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return WorksmobileBackfillDryRun{}, err
}
tenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return WorksmobileBackfillDryRun{}, err
}
orgUnitTenantIDs := make([]string, 0, len(tenants))
userTenantIDs := make([]string, 0, len(tenants))
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, tenants...))
for _, tenant := range tenants {
if isWorksmobileOrgUnitTenant(tenant, tenantByID) {
orgUnitTenantIDs = append(orgUnitTenantIDs, tenant.ID)
}
if isWorksmobileUserScopeTenant(tenant) {
userTenantIDs = append(userTenantIDs, tenant.ID)
}
}
users, err := s.userRepo.FindByTenantIDs(ctx, userTenantIDs)
if err != nil {
return WorksmobileBackfillDryRun{}, err
}
_ = s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: root.ID,
Action: domain.WorksmobileActionDryRun,
DedupeKey: "backfill:dry-run:" + root.ID,
Payload: domain.JSONMap{
"tenantIds": orgUnitTenantIDs,
"userCount": len(users),
},
})
return WorksmobileBackfillDryRun{OrgUnitCount: len(orgUnitTenantIDs), UserCount: len(users)}, nil
}
func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return nil, err
}
tenant, err := s.tenantService.GetTenant(ctx, orgUnitID)
if err != nil {
return nil, err
}
tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil {
return nil, err
}
if !ok || tenantRoot.ID != root.ID {
return nil, errors.New("target orgunit is outside hanmac-family subtree")
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
}
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(
*tenant,
worksmobileDomainClassificationTenant(*tenant, tenantByID),
root.Config,
0,
)
if err != nil {
return nil, err
}
payload = normalizeWorksmobileOrgUnitParent(payload, *tenant, tenantByID, root.ID)
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: tenant.ID,
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{"request": payload},
}
if err := s.outboxRepo.Create(ctx, item); err != nil {
return nil, err
}
return item, nil
}
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return nil, err
}
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, err
}
if user.TenantID == nil {
return nil, errors.New("target user has no tenant")
}
tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID)
if err != nil {
return nil, err
}
tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil {
return nil, err
}
if !ok || tenantRoot.ID != root.ID {
return nil, errors.New("target user is outside hanmac-family subtree")
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
*user,
*tenant,
tenantByID,
root.Config,
)
if err != nil {
return nil, err
}
if err := s.validateUserAliasLocalParts(ctx, root, *user, payload); err != nil {
return nil, err
}
action := WorksmobileUserStatusAction(user.Status)
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: action,
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
Payload: worksmobileUserOutboxPayload(root.ID, payload),
}
if err := s.outboxRepo.Create(ctx, item); err != nil {
return nil, err
}
return item, nil
}
func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return nil, err
}
jobs, err := s.outboxRepo.ListRecent(ctx, 1000)
if err != nil {
return nil, err
}
credentials := make([]WorksmobileInitialPasswordCredential, 0)
seen := map[string]bool{}
for _, job := range jobs {
if job.ResourceType != domain.WorksmobileResourceUser {
continue
}
if stringValue(job.Payload["tenantRootId"]) != root.ID {
continue
}
email := stringValue(job.Payload["loginEmail"])
password := stringValue(job.Payload["initialPassword"])
if email == "" || password == "" || seen[email] {
continue
}
seen[email] = true
credentials = append(credentials, WorksmobileInitialPasswordCredential{
Email: email,
InitialPassword: password,
Status: job.Status,
LastError: job.LastError,
})
}
return credentials, nil
}
func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) {
if _, err := s.hanmacRoot(ctx, tenantID); err != nil {
return nil, err
}
if err := s.outboxRepo.MarkRetry(ctx, jobID); err != nil {
return nil, err
}
return s.outboxRepo.FindByID(ctx, jobID)
}
func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
root, ok, err := s.rootForTenant(ctx, tenant)
if err != nil || !ok {
return err
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
return nil
}
payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(
tenant,
worksmobileDomainClassificationTenant(tenant, tenantByID),
root.Config,
0,
)
if err != nil {
return err
}
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: tenant.ID,
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{"request": payload},
})
}
func (s *worksmobileSyncService) EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error {
root, ok, err := s.rootForTenant(ctx, tenant)
if err != nil || !ok {
return err
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
return nil
}
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: tenant.ID,
Action: domain.WorksmobileActionDelete,
DedupeKey: "orgunit:delete:" + tenant.ID,
Payload: domain.JSONMap{"orgUnitExternalKey": tenant.ID},
})
}
func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error {
if user.TenantID == nil || *user.TenantID == "" {
return nil
}
tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID)
if err != nil {
return err
}
root, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil || !ok {
return err
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
*tenant,
tenantByID,
root.Config,
)
if err != nil {
return err
}
if err := s.validateUserAliasLocalParts(ctx, root, user, payload); err != nil {
return err
}
action := WorksmobileUserStatusAction(user.Status)
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: action,
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
Payload: worksmobileUserOutboxPayload(root.ID, payload),
})
}
func (s *worksmobileSyncService) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
if user.TenantID == nil || *user.TenantID == "" {
return nil
}
tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID)
if err != nil {
return err
}
_, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil || !ok {
return err
}
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: domain.WorksmobileActionDelete,
DedupeKey: "user:delete:" + user.ID,
Payload: domain.JSONMap{
"userExternalKey": user.ID,
"loginEmail": user.Email,
},
})
}
func (s *worksmobileSyncService) hanmacRoot(ctx context.Context, tenantID string) (*domain.Tenant, error) {
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
if err != nil {
return nil, err
}
if tenant.Slug != HanmacFamilyTenantSlug || tenant.ParentID != nil {
return nil, errors.New("worksmobile is only available for hanmac-family root tenant")
}
return tenant, nil
}
func (s *worksmobileSyncService) hanmacSubtree(ctx context.Context, rootID string) ([]domain.Tenant, error) {
all, _, err := s.tenantService.ListTenants(ctx, 10000, 0, "")
if err != nil {
return nil, err
}
byParent := map[string][]domain.Tenant{}
for _, tenant := range all {
if tenant.ParentID != nil {
byParent[*tenant.ParentID] = append(byParent[*tenant.ParentID], tenant)
}
}
result := []domain.Tenant{}
var visit func(id string)
visit = func(id string) {
for _, child := range byParent[id] {
result = append(result, child)
visit(child.ID)
}
}
visit(rootID)
sort.Slice(result, func(i, j int) bool {
return result[i].Name < result[j].Name
})
return result, nil
}
func (s *worksmobileSyncService) rootForTenant(ctx context.Context, tenant domain.Tenant) (*domain.Tenant, bool, error) {
current := tenant
for current.ParentID != nil && *current.ParentID != "" {
parent, err := s.tenantService.GetTenant(ctx, *current.ParentID)
if err != nil {
return nil, false, err
}
current = *parent
}
return &current, current.Slug == HanmacFamilyTenantSlug, nil
}
func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context, root *domain.Tenant, user domain.User, payload WorksmobileUserPayload) error {
if len(payload.AliasEmails) == 0 {
return nil
}
tenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return err
}
tenantByID := make(map[string]domain.Tenant, len(tenants)+1)
tenantByID[root.ID] = *root
tenantIDs := make([]string, 0, len(tenants)+1)
tenantIDs = append(tenantIDs, root.ID)
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
if isWorksmobileUserScopeTenant(tenant) {
tenantIDs = append(tenantIDs, tenant.ID)
}
}
users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs)
if err != nil {
return err
}
existing := map[string]string{}
for _, existingUser := range users {
if existingUser.ID == user.ID {
continue
}
addWorksmobileLocalPart(existing, existingUser.Email, existingUser.ID)
if existingUser.TenantID == nil {
continue
}
tenant, ok := tenantByID[*existingUser.TenantID]
if !ok {
continue
}
for _, alias := range BuildWorksmobileAliasEmails(existingUser, tenant) {
addWorksmobileLocalPart(existing, alias, existingUser.ID)
}
}
return ValidateWorksmobileAliasLocalParts(payload.Email, payload.AliasEmails, existing)
}
func addWorksmobileLocalPart(target map[string]string, email string, owner string) {
localPart, err := domain.ExtractNormalizedEmailLocalPart(email)
if err == nil && localPart != "" {
target[localPart] = owner
}
}
func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
if tenant.Type == domain.TenantTypeOrganization {
return true
}
if tenant.Type == domain.TenantTypeCompany {
return isWorksmobileBarongroupChildCompany(tenant, tenantByID)
}
return false
}
func isWorksmobileUserScopeTenant(tenant domain.Tenant) bool {
return tenant.Type == domain.TenantTypeCompany || tenant.Type == domain.TenantTypeOrganization || tenant.Type == domain.TenantTypeUserGroup
}
func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant {
current := tenant
for {
envKey := worksmobileTenantDomainIDEnvKey(current)
if envKey != "BARONGROUP_DOMAIN_ID" || current.Type == domain.TenantTypeCompany {
return current
}
parentID := worksmobileTenantParentID(current)
if parentID == "" {
return tenant
}
parent, ok := tenantByID[parentID]
if !ok {
return tenant
}
current = parent
}
}
func isWorksmobileBarongroupChildCompany(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
if tenant.Type != domain.TenantTypeCompany || tenant.Slug == "baron-group" {
return false
}
parentID := worksmobileTenantParentID(tenant)
for parentID != "" {
parent, ok := tenantByID[parentID]
if !ok {
return false
}
if parent.Slug == "baron-group" {
return true
}
parentID = worksmobileTenantParentID(parent)
}
return false
}
func normalizeWorksmobileOrgUnitParent(payload WorksmobileOrgUnitPayload, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootID string) WorksmobileOrgUnitPayload {
if tenant.ParentID != nil && *tenant.ParentID == rootID {
payload.ParentOrgUnitID = ""
}
if tenant.ParentID != nil {
if parent, ok := tenantByID[*tenant.ParentID]; ok && parent.Slug == "baron-group" {
payload.ParentOrgUnitID = ""
}
}
return payload
}
func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload) domain.JSONMap {
return domain.JSONMap{
"request": payload,
"tenantRootId": rootID,
"loginEmail": payload.Email,
"initialPassword": payload.PasswordConfig.Password,
}
}
func stringValue(value any) string {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
default:
return ""
}
}
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant) []WorksmobileComparisonItem {
remoteByExternalID := map[string]WorksmobileRemoteUser{}
remoteByEmail := map[string]WorksmobileRemoteUser{}
for _, remote := range remoteUsers {
if remote.ExternalID != "" {
remoteByExternalID[remote.ExternalID] = remote
}
if normalizedEmail := strings.ToLower(strings.TrimSpace(remote.Email)); normalizedEmail != "" {
remoteByEmail[normalizedEmail] = remote
}
}
localByID := map[string]domain.User{}
matchedRemoteIDs := map[string]bool{}
result := make([]WorksmobileComparisonItem, 0)
for _, user := range localUsers {
localByID[user.ID] = user
remote, matched := remoteByExternalID[user.ID]
if !matched {
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
}
if matched && !includeMatched {
matchedRemoteIDs[remote.ID] = true
continue
}
item := WorksmobileComparisonItem{
ResourceType: "USER",
BaronID: user.ID,
BaronName: user.Name,
BaronEmail: user.Email,
BaronPrimaryOrgID: worksmobileUserPrimaryOrgID(user),
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
Status: "missing_in_worksmobile",
}
if matched {
item.Status = "matched"
item.WorksmobileID = remote.ID
item.ExternalKey = remote.ExternalID
item.WorksmobileName = remote.DisplayName
item.WorksmobileEmail = remote.Email
item.WorksmobileLevelID = remote.LevelID
item.WorksmobileLevelName = remote.LevelName
item.WorksmobileTask = remote.Task
item.WorksmobileDomainID = remote.DomainID
item.WorksmobileDomainName = remote.DomainName
item.WorksmobilePrimaryOrgID = remote.PrimaryOrgUnitID
item.WorksmobilePrimaryOrgName = remote.PrimaryOrgUnitName
item.WorksmobilePrimaryOrgPositionID = remote.PrimaryOrgUnitPositionID
item.WorksmobilePrimaryOrgPositionName = remote.PrimaryOrgUnitPositionName
item.WorksmobilePrimaryOrgIsManager = remote.PrimaryOrgUnitIsManager
matchedRemoteIDs[remote.ID] = true
}
result = append(result, item)
}
for _, remote := range remoteUsers {
if matchedRemoteIDs[remote.ID] {
continue
}
if remote.ExternalID == "" {
result = append(result, WorksmobileComparisonItem{
ResourceType: "USER",
WorksmobileID: remote.ID,
ExternalKey: remote.ExternalID,
WorksmobileName: remote.DisplayName,
WorksmobileEmail: remote.Email,
WorksmobileLevelID: remote.LevelID,
WorksmobileLevelName: remote.LevelName,
WorksmobileTask: remote.Task,
WorksmobileDomainID: remote.DomainID,
WorksmobileDomainName: remote.DomainName,
WorksmobilePrimaryOrgID: remote.PrimaryOrgUnitID,
WorksmobilePrimaryOrgName: remote.PrimaryOrgUnitName,
WorksmobilePrimaryOrgPositionID: remote.PrimaryOrgUnitPositionID,
WorksmobilePrimaryOrgPositionName: remote.PrimaryOrgUnitPositionName,
WorksmobilePrimaryOrgIsManager: remote.PrimaryOrgUnitIsManager,
Status: "missing_external_key",
})
continue
}
if _, ok := localByID[remote.ExternalID]; !ok {
result = append(result, WorksmobileComparisonItem{
ResourceType: "USER",
WorksmobileID: remote.ID,
ExternalKey: remote.ExternalID,
WorksmobileName: remote.DisplayName,
WorksmobileEmail: remote.Email,
WorksmobileLevelID: remote.LevelID,
WorksmobileLevelName: remote.LevelName,
WorksmobileTask: remote.Task,
WorksmobileDomainID: remote.DomainID,
WorksmobileDomainName: remote.DomainName,
WorksmobilePrimaryOrgID: remote.PrimaryOrgUnitID,
WorksmobilePrimaryOrgName: remote.PrimaryOrgUnitName,
WorksmobilePrimaryOrgPositionID: remote.PrimaryOrgUnitPositionID,
WorksmobilePrimaryOrgPositionName: remote.PrimaryOrgUnitPositionName,
WorksmobilePrimaryOrgIsManager: remote.PrimaryOrgUnitIsManager,
Status: "missing_in_baron",
})
}
}
return result
}
func worksmobileUserPrimaryOrgID(user domain.User) string {
if user.TenantID == nil {
return ""
}
return strings.TrimSpace(*user.TenantID)
}
func worksmobileUserPrimaryOrgName(user domain.User, localTenants map[string]domain.Tenant) string {
tenantID := worksmobileUserPrimaryOrgID(user)
if tenantID == "" {
return ""
}
if tenant, ok := localTenants[tenantID]; ok {
return strings.TrimSpace(tenant.Name)
}
if user.Tenant != nil && user.Tenant.ID == tenantID {
return strings.TrimSpace(user.Tenant.Name)
}
return ""
}
func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem {
remoteByExternalID := map[string]WorksmobileRemoteGroup{}
for _, remote := range remoteGroups {
if remote.ExternalID != "" {
remoteByExternalID[remote.ExternalID] = remote
}
}
tenantByID := worksmobileTenantByID(localTenants)
localByID := map[string]domain.Tenant{}
ignoredLocalByID := map[string]bool{}
result := make([]WorksmobileComparisonItem, 0)
for _, tenant := range localTenants {
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
ignoredLocalByID[tenant.ID] = true
continue
}
localByID[tenant.ID] = tenant
remote, matched := remoteByExternalID[tenant.ID]
if matched && !includeMatched {
continue
}
item := WorksmobileComparisonItem{
ResourceType: "GROUP",
BaronID: tenant.ID,
BaronName: tenant.Name,
BaronParentID: worksmobileTenantParentID(tenant),
BaronParentName: worksmobileTenantParentName(tenant, tenantByID),
Status: "missing_in_worksmobile",
}
if matched {
item.Status = "matched"
item.WorksmobileID = remote.ID
item.ExternalKey = remote.ExternalID
item.WorksmobileName = remote.DisplayName
item.WorksmobileDomainID = remote.DomainID
item.WorksmobileDomainName = remote.DomainName
item.WorksmobileParentID = remote.ParentID
item.WorksmobileParentName = remote.ParentName
}
result = append(result, item)
}
for _, remote := range remoteGroups {
if remote.ExternalID == "" {
result = append(result, WorksmobileComparisonItem{
ResourceType: "GROUP",
WorksmobileID: remote.ID,
ExternalKey: remote.ExternalID,
WorksmobileName: remote.DisplayName,
WorksmobileDomainID: remote.DomainID,
WorksmobileDomainName: remote.DomainName,
WorksmobileParentID: remote.ParentID,
WorksmobileParentName: remote.ParentName,
Status: "missing_external_key",
})
continue
}
if ignoredLocalByID[remote.ExternalID] {
continue
}
if _, ok := localByID[remote.ExternalID]; !ok {
result = append(result, WorksmobileComparisonItem{
ResourceType: "GROUP",
WorksmobileID: remote.ID,
ExternalKey: remote.ExternalID,
WorksmobileName: remote.DisplayName,
WorksmobileDomainID: remote.DomainID,
WorksmobileDomainName: remote.DomainName,
WorksmobileParentID: remote.ParentID,
WorksmobileParentName: remote.ParentName,
Status: "missing_in_baron",
})
}
}
return result
}
func worksmobileTenantByID(tenants []domain.Tenant) map[string]domain.Tenant {
result := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
result[tenant.ID] = tenant
}
return result
}
func worksmobileTenantParentID(tenant domain.Tenant) string {
if tenant.ParentID == nil {
return ""
}
return strings.TrimSpace(*tenant.ParentID)
}
func worksmobileTenantParentName(tenant domain.Tenant, tenantByID map[string]domain.Tenant) string {
parentID := worksmobileTenantParentID(tenant)
if parentID == "" {
return ""
}
return strings.TrimSpace(tenantByID[parentID].Name)
}

View File

@@ -0,0 +1,387 @@
package service
import (
"baron-sso-backend/internal/domain"
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
tenantID := "saman-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
target := domain.User{
ID: "target-user",
Email: "target@samaneng.com",
Name: "Target",
TenantID: &tenantID,
Metadata: domain.JSONMap{
"aliasEmails": []any{"used@hanmaceng.co.kr"},
},
}
existing := domain.User{
ID: "existing-user",
Email: "used@samaneng.com",
Name: "Existing",
TenantID: &tenantID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target, existing}},
outboxRepo,
nil,
)
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
require.Nil(t, item)
require.Error(t, err)
require.Contains(t, err.Error(), "이미 사용 중")
require.Empty(t, outboxRepo.created)
}
func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) {
parentID := "root-tenant"
root := domain.Tenant{
ID: parentID,
Name: "한맥가족",
Slug: HanmacFamilyTenantSlug,
Type: domain.TenantTypeCompanyGroup,
}
hanmac := domain.Tenant{
ID: "hanmac-tenant",
Name: "한맥기술",
Slug: "hanmac",
Type: domain.TenantTypeCompany,
ParentID: &parentID,
}
barongroup := domain.Tenant{
ID: "barongroup-tenant",
Name: "바론그룹",
Slug: "baron-group",
Type: domain.TenantTypeCompany,
ParentID: &parentID,
}
barongroupChildCompany := domain.Tenant{
ID: "barongroup-child-company",
Name: "바론그룹 하위 회사",
Type: domain.TenantTypeCompany,
ParentID: &barongroup.ID,
}
organization := domain.Tenant{
ID: "organization-tenant",
Name: "정규 조직",
Type: domain.TenantTypeOrganization,
ParentID: &hanmac.ID,
}
legacyUserGroup := domain.Tenant{
ID: "legacy-user-group-tenant",
Name: "레거시 사용자 그룹",
Type: domain.TenantTypeUserGroup,
ParentID: &hanmac.ID,
}
items := compareWorksmobileGroups(
[]domain.Tenant{root, hanmac, barongroup, barongroupChildCompany, organization, legacyUserGroup},
[]WorksmobileRemoteGroup{
{ID: "works-root", ExternalID: root.ID, DisplayName: root.Name},
{ID: "works-hanmac", ExternalID: hanmac.ID, DisplayName: hanmac.Name},
{ID: "works-barongroup", ExternalID: barongroup.ID, DisplayName: barongroup.Name},
{ID: "works-barongroup-child", ExternalID: barongroupChildCompany.ID, DisplayName: barongroupChildCompany.Name},
{ID: "works-organization", ExternalID: organization.ID, DisplayName: organization.Name},
{ID: "works-legacy-user-group", ExternalID: legacyUserGroup.ID, DisplayName: legacyUserGroup.Name},
{ID: "works-orphan", ExternalID: "works-orphan", DisplayName: "WORKS 전용 조직"},
},
true,
)
require.Len(t, items, 3)
require.Equal(t, barongroupChildCompany.ID, items[0].BaronID)
require.Equal(t, "matched", items[0].Status)
require.Equal(t, organization.ID, items[1].BaronID)
require.Equal(t, "matched", items[1].Status)
require.Equal(t, "works-orphan", items[2].ExternalKey)
require.Equal(t, "missing_in_baron", items[2].Status)
}
func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
companyID := "company-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
company := domain.Tenant{
ID: companyID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, companyID)
require.Nil(t, item)
require.Error(t, err)
require.Contains(t, err.Error(), "worksmobile orgunit tenant")
require.Empty(t, outboxRepo.created)
}
func TestWorksmobileSyncServiceEnqueuesBarongroupChildCompanyOrgUnitSync(t *testing.T) {
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
rootID := "root-tenant"
barongroupID := "barongroup-tenant"
companyID := "barongroup-child-company"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
barongroup := domain.Tenant{
ID: barongroupID,
Slug: "baron-group",
Name: "바론그룹",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
}
company := domain.Tenant{
ID: companyID,
Slug: "barongroup-child",
Name: "바론그룹 하위 회사",
Type: domain.TenantTypeCompany,
ParentID: &barongroupID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, barongroupID: barongroup, companyID: company}, list: []domain.Tenant{root, barongroup, company}},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, companyID)
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.Equal(t, companyID, request.OrgUnitExternalKey)
require.Empty(t, request.ParentOrgUnitID)
}
func TestWorksmobileSyncServiceEnqueuesOrganizationOrgUnitSync(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
companyID := "company-tenant"
organizationID := "organization-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
company := domain.Tenant{
ID: companyID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
organization := domain.Tenant{
ID: organizationID,
Slug: "engineering",
Name: "기술본부",
Type: domain.TenantTypeOrganization,
ParentID: &companyID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{rootID: root, companyID: company, organizationID: organization},
list: []domain.Tenant{root, company, organization},
},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, organizationID)
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.Equal(t, organizationID, request.OrgUnitExternalKey)
require.Equal(t, "externalKey:"+companyID, request.ParentOrgUnitID)
}
func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T) {
rootID := "root-tenant"
companyID := "company-tenant"
userGroupID := "user-group-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
company := domain.Tenant{
ID: companyID,
Name: "계열사",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
}
userGroup := domain.Tenant{
ID: userGroupID,
Name: "연동 조직",
Type: domain.TenantTypeOrganization,
ParentID: &companyID,
}
userRepo := &fakeWorksmobileUserRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company, userGroupID: userGroup}, list: []domain.Tenant{root, company, userGroup}},
userRepo,
&fakeWorksmobileOutboxRepo{},
&fakeWorksmobileDirectoryClient{},
)
_, err := service.GetComparison(context.Background(), rootID, true)
require.NoError(t, err)
require.ElementsMatch(t, []string{companyID, userGroupID}, userRepo.requestedTenantIDs)
}
type fakeWorksmobileTenantService struct {
tenants map[string]domain.Tenant
list []domain.Tenant
}
func (f *fakeWorksmobileTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) {
tenant := f.tenants[id]
return &tenant, nil
}
func (f *fakeWorksmobileTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
return f.list, int64(len(f.list)), nil
}
func (f *fakeWorksmobileTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
return false, nil
}
func (f *fakeWorksmobileTenantService) ApproveTenant(ctx context.Context, id string) error {
return nil
}
func (f *fakeWorksmobileTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
return nil, nil
}
func (f *fakeWorksmobileTenantService) SetKetoService(keto KetoService) {}
func (f *fakeWorksmobileTenantService) DeleteTenantsBulk(ctx context.Context, ids []string) error {
return nil
}
type fakeWorksmobileUserRepo struct {
byID map[string]domain.User
byTenant []domain.User
requestedTenantIDs []string
}
func (f *fakeWorksmobileUserRepo) Create(ctx context.Context, user *domain.User) error { return nil }
func (f *fakeWorksmobileUserRepo) Update(ctx context.Context, user *domain.User) error { return nil }
func (f *fakeWorksmobileUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) {
user := f.byID[id]
return &user, nil
}
func (f *fakeWorksmobileUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
return nil, 0, nil
}
func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
return 0, nil
}
func (f *fakeWorksmobileUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
f.requestedTenantIDs = append([]string(nil), tenantIDs...)
return f.byTenant, nil
}
func (f *fakeWorksmobileUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) Delete(ctx context.Context, id string) error { return nil }
func (f *fakeWorksmobileUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return nil
}
func (f *fakeWorksmobileUserRepo) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
return nil, nil
}
func (f *fakeWorksmobileUserRepo) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
return false, nil
}
func (f *fakeWorksmobileUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
return "", nil
}

0
backend/seed-tenant.csv Normal file
View File

View File

@@ -39,7 +39,7 @@ services:
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login
volumes:
- ./docker/ory/kratos:/etc/config/kratos
command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes
depends_on:
postgres_ory:
condition: service_healthy
@@ -134,6 +134,14 @@ services:
- ory-net
# --- Oathkeeper ---
oathkeeper_logs_init:
image: alpine:latest
command: ["sh", "-c", "mkdir -p /var/log/oathkeeper && chown -R ${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001} /var/log/oathkeeper"]
volumes:
- oathkeeper_logs:/var/log/oathkeeper
networks:
- ory-net
oathkeeper:
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v25.4.0}
container_name: ory_oathkeeper
@@ -149,6 +157,9 @@ services:
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
- oathkeeper_logs:/var/log/oathkeeper
entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"]
depends_on:
oathkeeper_logs_init:
condition: service_completed_successfully
networks:
- ory-net
- public_net
@@ -168,6 +179,9 @@ services:
ory_vector:
image: timberio/vector:0.36.0-alpine
container_name: ory_vector
environment:
- ORY_CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory}
- ORY_CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass}
volumes:
- ./docker/ory/vector:/etc/vector
- oathkeeper_logs:/var/log/oathkeeper
@@ -199,11 +213,21 @@ services:
# 기본 RP (Admin Front 등) 자동 등록 컨테이너
init-rp:
image: oryd/hydra:v25.4.0
entrypoint: ["/bin/sh"]
image: alpine:latest
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
hydra delete oauth2-client --endpoint http://hydra:4445 adminfront >/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

1
config/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -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"]

View File

@@ -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 \

View File

@@ -1,4 +1,4 @@
version: v25.4.0
version: v26.2.0
dsn: ${DSN}

View File

@@ -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"

View File

@@ -14,7 +14,7 @@ errors:
access_rules:
repositories:
- file:///etc/config/oathkeeper/rules.active.json
- file:///tmp/oathkeeper/rules.active.json
authenticators:
noop:

View File

@@ -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}"

View File

@@ -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

View File

@@ -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`: 본 문서를 위키 반영 전 검토본으로 유지

View File

@@ -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<string, UserSummary[]>();
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,
);

View File

@@ -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<TenantSummary, "id" | "slug" | "type" | "name">,
) {
@@ -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(() => {

View File

@@ -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;

View File

@@ -16,6 +16,7 @@ saman = "Saman"
[domain.tenant_type]
company = "Company"
company_group = "Company Group"
organization = "Organization"
personal = "Personal"
user_group = "User Group"

View File

@@ -16,6 +16,7 @@ saman = "삼안"
[domain.tenant_type]
company = "COMPANY (일반 기업)"
company_group = "COMPANY_GROUP (그룹사/지주사)"
organization = "ORGANIZATION (정규 조직)"
personal = "PERSONAL (개인 워크스페이스)"
user_group = "USER_GROUP (내부 부서/팀)"

View File

@@ -16,6 +16,7 @@ saman = ""
[domain.tenant_type]
company = ""
company_group = ""
organization = ""
personal = ""
user_group = ""

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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