forked from baron/baron-sso
worksmobile 연동 & ory stack 26.2.0으로 업그레이드
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@
|
||||
.npm-cache/
|
||||
reports
|
||||
reports/*
|
||||
config/*.pem
|
||||
|
||||
# Docker Services Data (Volumes)
|
||||
postgres_data/
|
||||
|
||||
3
adminfront/NAVERWORKS_member_add_sample_English.csv
Normal file
3
adminfront/NAVERWORKS_member_add_sample_English.csv
Normal 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,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,개인 사용자 기본 루트 테넌트,
|
||||
|
||||
|
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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(["직책 팀장", "직무 기술검토", "조직장"]);
|
||||
});
|
||||
});
|
||||
854
adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx
Normal file
854
adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,,",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@ const getTenantIcon = (type?: string) => {
|
||||
return Briefcase;
|
||||
case "PERSONAL":
|
||||
return UserCircle;
|
||||
case "ORGANIZATION":
|
||||
case "USER_GROUP":
|
||||
return Network;
|
||||
default:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
14
adminfront/src/features/users/userStatus.ts
Normal file
14
adminfront/src/features/users/userStatus.ts
Normal 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);
|
||||
}
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "테스트"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
363
adminfront/tests/worksmobile.spec.ts
Normal file
363
adminfront/tests/worksmobile.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
176
backend/cmd/adminctl/main.go
Normal file
176
backend/cmd/adminctl/main.go
Normal 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]")
|
||||
}
|
||||
62
backend/cmd/adminctl/main_test.go
Normal file
62
backend/cmd/adminctl/main_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
39
backend/cmd/server/worksmobile_config_test.go
Normal file
39
backend/cmd/server/worksmobile_config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
204
backend/internal/bootstrap/admin_account.go
Normal file
204
backend/internal/bootstrap/admin_account.go
Normal 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,
|
||||
})
|
||||
}
|
||||
159
backend/internal/bootstrap/admin_account_test.go
Normal file
159
backend/internal/bootstrap/admin_account_test.go
Normal 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
|
||||
}
|
||||
@@ -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{},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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))
|
||||
|
||||
72
backend/internal/domain/worksmobile.go
Normal file
72
backend/internal/domain/worksmobile.go
Normal 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
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
132
backend/internal/handler/worksmobile_handler.go
Normal file
132
backend/internal/handler/worksmobile_handler.go
Normal 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())
|
||||
}
|
||||
128
backend/internal/handler/worksmobile_handler_test.go
Normal file
128
backend/internal/handler/worksmobile_handler_test.go
Normal 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
|
||||
}
|
||||
114
backend/internal/repository/worksmobile_outbox_repository.go
Normal file
114
backend/internal/repository/worksmobile_outbox_repository.go
Normal 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
|
||||
}
|
||||
@@ -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]),
|
||||
|
||||
@@ -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
|
||||
|
||||
969
backend/internal/service/worksmobile_client.go
Normal file
969
backend/internal/service/worksmobile_client.go
Normal 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()
|
||||
}
|
||||
703
backend/internal/service/worksmobile_client_test.go
Normal file
703
backend/internal/service/worksmobile_client_test.go
Normal 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
|
||||
}
|
||||
145
backend/internal/service/worksmobile_live_flow_test.go
Normal file
145
backend/internal/service/worksmobile_live_flow_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
604
backend/internal/service/worksmobile_mapper.go
Normal file
604
backend/internal/service/worksmobile_mapper.go
Normal 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
|
||||
}
|
||||
347
backend/internal/service/worksmobile_mapper_test.go
Normal file
347
backend/internal/service/worksmobile_mapper_test.go
Normal 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"))
|
||||
}
|
||||
141
backend/internal/service/worksmobile_relay_worker.go
Normal file
141
backend/internal/service/worksmobile_relay_worker.go
Normal 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
|
||||
}
|
||||
881
backend/internal/service/worksmobile_sync_service.go
Normal file
881
backend/internal/service/worksmobile_sync_service.go
Normal 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 ¤t, 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)
|
||||
}
|
||||
387
backend/internal/service/worksmobile_sync_service_test.go
Normal file
387
backend/internal/service/worksmobile_sync_service_test.go
Normal 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
0
backend/seed-tenant.csv
Normal 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
1
config/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: v25.4.0
|
||||
version: v26.2.0
|
||||
|
||||
dsn: ${DSN}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -14,7 +14,7 @@ errors:
|
||||
|
||||
access_rules:
|
||||
repositories:
|
||||
- file:///etc/config/oathkeeper/rules.active.json
|
||||
- file:///tmp/oathkeeper/rules.active.json
|
||||
|
||||
authenticators:
|
||||
noop:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
421
docs/worksmobile-directory-sync-technical-review.md
Normal file
421
docs/worksmobile-directory-sync-technical-review.md
Normal 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`: 본 문서를 위키 반영 전 검토본으로 유지
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,6 +16,7 @@ saman = "Saman"
|
||||
[domain.tenant_type]
|
||||
company = "Company"
|
||||
company_group = "Company Group"
|
||||
organization = "Organization"
|
||||
personal = "Personal"
|
||||
user_group = "User Group"
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ saman = "삼안"
|
||||
[domain.tenant_type]
|
||||
company = "COMPANY (일반 기업)"
|
||||
company_group = "COMPANY_GROUP (그룹사/지주사)"
|
||||
organization = "ORGANIZATION (정규 조직)"
|
||||
personal = "PERSONAL (개인 워크스페이스)"
|
||||
user_group = "USER_GROUP (내부 부서/팀)"
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ saman = ""
|
||||
[domain.tenant_type]
|
||||
company = ""
|
||||
company_group = ""
|
||||
organization = ""
|
||||
personal = ""
|
||||
user_group = ""
|
||||
|
||||
|
||||
23
test/env_secret_file_policy_test.sh
Normal file
23
test/env_secret_file_policy_test.sh
Normal 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
|
||||
60
test/oathkeeper_access_log_e2e_test.sh
Executable file
60
test/oathkeeper_access_log_e2e_test.sh
Executable 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
|
||||
39
test/ory_log_pipeline_policy_test.sh
Executable file
39
test/ory_log_pipeline_policy_test.sh
Executable 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
|
||||
55
test/ory_v26_compose_policy_test.sh
Normal file
55
test/ory_v26_compose_policy_test.sh
Normal 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
|
||||
Reference in New Issue
Block a user