1
0
forked from baron/baron-sso

네이버 계정 정합성 맞춤

This commit is contained in:
2026-06-15 19:54:09 +09:00
parent 8e9d015443
commit 4d468cd39f
97 changed files with 5837 additions and 2031 deletions

View File

@@ -80,7 +80,6 @@ jobs:
AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }}
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
@@ -143,10 +142,6 @@ jobs:
LOKI_URL=${{ vars.LOKI_URL || 'http://loki:3100/loki/api/v1/push' }}
EOF
if ! grep -Eq "^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.+" .env; then
sed -i "s/^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.*/ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600/" .env
fi
# 코드 업데이트 (Git)
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}' && cd '${DEPLOY_PATH}' && \
if [ ! -d .git ]; then

View File

@@ -90,7 +90,6 @@ jobs:
AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }}
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
@@ -143,16 +142,11 @@ jobs:
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
EOF
if ! grep -Eq "^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.+" .env; then
sed -i "s/^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.*/ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600/" .env
fi
required_dotenv_keys="
APP_ENV BACKEND_LOG_LEVEL CLIENT_LOG_DEBUG WORKS_ADMIN_API_BASE_URL WORKS_ADMIN_OAUTH_TOKEN_URL TZ IDP_PROVIDER
DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_HOST CLICKHOUSE_USER CLICKHOUSE_PASSWORD
BACKEND_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT USERFRONT_PORT OATHKEEPER_API_URL
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR CORS_ALLOWED_ORIGINS PROFILE_CACHE_TTL
ORGFRONT_ORGCHART_CACHE_TTL_SECONDS
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER ADMIN_EMAIL ADMIN_PASSWORD
USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL

Binary file not shown.

View File

@@ -9,8 +9,8 @@ import AuthGuard from "../features/auth/AuthGuard";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
import OrySSOTPage from "../features/ory-ssot/OrySSOTPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import UserProjectionPage from "../features/projections/UserProjectionPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
@@ -67,7 +67,7 @@ export const adminRoutes: RouteObject[] = [
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "system/ory-ssot", element: <UserProjectionPage /> },
{ path: "system/ory-ssot", element: <OrySSOTPage /> },
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
],
},

View File

@@ -31,4 +31,27 @@ describe("LocaleRefreshBoundary", () => {
expect(screen.getByText("2")).toBeInTheDocument();
});
it("ignores storage events unrelated to locale changes", async () => {
render(
<LocaleRefreshBoundary>
<RenderCounter />
</LocaleRefreshBoundary>,
);
expect(screen.getByText("1")).toBeInTheDocument();
await act(async () => {
window.dispatchEvent(
new StorageEvent("storage", {
key: "admin_session",
newValue: "token",
oldValue: null,
storageArea: window.localStorage,
}),
);
});
expect(screen.getByText("1")).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,5 @@
import { Fragment, type ReactNode, useEffect, useState } from "react";
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
type LocaleRefreshBoundaryProps = {
children: ReactNode;
@@ -12,12 +13,19 @@ function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) {
setLocaleVersion((current) => current + 1);
};
const syncLocaleFromStorage = (event: StorageEvent) => {
if (event.key !== LOCALE_STORAGE_KEY && event.key !== null) {
return;
}
syncLocale();
};
window.addEventListener("localechange", syncLocale);
window.addEventListener("storage", syncLocale);
window.addEventListener("storage", syncLocaleFromStorage);
return () => {
window.removeEventListener("localechange", syncLocale);
window.removeEventListener("storage", syncLocale);
window.removeEventListener("storage", syncLocaleFromStorage);
};
}, []);

View File

@@ -74,7 +74,7 @@ describe("ApiKeyListPage", () => {
});
it("updates scopes without changing client_id", async () => {
const user = userEvent.setup();
const user = userEvent.setup({ delay: null });
renderPage();
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
@@ -88,7 +88,7 @@ describe("ApiKeyListPage", () => {
scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
});
});
});
}, 15_000);
it("rotates only the secret and shows the one-time secret", async () => {
const user = userEvent.setup();

View File

@@ -0,0 +1,33 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
function listSourceFiles(directory: string): string[] {
const entries = readdirSync(directory);
const files: string[] = [];
for (const entry of entries) {
const path = join(directory, entry);
const stat = statSync(path);
if (stat.isDirectory()) {
files.push(...listSourceFiles(path));
continue;
}
if (path.endsWith(".tsx")) {
files.push(path);
}
}
return files;
}
describe("admin page animation policy", () => {
it("does not use long enter fade animations on stable page containers", () => {
const sourceRoot = join(process.cwd(), "src");
const offenders = listSourceFiles(sourceRoot).filter((file) =>
readFileSync(file, "utf8").includes("animate-in fade-in duration-500"),
);
expect(offenders.map((file) => file.replace(`${sourceRoot}/`, ""))).toEqual(
[],
);
});
});

View File

@@ -6,8 +6,6 @@ import {
fetchDataIntegrityReport,
fetchMe,
fetchOrphanUserLoginIDs,
fetchOrySSOTSystemStatus,
flushIdentityCache,
} from "../../lib/adminApi";
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
import { createI18nMock } from "../../test/i18nMock";
@@ -63,21 +61,6 @@ vi.mock("../../lib/adminApi", () => ({
],
total: 1,
})),
fetchOrySSOTSystemStatus: vi.fn(async () => ({
identityCache: {
status: "ready",
redisReady: true,
observedCount: 151,
keyCount: 153,
lastRefreshedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
},
})),
flushIdentityCache: vi.fn(async () => ({
status: "success",
flushedKeys: 153,
updatedAt: "2026-05-11T03:02:00Z",
})),
deleteOrphanUserLoginIDs: vi.fn(async () => ({
deletedCount: 1,
deleted: [
@@ -121,12 +104,6 @@ describe("DataIntegrityPage", () => {
renderPage();
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "정합성 검사" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "Ory SSOT 시스템" }),
).toBeInTheDocument();
expect(
await screen.findByText(
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
@@ -138,28 +115,6 @@ describe("DataIntegrityPage", () => {
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("renders Ory SSOT cache management inside data integrity", async () => {
renderPage();
fireEvent.click(
await screen.findByRole("tab", { name: "Ory SSOT 시스템" }),
);
expect(
(await screen.findAllByText("Ory SSOT 시스템")).length,
).toBeGreaterThan(0);
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
expect(screen.getByText("151")).toBeInTheDocument();
expect(screen.queryByText("Local users")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
await waitFor(() => {
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
});
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});
it("shows orphan login ID targets and deletes selected rows", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const { container } = renderPage();

View File

@@ -19,7 +19,6 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
import { UserProjectionContent } from "../projections/UserProjectionPage";
function statusLabel(status: DataIntegrityStatus) {
switch (status) {
@@ -188,14 +187,6 @@ function recheckStatusText(status: "idle" | "running" | "success" | "error") {
}
}
function pageTabClassName(active: boolean) {
return `relative px-6 py-3 text-sm font-medium transition-colors ${
active
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`;
}
function OrphanLoginIDTable({
items,
selectedIds,
@@ -294,9 +285,6 @@ function OrphanLoginIDTable({
function DataIntegrityContent() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
"integrity",
);
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
const [recheckStatus, setRecheckStatus] = useState<
"idle" | "running" | "success" | "error"
@@ -373,243 +361,210 @@ function DataIntegrityContent() {
</p>
</div>
</div>
{activeTab === "integrity" ? (
<div className="flex flex-col items-end gap-1">
<Button
type="button"
variant="outline"
onClick={handleRecheck}
disabled={isLoading || isFetching || isManualRechecking}
<div className="flex flex-col items-end gap-1">
<Button
type="button"
variant="outline"
onClick={handleRecheck}
disabled={isLoading || isFetching || isManualRechecking}
>
<Database size={16} />
{isManualRechecking
? t("ui.admin.integrity.recheck.running", "검사 중")
: t("ui.admin.integrity.recheck.run", "다시 검사")}
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
>
<Database size={16} />
{isManualRechecking
? t("ui.admin.integrity.recheck.running", "검사 중")
: t("ui.admin.integrity.recheck.run", "다시 검사")}
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
>
{recheckMessage}
</output>
) : null}
</div>
) : null}
{recheckMessage}
</output>
) : null}
</div>
</header>
<div
className="flex border-b border-border"
role="tablist"
aria-label="데이터 정합성 탭"
>
<button
type="button"
role="tab"
aria-selected={activeTab === "integrity"}
className={pageTabClassName(activeTab === "integrity")}
onClick={() => setActiveTab("integrity")}
>
{t("ui.admin.integrity.tab_checks", "정합성 검사")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "projection"}
className={pageTabClassName(activeTab === "projection")}
onClick={() => setActiveTab("projection")}
>
{t("ui.admin.integrity.tab_ory_ssot", "Ory SSOT 시스템")}
</button>
</div>
{activeTab === "integrity" ? (
<div className="space-y-4 pb-6 animate-in fade-in duration-500">
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.integrity.report.load_error",
"정합성 리포트를 불러오지 못했습니다.",
)}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.read_model.title",
"Read model integrity",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.integrity.read_model.description",
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
)}
</p>
</div>
{data ? (
<Badge variant={statusBadgeVariant(data.status)}>
{statusLabel(data.status)}
</Badge>
) : null}
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.integrity.loading", "불러오는 중")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.passed", "정상")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.passed ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.failures", "실패 건수")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.failures ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
</div>
</dl>
)}
<div className="space-y-4 pb-6">
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.integrity.report.load_error",
"정합성 리포트를 불러오지 못했습니다.",
)}
</section>
) : null}
<div className="space-y-4">
{(data?.sections ?? []).map((section) => (
<section
key={section.key}
className="rounded-lg border border-border bg-card p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{integritySectionLabel(section.key, section.label)}
</h3>
<p className="text-sm text-muted-foreground">
{integritySectionDescription(section.key)}
</p>
</div>
<Badge variant={statusBadgeVariant(section.status)}>
{statusLabel(section.status)}
</Badge>
</div>
<div className="divide-y divide-border">
{section.checks.map((check) => (
<div
key={check.key}
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
>
<div className="flex gap-3">
<CheckIcon check={check} />
<div>
<div className="font-medium">
{integrityCheckLabel(check.key, check.label)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{integrityCheckDescription(
check.key,
check.description,
)}
</p>
</div>
</div>
<div className="flex items-center gap-3 md:justify-end">
<Badge variant={statusBadgeVariant(check.status)}>
{statusLabel(check.status)}
</Badge>
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
{check.count}
</span>
</div>
</div>
))}
</div>
</section>
))}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.read_model.title",
"Read model integrity",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.integrity.read_model.description",
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
)}
</p>
</div>
{data ? (
<Badge variant={statusBadgeVariant(data.status)}>
{statusLabel(data.status)}
</Badge>
) : null}
</div>
<section className="rounded-lg border border-border bg-card p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.orphan_login_ids.title",
"유령 로그인 ID 정리",
)}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.admin.integrity.orphan_login_ids.description",
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
)}
</p>
</div>
<Button
type="button"
variant="destructive"
onClick={handleDeleteSelected}
disabled={
selectedOrphanIds.length === 0 || deleteMutation.isPending
}
>
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
</Button>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.integrity.loading", "불러오는 중")}
</div>
{orphanLoginIDsQuery.isError ? (
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t(
"msg.admin.integrity.orphan_login_ids.load_error",
"유령 로그인 ID 대상을 불러오지 못했습니다.",
)}
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div>
) : null}
{deleteMutation.data ? (
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{t(
"msg.admin.integrity.orphan_login_ids.delete_success",
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
{ count: deleteMutation.data.deletedCount },
)}
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.passed", "정상")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.passed ?? 0}
</dd>
</div>
) : null}
<OrphanLoginIDTable
items={orphanItems}
selectedIds={selectedOrphanIds}
onToggle={toggleOrphanID}
/>
</section>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.failures", "실패 건수")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.failures ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
</div>
</dl>
)}
</section>
<div className="space-y-4">
{(data?.sections ?? []).map((section) => (
<section
key={section.key}
className="rounded-lg border border-border bg-card p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{integritySectionLabel(section.key, section.label)}
</h3>
<p className="text-sm text-muted-foreground">
{integritySectionDescription(section.key)}
</p>
</div>
<Badge variant={statusBadgeVariant(section.status)}>
{statusLabel(section.status)}
</Badge>
</div>
<div className="divide-y divide-border">
{section.checks.map((check) => (
<div
key={check.key}
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
>
<div className="flex gap-3">
<CheckIcon check={check} />
<div>
<div className="font-medium">
{integrityCheckLabel(check.key, check.label)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{integrityCheckDescription(
check.key,
check.description,
)}
</p>
</div>
</div>
<div className="flex items-center gap-3 md:justify-end">
<Badge variant={statusBadgeVariant(check.status)}>
{statusLabel(check.status)}
</Badge>
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
{check.count}
</span>
</div>
</div>
))}
</div>
</section>
))}
</div>
) : (
<div className="animate-in fade-in duration-500">
<UserProjectionContent embedded />
</div>
)}
<section className="rounded-lg border border-border bg-card p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.orphan_login_ids.title",
"유령 로그인 ID 정리",
)}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.admin.integrity.orphan_login_ids.description",
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
)}
</p>
</div>
<Button
type="button"
variant="destructive"
onClick={handleDeleteSelected}
disabled={
selectedOrphanIds.length === 0 || deleteMutation.isPending
}
>
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
</Button>
</div>
{orphanLoginIDsQuery.isError ? (
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t(
"msg.admin.integrity.orphan_login_ids.load_error",
"유령 로그인 ID 대상을 불러오지 못했습니다.",
)}
</div>
) : null}
{deleteMutation.data ? (
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{t(
"msg.admin.integrity.orphan_login_ids.delete_success",
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
{ count: deleteMutation.data.deletedCount },
)}
</div>
) : null}
<OrphanLoginIDTable
items={orphanItems}
selectedIds={selectedOrphanIds}
onToggle={toggleOrphanID}
/>
</section>
</div>
</main>
);
}

View File

@@ -2,11 +2,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchMe,
fetchOrySSOTSystemStatus,
flushIdentityCache,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import UserProjectionPage from "./UserProjectionPage";
import OrySSOTPage from "./OrySSOTPage";
vi.mock("../../lib/i18n", () => createI18nMock());
@@ -19,9 +20,9 @@ vi.mock("../../lib/adminApi", () => ({
status: "ready",
redisReady: true,
observedCount: 151,
keyCount: 153,
lastRefreshedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
keyCount: 153,
},
})),
flushIdentityCache: vi.fn(async () => ({
@@ -41,12 +42,12 @@ function renderPage() {
return render(
<QueryClientProvider client={queryClient}>
<UserProjectionPage />
<OrySSOTPage />
</QueryClientProvider>,
);
}
describe("UserProjectionPage", () => {
describe("OrySSOTPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
@@ -54,37 +55,22 @@ describe("UserProjectionPage", () => {
window.localStorage.setItem("locale", "ko");
});
it("renders Ory SSOT and Redis identity cache status for super_admin", async () => {
it("renders identity cache status and flushes cache", async () => {
renderPage();
expect(await screen.findByText("Ory SSOT 시스템")).toBeInTheDocument();
expect(
await screen.findByText(
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
),
).toBeInTheDocument();
(await screen.findAllByText("Ory SSOT 시스템")).length,
).toBeGreaterThan(0);
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
expect(screen.getByText("관측 identity")).toBeInTheDocument();
expect(screen.getByText("151")).toBeInTheDocument();
expect(screen.queryByText("Local users")).not.toBeInTheDocument();
expect(screen.queryByText("Backend 사용자 read model")).not.toBeInTheDocument();
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});
it("flushes only the Redis identity cache for super_admin", async () => {
renderPage();
await screen.findByText("Ory SSOT 시스템");
expect(screen.queryByRole("button", { name: /재동기화/ })).toBeNull();
expect(
screen.queryByRole("button", { name: /초기화 후 재구축/ }),
).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
await waitFor(() => {
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
});
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});
it("blocks non-super admins", async () => {
@@ -93,21 +79,7 @@ describe("UserProjectionPage", () => {
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect(screen.queryByText("Ory SSOT 시스템")).not.toBeInTheDocument();
expect(fetchMe).toHaveBeenCalled();
expect(fetchOrySSOTSystemStatus).not.toHaveBeenCalled();
});
it("renders localized labels in English", async () => {
window.localStorage.setItem("locale", "en");
renderPage();
expect(await screen.findByText("Ory SSOT System")).toBeInTheDocument();
expect(
await screen.findByText(
"Review Kratos source-of-truth and Redis identity cache status separately.",
),
).toBeInTheDocument();
expect(screen.getByText("Redis cache flush")).toBeInTheDocument();
expect((await screen.findAllByText("ready")).length).toBeGreaterThan(0);
});
});

View File

@@ -42,11 +42,7 @@ function StatusBadge({ ready, status }: { ready: boolean; status: string }) {
);
}
export function UserProjectionContent({
embedded = false,
}: {
embedded?: boolean;
}) {
function OrySSOTContent() {
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["ory-ssot-system-status"],
@@ -74,47 +70,39 @@ export function UserProjectionContent({
const identityCache = data?.identityCache;
const header = (
<header
className={
embedded
? "flex flex-shrink-0 flex-wrap items-start justify-between gap-4"
: "flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur"
}
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<Database size={20} />
return (
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<Database size={20} />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.ory_ssot.title", "Ory SSOT System")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.ory_ssot.subtitle",
"Review Kratos source-of-truth and Redis identity cache status separately.",
)}
</p>
</div>
</div>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.ory_ssot.title", "Ory SSOT System")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.ory_ssot.subtitle",
"Review Kratos source-of-truth and Redis identity cache status separately.",
)}
</p>
</div>
</div>
<Button
type="button"
variant="destructive"
onClick={handleFlush}
disabled={flushMutation.isPending}
>
<Trash2 size={16} />
{t(
"ui.admin.ory_ssot.actions.flush_identity_cache",
"Redis cache flush",
)}
</Button>
</header>
);
<Button
type="button"
variant="destructive"
onClick={handleFlush}
disabled={flushMutation.isPending}
>
<Trash2 size={16} />
{t(
"ui.admin.ory_ssot.actions.flush_identity_cache",
"Redis cache flush",
)}
</Button>
</header>
const body = (
<>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
@@ -220,27 +208,11 @@ export function UserProjectionContent({
</div>
) : null}
</section>
</>
);
if (embedded) {
return (
<div className="space-y-4 pb-6">
{header}
{body}
</div>
);
}
return (
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
{header}
{body}
</main>
);
}
export default function UserProjectionPage() {
export default function OrySSOTPage() {
return (
<RoleGuard
roles={["super_admin"]}
@@ -260,7 +232,7 @@ export default function UserProjectionPage() {
</main>
}
>
<UserProjectionContent />
<OrySSOTContent />
</RoleGuard>
);
}

View File

@@ -506,7 +506,7 @@ function GlobalOverviewPage() {
);
return (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">

View File

@@ -46,8 +46,10 @@ describe("ParentTenantSelector picker", () => {
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src");
expect(pickerSrc).toContain("/login");
const pickerSrc = screen
.getByTestId("parent-tenant-picker-frame")
.getAttribute("src");
expect(pickerSrc).toContain("http://localhost:5175/login");
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
fireEvent(
@@ -71,6 +73,30 @@ describe("ParentTenantSelector picker", () => {
await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1"));
});
it("scopes the org-chart picker to the requested tenant root", () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
orgChartTenantId="group-1"
orgChartPickerLabel="한맥가족에서 선택"
/>,
);
fireEvent.click(screen.getByRole("button", { name: "한맥가족에서 선택" }));
const pickerSrc = screen
.getByTestId("parent-tenant-picker-frame")
.getAttribute("src");
expect(decodeURIComponent(pickerSrc ?? "")).toContain("tenantId=group-1");
});
it("keeps the current tenant out of picker message selections", async () => {
const onChange = vi.fn();

View File

@@ -31,6 +31,7 @@ type ParentTenantSelectorProps = {
labelAction?: ReactNode;
contextLabel?: string;
orgChartPickerLabel?: string;
orgChartTenantId?: string;
localPickerLabel?: string;
localTenantFilter?: (tenant: TenantSummary) => boolean;
compact?: boolean;
@@ -49,6 +50,7 @@ export function ParentTenantSelector({
labelAction,
contextLabel,
orgChartPickerLabel,
orgChartTenantId,
localPickerLabel,
localTenantFilter,
compact = false,
@@ -66,6 +68,9 @@ export function ParentTenantSelector({
);
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
{
tenantId: orgChartTenantId,
},
);
useEffect(() => {
@@ -135,6 +140,7 @@ export function ParentTenantSelector({
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
data-testid="parent-tenant-picker-frame"
/>
</DialogContent>
</Dialog>

View File

@@ -61,6 +61,13 @@ function TenantCreatePage() {
});
const tenants = parentQuery.data?.items ?? [];
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
const hanmacFamilyTenantId = useMemo(
() =>
tenants.find(
(tenant) => tenant.slug.trim().toLowerCase() === "hanmac-family",
)?.id ?? "",
[tenants],
);
const canConfigureHanmacOrg = useMemo(() => {
if (!selectedParentTenant) return false;
if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") {
@@ -206,6 +213,7 @@ function TenantCreatePage() {
"ui.admin.tenants.create.form.pick_hanmac_parent",
"한맥가족에서 선택",
)}
orgChartTenantId={hanmacFamilyTenantId}
localPickerLabel={t(
"ui.admin.tenants.create.form.pick_other_parent",
"다른 테넌트 선택",

View File

@@ -125,7 +125,7 @@ function TenantDetailPage() {
</div>
{/* Outlet for nested routes */}
<div className="animate-in fade-in duration-500">
<div>
<Outlet />
</div>
</div>

View File

@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
buildWorksmobilePasswordManageUrl,
@@ -28,6 +30,18 @@ import {
} from "./worksmobileComparison";
describe("TenantWorksmobilePage comparison helpers", () => {
it("does not apply page-level enter animations to Worksmobile tab panels", () => {
const source = readFileSync(
join(
process.cwd(),
"src/features/tenants/routes/TenantWorksmobilePage.tsx",
),
"utf8",
);
expect(source).not.toContain("space-y-4 animate-in fade-in duration-500");
});
it("summarizes comparison rows by status", () => {
const summary = summarizeWorksmobileComparison([
{ resourceType: "USER", status: "matched" },
@@ -594,6 +608,41 @@ describe("TenantWorksmobilePage comparison helpers", () => {
]);
});
it("formats backend update reasons when value diff details are not directly visible", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
baronName: "신현우",
worksmobileName: "신현우",
baronEmail: "hwshin2@hanmaceng.co.kr",
worksmobileEmail: "hwshin2@hanmaceng.co.kr",
externalKey: "user-1",
updateReasons: ["organization"],
}),
).toEqual(["조직: Baron 소속 정보를 WORKS에 반영해야 합니다."]);
});
it("does not format phone update details for spaced Korean country code formatting only", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
baronName: "강명진",
worksmobileName: "강명진",
baronEmail: "mjkang4@hanmaceng.co.kr",
worksmobileEmail: "mjkang4@hanmaceng.co.kr",
externalKey: "user-1",
baronPhone: "+821041585840",
worksmobilePhone: "+82 1041585840",
baronEmployeeNumber: "mjkang4",
worksmobileEmployeeNumber: "M17205",
}),
).toEqual(["사번: M17205 -> mjkang4"]);
});
it("formats WORKS account name with level on one line", () => {
expect(
formatWorksmobilePersonName({

View File

@@ -520,7 +520,7 @@ export function TenantWorksmobilePage() {
</div>
{activeTab === "history" ? (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="space-y-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
@@ -627,7 +627,7 @@ export function TenantWorksmobilePage() {
) : null}
{activeTab === "users" ? (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="space-y-4">
<ComparisonSummary
title={t(
"ui.admin.tenants.worksmobile.compare",
@@ -715,7 +715,7 @@ export function TenantWorksmobilePage() {
) : null}
{activeTab === "groups" ? (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="space-y-4">
<ComparisonSummary
title={t(
"ui.admin.tenants.worksmobile.compare_groups",

View File

@@ -374,28 +374,39 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
}
const details: string[] = [];
const renderedReasons = new Set<string>();
const addDetail = (reason: string, detail: string) => {
details.push(detail);
renderedReasons.add(reason);
};
const baronName = row.baronName?.trim();
const worksmobileName = row.worksmobileName?.trim();
if (baronName && worksmobileName && baronName !== worksmobileName) {
details.push(`이름: ${worksmobileName} -> ${baronName}`);
addDetail("name", `이름: ${worksmobileName} -> ${baronName}`);
}
if (row.resourceType === "USER") {
const expectedExternalKey = row.baronId?.trim() ?? "";
const actualExternalKey = row.externalKey?.trim() ?? "";
if (expectedExternalKey && expectedExternalKey !== actualExternalKey) {
details.push(
addDetail(
"external_key",
`external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`,
);
}
const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? "";
const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? "";
if (expectedEmail && actualEmail && expectedEmail !== actualEmail) {
details.push(`이메일: ${actualEmail} -> ${expectedEmail}`);
addDetail("email", `이메일: ${actualEmail} -> ${expectedEmail}`);
}
const expectedPhone = row.baronPhone?.trim() ?? "";
const actualPhone = row.worksmobilePhone?.trim() ?? "";
if (expectedPhone && actualPhone && expectedPhone !== actualPhone) {
details.push(`전화번호: ${actualPhone} -> ${expectedPhone}`);
if (
expectedPhone &&
actualPhone &&
normalizeWorksmobilePhoneForCompare(expectedPhone) !==
normalizeWorksmobilePhoneForCompare(actualPhone)
) {
addDetail("phone", `전화번호: ${actualPhone} -> ${expectedPhone}`);
}
const expectedEmployeeNumber = row.baronEmployeeNumber?.trim() ?? "";
const actualEmployeeNumber = row.worksmobileEmployeeNumber?.trim() ?? "";
@@ -404,10 +415,12 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
actualEmployeeNumber &&
expectedEmployeeNumber !== actualEmployeeNumber
) {
details.push(
addDetail(
"employee_number",
`사번: ${actualEmployeeNumber} -> ${expectedEmployeeNumber}`,
);
}
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
return details;
}
@@ -427,14 +440,86 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
const actualParentKey =
row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? "";
if (expectedParentKey !== actualParentKey) {
details.push(
addDetail(
"organization",
`상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`,
);
}
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
return details;
}
function appendWorksmobileUpdateReasonFallbacks(
details: string[],
row: WorksmobileComparisonItem,
renderedReasons: Set<string>,
) {
for (const reason of row.updateReasons ?? []) {
const normalizedReason = reason.trim();
if (!normalizedReason || renderedReasons.has(normalizedReason)) {
continue;
}
const detail = formatWorksmobileUpdateReasonFallback(normalizedReason, row);
if (!detail) {
continue;
}
details.push(detail);
renderedReasons.add(normalizedReason);
}
}
function formatWorksmobileUpdateReasonFallback(
reason: string,
row: WorksmobileComparisonItem,
) {
switch (reason) {
case "name":
return "이름: Baron 사용자명을 WORKS에 반영해야 합니다.";
case "external_key":
return "external_key: Baron 사용자 ID를 WORKS 외부 키로 반영해야 합니다.";
case "email":
return "이메일: Baron 이메일을 WORKS에 반영해야 합니다.";
case "phone":
return "전화번호: Baron 전화번호를 WORKS에 반영해야 합니다.";
case "employee_number":
return "사번: Baron 사번을 WORKS에 반영해야 합니다.";
case "organization":
return row.resourceType === "GROUP"
? "조직: Baron 조직 정보를 WORKS에 반영해야 합니다."
: "조직: Baron 소속 정보를 WORKS에 반영해야 합니다.";
case "manager":
return "조직장: Baron 조직장 설정을 WORKS에 반영해야 합니다.";
default:
return `업데이트 사유: ${reason}`;
}
}
function normalizeWorksmobilePhoneForCompare(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const digits = trimmed.replace(/\D/g, "");
if (!digits) {
return "";
}
if (digits.startsWith("010")) {
return `+82${digits.slice(1)}`;
}
if (digits.startsWith("82")) {
let rest = digits.slice(2);
while (rest.startsWith("82")) {
rest = rest.slice(2);
}
if (rest.startsWith("0")) {
rest = rest.slice(1);
}
return `+82${rest}`;
}
return `+${digits}`;
}
export function buildWorksmobilePasswordManageUrl({
tenantId,
domainId,

View File

@@ -61,6 +61,7 @@ import {
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import type { UserSchemaField } from "./userSchemaFields";
import { resolvePersonalTenant } from "./utils/personalTenant";
@@ -399,7 +400,7 @@ function UserCreatePage() {
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(
err.response?.data?.error ||
formatUserPolicyMessage(err.response?.data?.error) ||
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
);
},
@@ -943,8 +944,12 @@ function UserCreatePage() {
data-testid={`appointment-tenant-picker-${index}`}
>
<Building2 className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">
{appointment.tenantName || "테넌트 선택"}
<span className="pointer-events-none truncate">
{appointment.tenantName ||
t(
"ui.admin.users.create.form.pick_from_hanmac_family",
"한맥가족에서 선택",
)}
</span>
</Button>
{appointment.tenantSlug && (

View File

@@ -93,6 +93,7 @@ import {
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import type { UserSchemaField } from "./userSchemaFields";
import {
normalizeUserStatusValue,
@@ -1012,7 +1013,7 @@ function UserDetailPage() {
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
formatUserPolicyMessage(err.response?.data?.error) ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
@@ -1078,6 +1079,7 @@ function UserDetailPage() {
try {
const tenant = await ensurePersonalTenant();
payload.tenantSlug = tenant.slug;
payload.isPrimaryTenant = true;
payload.department = undefined;
payload.grade = undefined;
payload.position = undefined;
@@ -1111,6 +1113,7 @@ function UserDetailPage() {
const primary = appointments.find((a) => a.isPrimary);
if (primary) {
payload.tenantSlug = primary.tenantSlug;
payload.isPrimaryTenant = true;
payload.primaryTenantId = primary.tenantId;
payload.primaryTenantName = primary.tenantName;
metadata.primaryTenantId = primary.tenantId;
@@ -1133,6 +1136,7 @@ function UserDetailPage() {
primaryTenantSlug: primary?.tenantSlug,
};
payload.tenantSlug = primary?.tenantSlug;
payload.isPrimaryTenant = primary ? true : undefined;
payload.primaryTenantId = primary?.tenantId;
payload.primaryTenantName = primary?.tenantName;
}

View File

@@ -139,6 +139,19 @@ describe("UserListPage search rendering", () => {
expect(selectRenderCounter.count).toBe(renderCountBeforeTyping);
});
it("describes the user list as identity mirror backed, not local DB backed", async () => {
renderUserListPage();
await screen.findByText("User 0");
expect(
screen.getByText(
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
),
).toBeInTheDocument();
expect(screen.queryByText(/Local DB/)).not.toBeInTheDocument();
});
it("keeps rendered row controls below the full 200-user result set", async () => {
renderUserListPage();

View File

@@ -102,6 +102,7 @@ import {
downloadUserTemplate,
UserBulkUploadModal,
} from "./components/UserBulkUploadModal";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import {
normalizeUserStatusValue,
type UserStatusValue,
@@ -652,7 +653,21 @@ function UserListPage() {
const bulkUpdateMutation = useMutation({
mutationFn: bulkUpdateUsers,
onSuccess: () => {
onSuccess: (data) => {
const failed = data.results?.filter((result) => !result.success) ?? [];
if (failed.length > 0) {
toast.error(
t(
"msg.admin.users.bulk.update_partial_error",
"{{count}}명의 사용자 정보 수정에 실패했습니다.",
{ count: failed.length },
),
{
description: formatUserPolicyMessage(failed[0]?.message),
},
);
return;
}
query.refetch();
setSelectedUserIds([]);
setSelectedBulkStatus("");
@@ -725,7 +740,7 @@ function UserListPage() {
}
description={t(
"msg.admin.users.list.subtitle",
"시스템 사용자를 조회하고 관리합니다.",
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
)}
actions={
<>

View File

@@ -35,6 +35,7 @@ import {
type TenantImportPreviewRow,
} from "../../tenants/utils/tenantCsvImport";
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
import { formatUserPolicyMessage } from "../userPolicyMessages";
import { parseUserCSV } from "../utils/csvParser";
import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority";
import {
@@ -768,7 +769,7 @@ export function UserBulkUploadModal({
)}
{!r.success && (
<div className="text-xs text-destructive">
{r.message}
{formatUserPolicyMessage(r.message)}
</div>
)}
</div>

View File

@@ -51,6 +51,16 @@ describe("orgChartPicker", () => {
);
});
it("falls back to the orgfront development origin for authenticated picker URLs", () => {
expect(
buildAuthenticatedOrgChartTenantPickerUrl(undefined, {
tenantId: "hanmac-family-id",
}),
).toBe(
"http://localhost:5175/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id%26includeInternal%3Dtrue",
);
});
it("builds an authenticated multi picker URL for tenant member selection", () => {
expect(
buildAuthenticatedOrgChartUserMultiPickerUrl(

View File

@@ -52,6 +52,8 @@ type OrgChartLoginOptions = {
returnTo?: string;
};
const DEFAULT_ORGFRONT_BASE_URL = "http://localhost:5175";
export const GPDTDC_GRADE_OPTIONS = [
"연구원",
"선임",
@@ -348,7 +350,8 @@ export function buildAuthenticatedOrgChartUrl(
baseUrl?: string,
options: OrgChartLoginOptions = { includeInternal: true },
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const normalizedBase =
baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_ORGFRONT_BASE_URL;
let returnTo = options.returnTo?.trim() || "/chart";
if (options.includeInternal && returnTo.startsWith("/chart")) {
const [path, query = ""] = returnTo.split("?", 2);

View File

@@ -0,0 +1,20 @@
const INTERNAL_DOMAIN_PERSONAL_POLICY_PATTERNS = [
"internal email domain cannot be assigned to personal tenant",
"내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다",
];
export function formatUserPolicyMessage(message?: string | null) {
const raw = String(message ?? "").trim();
if (!raw) {
return "";
}
const normalized = raw.toLowerCase();
if (
INTERNAL_DOMAIN_PERSONAL_POLICY_PATTERNS.some((pattern) =>
normalized.includes(pattern.toLowerCase()),
)
) {
return "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다. 대표소속을 회사 또는 조직 소속으로 지정해 주세요.";
}
return raw;
}

View File

@@ -36,11 +36,17 @@ describe("adminApi user tenant payloads", () => {
const { updateUser } = await import("./adminApi");
apiClient.put.mockResolvedValue({ data: {} });
await updateUser("user-id", { tenantSlug: "new-tenant" });
await updateUser("user-id", {
tenantSlug: "new-tenant",
isPrimaryTenant: true,
});
expect(apiClient.put).toHaveBeenCalledWith(
"/v1/admin/users/user-id",
expect.objectContaining({ tenantSlug: "new-tenant" }),
expect.objectContaining({
tenantSlug: "new-tenant",
isPrimaryTenant: true,
}),
);
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
@@ -61,6 +67,7 @@ describe("adminApi user tenant payloads", () => {
await bulkUpdateUsers({
userIds: ["user-id"],
tenantSlug: "new-tenant",
isPrimaryTenant: true,
});
expect(apiClient.post.mock.calls[0][1].users[0]).toMatchObject({
@@ -71,6 +78,7 @@ describe("adminApi user tenant payloads", () => {
);
expect(apiClient.put.mock.calls[0][1]).toMatchObject({
tenantSlug: "new-tenant",
isPrimaryTenant: true,
});
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});

View File

@@ -146,16 +146,6 @@ export type AdminOverviewStats = {
auditEvents24h: number;
};
export type UserProjectionStatus = {
name: string;
status: "ready" | "failed" | "syncing" | string;
ready: boolean;
lastSyncedAt?: string;
lastError?: string;
updatedAt?: string;
projectedUsers: number;
};
export type IdentityCacheStatus = {
status: string;
redisReady: boolean;
@@ -270,13 +260,6 @@ export async function deleteOrphanUserLoginIDs(ids: string[]) {
return data;
}
export async function fetchUserProjectionStatus() {
const { data } = await apiClient.get<UserProjectionStatus>(
"/v1/admin/projections/users",
);
return data;
}
export async function fetchOrySSOTSystemStatus() {
const { data } =
await apiClient.get<OrySSOTSystemStatus>("/v1/admin/ory/ssot");
@@ -718,6 +701,7 @@ export type UserUpdateRequest = {
role?: string;
status?: string;
tenantSlug?: string;
isPrimaryTenant?: boolean;
isAddTenant?: boolean;
isRemoveTenant?: boolean;
department?: string;
@@ -920,6 +904,7 @@ export type WorksmobileComparisonItem = {
worksmobileJobRetryCount?: number;
worksmobileLastError?: string;
worksmobileLastAttemptAt?: string;
updateReasons?: string[];
status: string;
};
@@ -1144,13 +1129,17 @@ export async function bulkUpdateUsers(payload: {
status?: string;
role?: string;
tenantSlug?: string;
isPrimaryTenant?: boolean;
isAddTenant?: boolean;
department?: string;
position?: string;
grade?: string;
jobTitle?: string;
}) {
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
const { data } = await apiClient.put<BulkUserResponse>(
"/v1/admin/users/bulk",
payload,
);
return data;
}

View File

@@ -4,7 +4,10 @@ import {
buildCommonOidcRuntimeConfig,
buildCommonUserManagerSettings,
} from "../../../common/core/auth";
import { resolveAdminPublicOrigin } from "./authConfig";
import {
resolveAdminOidcAuthority,
resolveAdminPublicOrigin,
} from "./authConfig";
const adminPublicOrigin = resolveAdminPublicOrigin(
import.meta.env.VITE_ADMIN_PUBLIC_URL,
@@ -12,7 +15,10 @@ const adminPublicOrigin = resolveAdminPublicOrigin(
);
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc",
authority: resolveAdminOidcAuthority(
import.meta.env.VITE_OIDC_AUTHORITY,
window.location.origin,
),
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
origin: adminPublicOrigin,
userStore: new WebStorageStateStore({ store: window.localStorage }),

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildAdminAuthRedirectUris,
canStartBrowserPkceLogin,
resolveAdminOidcAuthority,
resolveAdminPublicOrigin,
} from "./authConfig";
@@ -26,6 +27,12 @@ describe("admin auth config", () => {
);
});
it("uses the local OIDC authority for localhost when no explicit authority is set", () => {
expect(resolveAdminOidcAuthority(undefined, "http://localhost:5173")).toBe(
"http://localhost:5000/oidc",
);
});
it("blocks browser PKCE login when WebCrypto is unavailable", () => {
expect(
canStartBrowserPkceLogin({

View File

@@ -5,6 +5,8 @@ export interface AdminAuthRedirectUris {
}
export const ADMIN_AUTH_CALLBACK_PATH = "/auth/callback";
const ADMIN_DEFAULT_PRODUCTION_OIDC_AUTHORITY = "https://sso.hmac.kr/oidc";
const ADMIN_LOCAL_OIDC_PORT = "5000";
export function resolveAdminPublicOrigin(
configuredOrigin: string | undefined,
@@ -71,6 +73,27 @@ function isDevTrustedPkceOrigin(origin: string) {
}
}
export function resolveAdminOidcAuthority(
configuredAuthority: string | undefined,
browserOrigin: string,
) {
const trimmed = configuredAuthority?.trim();
if (trimmed) {
return trimmed;
}
try {
const originUrl = new URL(browserOrigin);
if (isDevTrustedPkceOrigin(originUrl.origin)) {
return `${originUrl.protocol}//${originUrl.hostname}:${ADMIN_LOCAL_OIDC_PORT}/oidc`;
}
} catch {
return ADMIN_DEFAULT_PRODUCTION_OIDC_AUTHORITY;
}
return ADMIN_DEFAULT_PRODUCTION_OIDC_AUTHORITY;
}
export function canStartBrowserPkceLogin({
isSecureContext = window.isSecureContext,
origin = window.location.origin,

View File

@@ -313,6 +313,7 @@ move_description = "Bulk move selected users to another tenant."
move_error = "Error moving users."
move_success = "{{count}} users moved successfully."
parsed_count = "Parsed {{count}} rows."
update_partial_error = "Failed to update {{count}} users."
update_success = "User info updated successfully."
[msg.admin.users.create]

View File

@@ -317,6 +317,7 @@ move_description = "선택한 사용자를 다른 테넌트로 일괄 이동합
move_error = "사용자 이동 중 오류가 발생했습니다."
move_success = "{{count}}명의 사용자가 성공적으로 이동되었습니다."
parsed_count = "{{count}}행의 데이터가 파싱되었습니다."
update_partial_error = "{{count}}명의 사용자 정보 수정에 실패했습니다."
update_success = "사용자 정보가 일괄 업데이트되었습니다."
[msg.admin.users.create]
@@ -378,7 +379,7 @@ self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다."
subtitle = "시스템 사용자를 조회하고 관리합니다. (Local DB)"
subtitle = "Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다."
[msg.admin.users.list.columns]
description = "테이블에 표시할 컬럼을 선택합니다."

View File

@@ -335,6 +335,7 @@ move_description = ""
move_error = ""
move_success = ""
parsed_count = ""
update_partial_error = ""
update_success = ""
[msg.admin.users.create]

View File

@@ -77,7 +77,8 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
"Ory SSOT 시스템 상태를 불러오지 못했습니다.",
"msg.admin.ory_ssot.subtitle":
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
"msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다.",
"msg.admin.users.list.subtitle":
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
"msg.admin.users.list.registry.count":
"총 {{count}}명의 사용자가 등록되어 있습니다.",
"msg.admin.integrity.check.duplicate_tenant_slugs.description":
@@ -168,7 +169,7 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
"msg.admin.ory_ssot.subtitle":
"Review Kratos source-of-truth and Redis identity cache status separately.",
"msg.admin.users.list.subtitle":
"Search and manage users registered in the current tenant.",
"Search and manage users from the Kratos identity mirror.",
"msg.admin.users.list.registry.count": "{{count}} users loaded.",
"msg.admin.integrity.check.duplicate_tenant_slugs.description":
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",

View File

@@ -1,7 +1,10 @@
import { expect, test } from "@playwright/test";
import { installAdminFrontStaticRoutes } from "./helpers/static-adminfront";
test.describe("Bulk Actions and Tree Search", () => {
test.beforeEach(async ({ page }) => {
await installAdminFrontStaticRoutes(page);
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
@@ -196,6 +199,55 @@ test.describe("Bulk Actions and Tree Search", () => {
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
});
test("should show a failure toast when bulk update returns blocked rows", async ({
page,
}) => {
await page.route("**/api/v1/admin/users/bulk", async (route) => {
if (route.request().method() === "PUT") {
return route.fulfill({
json: {
results: [
{
id: "u-1",
success: false,
message:
"internal email domain cannot be assigned to personal tenant: u1@brsw.kr",
},
],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
}
return route.fallback();
});
await page.goto("/users");
await expect(page.locator("table")).toContainText("User One", {
timeout: 20000,
});
await page.locator('table input[type="checkbox"]').nth(1).click();
const selectionBar = page.getByTestId("bulk-action-bar");
await expect(selectionBar).toBeVisible({ timeout: 15000 });
await page.getByTestId("bulk-status-select").click();
await page.getByRole("option", { name: /입사대기|Preboarding/i }).click();
await page.getByTestId("bulk-apply-btn").click();
await expect(
page.getByText(/1명의 사용자 정보 수정에 실패했습니다/),
).toBeVisible();
await expect(
page.getByText(
/내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다/,
),
).toBeVisible();
await expect(
page.getByText(/선택한 사용자들의 정보가 수정되었습니다/),
).not.toBeVisible();
await expect(selectionBar).toBeVisible();
});
test("should let super admins apply selected admin permission to selected users", async ({
page,
}) => {

View File

@@ -925,6 +925,17 @@ test.describe("Tenants Management", () => {
await expect(
page.getByRole("button", { name: "다른 테넌트 선택" }),
).toBeVisible();
await page.getByRole("button", { name: "한맥가족에서 선택" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
const hanmacPickerSrc = await page
.getByTestId("parent-tenant-picker-frame")
.getAttribute("src");
expect(hanmacPickerSrc).toContain("http://localhost:5175/login");
expect(decodeURIComponent(hanmacPickerSrc ?? "")).toContain(
"tenantId=family-1",
);
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
const parentLabelTop = await page
.getByText(/상위 테넌트/)
.first()
@@ -1945,6 +1956,22 @@ test.describe("Tenants Management", () => {
expect(topColumns.split(" ").length).toBe(3);
expect(configColumns.split(" ").length).toBe(4);
await page
.getByTestId("tenant-parent-picker-slot")
.getByRole("button")
.first()
.click();
await expect(page.getByRole("dialog")).toBeVisible();
const detailPickerSrc = await page
.getByTestId("parent-tenant-picker-frame")
.getAttribute("src");
expect(detailPickerSrc).toContain("http://localhost:5175/login");
expect(decodeURIComponent(detailPickerSrc ?? "")).toContain(
"/embed/picker",
);
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
const nameTop = await page
.getByTestId("tenant-name-slot")
.evaluate((element) => element.getBoundingClientRect().top);

View File

@@ -689,6 +689,37 @@ test.describe("User Management", () => {
await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 });
});
test("should show a Korean policy message when an internal domain user is created as personal", async ({
page,
}) => {
await page.route(/\/admin\/users$/, async (route) => {
if (route.request().method() !== "POST") {
return route.fallback();
}
return route.fulfill({
status: 400,
json: {
error:
"internal email domain cannot be assigned to personal tenant: user@hanmaceng.co.kr",
},
});
});
await page.goto("/users/new");
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
await page.getByRole("tab", { name: /개인 회원/i }).click();
await page.locator('input[name="name"]').fill("Internal User");
await page.locator('input[name="email"]').fill("user@hanmaceng.co.kr");
await page.getByRole("button", { name: /생성/i }).click();
await expect(
page.getByText(
/내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다/,
),
).toBeVisible();
});
test("should export users through the authenticated API client", async ({
page,
}) => {
@@ -1032,6 +1063,43 @@ test.describe("User Management", () => {
expect(createPayload).toBeUndefined();
});
test("should open Hanmac family tenant picker without submitting the user create form", async ({
page,
}) => {
let createRequests = 0;
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() === "POST") {
createRequests += 1;
return route.fulfill({
status: 201,
json: {
id: "new-user-id",
name: "Family User",
email: "family@test.com",
},
});
}
return route.fallback();
});
await page.goto("/users/new");
await page.getByTestId("add-appointment-btn").click();
await expect(page.getByTestId("appointment-row-0")).toBeVisible();
await page.getByRole("button", { name: "한맥가족에서 선택" }).click();
await expect(page).toHaveURL(/\/users\/new$/);
await expect(page.getByRole("dialog")).toBeVisible();
const pickerSrc = await page
.getByTestId("appointment-tenant-picker-frame")
.getAttribute("src");
expect(decodeURIComponent(pickerSrc ?? "")).toContain(
"tenantId=hanmac-family-id",
);
expect(createRequests).toBe(0);
});
test("should hide Hanmac family subtree and system tenants when creating a non-family user", async ({
page,
}) => {

View File

@@ -1,7 +1,10 @@
import { expect, test } from "@playwright/test";
import { installAdminFrontStaticRoutes } from "./helpers/static-adminfront";
test.describe("Users Bulk Upload", () => {
test.beforeEach(async ({ page }) => {
await installAdminFrontStaticRoutes(page);
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
@@ -117,6 +120,56 @@ test.describe("Users Bulk Upload", () => {
await expect(uploadBtn).toBeDisabled();
});
test("should show Korean policy message for internal domain personal failures", async ({
page,
}) => {
await page.route("**/api/v1/admin/users/bulk", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
json: {
results: [
{
email: "user@pre-cast.co.kr",
success: false,
message:
"internal email domain cannot be assigned to personal tenant: user@pre-cast.co.kr",
},
],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
return;
}
await route.continue();
});
await page.goto("/users");
await expect(page.getByTestId("page-title")).toContainText(
/사용자|Users/i,
{ timeout: 20000 },
);
await page.getByTestId("user-data-mgmt-btn").click();
await page
.getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i })
.click();
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "users.csv",
mimeType: "text/csv",
buffer: Buffer.from("email,name\nuser@pre-cast.co.kr,Internal User\n"),
});
await page.getByTestId("bulk-start-btn").click();
await expect(
page.getByText(
/내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다/,
),
).toBeVisible();
});
test("should create missing tenant before user bulk import", async ({
page,
}) => {

View File

@@ -2,15 +2,18 @@ package main
import (
"baron-sso-backend/internal/bootstrap"
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/idp"
"baron-sso-backend/internal/logger"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"log/slog"
"maps"
"os"
"strings"
"time"
@@ -32,6 +35,16 @@ type clearOrphanUserTenantMembershipsConfig struct {
DryRun bool
}
type repairDeletedTenantIdentitiesConfig struct {
DryRun bool
}
type repairUserTenantConfig struct {
UserID string
TenantSlug string
RemoveTenantSlug string
}
func main() {
loadEnv()
logger.Init(logger.Config{
@@ -56,6 +69,16 @@ func main() {
slog.Error("clear-orphan-user-tenant-memberships failed", "error", err)
os.Exit(1)
}
case "repair-deleted-tenant-identities":
if err := runRepairDeletedTenantIdentities(os.Args[2:]); err != nil {
slog.Error("repair-deleted-tenant-identities failed", "error", err)
os.Exit(1)
}
case "repair-user-tenant":
if err := runRepairUserTenant(os.Args[2:]); err != nil {
slog.Error("repair-user-tenant failed", "error", err)
os.Exit(1)
}
case "worksmobile-sync":
if err := runWorksmobileSync(os.Args[2:]); err != nil {
slog.Error("worksmobile-sync failed", "error", err)
@@ -121,6 +144,69 @@ func runCreateSuperAdmin(args []string) error {
return nil
}
func runRepairUserTenant(args []string) error {
config, err := resolveRepairUserTenantConfig(args)
if err != nil {
return err
}
db, err := openDB()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var tenant domain.Tenant
if err := db.WithContext(ctx).First(&tenant, "slug = ?", config.TenantSlug).Error; err != nil {
return fmt.Errorf("target tenant not found slug=%s: %w", config.TenantSlug, err)
}
var removeTenant *domain.Tenant
if config.RemoveTenantSlug != "" {
var found domain.Tenant
if err := db.WithContext(ctx).First(&found, "slug = ?", config.RemoveTenantSlug).Error; err != nil {
return fmt.Errorf("remove tenant not found slug=%s: %w", config.RemoveTenantSlug, err)
}
removeTenant = &found
}
kratos := service.NewKratosAdminService()
identity, err := kratos.GetIdentity(ctx, config.UserID)
if err != nil {
return err
}
if identity == nil {
return fmt.Errorf("identity not found: %s", config.UserID)
}
traits := adminctlCloneIdentityTraits(identity.Traits)
adminctlSetPrimaryTenantTraits(traits, tenant, removeTenant)
updated, err := kratos.UpdateIdentity(ctx, config.UserID, traits, identity.State)
if err != nil {
return err
}
if updated == nil {
return fmt.Errorf("kratos update returned empty identity")
}
if err := db.WithContext(ctx).
Model(&domain.User{}).
Where("id = ?", config.UserID).
Updates(map[string]any{
"tenant_id": tenant.ID,
"metadata": domain.JSONMap(updated.Traits),
"updated_at": time.Now(),
}).Error; err != nil {
return err
}
if redisService, err := service.NewRedisService(); err == nil {
_, _ = redisService.FlushIdentityCache(ctx)
} else {
slog.Warn("identity mirror flush skipped", "error", err)
}
fmt.Printf("user tenant repaired: user=%s tenant=%s<%s> removed=%s\n", config.UserID, tenant.Name, tenant.Slug, config.RemoveTenantSlug)
return nil
}
func runClearOrphanUserTenantMemberships(args []string) error {
config, err := resolveClearOrphanUserTenantMembershipsConfig(args)
if err != nil {
@@ -152,6 +238,92 @@ func runClearOrphanUserTenantMemberships(args []string) error {
return nil
}
func runRepairDeletedTenantIdentities(args []string) error {
config, err := resolveRepairDeletedTenantIdentitiesConfig(args)
if err != nil {
return err
}
db, err := openDB()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
var tenants []domain.Tenant
if err := db.WithContext(ctx).Unscoped().Find(&tenants).Error; err != nil {
return err
}
tenantByID, deletedBySlug := adminctlTenantIndexes(tenants)
kratos := service.NewKratosAdminService()
identities, err := kratos.ListIdentities(ctx)
if err != nil {
return err
}
scanned := 0
candidates := 0
updated := 0
localUpdated := int64(0)
for _, identity := range identities {
scanned++
deletedTenant, targetTenant, ok := adminctlDeletedTenantPromotion(identity.Traits, tenantByID, deletedBySlug)
if !ok {
continue
}
candidates++
nextTraits, changed := adminctlPromoteIdentityTraits(identity.Traits, deletedTenant, targetTenant)
if !changed {
continue
}
fmt.Printf("repair candidate: user=%s email=%s deleted=%s<%s> target=%s<%s>\n",
identity.ID,
adminctlTraitString(identity.Traits["email"]),
deletedTenant.Name,
adminctlLegacyTenantSlug(deletedTenant),
targetTenant.Name,
targetTenant.Slug,
)
if config.DryRun {
continue
}
if _, err := kratos.UpdateIdentity(ctx, identity.ID, nextTraits, identity.State); err != nil {
return fmt.Errorf("update kratos identity user=%s: %w", identity.ID, err)
}
result := db.WithContext(ctx).
Model(&domain.User{}).
Where("id = ?", identity.ID).
Updates(map[string]any{"tenant_id": targetTenant.ID, "updated_at": time.Now()})
if result.Error != nil {
return result.Error
}
localUpdated += result.RowsAffected
updated++
}
orphanUpdated := int64(0)
if !config.DryRun {
affected, err := repository.ClearOrphanUserTenantMemberships(ctx, db)
if err != nil {
return err
}
orphanUpdated = affected
if redisService, err := service.NewRedisService(); err == nil {
if _, err := redisService.FlushIdentityCache(ctx); err != nil {
return err
}
} else {
slog.Warn("identity mirror flush skipped", "error", err)
}
}
fmt.Printf("deleted tenant identity repair: scanned=%d candidates=%d kratos_updated=%d local_users_updated=%d orphan_memberships_updated=%d dry_run=%t\n",
scanned, candidates, updated, localUpdated, orphanUpdated, config.DryRun)
return nil
}
func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error) {
fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
@@ -193,6 +365,294 @@ func resolveClearOrphanUserTenantMembershipsConfig(args []string) (clearOrphanUs
return config, nil
}
func resolveRepairDeletedTenantIdentitiesConfig(args []string) (repairDeletedTenantIdentitiesConfig, error) {
fs := flag.NewFlagSet("repair-deleted-tenant-identities", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
config := repairDeletedTenantIdentitiesConfig{}
fs.BoolVar(&config.DryRun, "dry-run", false, "print identities that reference deleted tenants without updating Kratos or local DB")
if err := fs.Parse(args); err != nil {
return config, err
}
return config, nil
}
func resolveRepairUserTenantConfig(args []string) (repairUserTenantConfig, error) {
fs := flag.NewFlagSet("repair-user-tenant", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
config := repairUserTenantConfig{}
fs.StringVar(&config.UserID, "user-id", "", "identity/user id to repair")
fs.StringVar(&config.TenantSlug, "tenant-slug", "", "target representative tenant slug")
fs.StringVar(&config.RemoveTenantSlug, "remove-tenant-slug", "", "appointment tenant slug to remove")
if err := fs.Parse(args); err != nil {
return config, err
}
config.UserID = strings.TrimSpace(config.UserID)
config.TenantSlug = strings.TrimSpace(config.TenantSlug)
config.RemoveTenantSlug = strings.TrimSpace(config.RemoveTenantSlug)
if config.UserID == "" {
return config, fmt.Errorf("--user-id is required")
}
if config.TenantSlug == "" {
return config, fmt.Errorf("--tenant-slug is required")
}
return config, nil
}
func adminctlSetPrimaryTenantTraits(traits map[string]any, target domain.Tenant, removeTenant *domain.Tenant) {
traits["tenant_id"] = target.ID
traits["primaryTenantId"] = target.ID
traits["primaryTenantSlug"] = target.Slug
traits["primaryTenantName"] = target.Name
delete(traits, "companyCode")
delete(traits, "companyCodes")
rawAppointments, _ := adminctlPromoteIdentityAppointments(traits["additionalAppointments"], target, target)
if rawAppointments == nil {
rawAppointments = []any{}
}
next := make([]any, 0, len(rawAppointments)+1)
targetSeen := false
for _, raw := range rawAppointments {
appointment, ok := raw.(map[string]any)
if !ok {
next = append(next, raw)
continue
}
if removeTenant != nil && adminctlAppointmentMatchesTenant(appointment, *removeTenant) {
continue
}
copied := maps.Clone(appointment)
if adminctlAppointmentMatchesTenant(copied, target) {
copied["tenantId"] = target.ID
copied["tenantSlug"] = target.Slug
copied["tenantName"] = target.Name
copied["isPrimary"] = true
targetSeen = true
} else {
copied["isPrimary"] = false
}
next = append(next, copied)
}
if !targetSeen {
next = append(next, map[string]any{
"tenantId": target.ID,
"tenantSlug": target.Slug,
"tenantName": target.Name,
"isPrimary": true,
})
}
traits["additionalAppointments"] = next
}
func adminctlAppointmentMatchesTenant(appointment map[string]any, tenant domain.Tenant) bool {
return adminctlTraitMatchesTenant(appointment["tenantId"], tenant) ||
adminctlTraitMatchesTenant(appointment["tenantSlug"], tenant)
}
func adminctlTenantIndexes(tenants []domain.Tenant) (map[string]domain.Tenant, map[string]domain.Tenant) {
tenantByID := make(map[string]domain.Tenant, len(tenants))
deletedBySlug := map[string]domain.Tenant{}
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
if tenant.DeletedAt.Valid {
if slug := strings.ToLower(strings.TrimSpace(tenant.Slug)); slug != "" {
deletedBySlug[slug] = tenant
}
if legacy := adminctlLegacyTenantSlug(tenant); legacy != "" {
deletedBySlug[strings.ToLower(legacy)] = tenant
}
}
}
return tenantByID, deletedBySlug
}
func adminctlDeletedTenantPromotion(traits map[string]any, tenantByID map[string]domain.Tenant, deletedBySlug map[string]domain.Tenant) (domain.Tenant, domain.Tenant, bool) {
deleted, ok := adminctlFindDeletedTenantInTraits(traits, tenantByID, deletedBySlug)
if !ok {
return domain.Tenant{}, domain.Tenant{}, false
}
target, ok := adminctlNearestActiveAncestor(deleted, tenantByID)
return deleted, target, ok
}
func adminctlFindDeletedTenantInTraits(traits map[string]any, tenantByID map[string]domain.Tenant, deletedBySlug map[string]domain.Tenant) (domain.Tenant, bool) {
for _, key := range []string{"tenant_id", "primaryTenantId", "primaryTenantSlug", "companyCode", "company_code"} {
if tenant, ok := adminctlDeletedTenantFromValue(traits[key], tenantByID, deletedBySlug); ok {
return tenant, true
}
}
switch appointments := traits["additionalAppointments"].(type) {
case []any:
for _, raw := range appointments {
appointment, ok := raw.(map[string]any)
if !ok {
continue
}
for _, key := range []string{"tenantId", "tenantSlug"} {
if tenant, ok := adminctlDeletedTenantFromValue(appointment[key], tenantByID, deletedBySlug); ok {
return tenant, true
}
}
}
case []map[string]any:
for _, appointment := range appointments {
for _, key := range []string{"tenantId", "tenantSlug"} {
if tenant, ok := adminctlDeletedTenantFromValue(appointment[key], tenantByID, deletedBySlug); ok {
return tenant, true
}
}
}
}
return domain.Tenant{}, false
}
func adminctlDeletedTenantFromValue(value any, tenantByID map[string]domain.Tenant, deletedBySlug map[string]domain.Tenant) (domain.Tenant, bool) {
raw := strings.TrimSpace(fmt.Sprint(value))
if raw == "" || raw == "<nil>" {
return domain.Tenant{}, false
}
if tenant, ok := tenantByID[raw]; ok && tenant.DeletedAt.Valid {
return tenant, true
}
tenant, ok := deletedBySlug[strings.ToLower(raw)]
return tenant, ok
}
func adminctlNearestActiveAncestor(deleted domain.Tenant, tenantByID map[string]domain.Tenant) (domain.Tenant, bool) {
seen := map[string]bool{}
parentID := deleted.ParentID
for parentID != nil {
id := strings.TrimSpace(*parentID)
if id == "" || seen[id] {
return domain.Tenant{}, false
}
seen[id] = true
parent, ok := tenantByID[id]
if !ok {
return domain.Tenant{}, false
}
if !parent.DeletedAt.Valid {
return parent, true
}
parentID = parent.ParentID
}
return domain.Tenant{}, false
}
func adminctlPromoteIdentityTraits(traits map[string]any, deletedTenant domain.Tenant, targetTenant domain.Tenant) (map[string]any, bool) {
next := adminctlCloneIdentityTraits(traits)
changed := false
if adminctlTraitMatchesTenant(next["tenant_id"], deletedTenant) || strings.TrimSpace(adminctlTraitString(next["tenant_id"])) == "" {
next["tenant_id"] = targetTenant.ID
changed = true
}
if adminctlTraitMatchesTenant(next["primaryTenantId"], deletedTenant) || adminctlTraitMatchesTenant(next["primaryTenantSlug"], deletedTenant) {
next["primaryTenantId"] = targetTenant.ID
next["primaryTenantSlug"] = targetTenant.Slug
next["primaryTenantName"] = targetTenant.Name
changed = true
}
if adminctlTraitMatchesTenant(next["companyCode"], deletedTenant) {
next["companyCode"] = targetTenant.Slug
changed = true
}
if adminctlTraitMatchesTenant(next["company_code"], deletedTenant) {
next["company_code"] = targetTenant.Slug
changed = true
}
if appointments, appointmentsChanged := adminctlPromoteIdentityAppointments(next["additionalAppointments"], deletedTenant, targetTenant); appointmentsChanged {
next["additionalAppointments"] = appointments
changed = true
}
return next, changed
}
func adminctlPromoteIdentityAppointments(raw any, deletedTenant domain.Tenant, targetTenant domain.Tenant) ([]any, bool) {
switch appointments := raw.(type) {
case []any:
next := make([]any, 0, len(appointments))
changed := false
for _, rawAppointment := range appointments {
appointment, ok := rawAppointment.(map[string]any)
if !ok {
next = append(next, rawAppointment)
continue
}
copied := maps.Clone(appointment)
if adminctlTraitMatchesTenant(copied["tenantId"], deletedTenant) || adminctlTraitMatchesTenant(copied["tenantSlug"], deletedTenant) {
copied["tenantId"] = targetTenant.ID
copied["tenantSlug"] = targetTenant.Slug
copied["tenantName"] = targetTenant.Name
changed = true
}
next = append(next, copied)
}
return next, changed
case []map[string]any:
next := make([]any, 0, len(appointments))
changed := false
for _, appointment := range appointments {
copied := maps.Clone(appointment)
if adminctlTraitMatchesTenant(copied["tenantId"], deletedTenant) || adminctlTraitMatchesTenant(copied["tenantSlug"], deletedTenant) {
copied["tenantId"] = targetTenant.ID
copied["tenantSlug"] = targetTenant.Slug
copied["tenantName"] = targetTenant.Name
changed = true
}
next = append(next, copied)
}
return next, changed
default:
return nil, false
}
}
func adminctlTraitMatchesTenant(value any, tenant domain.Tenant) bool {
raw := strings.TrimSpace(adminctlTraitString(value))
if raw == "" {
return false
}
if strings.EqualFold(raw, tenant.ID) || strings.EqualFold(raw, tenant.Slug) {
return true
}
return strings.EqualFold(raw, adminctlLegacyTenantSlug(tenant))
}
func adminctlLegacyTenantSlug(tenant domain.Tenant) string {
slug := strings.TrimSpace(tenant.Slug)
idx := strings.LastIndex(slug, "-deleted-")
if idx <= 0 {
return slug
}
return slug[:idx]
}
func adminctlTraitString(value any) string {
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
func adminctlCloneIdentityTraits(traits map[string]any) map[string]any {
if traits == nil {
return map[string]any{}
}
raw, err := json.Marshal(traits)
if err != nil {
return maps.Clone(traits)
}
var next map[string]any
if err := json.Unmarshal(raw, &next); err != nil {
return maps.Clone(traits)
}
return next
}
func openDB() (*gorm.DB, error) {
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
@@ -232,5 +692,7 @@ func printUsage() {
fmt.Fprintln(os.Stderr, "usage:")
fmt.Fprintln(os.Stderr, " adminctl create-super-admin [--email EMAIL] [--password PASSWORD] [--name NAME] [--update-password]")
fmt.Fprintln(os.Stderr, " adminctl clear-orphan-user-tenant-memberships [--dry-run]")
fmt.Fprintln(os.Stderr, " adminctl repair-deleted-tenant-identities [--dry-run]")
fmt.Fprintln(os.Stderr, " adminctl repair-user-tenant --user-id ID --tenant-slug SLUG [--remove-tenant-slug SLUG]")
fmt.Fprintln(os.Stderr, " adminctl worksmobile-sync [--orgunits] [--users-csv PATH] [--credential-batch-id ID] [--process] [--serialize-orgunits] [--serialize-users-batch ID] [--batch-size N] [--delay DURATION]")
}

View File

@@ -160,7 +160,6 @@ func TestRecreatePendingWorksmobileUsersFromSnapshotCreatesOnlyMatchedUsers(t *t
writer,
client,
)
if err != nil {
t.Fatalf("recreatePendingWorksmobileUsersFromSnapshot returned error: %v", err)
}
@@ -224,7 +223,6 @@ func TestRecreatePendingWorksmobileUsersFromSnapshotRollsBackWhenCreateFails(t *
writer,
client,
)
if err != nil {
t.Fatalf("recreatePendingWorksmobileUsersFromSnapshot returned error: %v", err)
}
@@ -283,7 +281,6 @@ func TestImportHanmacWorksmobileUsersFromRowsSkipsExistingRemoteLocalPart(t *tes
writer,
client,
)
if err != nil {
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
}
@@ -341,7 +338,6 @@ func TestImportHanmacWorksmobileUsersFromRowsSavesBaronUserAndCreatesWorksmobile
writer,
client,
)
if err != nil {
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
}
@@ -409,7 +405,6 @@ func TestImportHanmacWorksmobileUsersFromRowsKeepsExternalSubEmailOutOfWorksmobi
writer,
client,
)
if err != nil {
t.Fatalf("importHanmacWorksmobileUsersFromRows returned error: %v", err)
}
@@ -441,7 +436,6 @@ func TestBuildAdminctlWorksmobileOrgUnitPayloadClearsDomainRootParent(t *testing
companyID: company,
orgID: org,
})
if err != nil {
t.Fatalf("buildAdminctlWorksmobileOrgUnitPayload returned error: %v", err)
}
@@ -512,6 +506,10 @@ func (f *fakeWorksmobilePendingRecreateClient) UpsertUser(ctx context.Context, p
return nil
}
func (f *fakeWorksmobilePendingRecreateClient) UpdateUserOnly(ctx context.Context, payload service.WorksmobileUserPayload) error {
return nil
}
func (f *fakeWorksmobilePendingRecreateClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
return nil
}

View File

@@ -301,7 +301,6 @@ func main() {
tenantRepo := repository.NewTenantRepository(db)
userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db)
userProjectionRepo := repository.NewUserProjectionRepository(db)
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db)
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
@@ -309,13 +308,6 @@ func main() {
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
userProjectionSyncer := service.NewUserProjectionSyncService(kratosAdminService, userProjectionRepo)
if synced, err := userProjectionSyncer.Reconcile(context.Background()); err != nil {
slog.Error("❌ Kratos user projection sync failed", "error", err)
} else {
slog.Info("✅ Kratos user projection synced", "users", synced)
}
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 {
@@ -336,6 +328,7 @@ func main() {
)
configureWorksmobileClientFromEnv(worksmobileClient)
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
worksmobileService.SetIdentityMirror(redisService)
worksmobileRelayClient := *worksmobileClient
worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient)
@@ -371,7 +364,6 @@ func main() {
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
authHandler.HeadlessJWKS = headlessJWKSCache
authHandler.UserProjectionRepo = userProjectionRepo
authHandler.RPUserMetadataRepo = rpUserMetadataRepo
authHandler.RPUsageSink = rpUsageEmitter
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
@@ -380,7 +372,6 @@ func main() {
adminHandler.TenantRepo = tenantRepo
adminHandler.Hydra = hydraService
adminHandler.AuditRepo = auditRepo
adminHandler.UserProjectionRepo = userProjectionRepo
adminHandler.IdentityCache = redisService
adminHandler.IntegrityChecker = repository.NewDataIntegrityChecker(db)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
@@ -389,12 +380,20 @@ func main() {
devHandler.IdentityWriter = service.NewIdentityWriteService(kratosAdminService, redisService)
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
devHandler.RPUsageQueries = rpUsageQueryRepo
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService, hydraService, consentRepo)
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService, hydraService, consentRepo)
tenantHandler.OrgChartCache = redisService
tenantHandler.IdentityCache = redisService
go func() {
startedAt := time.Now()
if err := tenantHandler.WarmOrgChartSnapshotCache(context.Background()); err != nil {
slog.Warn("Orgfront orgchart snapshot cache warmup failed", "error", err, "latency", time.Since(startedAt).String())
return
}
slog.Info("Orgfront orgchart snapshot cache warmup completed", "latency", time.Since(startedAt).String())
}()
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
userHandler.UserProjectionRepo = userProjectionRepo
userHandler.IdentityCache = redisService
go func() {
startedAt := time.Now()
@@ -735,7 +734,6 @@ func main() {
admin.Get("/integrity", requireSuperAdmin, adminHandler.GetDataIntegrity)
admin.Get("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.ListOrphanUserLoginIDs)
admin.Delete("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.DeleteOrphanUserLoginIDs)
admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus)
admin.Get("/ory/ssot", requireSuperAdmin, adminHandler.GetOrySSOTSystemStatus)
admin.Post("/ory/ssot/identity-cache/flush", requireSuperAdmin, adminHandler.FlushIdentityCache)
admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily)

View File

@@ -29,6 +29,9 @@ func Run(db *gorm.DB) error {
if err := SanitizeLegacyUserMetadata(db); err != nil {
return fmt.Errorf("legacy user metadata sanitize failed: %w", err)
}
if err := CanonicalizeUserAppointmentTenants(db); err != nil {
return fmt.Errorf("user appointment tenant canonicalization failed: %w", err)
}
slog.Info("[Bootstrap] User seed skipped (Kratos is SoT)")
slog.Info("[Bootstrap] Bootstrap completed successfully.")
@@ -50,7 +53,6 @@ func migrateSchemas(db *gorm.DB) error {
&domain.TenantDomain{},
&domain.User{},
&domain.UserLoginID{},
&domain.UserProjectionState{},
&domain.UserGroup{},
&domain.ApiKey{},
&domain.IdentityProviderConfig{},

View File

@@ -15,6 +15,42 @@ where metadata ? 'hanmacFamily'
or metadata ? 'userType'
`
const canonicalizeUserAppointmentTenantsSQL = `
with normalized as (
select
u.id,
jsonb_agg(
case
when jsonb_typeof(item.value) = 'object' and t.id is not null then
item.value || jsonb_build_object(
'tenantId', t.id::text,
'tenantSlug', t.slug,
'tenantName', t.name
)
else item.value
end
order by item.ordinality
) as appointments
from users u
cross join lateral jsonb_array_elements(u.metadata -> 'additionalAppointments') with ordinality as item(value, ordinality)
left join tenants t on t.id::text = item.value ->> 'tenantId'
and t.deleted_at is null
where jsonb_typeof(u.metadata -> 'additionalAppointments') = 'array'
group by u.id
),
changed as (
select u.id, normalized.appointments
from users u
join normalized on normalized.id = u.id
where u.metadata -> 'additionalAppointments' is distinct from normalized.appointments
)
update users u
set metadata = jsonb_set(u.metadata, '{additionalAppointments}', changed.appointments, true),
updated_at = now()
from changed
where changed.id = u.id
`
// SanitizeLegacyUserMetadata removes legacy UI classification flags from Baron user metadata.
func SanitizeLegacyUserMetadata(db *gorm.DB) error {
if db == nil {
@@ -32,3 +68,21 @@ func SanitizeLegacyUserMetadata(db *gorm.DB) error {
slog.Info("[Bootstrap] Legacy user metadata sanitized", "rowsAffected", result.RowsAffected)
return nil
}
// CanonicalizeUserAppointmentTenants rewrites appointment display fields from the tenant UUID source.
func CanonicalizeUserAppointmentTenants(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database is not configured")
}
if !db.Migrator().HasTable("users") || !db.Migrator().HasTable("tenants") {
slog.Info("[Bootstrap] User appointment tenant canonicalization skipped because required tables do not exist")
return nil
}
result := db.Exec(canonicalizeUserAppointmentTenantsSQL)
if result.Error != nil {
return fmt.Errorf("canonicalize user appointment tenants: %w", result.Error)
}
slog.Info("[Bootstrap] User appointment tenant metadata canonicalized", "rowsAffected", result.RowsAffected)
return nil
}

View File

@@ -114,6 +114,89 @@ func TestCanonicalizeLegacyUserStatuses(t *testing.T) {
}
}
func TestCanonicalizeUserAppointmentTenantsUsesTenantUUID(t *testing.T) {
db := openBootstrapPostgresTestDB(t)
if err := db.AutoMigrate(&domain.Tenant{}, &domain.User{}); err != nil {
t.Fatalf("failed to migrate users and tenants tables: %v", err)
}
tenant := domain.Tenant{
ID: "30000000-0000-0000-0000-000000000101",
Type: domain.TenantTypeOrganization,
Name: "통합시스템",
Slug: "intigrated-system",
Status: domain.TenantStatusActive,
}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("failed to create tenant: %v", err)
}
user := domain.User{
ID: "30000000-0000-0000-0000-000000000201",
Email: "appointment@example.com",
Name: "Appointment User",
Role: domain.RoleUser,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": tenant.ID,
"tenantSlug": "tech-planning",
"tenantName": "기술기획",
"grade": "연구원",
},
map[string]any{
"tenantId": "30000000-0000-0000-0000-000000000999",
"tenantSlug": "unknown-old",
"tenantName": "Unknown Old",
},
},
},
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
if err := CanonicalizeUserAppointmentTenants(db); err != nil {
t.Fatalf("CanonicalizeUserAppointmentTenants returned error: %v", err)
}
if err := CanonicalizeUserAppointmentTenants(db); err != nil {
t.Fatalf("CanonicalizeUserAppointmentTenants must be idempotent: %v", err)
}
var got domain.User
if err := db.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatalf("failed to load canonicalized user: %v", err)
}
appointments, ok := got.Metadata["additionalAppointments"].([]any)
if !ok || len(appointments) != 2 {
t.Fatalf("additionalAppointments = %#v, want two appointments", got.Metadata["additionalAppointments"])
}
first, ok := appointments[0].(map[string]any)
if !ok {
t.Fatalf("first appointment = %#v, want object", appointments[0])
}
if first["tenantId"] != tenant.ID {
t.Fatalf("tenantId = %#v, want %s", first["tenantId"], tenant.ID)
}
if first["tenantSlug"] != tenant.Slug {
t.Fatalf("tenantSlug = %#v, want %s", first["tenantSlug"], tenant.Slug)
}
if first["tenantName"] != tenant.Name {
t.Fatalf("tenantName = %#v, want %s", first["tenantName"], tenant.Name)
}
if first["grade"] != "연구원" {
t.Fatalf("grade = %#v, want preserved value", first["grade"])
}
second, ok := appointments[1].(map[string]any)
if !ok {
t.Fatalf("second appointment = %#v, want object", appointments[1])
}
if second["tenantSlug"] != "unknown-old" || second["tenantName"] != "Unknown Old" {
t.Fatalf("unknown tenant appointment must be preserved: %#v", second)
}
}
func TestRunSanitizesLegacyUserMetadata(t *testing.T) {
db := openBootstrapPostgresTestDB(t)
if err := db.AutoMigrate(&domain.User{}); err != nil {

View File

@@ -1,29 +0,0 @@
package domain
import "time"
const (
UserProjectionNameKratos = "kratos_users"
UserProjectionStatusSyncing = "syncing"
UserProjectionStatusReady = "ready"
UserProjectionStatusFailed = "failed"
)
type UserProjectionState struct {
Name string `gorm:"primaryKey;column:name" json:"name"`
Status string `gorm:"column:status;not null" json:"status"`
LastSyncedAt *time.Time `gorm:"column:last_synced_at" json:"lastSyncedAt,omitempty"`
LastError string `gorm:"column:last_error;type:text" json:"lastError,omitempty"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
type UserProjectionStatus struct {
Name string `json:"name"`
Status string `json:"status"`
Ready bool `json:"ready"`
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
LastError string `json:"lastError,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
ProjectedUsers int64 `json:"projectedUsers"`
}

View File

@@ -24,16 +24,15 @@ type identityCacheAdmin interface {
}
type AdminHandler struct {
DB *gorm.DB
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
RPUsageQueries domain.RPUsageQueryRepository
TenantRepo repository.TenantRepository
Hydra adminHydraClientLister
AuditRepo domain.AuditRepository
UserProjectionRepo repository.UserProjectionRepository
IdentityCache identityCacheAdmin
IntegrityChecker repository.DataIntegrityChecker
DB *gorm.DB
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
RPUsageQueries domain.RPUsageQueryRepository
TenantRepo repository.TenantRepository
Hydra adminHydraClientLister
AuditRepo domain.AuditRepository
IdentityCache identityCacheAdmin
IntegrityChecker repository.DataIntegrityChecker
}
const globalCustomClaimsSettingKey = "global_custom_claim_definitions"
@@ -289,20 +288,6 @@ func requireSuperAdminProfile(c *fiber.Ctx) bool {
return true
}
func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error {
if !requireSuperAdminProfile(c) {
return nil
}
if h == nil || h.UserProjectionRepo == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
}
status, err := h.UserProjectionRepo.GetStatus(c.Context())
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(status)
}
func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error {
if !requireSuperAdminProfile(c) {
return nil
@@ -428,14 +413,14 @@ func (h *AdminHandler) countTenants(ctx context.Context) int64 {
}
func (h *AdminHandler) countUsers(ctx context.Context) int64 {
if h == nil || h.UserProjectionRepo == nil {
if h == nil || h.DB == nil {
return 0
}
status, err := h.UserProjectionRepo.GetStatus(ctx)
if err != nil {
var total int64
if err := h.DB.WithContext(ctx).Model(&domain.User{}).Count(&total).Error; err != nil {
return 0
}
return status.ProjectedUsers
return total
}
func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 {

View File

@@ -65,34 +65,6 @@ func (f *fakeOverviewAuditRepo) CountEventsSince(ctx context.Context, since time
return f.count, nil
}
type fakeAdminUserProjectionRepo struct {
status domain.UserProjectionStatus
}
func (f *fakeAdminUserProjectionRepo) IsReady(ctx context.Context) (bool, error) {
return f.status.Ready, nil
}
func (f *fakeAdminUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
return nil, nil
}
func (f *fakeAdminUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
return nil, nil
}
func (f *fakeAdminUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
return nil
}
func (f *fakeAdminUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error {
return nil
}
func (f *fakeAdminUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
return f.status, nil
}
type fakeIdentityCacheAdmin struct {
status domain.IdentityCacheStatus
flush domain.IdentityCacheFlushResult
@@ -157,58 +129,6 @@ func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
require.Equal(t, uint64(12), body.Items[0].LoginRequests)
}
func TestAdminHandler_UserProjectionStatusRequiresSuperAdmin(t *testing.T) {
h := &AdminHandler{
UserProjectionRepo: &fakeAdminUserProjectionRepo{
status: domain.UserProjectionStatus{Name: domain.UserProjectionNameKratos, Status: domain.UserProjectionStatusReady, Ready: true},
},
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: "tenant_admin"})
return c.Next()
})
app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestAdminHandler_UserProjectionStatusReturnsProjectionStateForSuperAdmin(t *testing.T) {
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
h := &AdminHandler{
UserProjectionRepo: &fakeAdminUserProjectionRepo{
status: domain.UserProjectionStatus{
Name: domain.UserProjectionNameKratos,
Status: domain.UserProjectionStatusReady,
Ready: true,
LastSyncedAt: &syncedAt,
ProjectedUsers: 152,
},
},
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var body domain.UserProjectionStatus
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, domain.UserProjectionNameKratos, body.Name)
require.Equal(t, domain.UserProjectionStatusReady, body.Status)
require.True(t, body.Ready)
require.Equal(t, int64(152), body.ProjectedUsers)
}
func TestAdminHandler_GetOrySSOTSystemStatusReturnsIdentityCacheOnly(t *testing.T) {
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
cache := &fakeIdentityCacheAdmin{
@@ -237,11 +157,9 @@ func TestAdminHandler_GetOrySSOTSystemStatusReturnsIdentityCacheOnly(t *testing.
require.Equal(t, http.StatusOK, resp.StatusCode)
var body struct {
UserProjection *domain.UserProjectionStatus `json:"userProjection,omitempty"`
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
IdentityCache domain.IdentityCacheStatus `json:"identityCache"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Nil(t, body.UserProjection)
require.True(t, body.IdentityCache.RedisReady)
require.Equal(t, int64(151), body.IdentityCache.ObservedCount)
require.Equal(t, int64(153), body.IdentityCache.KeyCount)
@@ -305,14 +223,6 @@ func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
auditRepo := &fakeOverviewAuditRepo{count: 22}
h := &AdminHandler{
AuditRepo: auditRepo,
UserProjectionRepo: &fakeAdminUserProjectionRepo{
status: domain.UserProjectionStatus{
Name: domain.UserProjectionNameKratos,
Status: domain.UserProjectionStatusReady,
Ready: true,
ProjectedUsers: 152,
},
},
}
app := fiber.New()
app.Get("/api/v1/admin/stats", h.GetSystemStats)
@@ -328,7 +238,7 @@ func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
require.Contains(t, body, "totalUsers")
require.Contains(t, body, "oidcClients")
require.Contains(t, body, "auditEvents24h")
require.Equal(t, float64(152), body["totalUsers"])
require.Equal(t, float64(0), body["totalUsers"])
require.Equal(t, float64(22), body["auditEvents24h"])
require.Equal(t, time.UTC, auditRepo.since.Location())
}

View File

@@ -103,7 +103,6 @@ type AuthHandler struct {
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
ConsentRepo repository.ClientConsentRepository
RPUserMetadataRepo repository.RPUserMetadataRepository
RPUsageSink domain.RPUsageEventSink
@@ -860,7 +859,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
if err := h.UserRepo.Update(ctx, u); err != nil {
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
} else {
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
@@ -870,7 +868,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
}
if err := h.UserRepo.UpdateUserLoginIDs(ctx, u.ID, ids); err != nil {
slog.Error("[Signup] Failed to update user login IDs", "userID", u.ID, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
// [Keto] Sync user-tenant relationship via Outbox
@@ -8120,7 +8117,6 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
ctx := context.Background()
if err := h.syncUpdatedKratosUserReadModel(ctx, identityID, traits); err != nil {
slog.Error("[UpdateMe] Failed to sync local user read-model", "userID", identityID, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
if err := h.UserRepo.UpdateUserLoginIDs(ctx, identityID, loginIDRecords); err != nil {
slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err)

View File

@@ -4,7 +4,9 @@ import (
"baron-sso-backend/internal/domain"
"context"
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
)
@@ -28,7 +30,13 @@ type hanmacEmailEvaluation struct {
LocalPart string
}
func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUserItem, scope *hanmacEmailScope, usedLocalParts map[string]bool) hanmacEmailEvaluation {
type hanmacLocalPartOwner struct {
UserID string
Email string
Name string
}
func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUserItem, scope *hanmacEmailScope, usedLocalParts map[string]hanmacLocalPartOwner) hanmacEmailEvaluation {
originalEmail := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name)
evaluation := hanmacEmailEvaluation{
@@ -68,9 +76,9 @@ func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUs
}
evaluation.LocalPart = localPart
if usedLocalParts[localPart] {
if owner, exists := usedLocalParts[localPart]; exists {
evaluation.Status = "blockingError"
evaluation.Message = "한맥가족 내에서 이미 사용 중인 이메일 ID입니다."
evaluation.Message = formatHanmacLocalPartConflictMessage(localPart, owner)
evaluation.Blocking = true
return evaluation
}
@@ -88,6 +96,14 @@ func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUs
}
func (h *UserHandler) ensureHanmacCreateEmailAllowed(ctx context.Context, email string, tenantSlug string, tenantID string) error {
return h.ensureHanmacEmailAllowedWithLog(ctx, email, tenantSlug, tenantID, "", "hanmac create email local-part conflict")
}
func (h *UserHandler) ensureHanmacEmailAllowed(ctx context.Context, email string, tenantSlug string, tenantID string, currentUserID string) error {
return h.ensureHanmacEmailAllowedWithLog(ctx, email, tenantSlug, tenantID, currentUserID, "hanmac email local-part conflict")
}
func (h *UserHandler) ensureHanmacEmailAllowedWithLog(ctx context.Context, email string, tenantSlug string, tenantID string, currentUserID string, logMessage string) error {
scope, err := h.resolveHanmacEmailScope(ctx)
if err != nil || scope == nil || !scope.ContainsTenant(tenantID, tenantSlug) {
return nil
@@ -102,8 +118,22 @@ func (h *UserHandler) ensureHanmacCreateEmailAllowed(ctx context.Context, email
if err != nil {
return err
}
if usedLocalParts[localPart] {
return fmt.Errorf("한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
if owner, exists := usedLocalParts[localPart]; exists {
ownerUserID := strings.TrimSpace(owner.UserID)
if currentUserID != "" && ownerUserID != "" && ownerUserID == strings.TrimSpace(currentUserID) {
return nil
}
slog.Warn(
logMessage,
"requestedEmail", email,
"localPart", localPart,
"ownerUserID", owner.UserID,
"ownerEmail", owner.Email,
"ownerName", owner.Name,
"tenantID", tenantID,
"tenantSlug", tenantSlug,
)
return fmt.Errorf("%s", formatHanmacLocalPartConflictMessage(localPart, owner))
}
return nil
}
@@ -149,8 +179,8 @@ func (h *UserHandler) resolveHanmacEmailScope(ctx context.Context) (*hanmacEmail
return scope, nil
}
func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEmailScope) (map[string]bool, error) {
used := make(map[string]bool)
func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEmailScope) (map[string]hanmacLocalPartOwner, error) {
used := make(map[string]hanmacLocalPartOwner)
if h.UserRepo == nil || scope == nil {
return used, nil
}
@@ -160,7 +190,7 @@ func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEma
if err != nil {
return nil, err
}
addUserEmailLocalParts(used, users)
addUserEmailLocalPartOwners(used, users)
}
if len(scope.SlugList) > 0 {
@@ -168,7 +198,7 @@ func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEma
if err != nil {
return nil, err
}
addUserEmailLocalParts(used, users)
addUserEmailLocalPartOwners(used, users)
}
return used, nil
@@ -210,31 +240,79 @@ func isTenantDescendantOf(tenant domain.Tenant, rootID string, tenantByID map[st
return false
}
func addUserEmailLocalParts(target map[string]bool, users []domain.User) {
func addUserEmailLocalPartOwners(target map[string]hanmacLocalPartOwner, users []domain.User) {
for _, user := range users {
localPart, err := domain.ExtractNormalizedEmailLocalPart(user.Email)
if err == nil && localPart != "" {
target[localPart] = true
if err != nil || localPart == "" {
continue
}
if _, exists := target[localPart]; exists {
continue
}
target[localPart] = hanmacLocalPartOwner{
UserID: strings.TrimSpace(user.ID),
Email: strings.TrimSpace(user.Email),
Name: strings.TrimSpace(user.Name),
}
}
}
func nextAvailableHanmacLocalPart(base string, usedLocalParts map[string]bool) string {
func formatHanmacLocalPartConflictMessage(localPart string, owner hanmacLocalPartOwner) string {
message := fmt.Sprintf("한맥가족 내에서 이미 사용 중인 이메일 ID입니다. local-part=%s", strings.TrimSpace(localPart))
if owner.Email != "" {
message += ", 사용 계정=" + owner.Email
}
if owner.Name != "" {
message += ", 사용자=" + owner.Name
}
if owner.UserID != "" {
message += ", 사용자 ID=" + owner.UserID
}
return message
}
func nextAvailableHanmacLocalPart(base string, usedLocalParts map[string]hanmacLocalPartOwner) string {
base = strings.ToLower(strings.TrimSpace(base))
if base == "" {
return ""
}
if !usedLocalParts[base] {
if _, exists := usedLocalParts[base]; !exists {
return base
}
for index := 1; ; index++ {
candidate := fmt.Sprintf("%s%d", base, index)
if !usedLocalParts[candidate] {
stem, nextIndex := splitTrailingNumericSuffix(base)
if stem == "" {
stem = base
}
for index := nextIndex; ; index++ {
candidate := fmt.Sprintf("%s%d", stem, index)
if _, exists := usedLocalParts[candidate]; !exists {
return candidate
}
}
}
func splitTrailingNumericSuffix(value string) (string, int) {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return "", 1
}
index := len(value)
for index > 0 && value[index-1] >= '0' && value[index-1] <= '9' {
index--
}
if index == len(value) {
return value, 1
}
stem := value[:index]
suffix := value[index:]
number, err := strconv.Atoi(suffix)
if err != nil {
return value, 1
}
return stem, number + 1
}
func appendUniqueString(values []string, value string) []string {
if slices.Contains(values, value) {
return values

View File

@@ -0,0 +1,66 @@
package handler
import (
"baron-sso-backend/internal/domain"
"context"
"fmt"
"strings"
)
var internalEmailDomainsDisallowedForPersonal = map[string]bool{
"brsw.kr": true,
"hanmaceng.co.kr": true,
"samaneng.com": true,
"hallasanup.com": true,
"jangheon.co.kr": true,
"jangheon.com": true,
"pre-cast.co.kr": true,
}
func internalDomainPersonalPolicyMessage(email string) string {
return fmt.Sprintf("내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다: %s", strings.ToLower(strings.TrimSpace(email)))
}
func emailUsesInternalPersonalRestrictedDomain(email string) bool {
_, domainPart, err := domain.SplitEmailDomain(email)
if err != nil {
return false
}
return internalEmailDomainsDisallowedForPersonal[strings.ToLower(strings.TrimSpace(domainPart))]
}
func isPersonalTenantForInternalDomainPolicy(tenant *domain.Tenant) bool {
if tenant == nil {
return false
}
if strings.EqualFold(strings.TrimSpace(tenant.Type), domain.TenantTypePersonal) {
return true
}
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
return slug == "personal" || strings.HasPrefix(slug, "personal-")
}
func (h *UserHandler) ensureInternalDomainNotAssignedToPersonal(ctx context.Context, email string, tenantID string, tenantSlug string, resolvedTenant *domain.Tenant) error {
if !emailUsesInternalPersonalRestrictedDomain(email) {
return nil
}
tenant := resolvedTenant
if tenant == nil && h.TenantService != nil {
if id := strings.TrimSpace(tenantID); id != "" {
if found, err := h.TenantService.GetTenant(ctx, id); err == nil && found != nil {
tenant = found
}
}
if tenant == nil {
if slug := strings.TrimSpace(tenantSlug); slug != "" {
if found, err := h.TenantService.GetTenantBySlug(ctx, slug); err == nil && found != nil {
tenant = found
}
}
}
}
if isPersonalTenantForInternalDomainPolicy(tenant) {
return fmt.Errorf("%s", internalDomainPersonalPolicyMessage(email))
}
return nil
}

View File

@@ -16,10 +16,8 @@ import (
"io"
"log/slog"
"maps"
"os"
"reflect"
"sort"
"strconv"
"strings"
"time"
@@ -29,25 +27,28 @@ import (
)
type TenantHandler struct {
DB *gorm.DB
Service service.TenantService
UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
OrgChartCache orgChartCacheStore
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
SharedLink service.SharedLinkService
Worksmobile service.WorksmobileSyncer
Hydra *service.HydraAdminService
ConsentRepo repository.ClientConsentRepository
DB *gorm.DB
Service service.TenantService
UserRepo repository.UserRepository
OrgChartCache orgChartCacheStore
IdentityCache domain.RedisRepository
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
SharedLink service.SharedLinkService
Worksmobile service.WorksmobileSyncer
Hydra *service.HydraAdminService
ConsentRepo repository.ClientConsentRepository
}
type orgChartCacheStore interface {
Get(key string) (string, error)
Set(key string, value string, expiration time.Duration) error
DeleteByPrefix(ctx context.Context, prefix string) (int64, error)
}
const orgChartSnapshotCacheKeyPrefix = "orgchart:snapshot:v1:"
func seedTenantDeleteError(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusConflict, "seed tenants cannot be deleted")
}
@@ -65,18 +66,17 @@ func seedTenantSlugsForDeleteGuard() []string {
return result
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, userProjectionRepo repository.UserProjectionRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
return &TenantHandler{
DB: db,
Service: svc,
UserRepo: userRepo,
UserProjectionRepo: userProjectionRepo,
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
SharedLink: sharedLink,
Hydra: hydra,
ConsentRepo: consentRepo,
DB: db,
Service: svc,
UserRepo: userRepo,
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
SharedLink: sharedLink,
Hydra: hydra,
ConsentRepo: consentRepo,
}
}
@@ -263,6 +263,7 @@ func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_approved")
return c.JSON(fiber.Map{"message": "Tenant approved successfully"})
}
@@ -311,12 +312,25 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
parentMap := make(map[string]string)
tenantIDs := make(map[string]bool, len(allTenants))
for _, t := range allTenants {
tenantIDs[t.ID] = true
if t.ParentID != nil {
parentMap[t.ID] = *t.ParentID
}
}
hasValidBaseTenant := false
for _, id := range baseTenantIDs {
if tenantIDs[strings.TrimSpace(id)] {
hasValidBaseTenant = true
break
}
}
if len(baseTenantIDs) > 0 && !hasValidBaseTenant {
return errorJSON(c, fiber.StatusConflict, "tenant scope is not available")
}
roots := make(map[string]bool)
for _, id := range baseTenantIDs {
roots[findTenantRootID(parentMap, id)] = true
@@ -372,7 +386,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
}
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants)
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), tenants)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
@@ -688,6 +702,9 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
}
result.Details = append(result.Details, detail)
}
if result.Created > 0 || result.Updated > 0 {
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenants_imported")
}
return c.JSON(result)
}
@@ -1247,6 +1264,9 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} {
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", privateRootID, relation)
if err != nil {
if isMissingKetoRelationError(err) {
continue
}
return false, fmt.Errorf("private tenant permission check failed: %w", err)
}
if allowed {
@@ -1257,6 +1277,9 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
for _, ancestorID := range tenantAncestorIDs(privateRootID, tenants) {
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", ancestorID, "view_private_descendants")
if err != nil {
if isMissingKetoRelationError(err) {
continue
}
return false, fmt.Errorf("private tenant descendant permission check failed: %w", err)
}
if allowed {
@@ -1266,6 +1289,14 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
return false, nil
}
func isMissingKetoRelationError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "relation ") && strings.Contains(message, "does not exist")
}
func profileCanManageTenantOrAncestor(profile *domain.UserProfileResponse, tenantID string, tenants []domain.Tenant) bool {
manageableIDs := make(map[string]bool, len(profile.ManageableTenants))
for _, tenant := range profile.ManageableTenants {
@@ -1669,7 +1700,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant})
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), []domain.Tenant{tenant})
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
@@ -1795,6 +1826,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
}
}
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_created")
return c.Status(fiber.StatusCreated).JSON(summary)
}
@@ -1955,6 +1987,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant update sync: %v\n", err)
}
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_updated")
return c.JSON(mapTenantSummary(tenant))
}
@@ -1980,6 +2013,10 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
return seedTenantDeleteError(c)
}
if err := h.reassignUserMembershipsBeforeTenantDelete(c.Context(), []string{tenantID}); err != nil {
return errorJSON(c, fiber.StatusConflict, err.Error())
}
if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, []string{tenantID}); err != nil {
logTenantCleanupFailure(err, []string{tenantID})
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
@@ -1999,6 +2036,7 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant delete sync: %v\n", err)
}
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_deleted")
return c.SendStatus(fiber.StatusNoContent)
}
@@ -2287,6 +2325,10 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if len(req.IDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
}
req.IDs = uniqueNonEmptyStrings(req.IDs)
if len(req.IDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
}
@@ -2313,6 +2355,10 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
}
}
if err := h.reassignUserMembershipsBeforeTenantDelete(c.Context(), req.IDs); err != nil {
return errorJSON(c, fiber.StatusConflict, err.Error())
}
if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, req.IDs); err != nil {
logTenantCleanupFailure(err, req.IDs)
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
@@ -2321,6 +2367,7 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenants_bulk_deleted")
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Tenants deleted successfully",
@@ -2328,6 +2375,362 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
})
}
func uniqueNonEmptyStrings(values []string) []string {
seen := make(map[string]bool, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" || seen[trimmed] {
continue
}
seen[trimmed] = true
result = append(result, trimmed)
}
return result
}
func (h *TenantHandler) reassignUserMembershipsBeforeTenantDelete(ctx context.Context, tenantIDs []string) error {
if h == nil || h.DB == nil {
return nil
}
deletedIDs := uniqueNonEmptyStrings(tenantIDs)
if len(deletedIDs) == 0 {
return nil
}
affectedIDs, err := h.deletedTenantIDsReferencedByUsers(ctx, deletedIDs)
if err != nil {
return err
}
if len(affectedIDs) == 0 {
return nil
}
var tenants []domain.Tenant
if err := h.DB.WithContext(ctx).Unscoped().Find(&tenants).Error; err != nil {
return err
}
targets, err := resolveTenantDeletionPromotionTargets(tenants, deletedIDs, affectedIDs)
if err != nil {
return err
}
var affectedUsers []domain.User
if err := h.DB.WithContext(ctx).
Where("tenant_id IN ?", affectedIDs).
Find(&affectedUsers).Error; err != nil {
return err
}
tenantByID := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
}
if err := h.promoteKratosUserMembershipsForTenantDelete(ctx, affectedUsers, tenantByID, targets); err != nil {
return err
}
return h.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for deletedID, targetID := range targets {
if err := tx.Model(&domain.User{}).
Where("tenant_id = ?", deletedID).
Updates(map[string]any{"tenant_id": targetID, "updated_at": time.Now()}).Error; err != nil {
return err
}
if err := tx.Model(&domain.UserLoginID{}).
Where("tenant_id = ?", deletedID).
Update("tenant_id", targetID).Error; err != nil {
return err
}
}
return nil
})
}
func (h *TenantHandler) promoteKratosUserMembershipsForTenantDelete(ctx context.Context, users []domain.User, tenantByID map[string]domain.Tenant, targets map[string]string) error {
if h == nil || h.KratosAdmin == nil {
return nil
}
for _, user := range users {
if user.TenantID == nil || strings.TrimSpace(user.ID) == "" {
continue
}
deletedID := strings.TrimSpace(*user.TenantID)
targetID := strings.TrimSpace(targets[deletedID])
if deletedID == "" || targetID == "" {
continue
}
deletedTenant, ok := tenantByID[deletedID]
if !ok {
return fmt.Errorf("deleted tenant not found while promoting user membership: %s", deletedID)
}
targetTenant, ok := tenantByID[targetID]
if !ok {
return fmt.Errorf("promotion target tenant not found while promoting user membership: %s", targetID)
}
identity, err := h.KratosAdmin.GetIdentity(ctx, user.ID)
if err != nil {
return fmt.Errorf("get kratos identity for tenant promotion user=%s: %w", user.ID, err)
}
if identity == nil {
return fmt.Errorf("kratos identity not found for tenant promotion user=%s", user.ID)
}
traits, changed := promoteIdentityTraitsFromDeletedTenant(identity.Traits, deletedTenant, targetTenant, true)
if !changed {
continue
}
updated, err := h.KratosAdmin.UpdateIdentity(ctx, user.ID, traits, identity.State)
if err != nil {
return fmt.Errorf("update kratos identity for tenant promotion user=%s: %w", user.ID, err)
}
if updated == nil {
identity.Traits = traits
h.storePromotedIdentityMirror(*identity)
continue
}
h.storePromotedIdentityMirror(*updated)
}
return nil
}
func promoteIdentityTraitsFromDeletedTenant(traits map[string]any, deletedTenant domain.Tenant, targetTenant domain.Tenant, forcePrimary bool) (map[string]any, bool) {
next := cloneIdentityTraits(traits)
changed := false
primaryChanged := forcePrimary
if traitStringEqualTenant(next["tenant_id"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
next["tenant_id"] = targetTenant.ID
changed = true
primaryChanged = true
}
if traitStringEqualTenant(next["primaryTenantId"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
next["primaryTenantId"] = targetTenant.ID
changed = true
primaryChanged = true
}
if traitStringEqualTenant(next["primaryTenantSlug"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
next["primaryTenantSlug"] = targetTenant.Slug
changed = true
primaryChanged = true
}
if traitStringEqualTenant(next["primaryTenantName"], deletedTenant.Name, "") || forcePrimary {
next["primaryTenantName"] = targetTenant.Name
changed = true
primaryChanged = true
}
if traitStringEqualTenant(next["companyCode"], deletedTenant.ID, deletedTenant.Slug) {
next["companyCode"] = targetTenant.Slug
changed = true
}
if traitStringEqualTenant(next["company_code"], deletedTenant.ID, deletedTenant.Slug) {
next["company_code"] = targetTenant.Slug
changed = true
}
appointments, appointmentsChanged := promoteIdentityAppointmentsFromDeletedTenant(next["additionalAppointments"], deletedTenant, targetTenant)
if appointmentsChanged {
next["additionalAppointments"] = appointments
changed = true
}
if primaryChanged {
next["primaryTenantId"] = targetTenant.ID
next["primaryTenantSlug"] = targetTenant.Slug
next["primaryTenantName"] = targetTenant.Name
}
return next, changed
}
func promoteIdentityAppointmentsFromDeletedTenant(raw any, deletedTenant domain.Tenant, targetTenant domain.Tenant) ([]any, bool) {
switch appointments := raw.(type) {
case []any:
next := make([]any, 0, len(appointments))
changed := false
for _, rawAppointment := range appointments {
appointment, ok := rawAppointment.(map[string]any)
if !ok {
next = append(next, rawAppointment)
continue
}
copied := maps.Clone(appointment)
if identityAppointmentMatchesTenant(copied, deletedTenant) {
copied["tenantId"] = targetTenant.ID
copied["tenantSlug"] = targetTenant.Slug
copied["tenantName"] = targetTenant.Name
changed = true
}
next = append(next, copied)
}
return next, changed
case []map[string]any:
next := make([]any, 0, len(appointments))
changed := false
for _, appointment := range appointments {
copied := maps.Clone(appointment)
if identityAppointmentMatchesTenant(copied, deletedTenant) {
copied["tenantId"] = targetTenant.ID
copied["tenantSlug"] = targetTenant.Slug
copied["tenantName"] = targetTenant.Name
changed = true
}
next = append(next, copied)
}
return next, changed
default:
return nil, false
}
}
func identityAppointmentMatchesTenant(appointment map[string]any, tenant domain.Tenant) bool {
return traitStringEqualTenant(appointment["tenantId"], tenant.ID, tenant.Slug) ||
traitStringEqualTenant(appointment["tenantSlug"], tenant.ID, tenant.Slug) ||
traitStringEqualTenant(appointment["tenantName"], tenant.Name, "")
}
func traitStringEqualTenant(value any, id string, slug string) bool {
raw := strings.TrimSpace(fmt.Sprint(value))
if raw == "" || raw == "<nil>" {
return false
}
if strings.EqualFold(raw, strings.TrimSpace(id)) {
return true
}
return strings.TrimSpace(slug) != "" && strings.EqualFold(raw, strings.TrimSpace(slug))
}
func cloneIdentityTraits(traits map[string]any) map[string]any {
if traits == nil {
return map[string]any{}
}
next := make(map[string]any, len(traits))
for key, value := range traits {
next[key] = cloneIdentityTraitValue(value)
}
return next
}
func cloneIdentityTraitValue(value any) any {
switch typed := value.(type) {
case map[string]any:
next := make(map[string]any, len(typed))
for key, nested := range typed {
next[key] = cloneIdentityTraitValue(nested)
}
return next
case []any:
next := make([]any, len(typed))
for i, nested := range typed {
next[i] = cloneIdentityTraitValue(nested)
}
return next
case []map[string]any:
next := make([]any, len(typed))
for i, nested := range typed {
next[i] = cloneIdentityTraitValue(nested)
}
return next
default:
return value
}
}
func (h *TenantHandler) storePromotedIdentityMirror(identity service.KratosIdentity) {
if h == nil || h.IdentityCache == nil || strings.TrimSpace(identity.ID) == "" {
return
}
raw, err := json.Marshal(identity)
if err != nil {
return
}
_ = h.IdentityCache.Set(identityMirrorKey(identity.ID), string(raw), 0)
_ = h.IdentityCache.Delete("identity:mirror:state")
}
func (h *TenantHandler) deletedTenantIDsReferencedByUsers(ctx context.Context, tenantIDs []string) ([]string, error) {
referenced := make(map[string]bool)
var userTenantIDs []string
if err := h.DB.WithContext(ctx).
Model(&domain.User{}).
Where("tenant_id IN ?", tenantIDs).
Distinct("tenant_id").
Pluck("tenant_id", &userTenantIDs).Error; err != nil {
return nil, err
}
for _, tenantID := range userTenantIDs {
if tenantID != "" {
referenced[tenantID] = true
}
}
var loginTenantIDs []string
if err := h.DB.WithContext(ctx).
Model(&domain.UserLoginID{}).
Where("tenant_id IN ?", tenantIDs).
Distinct("tenant_id").
Pluck("tenant_id", &loginTenantIDs).Error; err != nil {
return nil, err
}
for _, tenantID := range loginTenantIDs {
if tenantID != "" {
referenced[tenantID] = true
}
}
result := make([]string, 0, len(referenced))
for _, tenantID := range tenantIDs {
if referenced[tenantID] {
result = append(result, tenantID)
}
}
return result, nil
}
func resolveTenantDeletionPromotionTargets(tenants []domain.Tenant, deletedTenantIDs []string, affectedTenantIDs []string) (map[string]string, error) {
deleted := make(map[string]bool, len(deletedTenantIDs))
for _, tenantID := range deletedTenantIDs {
tenantID = strings.TrimSpace(tenantID)
if tenantID != "" {
deleted[tenantID] = true
}
}
tenantByID := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
if strings.TrimSpace(tenant.ID) != "" {
tenantByID[tenant.ID] = tenant
}
}
targets := make(map[string]string, len(affectedTenantIDs))
for _, affectedID := range uniqueNonEmptyStrings(affectedTenantIDs) {
tenant, ok := tenantByID[affectedID]
if !ok {
return nil, fmt.Errorf("tenant %s not found for membership reassignment", affectedID)
}
visited := map[string]bool{affectedID: true}
for {
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" || *tenant.ParentID == tenant.ID {
return nil, fmt.Errorf("tenant %s cannot be deleted while referenced by users because it has no remaining parent tenant", affectedID)
}
parentID := strings.TrimSpace(*tenant.ParentID)
if visited[parentID] {
return nil, fmt.Errorf("tenant %s cannot be reassigned because its parent chain has a cycle", affectedID)
}
visited[parentID] = true
parent, ok := tenantByID[parentID]
if !ok {
return nil, fmt.Errorf("tenant %s cannot be reassigned because parent tenant %s was not found", affectedID, parentID)
}
if !deleted[parentID] && !parent.DeletedAt.Valid {
targets[affectedID] = parent.ID
break
}
tenant = parent
}
}
return targets, nil
}
func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains {
@@ -2673,7 +3076,7 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
return build(rootID)
}
func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
counts := make(map[string]int64, len(tenants))
for _, tenant := range tenants {
counts[tenant.ID] = 0
@@ -2681,27 +3084,78 @@ func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, te
if len(tenants) == 0 {
return counts, counts, nil
}
if h.UserProjectionRepo == nil {
return nil, nil, errors.New("user projection is not configured")
if h.UserRepo == nil {
return counts, counts, nil
}
ready, err := h.UserProjectionRepo.IsReady(ctx)
if err != nil {
return nil, nil, fmt.Errorf("user projection status unavailable: %w", err)
tenantIDs := make([]string, 0, len(tenants))
for _, tenant := range tenants {
if strings.TrimSpace(tenant.ID) != "" {
tenantIDs = append(tenantIDs, tenant.ID)
}
}
if !ready {
return nil, nil, errors.New("user projection is not ready")
}
directCounts, err := h.UserProjectionRepo.CountTenantMembers(ctx, tenants)
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, tenantIDs)
if err != nil {
return nil, nil, err
}
totalCounts, err := h.UserProjectionRepo.CountTenantMembersRecursive(ctx, tenants)
if err != nil {
return nil, nil, err
totalCounts := make(map[string]int64, len(tenants))
allTenants := tenants
if h.Service != nil {
if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
allTenants = listed
}
}
childrenByParentID := make(map[string][]domain.Tenant)
for _, tenant := range allTenants {
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" {
continue
}
childrenByParentID[*tenant.ParentID] = append(childrenByParentID[*tenant.ParentID], tenant)
}
for _, tenant := range tenants {
descendantIDs := collectTenantSubtreeIDs(tenant.ID, childrenByParentID)
if len(descendantIDs) == 0 {
totalCounts[tenant.ID] = directCounts[tenant.ID]
continue
}
_, total, _, countErr := h.UserRepo.List(ctx, 0, 1, "", descendantIDs, "")
if countErr != nil {
return nil, nil, countErr
}
totalCounts[tenant.ID] = total
}
return directCounts, totalCounts, nil
}
func collectTenantSubtreeIDs(rootID string, childrenByParentID map[string][]domain.Tenant) []string {
rootID = strings.TrimSpace(rootID)
if rootID == "" {
return nil
}
ids := []string{rootID}
queue := []string{rootID}
seen := map[string]struct{}{rootID: {}}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
for _, child := range childrenByParentID[current] {
childID := strings.TrimSpace(child.ID)
if childID == "" {
continue
}
if _, ok := seen[childID]; ok {
continue
}
seen[childID] = struct{}{}
ids = append(ids, childID)
queue = append(queue, childID)
}
}
return ids
}
func normalizeTenantStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
@@ -2763,7 +3217,6 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
ttl := orgChartSnapshotCacheTTL()
role, userID, profileTenantID := orgChartProfileLogValues(profile)
slog.Info("orgchart snapshot request started",
"user_id", userID,
@@ -2778,9 +3231,8 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
var cached orgChartSnapshotResponse
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
cached.Cache = orgChartSnapshotCacheInfo{
Source: "redis",
Hit: true,
TTLSeconds: int(ttl.Seconds()),
Source: "redis",
Hit: true,
}
c.Set("X-Orgfront-Cache", "HIT")
slog.Info("orgchart snapshot cache hit",
@@ -2823,14 +3275,13 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
snapshot.Cache = orgChartSnapshotCacheInfo{
Source: "database",
Hit: false,
TTLSeconds: int(ttl.Seconds()),
Source: "database",
Hit: false,
}
if cacheMode == "redis" && h.OrgChartCache != nil {
if raw, err := json.Marshal(snapshot); err == nil {
if err := h.OrgChartCache.Set(cacheKey, string(raw), ttl); err != nil {
if err := h.OrgChartCache.Set(cacheKey, string(raw), orgChartSnapshotCacheExpiration()); err != nil {
slog.Warn("orgchart snapshot cache write failed",
"user_id", userID,
"role", role,
@@ -2858,13 +3309,68 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
return c.JSON(snapshot)
}
func (h *TenantHandler) WarmOrgChartSnapshotCache(ctx context.Context) error {
if h == nil || h.OrgChartCache == nil {
return nil
}
profile := &domain.UserProfileResponse{
ID: "orgfront-cache-warmup",
Role: domain.RoleSuperAdmin,
}
snapshot, err := h.buildOrgChartSnapshot(ctx, profile)
if err != nil {
return err
}
snapshot.Cache = orgChartSnapshotCacheInfo{
Source: "database",
Hit: false,
}
raw, err := json.Marshal(snapshot)
if err != nil {
return err
}
return h.OrgChartCache.Set(
orgChartSnapshotCacheKey(profile, ""),
string(raw),
orgChartSnapshotCacheExpiration(),
)
}
func (h *TenantHandler) refreshOrgChartSnapshotCacheAfterTenantChange(ctx context.Context, reason string) {
if h == nil || h.OrgChartCache == nil {
return
}
deleted, err := h.OrgChartCache.DeleteByPrefix(ctx, orgChartSnapshotCacheKeyPrefix)
if err != nil {
slog.Warn("Orgfront orgchart snapshot cache invalidation failed after tenant change",
"reason", reason,
"prefix", orgChartSnapshotCacheKeyPrefix,
"error", err,
)
return
}
if err := h.WarmOrgChartSnapshotCache(ctx); err != nil {
slog.Warn("Orgfront orgchart snapshot cache refresh failed after tenant change",
"reason", reason,
"error", err,
)
return
}
slog.Info("Orgfront orgchart snapshot cache refreshed after tenant change",
"reason", reason,
"invalidated_keys", deleted,
)
}
func (h *TenantHandler) buildOrgChartSnapshot(ctx context.Context, profile *domain.UserProfileResponse) (orgChartSnapshotResponse, error) {
tenants, err := h.listOrgChartTenantsForProfile(ctx, profile)
if err != nil {
return orgChartSnapshotResponse{}, err
}
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(ctx, tenants)
memberCounts, totalMemberCounts, err := h.countTenantMembers(ctx, tenants)
if err != nil {
return orgChartSnapshotResponse{}, err
}
@@ -3002,7 +3508,10 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
if profile != nil {
role = domain.NormalizeRole(profile.Role)
userID = strings.TrimSpace(profile.ID)
if tenantID == "" && profile.TenantID != nil {
if role == domain.RoleSuperAdmin {
userID = "all"
tenantID = "none"
} else if tenantID == "" && profile.TenantID != nil {
tenantID = strings.TrimSpace(*profile.TenantID)
}
}
@@ -3012,7 +3521,7 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
if tenantID == "" {
tenantID = "none"
}
return fmt.Sprintf("orgchart:snapshot:v1:%s:%s:%s", role, userID, tenantID)
return fmt.Sprintf("%s%s:%s:%s", orgChartSnapshotCacheKeyPrefix, role, userID, tenantID)
}
func orgChartProfileLogValues(profile *domain.UserProfileResponse) (string, string, string) {
@@ -3045,17 +3554,8 @@ func findTenantRootID(parentMap map[string]string, tenantID string) string {
}
}
func orgChartSnapshotCacheTTL() time.Duration {
const defaultTTL = 5 * time.Minute
raw := strings.TrimSpace(os.Getenv("ORGFRONT_ORGCHART_CACHE_TTL_SECONDS"))
if raw == "" {
return defaultTTL
}
seconds, err := strconv.Atoi(raw)
if err != nil || seconds <= 0 {
return defaultTTL
}
return time.Duration(seconds) * time.Second
func orgChartSnapshotCacheExpiration() time.Duration {
return 0
}
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {

View File

@@ -2,6 +2,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/testsupport"
"bytes"
"context"
@@ -15,6 +16,7 @@ import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/mock"
"github.com/testcontainers/testcontainers-go"
postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
@@ -56,8 +58,8 @@ func newTenantHandlerSeedDeleteDB(t *testing.T) *gorm.DB {
if err != nil {
t.Fatalf("failed to open postgres connection: %v", err)
}
if err := db.AutoMigrate(&domain.Tenant{}); err != nil {
t.Fatalf("failed to migrate tenants: %v", err)
if err := db.AutoMigrate(&domain.Tenant{}, &domain.User{}, &domain.UserLoginID{}); err != nil {
t.Fatalf("failed to migrate tenant delete models: %v", err)
}
return db
}
@@ -108,6 +110,137 @@ func TestTenantHandlerDeleteTenantRejectsSeedTenant(t *testing.T) {
}
}
func TestTenantHandlerDeleteTenantReassignsUserMembershipsToParentTenant(t *testing.T) {
db := newTenantHandlerSeedDeleteDB(t)
parent := domain.Tenant{
ID: "10000000-0000-0000-0000-000000000001",
Name: "Parent",
Slug: "delete-policy-parent",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
child := domain.Tenant{
ID: "10000000-0000-0000-0000-000000000002",
Name: "Collaboration",
Slug: "delete-policy-collaboration",
Type: domain.TenantTypeUserGroup,
ParentID: &parent.ID,
Status: domain.TenantStatusActive,
}
user := domain.User{
ID: "10000000-0000-0000-0000-000000000101",
Email: "delete-policy-user@example.com",
Name: "Delete Policy User",
Role: domain.RoleUser,
TenantID: &child.ID,
}
loginID := domain.UserLoginID{
ID: "10000000-0000-0000-0000-000000000201",
UserID: user.ID,
TenantID: child.ID,
FieldKey: "employee_number",
LoginID: "delete-policy-user",
}
if err := db.Create(&parent).Error; err != nil {
t.Fatalf("failed to create parent tenant: %v", err)
}
if err := db.Create(&child).Error; err != nil {
t.Fatalf("failed to create child tenant: %v", err)
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
if err := db.Create(&loginID).Error; err != nil {
t.Fatalf("failed to create login id: %v", err)
}
staleIdentity := service.KratosIdentity{
ID: user.ID,
State: "active",
Traits: map[string]any{
"email": user.Email,
"name": user.Name,
"tenant_id": child.ID,
"primaryTenantId": child.ID,
"primaryTenantSlug": child.Slug,
"primaryTenantName": child.Name,
"additionalAppointments": []any{
map[string]any{
"tenantId": child.ID,
"tenantSlug": child.Slug,
"tenantName": child.Name,
"isPrimary": true,
"grade": "G5",
},
},
},
}
updatedIdentity := staleIdentity
mockKratos := new(MockKratosAdmin)
mockKratos.On("GetIdentity", mock.Anything, user.ID).Return(&staleIdentity, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, user.ID, mock.MatchedBy(func(traits map[string]any) bool {
if traits["tenant_id"] != parent.ID || traits["primaryTenantId"] != parent.ID {
return false
}
if traits["primaryTenantSlug"] != parent.Slug || traits["primaryTenantName"] != parent.Name {
return false
}
appointments, ok := traits["additionalAppointments"].([]any)
if !ok || len(appointments) != 1 {
return false
}
appointment, ok := appointments[0].(map[string]any)
return ok &&
appointment["tenantId"] == parent.ID &&
appointment["tenantSlug"] == parent.Slug &&
appointment["tenantName"] == parent.Name &&
appointment["grade"] == "G5" &&
appointment["isPrimary"] == true
}), "active").Run(func(args mock.Arguments) {
updatedIdentity.Traits = args.Get(2).(map[string]any)
}).Return(&updatedIdentity, nil).Once()
redis := &mockRedisRepo{data: map[string]string{}}
staleRaw, _ := json.Marshal(staleIdentity)
if err := redis.Set(identityMirrorKey(user.ID), string(staleRaw), 0); err != nil {
t.Fatalf("failed to seed identity mirror: %v", err)
}
app := fiber.New()
app.Delete("/tenants/:id", (&TenantHandler{DB: db, KratosAdmin: mockKratos, IdentityCache: redis}).DeleteTenant)
req := httptest.NewRequest(http.MethodDelete, "/tenants/"+child.ID, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
}
var foundUser domain.User
if err := db.First(&foundUser, "id = ?", user.ID).Error; err != nil {
t.Fatalf("failed to reload user: %v", err)
}
if foundUser.TenantID == nil || *foundUser.TenantID != parent.ID {
t.Fatalf("user tenant_id = %v, want %s", foundUser.TenantID, parent.ID)
}
var foundLogin domain.UserLoginID
if err := db.First(&foundLogin, "id = ?", loginID.ID).Error; err != nil {
t.Fatalf("failed to reload login id: %v", err)
}
if foundLogin.TenantID != parent.ID {
t.Fatalf("login tenant_id = %s, want %s", foundLogin.TenantID, parent.ID)
}
mockKratos.AssertExpectations(t)
var mirrored service.KratosIdentity
if err := json.Unmarshal([]byte(redis.data[identityMirrorKey(user.ID)]), &mirrored); err != nil {
t.Fatalf("failed to decode mirrored identity: %v", err)
}
if mirrored.Traits["tenant_id"] != parent.ID || mirrored.Traits["primaryTenantSlug"] != parent.Slug {
t.Fatalf("mirrored traits = %#v, want promoted tenant %s/%s", mirrored.Traits, parent.ID, parent.Slug)
}
}
func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
setSeedTenantCSVForDeleteGuard(t, "protected-root")
db := newTenantHandlerSeedDeleteDB(t)
@@ -157,3 +290,78 @@ func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
t.Fatalf("remaining tenant count = %d, want 2", count)
}
}
func TestTenantHandlerDeleteTenantsBulkReassignsUsersToNearestRemainingAncestor(t *testing.T) {
db := newTenantHandlerSeedDeleteDB(t)
root := domain.Tenant{
ID: "10000000-0000-0000-0000-000000000011",
Name: "Root",
Slug: "delete-policy-root",
Type: domain.TenantTypeCompanyGroup,
Status: domain.TenantStatusActive,
}
parent := domain.Tenant{
ID: "10000000-0000-0000-0000-000000000012",
Name: "Parent",
Slug: "delete-policy-bulk-parent",
Type: domain.TenantTypeCompany,
ParentID: &root.ID,
Status: domain.TenantStatusActive,
}
child := domain.Tenant{
ID: "10000000-0000-0000-0000-000000000013",
Name: "Collaboration",
Slug: "delete-policy-bulk-collaboration",
Type: domain.TenantTypeUserGroup,
ParentID: &parent.ID,
Status: domain.TenantStatusActive,
}
user := domain.User{
ID: "10000000-0000-0000-0000-000000000111",
Email: "bulk-delete-policy-user@example.com",
Name: "Bulk Delete Policy User",
Role: domain.RoleUser,
TenantID: &child.ID,
}
if err := db.Create(&root).Error; err != nil {
t.Fatalf("failed to create root tenant: %v", err)
}
if err := db.Create(&parent).Error; err != nil {
t.Fatalf("failed to create parent tenant: %v", err)
}
if err := db.Create(&child).Error; err != nil {
t.Fatalf("failed to create child tenant: %v", err)
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
mockSvc := new(MockTenantService)
mockSvc.On("DeleteTenantsBulk", mock.Anything, []string{parent.ID, child.ID}).Return(nil).Once()
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Delete("/tenants/bulk", (&TenantHandler{DB: db, Service: mockSvc}).DeleteTenantsBulk)
body, _ := json.Marshal(map[string][]string{"ids": {parent.ID, child.ID}})
req := httptest.NewRequest(http.MethodDelete, "/tenants/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
var foundUser domain.User
if err := db.First(&foundUser, "id = ?", user.ID).Error; err != nil {
t.Fatalf("failed to reload user: %v", err)
}
if foundUser.TenantID == nil || *foundUser.TenantID != root.ID {
t.Fatalf("user tenant_id = %v, want %s", foundUser.TenantID, root.ID)
}
mockSvc.AssertExpectations(t)
}

View File

@@ -113,7 +113,16 @@ func (m *MockUserRepoForHandler) DB() *gorm.DB {
}
func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error {
for _, call := range m.ExpectedCalls {
if call.Method == "Update" {
args := m.Called(ctx, user)
return args.Error(0)
}
}
return nil
}
func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error {
m.deletedIDs = append(m.deletedIDs, id)
return nil
@@ -155,7 +164,20 @@ func (m *MockUserRepoForHandler) FindByTenantIDs(ctx context.Context, tenantIDs
}
func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
for _, call := range m.ExpectedCalls {
if call.Method == "CountByTenantIDs" {
args := m.Called(ctx, tenantIDs)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]int64), args.Error(1)
}
}
counts := make(map[string]int64, len(tenantIDs))
for _, tenantID := range tenantIDs {
counts[tenantID] = 0
}
return counts, nil
}
func (m *MockUserRepoForHandler) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
@@ -187,10 +209,6 @@ func (m *MockUserRepoForHandler) FindTenantIDByLoginID(ctx context.Context, logi
return "", nil
}
type MockUserProjectionRepoForHandler struct {
mock.Mock
}
type mockOrgChartCache struct {
mock.Mock
values map[string]string
@@ -210,40 +228,9 @@ func (m *mockOrgChartCache) Set(key string, value string, expiration time.Durati
return args.Error(0)
}
func (m *MockUserProjectionRepoForHandler) IsReady(ctx context.Context) (bool, error) {
args := m.Called(ctx)
return args.Bool(0), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
args := m.Called(ctx)
return args.Get(0).(domain.UserProjectionStatus), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
args := m.Called(ctx, tenants)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]int64), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
args := m.Called(ctx, tenants)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]int64), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
args := m.Called(ctx, users)
return args.Error(0)
}
func (m *MockUserProjectionRepoForHandler) MarkFailed(ctx context.Context, syncErr error) error {
args := m.Called(ctx, syncErr)
return args.Error(0)
func (m *mockOrgChartCache) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
args := m.Called(prefix)
return args.Get(0).(int64), args.Error(1)
}
func toJSONString(t *testing.T, value any) string {
@@ -281,14 +268,14 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
assert.Equal(t, "t1", got["id"])
}
func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *testing.T) {
func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
app.Use(func(c *fiber.Ctx) error {
@@ -303,11 +290,11 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 7}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
Return([]domain.User{}, int64(7), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -320,7 +307,7 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
require.Len(t, res.Items, 1)
assert.Equal(t, int64(2), res.Items[0].MemberCount)
assert.Equal(t, int64(7), res.Items[0].TotalMemberCount)
mockProjection.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
@@ -353,7 +340,6 @@ func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 9, 0, 0, 0, 0, time.UTC)
@@ -370,15 +356,15 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
cache.On("Get", mock.Anything).Return("", redis.Nil).Once()
cache.On("Set", mock.MatchedBy(func(key string) bool {
return strings.HasPrefix(key, "orgchart:snapshot:")
}), mock.Anything, mock.AnythingOfType("time.Duration")).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
}), mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Twice()
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1]}, nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{familyID: 1, samanID: 1}, nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID}).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return(users, int64(1), "", nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection, OrgChartCache: cache}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
@@ -401,14 +387,103 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
require.Equal(t, int64(1), body.Tenants[0].TotalMemberCount)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockProjection.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestOrgChartSnapshotCacheKeySharesSuperAdminGlobalSnapshot(t *testing.T) {
first := orgChartSnapshotCacheKey(&domain.UserProfileResponse{
ID: "super-admin-1",
Role: domain.RoleSuperAdmin,
}, "")
second := orgChartSnapshotCacheKey(&domain.UserProfileResponse{
ID: "super-admin-2",
Role: domain.RoleSuperAdmin,
}, "")
require.Equal(t, first, second)
require.Equal(t, "orgchart:snapshot:v1:super_admin:all:none", first)
}
func TestResolveTenantDeletionPromotionTargetsUsesNearestRemainingAncestor(t *testing.T) {
rootID := "root"
parentID := "parent"
childID := "child"
tenants := []domain.Tenant{
{ID: rootID, Slug: "root"},
{ID: parentID, Slug: "parent", ParentID: &rootID},
{ID: childID, Slug: "child", ParentID: &parentID},
}
targets, err := resolveTenantDeletionPromotionTargets(tenants, []string{parentID, childID}, []string{childID})
require.NoError(t, err)
require.Equal(t, map[string]string{
childID: rootID,
}, targets)
}
func TestTenantHandler_WarmOrgChartSnapshotCacheStoresSuperAdminGlobalSnapshot(t *testing.T) {
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
familyID := "family"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
require.NoError(t, h.WarmOrgChartSnapshotCache(context.Background()))
raw := cache.values["orgchart:snapshot:v1:super_admin:all:none"]
require.NotEmpty(t, raw)
var cached orgChartSnapshotResponse
require.NoError(t, json.Unmarshal([]byte(raw), &cached))
require.Len(t, cached.Tenants, 1)
require.Equal(t, "database", cached.Cache.Source)
require.False(t, cached.Cache.Hit)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_RefreshOrgChartSnapshotCacheAfterTenantChangeInvalidatesAllSnapshotKeys(t *testing.T) {
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
familyID := "family"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
cache.On("DeleteByPrefix", "orgchart:snapshot:v1:").Return(int64(3), nil).Once()
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
h.refreshOrgChartSnapshotCacheAfterTenantChange(context.Background(), "tenant_created")
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
parent := func(id string) *string { return &id }
@@ -424,7 +499,7 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
{ID: "user-1", Email: "user@samaneng.com", Name: "Saman User", Role: domain.RoleUser, Status: domain.UserStatusActive, TenantID: &samanID, Tenant: &tenants[1], CreatedAt: now, UpdatedAt: now},
}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
@@ -438,14 +513,11 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
})
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform")
})).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform")
})).Return(map[string]int64{familyID: 1, samanID: 1, teamID: 0}, nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID, teamID}).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{teamID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{familyID, samanID, teamID}, "").Return(users, int64(1), "", nil).Once()
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1], tenants[2]}, nil).Once()
@@ -463,18 +535,17 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
require.True(t, tenantSummarySlugsMatch(body.Tenants, "hanmac-family", "saman", "saman-platform"))
require.Len(t, body.Users, 1)
mockSvc.AssertExpectations(t)
mockProjection.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
app.Use(func(c *fiber.Ctx) error {
@@ -492,11 +563,13 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
{ID: childID, Name: "Child", Slug: "child", ParentID: &parentID},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(2), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{parentID, childID}).
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
Return(map[string]int64{parentID: 3, childID: 2}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{parentID, childID}, "").
Return([]domain.User{}, int64(3), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{childID}, "").
Return([]domain.User{}, int64(2), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -510,49 +583,17 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
assert.Equal(t, int64(3), res.Items[0].TotalMemberCount)
assert.Equal(t, int64(2), res.Items[1].MemberCount)
assert.Equal(t, int64(2), res.Items[1].TotalMemberCount)
mockProjection.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
mockProjection.AssertNotCalled(t, "CountTenantMembers", mock.Anything, mock.Anything)
mockProjection.AssertNotCalled(t, "CountTenantMembersRecursive", mock.Anything, mock.Anything)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_ListTenants(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
app.Use(func(c *fiber.Ctx) error {
@@ -569,11 +610,10 @@ func TestTenantHandler_ListTenants(t *testing.T) {
// Mocking for the new allTenants check in ListTenants
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"t1", "t2"}).
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t1"}, "").Return([]domain.User{}, int64(5), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t2"}, "").Return([]domain.User{}, int64(10), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -599,11 +639,11 @@ func TestTenantHandler_ListTenants(t *testing.T) {
func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
app.Use(func(c *fiber.Ctx) error {
@@ -621,9 +661,10 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.
}
mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(3), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000001"}).Return(map[string]int64{}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000002"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
resp, _ := app.Test(req)
@@ -662,11 +703,11 @@ func TestPageTenantsByCursorUsesStableCreatedAtAndIDOrder(t *testing.T) {
func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
parent := func(id string) *string { return &id }
@@ -688,14 +729,12 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
})).Return(map[string]int64{}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
})).Return(map[string]int64{}, nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"family", "company", "public-team"}).Return(map[string]int64{}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"family", "company", "public-team", "private-team", "private-child"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"company", "public-team", "private-team", "private-child"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"public-team"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
resp, err := app.Test(req)
@@ -712,11 +751,11 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
parent := func(id string) *string { return &id }
@@ -740,14 +779,10 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
})).Return(map[string]int64{}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
})).Return(map[string]int64{}, nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"family", "company", "private-team", "private-child"}).Return(map[string]int64{}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", mock.Anything, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
resp, err := app.Test(req)
@@ -761,6 +796,41 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
require.Contains(t, toJSONString(t, res), "private-child")
}
func TestTenantHandler_ListTenantsRejectsStaleProfileTenantScope(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
}
staleTenantID := "deleted-tenant"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleUser,
TenantID: &staleTenantID,
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusConflict, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Contains(t, body["error"], "tenant scope is not available")
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *testing.T) {
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
@@ -785,6 +855,41 @@ func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *te
mockKeto.AssertExpectations(t)
}
func TestTenantHandler_FilterPrivateTenantsTreatsMissingKetoRelationsAsDenied(t *testing.T) {
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "company", Type: domain.TenantTypeCompany, Name: "한맥", Slug: "hanmac"},
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
{ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"},
}
mockKeto := new(devMockKetoService)
h := &TenantHandler{Keto: mockKeto}
relationErr := errors.New(`keto returned status 400: {"reason":"relation \"view_private\" does not exist"}`)
for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} {
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "private-team", relation).Return(false, relationErr).Once()
}
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "company", "view_private_descendants").Return(false, relationErr).Once()
filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{
ID: "user-1",
Role: "tenant_admin",
TenantID: parent("company"),
})
require.NoError(t, err)
require.ElementsMatch(t, []string{"hanmac", "public-team"}, tenantSlugs(filtered))
mockKeto.AssertExpectations(t)
}
func tenantSlugs(tenants []domain.Tenant) []string {
slugs := make([]string, 0, len(tenants))
for _, tenant := range tenants {
slugs = append(slugs, tenant.Slug)
}
return slugs
}
func tenantSlugsMatch(got []domain.Tenant, want ...string) bool {
if len(got) != len(want) {
return false
@@ -1127,47 +1232,14 @@ func TestTenantHandler_GetOrgContextJSONRequiresApiKey(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusFails(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
mockProjection.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T) {
func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUserRepo := new(MockUserRepoForHandler)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserRepo: mockUserRepo,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUserRepo,
}
app.Use(func(c *fiber.Ctx) error {
@@ -1183,16 +1255,11 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"saman"}).
Return(map[string]int64{"saman": 152}, nil).Maybe()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Maybe()
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Once()
mockUserRepo.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
Return([]domain.User{}, int64(152), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -1203,8 +1270,8 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
json.NewDecoder(resp.Body).Decode(&res)
assert.Len(t, res.Items, 1)
assert.Equal(t, int64(2), res.Items[0].MemberCount)
mockProjection.AssertExpectations(t)
assert.Equal(t, int64(152), res.Items[0].MemberCount)
mockUserRepo.AssertExpectations(t)
}
func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
@@ -1718,6 +1785,46 @@ func TestTenantHandler_ApproveTenant(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestTenantHandler_ApproveTenantRefreshesOrgChartSnapshotCache(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
familyID := "family"
tenantID := "tenant-1"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
{ID: tenantID, Type: domain.TenantTypeOrganization, Name: "조직", Slug: "team", ParentID: &familyID, Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
mockSvc.On("ApproveTenant", mock.Anything, tenantID).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, tenantID}).Return(map[string]int64{familyID: 0, tenantID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
cache.On("DeleteByPrefix", "orgchart:snapshot:v1:").Return(int64(1), nil).Once()
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
h := &TenantHandler{
Service: mockSvc,
UserRepo: mockUsers,
OrgChartCache: cache,
}
app.Post("/tenants/:id/approve", h.ApproveTenant)
req := httptest.NewRequest("POST", "/tenants/"+tenantID+"/approve", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
args := m.Called(ctx, tenantIDs)
return args.Error(0)

View File

@@ -34,17 +34,16 @@ type OryProviderAPI interface {
}
type UserHandler struct {
KratosAdmin service.KratosAdminService
OryProvider OryProviderAPI
TenantService service.TenantService
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
UserGroupRepo repository.UserGroupRepository
AuditRepo domain.AuditRepository
IdentityCache domain.RedisRepository
Worksmobile service.WorksmobileSyncer
KratosAdmin service.KratosAdminService
OryProvider OryProviderAPI
TenantService service.TenantService
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
UserGroupRepo repository.UserGroupRepository
AuditRepo domain.AuditRepository
IdentityCache domain.RedisRepository
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 {
@@ -111,6 +110,16 @@ func userAppointmentSliceFromRaw(raw any) []any {
appointments = append(appointments, value)
}
return appointments
case []map[string]string:
appointments := make([]any, 0, len(values))
for _, value := range values {
appointment := make(map[string]any, len(value))
for key, item := range value {
appointment[key] = item
}
appointments = append(appointments, appointment)
}
return appointments
default:
return nil
}
@@ -323,7 +332,7 @@ func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService servic
delete(metadata, "primaryTenantIsOwner")
cleared = true
}
if isNonPublicRepresentativeTenant(ctx, tenantService, normalizeMetadataString(metadata["primaryTenantId"]), normalizeMetadataString(metadata["primaryTenantSlug"])) {
if isBlockedRepresentativeTenant(ctx, tenantService, normalizeMetadataString(metadata["primaryTenantId"]), normalizeMetadataString(metadata["primaryTenantSlug"])) {
clearMetadataPrimary()
}
@@ -336,7 +345,7 @@ func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService servic
if tenantSlug == "" {
tenantSlug = normalizeMetadataString(appointment["slug"])
}
if !isNonPublicRepresentativeTenant(ctx, tenantService, tenantID, tenantSlug) {
if !isBlockedRepresentativeTenant(ctx, tenantService, tenantID, tenantSlug) {
return
}
appointment["isPrimary"] = false
@@ -359,7 +368,7 @@ func sanitizeUserRepresentativeTenants(ctx context.Context, tenantService servic
return cleared, nil
}
func isNonPublicRepresentativeTenant(ctx context.Context, tenantService service.TenantService, tenantID string, tenantSlug string) bool {
func isBlockedRepresentativeTenant(ctx context.Context, tenantService service.TenantService, tenantID string, tenantSlug string) bool {
var tenant *domain.Tenant
var err error
if strings.TrimSpace(tenantID) != "" {
@@ -371,7 +380,7 @@ func isNonPublicRepresentativeTenant(ctx context.Context, tenantService service.
return false
}
visibility := tenantVisibility(tenant.Config)
return visibility == "internal" || visibility == "private"
return visibility == "private"
}
func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
@@ -544,9 +553,34 @@ func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string)
}
func identityTenantAccessKeys(traits map[string]any) []string {
keys := make([]string, 0, 2)
if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" {
keys = append(keys, tenantID)
keys := make([]string, 0, 4)
seen := make(map[string]bool)
appendKey := func(value string) {
key := strings.ToLower(strings.TrimSpace(value))
if key == "" || seen[key] {
return
}
seen[key] = true
keys = append(keys, key)
}
appendKey(extractTraitString(traits, "tenant_id"))
appendKey(extractTraitString(traits, "tenantSlug"))
appointments := userAppointmentSliceFromRaw(traits["additionalAppointments"])
if len(appointments) == 0 {
if metadata, ok := traits["metadata"].(map[string]any); ok {
appointments = userAppointmentSliceFromRaw(metadata["additionalAppointments"])
}
}
for _, raw := range appointments {
appointment, ok := raw.(map[string]any)
if !ok {
continue
}
appendKey(normalizeMetadataString(appointment["tenantId"]))
appendKey(normalizeMetadataString(appointment["tenantSlug"]))
appendKey(normalizeMetadataString(appointment["slug"]))
}
return keys
}
@@ -673,6 +707,7 @@ func kratosIdentityCursorKey(identity service.KratosIdentity) (time.Time, string
}
func identityMatchesSearch(identity service.KratosIdentity, searchLower string) bool {
searchLower = strings.TrimSpace(searchLower)
if searchLower == "" {
return true
}
@@ -685,6 +720,9 @@ func identityMatchesSearch(identity service.KratosIdentity, searchLower string)
if strings.Contains(strings.ToLower(extractTraitString(identity.Traits, "name")), searchLower) {
return true
}
if identityEmailLocalPartMatchesSearch(identity, searchLower) {
return true
}
rawTraits, err := json.Marshal(identity.Traits)
if err != nil {
return false
@@ -692,6 +730,21 @@ func identityMatchesSearch(identity service.KratosIdentity, searchLower string)
return strings.Contains(strings.ToLower(string(rawTraits)), searchLower)
}
func identityEmailLocalPartMatchesSearch(identity service.KratosIdentity, searchLower string) bool {
if !strings.Contains(searchLower, "@") {
return false
}
searchLocalPart, err := domain.ExtractNormalizedEmailLocalPart(searchLower)
if err != nil || searchLocalPart == "" {
return false
}
identityLocalPart, err := domain.ExtractNormalizedEmailLocalPart(extractTraitString(identity.Traits, "email"))
if err != nil || identityLocalPart == "" {
return false
}
return searchLocalPart == identityLocalPart
}
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
// [New] Get requester profile from middleware
var requesterRole string
@@ -813,11 +866,11 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
searchLower := strings.ToLower(search)
for _, identity := range identities {
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
tenantAccessKeys := identityTenantAccessKeys(identity.Traits)
// Tenant Admin & Member filtering
if requesterRole != domain.RoleSuperAdmin {
hasAccess := manageableSlugs[tID]
hasAccess := anyTenantKeyAllowed(tenantAccessKeys, manageableSlugs)
if !hasAccess {
continue
}
@@ -825,7 +878,11 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
// Dedicated tenantSlug filter
if tenantSlug != "" {
matches := tID == targetTenantID
targetKeys := map[string]bool{
targetTenantID: true,
strings.ToLower(tenantSlug): true,
}
matches := anyTenantKeyAllowed(tenantAccessKeys, targetKeys)
if !matches {
continue
}
@@ -1195,6 +1252,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
// [Resolve TenantID and Custom Login IDs before Kratos creation]
var tenantID string
var resolvedTenant *domain.Tenant
primaryAppointments := req.AdditionalAppointments
if representativeCleared {
primaryAppointments = nil
@@ -1205,18 +1263,26 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if tenant, err := h.TenantService.GetTenant(c.Context(), requestedPrimaryTenantID); err == nil && tenant != nil {
tenantID = tenant.ID
req.CompanyCode = tenant.Slug
resolvedTenant = tenant
}
}
}
if req.CompanyCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
tenantID = tenant.ID
resolvedTenant = tenant
}
}
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), email, tenantID, req.CompanyCode, resolvedTenant); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
if tenantID == "" {
if req.CompanyCode != "" || requestedPrimaryTenantID != "" {
return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
}
if emailUsesInternalPersonalRestrictedDomain(email) {
return errorJSON(c, fiber.StatusBadRequest, internalDomainPersonalPolicyMessage(email))
}
tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, email)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant")
@@ -1304,21 +1370,13 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
// Sync to local DB (Synchronous for immediate consistency)
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)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, 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 {
loginIDRecords[i].UserID = localUser.ID
}
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
slog.Error("[UserHandler] Failed to update user login IDs", "userID", localUser.ID, "error", err)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
}
// [Keto] Sync relations via Outbox (Synchronous for accurate counting)
@@ -1405,7 +1463,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
results := make([]bulkUserResult, 0, len(req.Users))
var hanmacScope *hanmacEmailScope
var hanmacLocalParts map[string]bool
var hanmacLocalParts map[string]hanmacLocalPartOwner
hanmacScopeLoaded := false
bulkEmailErrors := validateBulkUserEmailUniqueness(req.Users)
@@ -1414,6 +1472,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
ID string
Slug string
Name string
Type string
ParentID *string
Schema []any
Groups []domain.UserGroup
@@ -1428,6 +1487,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
ID: tenant.ID,
Slug: tenant.Slug,
Name: tenant.Name,
Type: tenant.Type,
ParentID: tenant.ParentID,
}
if s, ok := tenant.Config["userSchema"].([]any); ok {
@@ -1558,6 +1618,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
continue
}
tenantSlug = tItem.Slug
if isPersonalTenantForInternalDomainPolicy(&domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}) && emailUsesInternalPersonalRestrictedDomain(email) {
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
continue
}
} else if tenantSlug != "" {
tItem, err = resolveTenantBySlug(tenantSlug)
if err != nil {
@@ -1565,6 +1629,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
continue
}
tenantSlug = tItem.Slug
if isPersonalTenantForInternalDomainPolicy(&domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}) && emailUsesInternalPersonalRestrictedDomain(email) {
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
continue
}
} else {
for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, email) {
if domainTenant, ok := resolveTenantByDomain(domainName); ok {
@@ -1574,6 +1642,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
}
if tenantSlug == "" {
if emailUsesInternalPersonalRestrictedDomain(email) {
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
continue
}
tItem, err = createPersonalTenantItem(email)
if err != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to create personal tenant"})
@@ -1582,6 +1654,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
tenantSlug = tItem.Slug
}
}
if isPersonalTenantForInternalDomainPolicy(&domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}) && emailUsesInternalPersonalRestrictedDomain(email) {
results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: internalDomainPersonalPolicyMessage(email)})
continue
}
// Role-based access check
if requester != nil && requester.Role != domain.RoleSuperAdmin {
@@ -1678,7 +1754,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
userEmail = emailEvaluation.Email
if emailEvaluation.LocalPart != "" {
hanmacLocalParts[emailEvaluation.LocalPart] = true
hanmacLocalParts[emailEvaluation.LocalPart] = hanmacLocalPartOwner{
Email: userEmail,
Name: item.Name,
}
}
} else {
if _, _, err := domain.SplitEmailDomain(email); err != nil {
@@ -1878,14 +1957,7 @@ 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)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, 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 {
loginIDRecords[i].UserID = localUser.ID
@@ -1903,7 +1975,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
results = append(results, bulkUserResult{
Email: userEmail,
OriginalEmail: emailEvaluation.OriginalEmail,
@@ -2165,6 +2236,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
// Pre-fetch tenant cache if tenantSlug is being changed.
type tenantCacheItem struct {
ID string
Slug string
Type string
Schema []any
}
tenantCache := make(map[string]tenantCacheItem)
@@ -2223,6 +2296,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if req.CompanyCode != nil {
delete(traits, "companyCode")
delete(traits, "companyCodes")
var targetTenant *domain.Tenant
if req.IsAddTenant {
if h.TenantService == nil {
@@ -2234,6 +2308,10 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
results = append(results, map[string]any{"id": id, "success": false, "message": "invalid tenant assignment"})
continue
}
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), tenant.ID, tenant.Slug, tenant); err != nil {
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
continue
}
metadata := mergeUserAddTenantAppointment(traits, nil, tenant)
if appointments, ok := metadata["additionalAppointments"]; ok {
traits["additionalAppointments"] = appointments
@@ -2249,12 +2327,22 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
}
} else if tItem, exists := tenantCache[*req.CompanyCode]; exists {
traits["tenant_id"] = tItem.ID
targetTenant = &domain.Tenant{ID: tItem.ID, Slug: tItem.Slug, Type: tItem.Type}
} else if h.TenantService != nil {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
if err == nil && tenant != nil {
tItem.ID = tenant.ID
tItem.Slug = tenant.Slug
tItem.Type = tenant.Type
tenantCache[*req.CompanyCode] = tItem
traits["tenant_id"] = tenant.ID
targetTenant = tenant
}
}
if !req.IsAddTenant {
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), extractTraitString(traits, "tenant_id"), *req.CompanyCode, targetTenant); err != nil {
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
continue
}
}
}
@@ -2306,8 +2394,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
localUser.JobTitle = *req.JobTitle
}
// Resolve TenantID if changing tenantSlug.
if req.CompanyCode != nil && h.TenantService != nil {
// Resolve TenantID only for representative tenant changes.
if req.CompanyCode != nil && !req.IsAddTenant && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
localUser.TenantID = &tenant.ID
}
@@ -2315,7 +2403,7 @@ 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 {
if err := h.Worksmobile.EnqueueUserUpdateIfInScope(c.Context(), *localUser); err != nil {
slog.Warn("Failed to enqueue Worksmobile bulk user update sync", "userID", localUser.ID, "error", err)
}
}
@@ -2468,6 +2556,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
Status *string `json:"status"`
TenantSlug *string `json:"tenantSlug"`
CompanyCode *string `json:"companyCode"`
IsPrimaryTenant bool `json:"isPrimaryTenant"`
IsAddTenant bool `json:"isAddTenant"`
IsRemoveTenant bool `json:"isRemoveTenant"`
Department *string `json:"department"`
@@ -2498,6 +2587,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
req.PrimaryTenantID = ""
req.PrimaryTenantName = ""
req.PrimaryTenantIsOwner = nil
req.IsPrimaryTenant = false
req.CompanyCode = nil
}
}
@@ -2592,6 +2682,17 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user email")
}
targetTenantID := extractTraitString(traits, "tenant_id")
if targetTenantID == "" {
targetTenantID = oldTenantID
}
targetTenantSlug := ""
if req.CompanyCode != nil && !req.IsRemoveTenant {
targetTenantSlug = strings.TrimSpace(*req.CompanyCode)
}
if err := h.ensureHanmacEmailAllowed(c.Context(), nextEmail, targetTenantSlug, targetTenantID, userID); err != nil {
return errorJSON(c, fiber.StatusConflict, err.Error())
}
if h.UserRepo != nil {
if existing, err := h.UserRepo.FindByEmail(c.Context(), nextEmail); err == nil && existing != nil && existing.ID != userID {
return errorJSON(c, fiber.StatusConflict, "email is already used by another user")
@@ -2619,6 +2720,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
if req.CompanyCode != nil {
code := strings.TrimSpace(*req.CompanyCode)
var resolvedTenant *domain.Tenant
representativeTenantRequested := req.IsPrimaryTenant || strings.TrimSpace(req.PrimaryTenantID) != ""
if req.IsRemoveTenant {
if h.TenantService != nil && code != "" {
@@ -2639,10 +2742,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
}
}
} else if !req.IsAddTenant {
} else if !req.IsAddTenant && representativeTenantRequested {
if h.TenantService != nil && code != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
traits["tenant_id"] = tenant.ID
resolvedTenant = tenant
} else {
traits["tenant_id"] = ""
}
@@ -2652,6 +2756,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if err != nil || tenant == nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment")
}
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), tenant.ID, tenant.Slug, tenant); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
req.Metadata = mergeUserAddTenantAppointment(traits, req.Metadata, tenant)
if h.KetoOutboxRepo != nil {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
@@ -2663,6 +2770,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
})
}
}
if !req.IsRemoveTenant && !req.IsAddTenant && representativeTenantRequested {
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), extractTraitString(traits, "tenant_id"), code, resolvedTenant); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
}
}
delete(traits, "companyCode")
delete(traits, "companyCodes")
@@ -2719,6 +2831,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["secondary_emails"] = subEmails
traits["worksmobileAliasEmails"] = subEmails
}
if err := h.ensureInternalDomainNotAssignedToPersonal(c.Context(), extractTraitString(traits, "email"), extractTraitString(traits, "tenant_id"), "", nil); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
// [LoginID Sync based on Tenant Settings]
// Perform sync AFTER metadata merge to ensure traits contains current values
@@ -2773,10 +2888,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
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)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(ctx, *updatedLocalUser); err != nil {
if err := h.Worksmobile.EnqueueUserUpdateIfInScope(ctx, *updatedLocalUser); err != nil {
slog.Warn("[UserHandler] Failed to enqueue Worksmobile updated user sync", "userID", updatedLocalUser.ID, "error", err)
}
}
@@ -2784,7 +2898,6 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
// Update User Login IDs in local DB
if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil {
slog.Error("[UserHandler] Failed to update user login IDs", "userID", updatedLocalUser.ID, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
// [Keto Sync] asynchronously as it's less critical for immediate UI count
@@ -2963,7 +3076,6 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
if h.UserRepo != nil {
if err := h.UserRepo.Delete(context.Background(), userID); err != nil {
slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err)
markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err)
} else {
slog.Info("[UserHandler] Successfully deleted local user read-model", "userID", userID)
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
@@ -171,6 +172,7 @@ func (m *userHandlerMockKetoOutboxRepository) MarkProcessed(ctx context.Context,
type fakeUserHandlerWorksmobileSyncer struct {
upserts []domain.User
updates []domain.User
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
@@ -186,6 +188,11 @@ func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx contex
return nil
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error {
f.updates = append(f.updates, user)
return nil
}
func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
return nil
}
@@ -206,6 +213,18 @@ func TestSanitizeUserMetadataRemovesLegacyClassificationFlags(t *testing.T) {
assert.Contains(t, metadata, "userType")
}
func TestIdentityMatchesSearchFindsSameEmailLocalPartAcrossHanmacFamilyDomains(t *testing.T) {
identity := service.KratosIdentity{
ID: "user-han",
Traits: map[string]any{
"email": "han@samaneng.com",
"name": "안헌",
},
}
require.True(t, identityMatchesSearch(identity, "han@hanmaceng.co.kr"))
}
func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
mockTenant := new(MockTenantServiceForUser)
internalTenantID := "internal-tenant"
@@ -249,6 +268,44 @@ func TestSanitizeUserRepresentativeTenantsClearsNonPublicPrimary(t *testing.T) {
mockTenant.AssertExpectations(t)
}
func TestSanitizeUserRepresentativeTenantsAllowsPublicTeamOrgPrimary(t *testing.T) {
mockTenant := new(MockTenantServiceForUser)
teamTenantID := "team-tenant"
metadata := map[string]any{
"primaryTenantId": teamTenantID,
"primaryTenantName": "IS3",
"primaryTenantSlug": "is-3",
"additionalAppointments": []any{
map[string]any{"tenantId": teamTenantID, "tenantSlug": "is-3", "isPrimary": true},
},
}
appointments := []map[string]any{
{"tenantId": teamTenantID, "tenantSlug": "is-3", "isPrimary": true},
}
mockTenant.On("GetTenant", mock.Anything, teamTenantID).Return(&domain.Tenant{
ID: teamTenantID,
Slug: "is-3",
Config: domain.JSONMap{
"visibility": "public",
"orgUnitType": "팀",
},
}, nil)
cleared, err := sanitizeUserRepresentativeTenants(context.Background(), mockTenant, metadata, appointments)
require.NoError(t, err)
assert.False(t, cleared)
assert.Equal(t, teamTenantID, metadata["primaryTenantId"])
assert.Equal(t, "IS3", metadata["primaryTenantName"])
assert.Equal(t, "is-3", metadata["primaryTenantSlug"])
assert.Equal(t, true, appointments[0]["isPrimary"])
metadataAppointments := metadata["additionalAppointments"].([]any)
firstAppointment := metadataAppointments[0].(map[string]any)
assert.Equal(t, true, firstAppointment["isPrimary"])
mockTenant.AssertExpectations(t)
}
type MockTenantServiceForUser struct {
mock.Mock
service.TenantService
@@ -292,11 +349,16 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us
}
func (m *MockTenantServiceForUser) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
args := m.Called(ctx, limit, offset, parentID, search)
if args.Get(0) == nil {
return nil, args.Get(1).(int64), args.Error(2)
for _, call := range m.ExpectedCalls {
if call.Method == "ListTenants" {
args := m.Called(ctx, limit, offset, parentID, search)
if args.Get(0) == nil {
return nil, args.Get(1).(int64), args.Error(2)
}
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
}
}
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
return nil, 0, nil
}
func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
@@ -659,6 +721,59 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) {
})
}
func TestUserHandler_BulkCreateUsersDoesNotAutoProvisionWorksmobileUsers(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/bulk", h.BulkCreateUsers)
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "t-123",
Slug: "test-tenant",
Config: domain.JSONMap{},
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "t-123").Return(&domain.Tenant{
ID: "t-123",
Slug: "test-tenant",
Config: domain.JSONMap{},
}, nil).Maybe()
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Maybe()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, mock.Anything).Return("", nil).Maybe()
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Once()
payload := map[string]any{
"users": []map[string]any{
{
"email": "user1@test.com",
"name": "User One",
"tenantSlug": "test-tenant",
},
},
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 200, resp.StatusCode)
assert.Empty(t, worksmobile.upserts)
mockOry.AssertExpectations(t)
}
func TestUserHandler_BulkCreateUsersRejectsRequestedUserID(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -1135,6 +1250,84 @@ func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *te
mockKratos.AssertExpectations(t)
}
func TestUserHandler_ListUsersTenantSlugFilterIncludesAdditionalAppointments(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
createdAt := time.Date(2026, 6, 15, 4, 55, 0, 0, time.UTC)
primaryTenantID := "primary-tenant-id"
targetTenantID := "target-tenant-id"
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users", h.ListUsers)
mockTenant.On("GetTenantBySlug", mock.Anything, "target-team").Return(&domain.Tenant{
ID: targetTenantID,
Slug: "target-team",
Name: "Target Team",
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, primaryTenantID).Return(&domain.Tenant{
ID: primaryTenantID,
Slug: "primary-team",
Name: "Primary Team",
}, nil).Maybe()
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
{
ID: "additional-member",
State: "active",
CreatedAt: createdAt,
UpdatedAt: createdAt,
Traits: map[string]any{
"email": "additional@example.com",
"name": "Additional Member",
"tenant_id": primaryTenantID,
"additionalAppointments": []any{
map[string]any{
"tenantId": targetTenantID,
"tenantSlug": "target-team",
"tenantName": "Target Team",
},
},
},
},
{
ID: "outside-member",
State: "active",
CreatedAt: createdAt.Add(-time.Minute),
UpdatedAt: createdAt,
Traits: map[string]any{
"email": "outside@example.com",
"name": "Outside Member",
"tenant_id": primaryTenantID,
},
},
}, nil).Once()
req := httptest.NewRequest("GET", "/users?tenantSlug=target-team&limit=20&offset=0", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var res userListResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
require.Equal(t, int64(1), res.Total)
require.Len(t, res.Items, 1)
require.Equal(t, "additional-member", res.Items[0].ID)
require.Equal(t, "additional@example.com", res.Items[0].Email)
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) {
mockKratos := new(MockKratosAdmin)
redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
@@ -1540,6 +1733,91 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
})
}
func TestNextAvailableHanmacLocalPartIncrementsTrailingNumericSuffix(t *testing.T) {
used := map[string]hanmacLocalPartOwner{
"jhchoi11": {Email: "jhchoi11@hanmaceng.co.kr"},
"jhchoi12": {Email: "jhchoi12@hallasanup.com"},
"yskim11": {Email: "yskim11@hanmaceng.co.kr"},
"yskim12": {Email: "yskim12@hanmaceng.co.kr"},
"yskim13": {Email: "yskim13@hanmaceng.co.kr"},
}
assert.Equal(t, "jhchoi13", nextAvailableHanmacLocalPart("jhchoi11", used))
assert.Equal(t, "yskim14", nextAvailableHanmacLocalPart("yskim11", used))
assert.Equal(t, "mjkim", nextAvailableHanmacLocalPart("mjkim", used))
}
func TestUserHandler_BulkCreateUsersRejectsInternalDomainPersonalTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users/bulk", h.BulkCreateUsers)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
ID: "personal-tenant-id",
Slug: "personal-team",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil)
body := `{"users":[{"email":"internal@jangheon.co.kr","name":"Internal User","tenantSlug":"personal-team"}]}`
req := httptest.NewRequest(http.MethodPost, "/users/bulk", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var payload map[string][]map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
require.Len(t, payload["results"], 1)
require.Equal(t, false, payload["results"][0]["success"])
require.Contains(t, payload["results"][0]["message"], "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다")
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_BulkCreateUsersRejectsInternalDomainPersonalAutoTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users/bulk", h.BulkCreateUsers)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockTenant.On("GetTenantByDomain", mock.Anything, "pre-cast.co.kr").Return(nil, nil)
body := `{"users":[{"email":"internal@pre-cast.co.kr","name":"Internal User"}]}`
req := httptest.NewRequest(http.MethodPost, "/users/bulk", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var payload map[string][]map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
require.Len(t, payload["results"], 1)
require.Equal(t, false, payload["results"][0]["success"])
require.Contains(t, payload["results"][0]["message"], "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다")
mockTenant.AssertNotCalled(t, "RegisterTenant", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -1570,7 +1848,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
}, nil).Maybe()
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID}).Return([]domain.User{
{Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
{ID: "owner-user", Email: "han@hanmaceng.co.kr", Name: "안헌", CompanyCode: "hanmac", TenantID: &companyID},
}, nil).Maybe()
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Maybe()
@@ -1583,12 +1861,23 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
var logBuffer bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuffer, nil)))
t.Cleanup(func() { slog.SetDefault(previousLogger) })
resp, _ := app.Test(req)
assert.Equal(t, http.StatusConflict, resp.StatusCode)
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
assert.Contains(t, result["error"].(string), "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
assert.Contains(t, result["error"].(string), "han")
assert.Contains(t, result["error"].(string), "han@hanmaceng.co.kr")
assert.Contains(t, result["error"].(string), "안헌")
assert.Contains(t, logBuffer.String(), "hanmac create email local-part conflict")
assert.Contains(t, logBuffer.String(), "owner-user")
assert.Contains(t, logBuffer.String(), "han@hanmaceng.co.kr")
mockOry.AssertNotCalled(t, "CreateUser")
}
@@ -1635,9 +1924,9 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
json.NewDecoder(resp.Body).Decode(&result)
results := result["results"].([]any)
assert.True(t, results[0].(map[string]any)["success"].(bool))
assert.Len(t, worksmobile.upserts, 1)
assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.upserts[0].Status)
assert.Len(t, worksmobile.updates, 1)
assert.Equal(t, "u-1", worksmobile.updates[0].ID)
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.updates[0].Status)
})
t.Run("Success - Super admin assigns legacy roles as user", func(t *testing.T) {
@@ -1682,10 +1971,12 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
mockOutbox := new(userHandlerMockKetoOutboxRepository)
mockRepo := new(MockUserRepoForHandler)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
KetoOutboxRepo: mockOutbox,
UserRepo: mockRepo,
}
app.Put("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
@@ -1735,6 +2026,12 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
"additionalAppointments": []any{map[string]any{"tenantId": "team-a-id", "tenantSlug": "team-a", "tenantName": "Team A"}},
},
}, nil).Once()
mockRepo.On("Update", mock.Anything, mock.MatchedBy(func(user *domain.User) bool {
return user != nil &&
user.ID == "u-1" &&
user.TenantID != nil &&
*user.TenantID == "primary-tenant-id"
})).Return(nil).Once()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
return entry.Namespace == "Tenant" &&
entry.Object == "team-a-id" &&
@@ -1753,6 +2050,7 @@ func TestUserHandler_BulkUpdateUsersAddTenantMembership(t *testing.T) {
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
mockRepo.AssertExpectations(t)
}
func TestUserHandler_BulkDeleteUsers(t *testing.T) {
@@ -2258,6 +2556,73 @@ func TestUserHandler_UpdateUser_AllowsSuperAdminEmailChange(t *testing.T) {
mockKratos.AssertExpectations(t)
}
func TestUserHandler_UpdateUserRejectsHanmacDuplicateLocalPart(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
mockRepo := new(MockUserRepoForHandler)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
UserRepo: mockRepo,
}
app.Put("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
return h.UpdateUser(c)
})
rootID := "hanmac-family-id"
targetTenantID := "brsw-id"
ownerTenantID := "hanmac-id"
tenants := []domain.Tenant{
{ID: rootID, Slug: "hanmac-family", Name: "한맥가족"},
{ID: targetTenantID, Slug: "brsw", Name: "바론", ParentID: &rootID},
{ID: ownerTenantID, Slug: "hanmac", Name: "한맥기술", ParentID: &rootID},
}
userID := "target-user-id"
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "jmhwang11@brsw.kr",
"name": "황재민",
"role": domain.RoleUser,
"tenant_id": targetTenantID,
},
State: "active",
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything).Return(&service.KratosIdentity{
ID: userID,
State: "active",
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, targetTenantID).Return(&tenants[1], nil).Maybe()
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
mockRepo.On("FindByTenantIDs", mock.Anything, mock.MatchedBy(func(ids []string) bool {
return slices.Contains(ids, targetTenantID) && slices.Contains(ids, ownerTenantID)
})).Return([]domain.User{
{ID: "owner-user-id", Email: "jmhwang2@hanmaceng.co.kr", Name: "황지만", TenantID: &ownerTenantID, CompanyCode: "hanmac"},
}, nil).Maybe()
mockRepo.On("FindByCompanyCodes", mock.Anything, mock.MatchedBy(func(slugs []string) bool {
return slices.Contains(slugs, "brsw") && slices.Contains(slugs, "hanmac")
})).Return([]domain.User{}, nil).Maybe()
body, _ := json.Marshal(map[string]interface{}{"email": "jmhwang2@brsw.kr"})
req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusConflict, resp.StatusCode)
var result map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
message, _ := result["error"].(string)
assert.Contains(t, message, "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.")
assert.Contains(t, message, "jmhwang2")
assert.Contains(t, message, "jmhwang2@hanmaceng.co.kr")
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestUserHandler_UpdateUserClearsWorksmobileAliasMetadataWhenSubEmailIsCleared(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -2769,9 +3134,7 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
resp, _ := app.Test(req)
assert.Equal(t, 201, resp.StatusCode)
assert.Len(t, worksmobile.upserts, 1)
assert.Equal(t, "some-id", worksmobile.upserts[0].ID)
assert.Equal(t, tenantID, *worksmobile.upserts[0].TenantID)
assert.Empty(t, worksmobile.upserts)
mockOry.AssertExpectations(t)
}
@@ -2850,6 +3213,66 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
mockKratos.AssertExpectations(t)
}
func TestUserHandler_CreateUserRejectsInternalDomainPersonalAutoTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users", h.CreateUser)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Maybe()
body := `{"email":"internal@hanmaceng.co.kr","password":"Password1!","name":"Internal User"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
mockTenant.AssertNotCalled(t, "RegisterTenant", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
}
func TestUserHandler_CreateUserRejectsInternalDomainExplicitPersonalTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users", h.CreateUser)
personalTenantID := "personal-tenant-id"
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Maybe()
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
ID: personalTenantID,
Slug: "personal-team",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil)
body := `{"email":"internal@samaneng.com","password":"Password1!","name":"Internal User","tenantSlug":"personal-team"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -2925,9 +3348,9 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
ID: "new-tenant-id",
Slug: "new-tenant",
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
mockTenant.On("GetTenant", mock.Anything, "old-tenant-id").Return(&domain.Tenant{
ID: "old-tenant-id",
Slug: "old-tenant",
Config: domain.JSONMap{},
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.Anything, mock.Anything).Return(&service.KratosIdentity{
@@ -2936,7 +3359,7 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "new-tenant-id",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}, nil).Maybe()
@@ -2952,6 +3375,100 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
mockKratos.AssertExpectations(t)
}
func TestUserHandler_UpdateUserRejectsInternalDomainMoveToPersonalTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "admin-id",
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Put("/users/:id", h.UpdateUser)
identity := &service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@brsw.kr",
"name": "Internal User",
"tenant_id": "company-tenant-id",
"role": domain.RoleUser,
},
}
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(identity, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
ID: "personal-tenant-id",
Slug: "personal-team",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil)
body := `{"tenantSlug":"personal-team"}`
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_UpdateUserRejectsPersonalTenantInternalDomainEmail(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "admin-id",
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Put("/users/:id", h.UpdateUser)
identity := &service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "external@example.com",
"name": "External User",
"tenant_id": "personal-tenant-id",
"role": domain.RoleUser,
},
}
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(identity, nil)
mockTenant.On("GetTenant", mock.Anything, "personal-tenant-id").Return(&domain.Tenant{
ID: "personal-tenant-id",
Slug: "personal-team",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil)
body := `{"email":"user@hallasanup.com"}`
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_UpdateUserAddTenantKeepsPrimaryAndAddsAppointment(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -3272,6 +3789,163 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
require.Contains(t, legacyErr.Error(), "companyCode is deprecated")
}
func TestUserHandler_UpdateUserTenantSlugWithoutPrimaryFlagKeepsRepresentativeTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Put("/users/:id", h.UpdateUser)
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "old-tenant-id").Return(&domain.Tenant{
ID: "old-tenant-id",
Slug: "old-tenant",
Config: domain.JSONMap{},
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]any) bool {
return extractTraitString(traits, "tenant_id") == "old-tenant-id"
}), mock.Anything).Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
body := `{"tenantSlug":"new-tenant"}`
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_UpdateUserPrimaryTenantFlagChangesRepresentativeTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Put("/users/:id", h.UpdateUser)
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
}, nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
Config: domain.JSONMap{},
}, nil).Maybe()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]any) bool {
return extractTraitString(traits, "tenant_id") == "new-tenant-id"
}), mock.Anything).Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "new-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
body := `{"tenantSlug":"new-tenant","isPrimaryTenant":true}`
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
mockKratos.AssertExpectations(t)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_BulkUpdateUsersRejectsInternalDomainMoveToPersonalTenant(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "admin-id",
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Put("/users/bulk", h.BulkUpdateUsers)
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]any{
"email": "user@brsw.kr",
"name": "Internal User",
"tenant_id": "company-tenant-id",
"role": domain.RoleUser,
},
}, nil)
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-team").Return(&domain.Tenant{
ID: "personal-tenant-id",
Slug: "personal-team",
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil)
body := `{"userIds":["user-id"],"tenantSlug":"personal-team"}`
req := httptest.NewRequest(http.MethodPut, "/users/bulk", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var payload map[string][]map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
require.Len(t, payload["results"], 1)
require.Equal(t, false, payload["results"][0]["success"])
require.Contains(t, payload["results"][0]["message"], "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다")
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockTenant.AssertExpectations(t)
}
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
handler := &UserHandler{}
identity := service.KratosIdentity{

View File

@@ -1,16 +0,0 @@
package handler
import (
"baron-sso-backend/internal/repository"
"context"
"log/slog"
)
func markUserProjectionFailed(ctx context.Context, repo repository.UserProjectionRepository, syncErr error) {
if repo == nil || syncErr == nil {
return
}
if err := repo.MarkFailed(ctx, syncErr); err != nil {
slog.Error("Failed to mark user projection as failed", "syncError", syncErr, "error", err)
}
}

View File

@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
}
// Auto-migrate
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{}, &domain.WorksmobileOutbox{})
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{}, &domain.WorksmobileOutbox{})
if err != nil {
log.Fatalf("failed to migrate database: %s", err)
}

View File

@@ -26,26 +26,76 @@ WHERE u.deleted_at IS NULL
}
func ClearOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
result := db.WithContext(ctx).Exec(`
userResult := db.WithContext(ctx).Exec(`
WITH orphan_users AS (
SELECT u.id
SELECT u.id AS user_id,
replacement.id AS replacement_tenant_id
FROM users AS u
WHERE u.deleted_at IS NULL
AND (
u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE t.id = u.tenant_id
AND t.deleted_at IS NULL
JOIN tenants AS deleted_tenant
ON deleted_tenant.id = u.tenant_id
AND deleted_tenant.deleted_at IS NOT NULL
JOIN LATERAL (
WITH RECURSIVE ancestors AS (
SELECT parent.id, parent.parent_id, parent.deleted_at, 1 AS depth
FROM tenants AS parent
WHERE parent.id = deleted_tenant.parent_id
UNION ALL
SELECT parent.id, parent.parent_id, parent.deleted_at, ancestors.depth + 1
FROM tenants AS parent
JOIN ancestors ON parent.id = ancestors.parent_id
WHERE ancestors.parent_id IS NOT NULL
AND ancestors.parent_id <> ancestors.id
)
)
SELECT id
FROM ancestors
WHERE deleted_at IS NULL
ORDER BY depth
LIMIT 1
) AS replacement ON true
WHERE u.deleted_at IS NULL
AND u.tenant_id IS NOT NULL
)
UPDATE users AS u
SET tenant_id = NULL,
SET tenant_id = ou.replacement_tenant_id,
updated_at = NOW()
FROM orphan_users AS ou
WHERE u.id = ou.id
WHERE u.id = ou.user_id
`)
return result.RowsAffected, result.Error
if userResult.Error != nil {
return userResult.RowsAffected, userResult.Error
}
loginResult := db.WithContext(ctx).Exec(`
WITH orphan_login_ids AS (
SELECT uli.id AS login_id,
replacement.id AS replacement_tenant_id
FROM user_login_ids AS uli
JOIN tenants AS deleted_tenant
ON deleted_tenant.id = uli.tenant_id
AND deleted_tenant.deleted_at IS NOT NULL
JOIN LATERAL (
WITH RECURSIVE ancestors AS (
SELECT parent.id, parent.parent_id, parent.deleted_at, 1 AS depth
FROM tenants AS parent
WHERE parent.id = deleted_tenant.parent_id
UNION ALL
SELECT parent.id, parent.parent_id, parent.deleted_at, ancestors.depth + 1
FROM tenants AS parent
JOIN ancestors ON parent.id = ancestors.parent_id
WHERE ancestors.parent_id IS NOT NULL
AND ancestors.parent_id <> ancestors.id
)
SELECT id
FROM ancestors
WHERE deleted_at IS NULL
ORDER BY depth
LIMIT 1
) AS replacement ON true
)
UPDATE user_login_ids AS uli
SET tenant_id = oli.replacement_tenant_id
FROM orphan_login_ids AS oli
WHERE uli.id = oli.login_id
`)
return userResult.RowsAffected + loginResult.RowsAffected, loginResult.Error
}

View File

@@ -20,7 +20,7 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
require.NoError(t, testDB.Unscoped().Where("slug IN ?", []string{"orphan-active", "orphan-deleted"}).Delete(&domain.Tenant{}).Error)
activeTenant := &domain.Tenant{Name: "Active Tenant", Slug: "orphan-active", Type: domain.TenantTypeCompany}
deletedTenant := &domain.Tenant{Name: "Deleted Tenant", Slug: "orphan-deleted", Type: domain.TenantTypeCompany}
deletedTenant := &domain.Tenant{Name: "Deleted Tenant", Slug: "orphan-deleted", Type: domain.TenantTypeUserGroup, ParentID: &activeTenant.ID}
require.NoError(t, tenantRepo.Create(ctx, activeTenant))
require.NoError(t, tenantRepo.Create(ctx, deletedTenant))
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", deletedTenant.ID).Error)
@@ -39,6 +39,13 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
}
require.NoError(t, repo.Create(ctx, activeUser))
require.NoError(t, repo.Create(ctx, orphanUser))
loginID := &domain.UserLoginID{
UserID: orphanUser.ID,
TenantID: deletedTenant.ID,
FieldKey: "employee_number",
LoginID: "orphan-membership-login",
}
require.NoError(t, testDB.Create(loginID).Error)
count, err := CountOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)
@@ -46,7 +53,7 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
affected, err := ClearOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)
assert.Equal(t, int64(1), affected)
assert.Equal(t, int64(2), affected)
foundActive, err := repo.FindByEmail(ctx, activeUser.Email)
require.NoError(t, err)
@@ -56,7 +63,12 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
foundOrphan, err := repo.FindByEmail(ctx, orphanUser.Email)
require.NoError(t, err)
assert.Nil(t, foundOrphan.TenantID)
require.NotNil(t, foundOrphan.TenantID)
assert.Equal(t, activeTenant.ID, *foundOrphan.TenantID)
var foundLogin domain.UserLoginID
require.NoError(t, testDB.First(&foundLogin, "id = ?", loginID.ID).Error)
assert.Equal(t, activeTenant.ID, foundLogin.TenantID)
count, err = CountOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)

View File

@@ -1,227 +0,0 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type UserProjectionRepository interface {
IsReady(ctx context.Context) (bool, error)
GetStatus(ctx context.Context) (domain.UserProjectionStatus, error)
CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error)
CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error)
ReplaceAllFromKratos(ctx context.Context, users []domain.User) error
MarkFailed(ctx context.Context, syncErr error) error
}
type userProjectionRepository struct {
db *gorm.DB
}
func NewUserProjectionRepository(db *gorm.DB) UserProjectionRepository {
return &userProjectionRepository{db: db}
}
func (r *userProjectionRepository) IsReady(ctx context.Context) (bool, error) {
status, err := r.GetStatus(ctx)
if err != nil {
return false, err
}
return status.Ready, nil
}
func (r *userProjectionRepository) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
var projectedUsers int64
if err := r.db.WithContext(ctx).Model(&domain.User{}).Count(&projectedUsers).Error; err != nil {
return domain.UserProjectionStatus{}, err
}
var state domain.UserProjectionState
err := r.db.WithContext(ctx).First(&state, "name = ?", domain.UserProjectionNameKratos).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return domain.UserProjectionStatus{
Name: domain.UserProjectionNameKratos,
Status: domain.UserProjectionStatusFailed,
Ready: false,
ProjectedUsers: projectedUsers,
}, nil
}
if err != nil {
return domain.UserProjectionStatus{}, err
}
return domain.UserProjectionStatus{
Name: state.Name,
Status: state.Status,
Ready: state.Status == domain.UserProjectionStatusReady && state.LastSyncedAt != nil,
LastSyncedAt: state.LastSyncedAt,
LastError: state.LastError,
UpdatedAt: &state.UpdatedAt,
ProjectedUsers: projectedUsers,
}, nil
}
func (r *userProjectionRepository) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
counts := make(map[string]int64, len(tenants))
for _, tenant := range tenants {
counts[tenant.ID] = 0
}
if len(tenants) == 0 {
return counts, nil
}
valuePlaceholders := make([]string, 0, len(tenants))
args := make([]any, 0, len(tenants)*2)
for _, tenant := range tenants {
valuePlaceholders = append(valuePlaceholders, "(?, ?)")
args = append(args, strings.TrimSpace(tenant.ID), strings.TrimSpace(tenant.Slug))
}
query := fmt.Sprintf(`
WITH requested(tenant_id, slug) AS (
VALUES %s
)
SELECT requested.tenant_id, COUNT(DISTINCT users.id) AS count
FROM requested
LEFT JOIN users ON users.deleted_at IS NULL AND (
users.tenant_id::text = requested.tenant_id
)
GROUP BY requested.tenant_id
`, strings.Join(valuePlaceholders, ","))
type result struct {
TenantID string
Count int64
}
var rows []result
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
counts[row.TenantID] = row.Count
}
return counts, nil
}
func (r *userProjectionRepository) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
counts := make(map[string]int64, len(tenants))
for _, tenant := range tenants {
counts[tenant.ID] = 0
}
if len(tenants) == 0 {
return counts, nil
}
valuePlaceholders := make([]string, 0, len(tenants))
args := make([]any, 0, len(tenants))
for _, tenant := range tenants {
valuePlaceholders = append(valuePlaceholders, "(?)")
args = append(args, strings.TrimSpace(tenant.ID))
}
query := fmt.Sprintf(`
WITH RECURSIVE requested(tenant_id) AS (
VALUES %s
),
descendants(root_tenant_id, tenant_id) AS (
SELECT requested.tenant_id, requested.tenant_id
FROM requested
UNION ALL
SELECT descendants.root_tenant_id, child.id::text
FROM descendants
JOIN tenants child
ON child.parent_id::text = descendants.tenant_id
AND child.deleted_at IS NULL
)
SELECT requested.tenant_id, COUNT(DISTINCT users.id) AS count
FROM requested
LEFT JOIN descendants
ON descendants.root_tenant_id = requested.tenant_id
LEFT JOIN users
ON users.deleted_at IS NULL
AND users.tenant_id::text = descendants.tenant_id
GROUP BY requested.tenant_id
`, strings.Join(valuePlaceholders, ","))
type result struct {
TenantID string
Count int64
}
var rows []result
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
counts[row.TenantID] = row.Count
}
return counts, nil
}
func (r *userProjectionRepository) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
now := time.Now()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for i := range users {
users[i].DeletedAt = gorm.DeletedAt{}
if users[i].CreatedAt.IsZero() {
users[i].CreatedAt = now
}
if users[i].UpdatedAt.IsZero() {
users[i].UpdatedAt = now
}
}
if len(users) > 0 {
// [FIX] Handle email conflicts before bulk upsert
for _, u := range users {
if u.Email != "" {
// Hard-delete any record with same email but different ID to clear unique constraint
_ = tx.Unscoped().Where("email = ? AND id != ?", u.Email, u.ID).Delete(&domain.User{}).Error
}
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,
}).Create(&users).Error; err != nil {
return err
}
}
return upsertUserProjectionState(tx, domain.UserProjectionStatusReady, &now, "")
})
}
func (r *userProjectionRepository) MarkFailed(ctx context.Context, syncErr error) error {
message := ""
if syncErr != nil {
message = syncErr.Error()
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return upsertUserProjectionState(tx, domain.UserProjectionStatusFailed, nil, message)
})
}
func upsertUserProjectionState(tx *gorm.DB, status string, syncedAt *time.Time, lastError string) error {
state := domain.UserProjectionState{
Name: domain.UserProjectionNameKratos,
Status: status,
LastSyncedAt: syncedAt,
LastError: lastError,
UpdatedAt: time.Now(),
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{
"status",
"last_synced_at",
"last_error",
"updated_at",
}),
}).Create(&state).Error
}

View File

@@ -1,168 +0,0 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyWithoutDeletingUsersMissingFromPartialList(t *testing.T) {
ctx := context.Background()
repo := NewUserProjectionRepository(testDB)
require.NoError(t, testDB.Exec("DELETE FROM user_projection_states").Error)
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
tenantID := "10000000-0000-0000-0000-000000000001"
tenantSlug := "projection-saman"
require.NoError(t, testDB.Create(&domain.Tenant{
ID: tenantID,
Name: "Projection Saman",
Slug: tenantSlug,
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}).Error)
existing := &domain.User{
ID: "00000000-0000-0000-0000-000000000099",
Email: "existing@example.com",
Name: "Existing",
CompanyCode: tenantSlug,
TenantID: &tenantID,
}
require.NoError(t, NewUserRepository(testDB).Create(ctx, existing))
users := []domain.User{
{
ID: "00000000-0000-0000-0000-000000000101",
Email: "one@example.com",
Name: "One",
CompanyCode: tenantSlug,
TenantID: &tenantID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
ID: "00000000-0000-0000-0000-000000000102",
Email: "two@example.com",
Name: "Two",
TenantID: &tenantID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
require.NoError(t, repo.ReplaceAllFromKratos(ctx, users))
ready, err := repo.IsReady(ctx)
require.NoError(t, err)
assert.True(t, ready)
counts, err := repo.CountTenantMembers(ctx, []domain.Tenant{
{ID: tenantID, Slug: tenantSlug},
})
require.NoError(t, err)
assert.Equal(t, int64(3), counts[tenantID])
var activeCount int64
require.NoError(t, testDB.Model(&domain.User{}).Count(&activeCount).Error)
assert.Equal(t, int64(3), activeCount)
var existingCount int64
require.NoError(t, testDB.Model(&domain.User{}).Where("id = ?", existing.ID).Count(&existingCount).Error)
assert.Equal(t, int64(1), existingCount)
var existingRow domain.User
require.NoError(t, testDB.Unscoped().First(&existingRow, "id = ?", existing.ID).Error)
assert.False(t, existingRow.DeletedAt.Valid)
}
func TestUserProjectionRepository_CountTenantMembersRecursiveIncludesDescendantsAndExcludesSoftDeletedUsers(t *testing.T) {
ctx := context.Background()
repo := NewUserProjectionRepository(testDB)
parentID := "20000000-0000-0000-0000-000000000001"
childID := "20000000-0000-0000-0000-000000000002"
grandchildID := "20000000-0000-0000-0000-000000000003"
siblingID := "20000000-0000-0000-0000-000000000004"
tenantIDs := []string{parentID, childID, grandchildID, siblingID}
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
require.NoError(t, testDB.Unscoped().Where("id IN ?", tenantIDs).Delete(&domain.Tenant{}).Error)
require.NoError(t, testDB.Create(&domain.Tenant{
ID: parentID,
Name: "Recursive Parent",
Slug: "recursive-parent",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}).Error)
require.NoError(t, testDB.Create(&domain.Tenant{
ID: childID,
Name: "Recursive Child",
Slug: "recursive-child",
Type: domain.TenantTypeOrganization,
Status: domain.TenantStatusActive,
ParentID: &parentID,
}).Error)
require.NoError(t, testDB.Create(&domain.Tenant{
ID: grandchildID,
Name: "Recursive Grandchild",
Slug: "recursive-grandchild",
Type: domain.TenantTypeUserGroup,
Status: domain.TenantStatusActive,
ParentID: &childID,
}).Error)
require.NoError(t, testDB.Create(&domain.Tenant{
ID: siblingID,
Name: "Recursive Sibling",
Slug: "recursive-sibling",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}).Error)
users := []domain.User{
{ID: "21000000-0000-0000-0000-000000000001", Email: "parent@example.com", Name: "Parent", TenantID: &parentID},
{ID: "21000000-0000-0000-0000-000000000002", Email: "child@example.com", Name: "Child", TenantID: &childID},
{ID: "21000000-0000-0000-0000-000000000003", Email: "grandchild@example.com", Name: "Grandchild", TenantID: &grandchildID},
{ID: "21000000-0000-0000-0000-000000000004", Email: "deleted-grandchild@example.com", Name: "Deleted Grandchild", TenantID: &grandchildID},
{ID: "21000000-0000-0000-0000-000000000005", Email: "sibling@example.com", Name: "Sibling", TenantID: &siblingID},
}
for i := range users {
require.NoError(t, testDB.Create(&users[i]).Error)
}
require.NoError(t, testDB.Delete(&domain.User{}, "id = ?", users[3].ID).Error)
directCounts, err := repo.CountTenantMembers(ctx, []domain.Tenant{{ID: parentID}, {ID: childID}, {ID: grandchildID}, {ID: siblingID}})
require.NoError(t, err)
assert.Equal(t, int64(1), directCounts[parentID])
assert.Equal(t, int64(1), directCounts[childID])
assert.Equal(t, int64(1), directCounts[grandchildID])
assert.Equal(t, int64(1), directCounts[siblingID])
recursiveCounts, err := repo.CountTenantMembersRecursive(ctx, []domain.Tenant{{ID: parentID}, {ID: childID}, {ID: grandchildID}, {ID: siblingID}})
require.NoError(t, err)
assert.Equal(t, int64(3), recursiveCounts[parentID])
assert.Equal(t, int64(2), recursiveCounts[childID])
assert.Equal(t, int64(1), recursiveCounts[grandchildID])
assert.Equal(t, int64(1), recursiveCounts[siblingID])
}
func TestUserProjectionRepository_MarkFailedMakesProjectionNotReady(t *testing.T) {
ctx := context.Background()
repo := NewUserProjectionRepository(testDB)
require.NoError(t, testDB.Exec("DELETE FROM user_projection_states").Error)
require.NoError(t, repo.MarkFailed(ctx, errors.New("kratos down")))
ready, err := repo.IsReady(ctx)
require.NoError(t, err)
assert.False(t, ready)
}

View File

@@ -272,7 +272,12 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
}
func (r *userRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&domain.User{}, "id = ?", id).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Where("user_id = ?", id).Delete(&domain.UserLoginID{}).Error; err != nil {
return err
}
return tx.Unscoped().Delete(&domain.User{}, "id = ?", id).Error
})
}
func (r *userRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {

View File

@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestUserRepository(t *testing.T) {
@@ -95,8 +96,14 @@ func TestUserRepository(t *testing.T) {
})
t.Run("Delete User", func(t *testing.T) {
require.NoError(t, testDB.AutoMigrate(&domain.UserLoginID{}))
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
require.NoError(t, testDB.Exec("DELETE FROM users WHERE email = ?", "delete@example.com").Error)
user := &domain.User{Email: "delete@example.com", Name: "To Delete"}
_ = repo.Create(ctx, user)
require.NoError(t, repo.Create(ctx, user))
require.NoError(t, repo.UpdateUserLoginIDs(ctx, user.ID, []domain.UserLoginID{
{UserID: user.ID, TenantID: uuid.NewString(), FieldKey: "employee_id", LoginID: "DELETE001"},
}))
err := repo.Delete(ctx, user.ID)
assert.NoError(t, err)
@@ -104,6 +111,14 @@ func TestUserRepository(t *testing.T) {
found, err := repo.FindByEmail(ctx, "delete@example.com")
assert.Error(t, err) // Should not be found
assert.Nil(t, found)
var hardDeleted domain.User
err = testDB.Unscoped().Where("id = ?", user.ID).First(&hardDeleted).Error
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
var loginIDCount int64
require.NoError(t, testDB.Unscoped().Model(&domain.UserLoginID{}).Where("user_id = ?", user.ID).Count(&loginIDCount).Error)
require.Zero(t, loginIDCount)
})
t.Run("CountByCompanyCodes", func(t *testing.T) {

View File

@@ -102,6 +102,44 @@ func (s *RedisService) Delete(key string) error {
return s.Client.Del(ctx, key).Err()
}
func (s *RedisService) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
if s == nil || s.Client == nil {
return 0, os.ErrInvalid
}
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return 0, os.ErrInvalid
}
var deleted int64
var cursor uint64
pattern := prefix + "*"
for {
keys, next, err := s.Client.Scan(ctx, cursor, pattern, 250).Result()
if err != nil {
return deleted, err
}
for len(keys) > 0 {
chunkSize := len(keys)
if chunkSize > 500 {
chunkSize = 500
}
chunk := keys[:chunkSize]
count, err := s.Client.Del(ctx, chunk...).Result()
if err != nil {
return deleted, err
}
deleted += count
keys = keys[chunkSize:]
}
cursor = next
if cursor == 0 {
break
}
}
return deleted, nil
}
func (s *RedisService) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
if s == nil || s.Client == nil {
return domain.IdentityCacheStatus{

View File

@@ -134,6 +134,27 @@ func TestRedisServiceFlushIdentityCacheDeletesOnlyIdentityMirrorAndIndexKeys(t *
}, stub.deleted)
}
func TestRedisServiceDeleteByPrefixScansAndDeletesMatchingKeys(t *testing.T) {
stub := &redisCommandStub{
scans: map[string][]string{
"orgchart:snapshot:v1:*": {
"orgchart:snapshot:v1:super_admin:all:none",
"orgchart:snapshot:v1:user:user-1:tenant-1",
},
},
}
service := newStubbedRedisService(stub)
deleted, err := service.DeleteByPrefix(context.Background(), "orgchart:snapshot:v1:")
require.NoError(t, err)
require.Equal(t, int64(2), deleted)
require.ElementsMatch(t, []string{
"orgchart:snapshot:v1:super_admin:all:none",
"orgchart:snapshot:v1:user:user-1:tenant-1",
}, stub.deleted)
}
func TestRedisServiceGetIdentityCacheStatusReturnsUnavailableWithoutClient(t *testing.T) {
status, err := (*RedisService)(nil).GetIdentityCacheStatus(context.Background())

View File

@@ -243,7 +243,7 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
if err := s.userRepo.Update(ctx, localUser); err != nil {
slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err)
} else if s.worksmobile != nil {
if err := s.worksmobile.EnqueueUserUpsertIfInScope(ctx, *localUser); err != nil {
if err := s.worksmobile.EnqueueUserUpdateIfInScope(ctx, *localUser); err != nil {
slog.Warn("Failed to enqueue Worksmobile user sync during AddMember", "user", userID, "error", err)
}
}

View File

@@ -156,6 +156,11 @@ func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.
return nil
}
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error {
f.userUpserts = append(f.userUpserts, user)
return nil
}
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
return nil
}

View File

@@ -1,153 +0,0 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"fmt"
"strings"
"time"
)
type UserProjectionSyncService struct {
kratos KratosAdminService
repo repository.UserProjectionRepository
}
type UserProjectionReconciler interface {
Reconcile(ctx context.Context) (int, error)
}
func NewUserProjectionSyncService(kratos KratosAdminService, repo repository.UserProjectionRepository) *UserProjectionSyncService {
return &UserProjectionSyncService{
kratos: kratos,
repo: repo,
}
}
func (s *UserProjectionSyncService) Reconcile(ctx context.Context) (int, error) {
if s == nil || s.kratos == nil || s.repo == nil {
return 0, fmt.Errorf("user projection sync dependencies are not configured")
}
identities, err := s.kratos.ListIdentities(ctx)
if err != nil {
_ = s.repo.MarkFailed(ctx, err)
return 0, err
}
users := make([]domain.User, 0, len(identities))
for _, identity := range identities {
users = append(users, MapKratosIdentityToLocalUser(identity))
}
if err := s.repo.ReplaceAllFromKratos(ctx, users); err != nil {
_ = s.repo.MarkFailed(ctx, err)
return 0, err
}
return len(users), nil
}
func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
traits := identity.Traits
now := time.Now()
createdAt := identity.CreatedAt
if createdAt.IsZero() {
createdAt = now
}
updatedAt := identity.UpdatedAt
if updatedAt.IsZero() {
updatedAt = now
}
role, ok := domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "role"))
if !ok {
role, ok = domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "grade"))
if !ok {
role = domain.RoleUser
}
}
grade := kratosProjectionTraitString(traits, "grade")
if _, ok := domain.NormalizeRoleAlias(grade); ok {
grade = ""
}
user := domain.User{
ID: identity.ID,
Email: kratosProjectionTraitString(traits, "email"),
Name: kratosProjectionTraitString(traits, "name"),
Phone: domain.NormalizePhoneNumber(kratosProjectionTraitString(traits, "phone_number")),
Role: role,
Status: normalizeProjectionStatus(identity.State),
Department: kratosProjectionTraitString(traits, "department"),
Grade: grade,
Position: kratosProjectionTraitString(traits, "position"),
JobTitle: kratosProjectionTraitString(traits, "jobTitle"),
AffiliationType: kratosProjectionTraitString(traits, "affiliationType"),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Metadata: make(domain.JSONMap),
}
if tenantID := kratosProjectionTraitString(traits, "tenant_id"); tenantID != "" {
user.TenantID = &tenantID
}
if relyingPartyID := kratosProjectionTraitString(traits, "relying_party_id"); relyingPartyID != "" {
user.RelyingPartyID = &relyingPartyID
}
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "role": true,
"companyCode": true, "company_code": true, "companyCodes": true,
"tenant_id": true, "department": true,
"position": true, "jobTitle": true, "affiliationType": true,
"relying_party_id": true, "custom_login_ids": true, "id": true,
}
for key, value := range traits {
if !coreTraits[key] {
user.Metadata[key] = value
}
}
return user
}
func kratosProjectionTraitString(traits map[string]any, key string) string {
if traits == nil {
return ""
}
value, ok := traits[key]
if !ok || value == nil {
return ""
}
if str, ok := value.(string); ok {
return str
}
return fmt.Sprint(value)
}
func kratosProjectionTraitStringArray(traits map[string]any, key string) []string {
if traits == nil {
return nil
}
switch value := traits[key].(type) {
case []string:
return value
case []any:
items := make([]string, 0, len(value))
for _, item := range value {
if str, ok := item.(string); ok && strings.TrimSpace(str) != "" {
items = append(items, str)
}
}
return items
default:
return nil
}
}
func normalizeProjectionStatus(state string) string {
normalized := domain.NormalizeUserStatus(state)
if normalized == "" {
return domain.UserStatusActive
}
return normalized
}

View File

@@ -1,142 +0,0 @@
package service
import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeUserProjectionRepo struct {
replacedUsers []domain.User
failedErr error
replaceErr error
}
func (f *fakeUserProjectionRepo) IsReady(ctx context.Context) (bool, error) {
return false, nil
}
func (f *fakeUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
return domain.UserProjectionStatus{}, nil
}
func (f *fakeUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
return nil, nil
}
func (f *fakeUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
return nil, nil
}
func (f *fakeUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
f.replacedUsers = append([]domain.User(nil), users...)
return f.replaceErr
}
func (f *fakeUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error {
f.failedErr = syncErr
return nil
}
func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *testing.T) {
ctx := context.Background()
kratos := new(MockKratosAdminServiceShared)
repo := &fakeUserProjectionRepo{}
svc := NewUserProjectionSyncService(kratos, repo)
tenantID := "00000000-0000-0000-0000-000000000001"
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
{
ID: "00000000-0000-0000-0000-000000000101",
Traits: map[string]any{
"email": "one@example.com",
"name": "One",
"phone_number": "+821012345678",
"companyCode": "saman",
"companyCodes": []any{"saman", "group-a"},
"tenant_id": tenantID,
"department": "DX",
"customAttr": "kept",
},
State: "active",
},
}, nil).Once()
count, err := svc.Reconcile(ctx)
require.NoError(t, err)
assert.Equal(t, 1, count)
require.Len(t, repo.replacedUsers, 1)
assert.Equal(t, "one@example.com", repo.replacedUsers[0].Email)
assert.Equal(t, "One", repo.replacedUsers[0].Name)
assert.Equal(t, "+821012345678", repo.replacedUsers[0].Phone)
assert.Empty(t, repo.replacedUsers[0].CompanyCode)
assert.Empty(t, repo.replacedUsers[0].CompanyCodes)
require.NotNil(t, repo.replacedUsers[0].TenantID)
assert.Equal(t, tenantID, *repo.replacedUsers[0].TenantID)
assert.Equal(t, "kept", repo.replacedUsers[0].Metadata["customAttr"])
assert.NoError(t, repo.failedErr)
kratos.AssertExpectations(t)
}
func TestUserProjectionSyncService_ReconcileDeduplicatesKoreanCountryCodePhone(t *testing.T) {
ctx := context.Background()
kratos := new(MockKratosAdminServiceShared)
repo := &fakeUserProjectionRepo{}
svc := NewUserProjectionSyncService(kratos, repo)
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
{
ID: "00000000-0000-0000-0000-000000000102",
Traits: map[string]any{
"email": "two@example.com",
"name": "Two",
"phone_number": "+82 +821091917771",
},
State: "active",
},
}, nil).Once()
count, err := svc.Reconcile(ctx)
require.NoError(t, err)
assert.Equal(t, 1, count)
require.Len(t, repo.replacedUsers, 1)
assert.Equal(t, "+821091917771", repo.replacedUsers[0].Phone)
kratos.AssertExpectations(t)
}
func TestUserProjectionSyncService_ReconcileMarksFailedWhenKratosFails(t *testing.T) {
ctx := context.Background()
kratos := new(MockKratosAdminServiceShared)
repo := &fakeUserProjectionRepo{}
svc := NewUserProjectionSyncService(kratos, repo)
expectedErr := errors.New("kratos down")
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{}, expectedErr).Once()
count, err := svc.Reconcile(ctx)
assert.Equal(t, 0, count)
assert.ErrorIs(t, err, expectedErr)
assert.ErrorIs(t, repo.failedErr, expectedErr)
assert.Empty(t, repo.replacedUsers)
kratos.AssertExpectations(t)
}
func TestMapKratosIdentityToLocalUserPreservesArchivedStatus(t *testing.T) {
user := MapKratosIdentityToLocalUser(KratosIdentity{
ID: "00000000-0000-0000-0000-000000000201",
State: domain.UserStatusArchived,
Traits: map[string]any{
"email": "archived@example.com",
"name": "Archived User",
},
})
assert.Equal(t, domain.UserStatusArchived, user.Status)
}

View File

@@ -14,6 +14,7 @@ import (
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
@@ -33,6 +34,7 @@ type WorksmobileDirectoryClient interface {
DeleteOrgUnit(ctx context.Context, orgUnitID string) error
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
UpdateUserOnly(ctx context.Context, payload WorksmobileUserPayload) error
AddUserAliasEmail(ctx context.Context, userID string, email string) error
ResetUserPassword(ctx context.Context, userID string, password string) error
DeleteUser(ctx context.Context, userID string) error
@@ -324,17 +326,14 @@ func (c *WorksmobileHTTPClient) DeleteOrgUnit(ctx context.Context, orgUnitID str
}
func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
payload = normalizeWorksmobileUserCreatePayload(payload)
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)
}
if patchErr := c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload)); patchErr != nil {
if patchErr := c.updateUserByPatchOnly(ctx, payload); patchErr != nil {
return fmt.Errorf("worksmobile user create conflict: %w; patch after conflict failed: %v", err, patchErr)
}
return nil
@@ -342,6 +341,163 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
return err
}
func (c *WorksmobileHTTPClient) UpdateUserOnly(ctx context.Context, payload WorksmobileUserPayload) error {
return c.updateUserByPatchOnly(ctx, payload)
}
func (c *WorksmobileHTTPClient) updateUserByPatchOnly(ctx context.Context, payload WorksmobileUserPayload) error {
identifier := strings.TrimSpace(payload.Email)
if identifier == "" {
identifier = strings.TrimSpace(payload.UserExternalKey)
}
patchPayload := NewWorksmobileUserPatchPayload(payload)
if patchErr := c.PatchUser(ctx, identifier, patchPayload); patchErr != nil {
externalKey := strings.TrimSpace(payload.UserExternalKey)
if patchAPIError, ok := patchErr.(WorksmobileHTTPError); ok && patchAPIError.StatusCode == http.StatusNotFound && externalKey != "" && externalKey != identifier {
if externalKeyPatchErr := c.PatchUser(ctx, externalKey, patchPayload); externalKeyPatchErr == nil {
return nil
} else {
if externalKeyPatchAPIError, ok := externalKeyPatchErr.(WorksmobileHTTPError); ok && externalKeyPatchAPIError.StatusCode == http.StatusNotFound {
if lookupPatchErr := c.patchUserByExternalKeyLookup(ctx, externalKey, payload.DomainID, patchPayload); lookupPatchErr == nil {
return nil
} else {
return fmt.Errorf("patch failed: %w; external key patch failed: %v; external key lookup patch failed: %v", patchErr, externalKeyPatchErr, lookupPatchErr)
}
}
return fmt.Errorf("patch failed: %w; external key patch failed: %v", patchErr, externalKeyPatchErr)
}
}
return patchErr
}
return nil
}
func (c *WorksmobileHTTPClient) patchUserByExternalKeyLookup(ctx context.Context, externalKey string, requestedDomainID int64, payload WorksmobileUserPatchPayload) error {
externalKey = strings.TrimSpace(externalKey)
if externalKey == "" {
return fmt.Errorf("worksmobile user external key is required")
}
matches, err := c.findUsersByExternalKey(ctx, externalKey, requestedDomainID)
if err != nil {
return err
}
if len(matches) == 0 {
return fmt.Errorf("worksmobile user external key match not found after create conflict: %s", externalKey)
}
if len(matches) > 1 {
domainIDs := worksmobileRemoteUserDomainIDs(matches)
userIDs := worksmobileRemoteUserIDs(matches)
slog.Error(
"Worksmobile external key matched multiple users during upsert conflict recovery",
"externalKey", externalKey,
"requestedDomainID", requestedDomainID,
"domainIDs", domainIDs,
"userIDs", userIDs,
"matchCount", len(matches),
)
return fmt.Errorf("multiple worksmobile users matched external key: externalKey=%s requestedDomainID=%d domainIDs=%v userIDs=%v", externalKey, requestedDomainID, domainIDs, userIDs)
}
remote := matches[0]
identifiers := compactUniqueStrings(remote.ID, remote.Email, remote.UserName)
if len(identifiers) == 0 {
return fmt.Errorf("worksmobile user external key match has no patch identifier: %s", externalKey)
}
var lastErr error
for _, identifier := range identifiers {
if err := c.PatchUser(ctx, identifier, payload); err != nil {
lastErr = err
continue
}
return nil
}
return lastErr
}
func (c *WorksmobileHTTPClient) findUsersByExternalKey(ctx context.Context, externalKey string, requestedDomainID int64) ([]WorksmobileRemoteUser, error) {
externalKey = strings.TrimSpace(externalKey)
domainIDs := worksmobileExternalKeyLookupDomainIDs(requestedDomainID, c.DomainIDs)
if c.directoryAuthConfigured() && len(domainIDs) > 0 {
matches := make([]WorksmobileRemoteUser, 0, 1)
for _, domainID := range domainIDs {
users, err := c.listDirectoryUsers(ctx, []int64{domainID})
if err != nil {
return nil, err
}
for _, user := range users {
if user.ExternalID == externalKey {
matches = append(matches, user)
}
}
}
return matches, nil
}
users, err := c.ListUsers(ctx)
if err != nil {
return nil, err
}
matches := make([]WorksmobileRemoteUser, 0, 1)
for _, user := range users {
if user.ExternalID == externalKey {
matches = append(matches, user)
}
}
return matches, nil
}
func worksmobileExternalKeyLookupDomainIDs(requestedDomainID int64, configuredDomainIDs []int64) []int64 {
domainIDs := make([]int64, 0, len(configuredDomainIDs)+1)
if requestedDomainID > 0 {
domainIDs = append(domainIDs, requestedDomainID)
}
domainIDs = append(domainIDs, configuredDomainIDs...)
return uniqueWorksmobileDomainIDs(domainIDs)
}
func worksmobileRemoteUserDomainIDs(users []WorksmobileRemoteUser) []int64 {
domainIDs := make([]int64, 0, len(users))
for _, user := range users {
domainIDs = append(domainIDs, user.DomainID)
}
return uniqueWorksmobileDomainIDs(domainIDs)
}
func worksmobileRemoteUserIDs(users []WorksmobileRemoteUser) []string {
ids := make([]string, 0, len(users))
for _, user := range users {
if id := strings.TrimSpace(user.ID); id != "" {
ids = append(ids, id)
}
}
return compactUniqueStrings(ids...)
}
func compactUniqueStrings(values ...string) []string {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func normalizeWorksmobileUserCreatePayload(payload WorksmobileUserPayload) WorksmobileUserPayload {
payload.Email = strings.TrimSpace(payload.Email)
payload.PrivateEmail = strings.TrimSpace(payload.PrivateEmail)
payload.CellPhone = normalizeWorksmobileOutboundCellPhone(payload.CellPhone)
if strings.EqualFold(strings.TrimSpace(payload.PasswordConfig.PasswordCreationType), "ADMIN") &&
strings.TrimSpace(payload.PasswordConfig.Password) != "" &&
payload.PasswordConfig.ChangePasswordAtNextLogin == nil {
changePasswordAtNextLogin := true
payload.PasswordConfig.ChangePasswordAtNextLogin = &changePasswordAtNextLogin
}
return payload
}
func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
userID = strings.TrimSpace(userID)
email = strings.TrimSpace(email)
@@ -995,7 +1151,22 @@ func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSC
}
func normalizeWorksmobileOutboundCellPhone(value string) string {
return domain.NormalizePhoneNumber(value)
normalized := domain.NormalizePhoneNumber(value)
if !strings.HasPrefix(normalized, "+82") {
if strings.HasPrefix(normalized, "0") {
return "+82 " + normalized
}
return normalized
}
national := strings.TrimPrefix(normalized, "+82")
if national == "" {
return normalized
}
national = strings.TrimLeft(national, "0")
if national == "" {
return "+82 0"
}
return "+82 0" + national
}
func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileUserPatchPayload {

View File

@@ -36,6 +36,7 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
Email: "tester@samaneng.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
CellPhone: "+821041585840",
AliasEmails: []string{"tester.alias@samaneng.com", "tester.alias2@samaneng.com"},
Locale: "ko_KR",
PasswordConfig: WorksmobilePasswordConfig{
@@ -57,11 +58,13 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
require.NoError(t, json.Unmarshal(transport.requestBody, &payload))
require.Equal(t, "tester@samaneng.com", payload["email"])
require.Equal(t, "user-1", payload["userExternalKey"])
require.Equal(t, "+82 01041585840", payload["cellPhone"])
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)
require.Equal(t, true, passwordConfig["changePasswordAtNextLogin"])
}
func TestWorksmobileHTTPClientDeleteUserUsesDirectDirectoryDeleteForEmail(t *testing.T) {
@@ -92,7 +95,7 @@ func TestNewWorksmobileUserPatchPayloadNormalizesMalformedKoreanCellPhone(t *tes
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
})
require.Equal(t, "+821062836786", payload.CellPhone)
require.Equal(t, "+82 01062836786", payload.CellPhone)
}
func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
@@ -103,7 +106,7 @@ func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *test
})
require.Len(t, payload.PhoneNumbers, 1)
require.Equal(t, "+821062836786", payload.PhoneNumbers[0].Value)
require.Equal(t, "+82 01062836786", payload.PhoneNumbers[0].Value)
}
func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOrPrivateEmail(t *testing.T) {
@@ -155,6 +158,196 @@ func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOr
require.Equal(t, "user-1", patchPayload["userExternalKey"])
}
func TestWorksmobileHTTPClientUpdateUserOnlyPatchesWithoutCreateOrPassword(t *testing.T) {
transport := &captureRoundTripper{
statusCode: http.StatusOK,
body: `{}`,
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
DomainID: 300285955,
Email: "moved@samaneng.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Moved User"},
PrivateEmail: "private@example.com",
PasswordConfig: WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: "Aa1!Aa1!Aa1!Aa1!",
},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 300285955,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{OrgUnitID: "externalKey:new-tenant", Primary: true},
},
},
},
})
require.NoError(t, err)
require.Len(t, transport.requests, 1)
require.Equal(t, http.MethodPatch, transport.requests[0].Method)
require.Equal(t, "/v1.0/users/moved@samaneng.com", transport.requests[0].URL.Path)
var patchPayload map[string]any
require.NoError(t, json.Unmarshal(transport.requestBodies[0], &patchPayload))
require.NotContains(t, patchPayload, "passwordConfig")
require.NotContains(t, patchPayload, "privateEmail")
require.Equal(t, "moved@samaneng.com", patchPayload["email"])
require.Equal(t, "user-1", patchPayload["userExternalKey"])
}
func TestWorksmobileHTTPClientUpsertUserFallsBackToExternalKeyPatchWhenEmailPatchNotFound(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
{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: 300286336,
Email: "new-email@example.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 300286336,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{OrgUnitID: "externalKey:tenant-hanmac", Primary: true},
},
},
},
})
require.NoError(t, err)
require.Len(t, transport.requests, 3)
require.Equal(t, http.MethodPost, transport.requests[0].Method)
require.Equal(t, http.MethodPatch, transport.requests[1].Method)
require.Equal(t, "/v1.0/users/new-email@example.com", transport.requests[1].URL.Path)
require.Equal(t, http.MethodPatch, transport.requests[2].Method)
require.Equal(t, "/v1.0/users/user-1", transport.requests[2].URL.Path)
}
func TestWorksmobileHTTPClientUpsertUserFallsBackToRemoteIDPatchWhenExternalKeyPatchNotFound(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (user-1) does not exist."}`},
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"old-email@example.com"}],"responseMetaData":{}}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
DomainIDs: []int64{300286336},
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
DomainID: 300286336,
Email: "new-email@example.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 300286336,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{OrgUnitID: "externalKey:tenant-hanmac", Primary: true},
},
},
},
})
require.NoError(t, err)
require.Len(t, transport.requests, 5)
require.Equal(t, http.MethodGet, transport.requests[3].Method)
require.Equal(t, "/v1.0/users", transport.requests[3].URL.Path)
require.Equal(t, "300286336", transport.requests[3].URL.Query().Get("domainId"))
require.Equal(t, http.MethodPatch, transport.requests[4].Method)
require.Equal(t, "/v1.0/users/works-user-1", transport.requests[4].URL.Path)
}
func TestWorksmobileHTTPClientExternalKeyLookupStartsWithPayloadDomain(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (user-1) does not exist."}`},
{statusCode: http.StatusOK, body: `{"users":[],"responseMetaData":{}}`},
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"old-email@example.com"}],"responseMetaData":{}}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
DomainIDs: []int64{300285955, 300286336},
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
DomainID: 300286336,
Email: "new-email@example.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
})
require.NoError(t, err)
require.Len(t, transport.requests, 6)
require.Equal(t, http.MethodGet, transport.requests[3].Method)
require.Equal(t, "300286336", transport.requests[3].URL.Query().Get("domainId"))
require.Equal(t, http.MethodGet, transport.requests[4].Method)
require.Equal(t, "300285955", transport.requests[4].URL.Query().Get("domainId"))
require.Equal(t, http.MethodPatch, transport.requests[5].Method)
require.Equal(t, "/v1.0/users/works-user-1", transport.requests[5].URL.Path)
}
func TestWorksmobileHTTPClientExternalKeyLookupRejectsDuplicateMatches(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusConflict, body: `{"code":"CONFLICT","description":"This externalKey(user-1) of user already exists."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (new-email@example.com) does not exist."}`},
{statusCode: http.StatusNotFound, body: `{"code":"NOT_FOUND","description":"User (user-1) does not exist."}`},
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-1","userExternalKey":"user-1","email":"old-email-1@example.com"}],"responseMetaData":{}}`},
{statusCode: http.StatusOK, body: `{"users":[{"userId":"works-user-2","userExternalKey":"user-1","email":"old-email-2@example.com"}],"responseMetaData":{}}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
DomainIDs: []int64{300286336, 300285955},
HTTPClient: &http.Client{Transport: transport},
}
err := client.UpsertUser(context.Background(), WorksmobileUserPayload{
DomainID: 300286336,
Email: "new-email@example.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Tester"},
})
require.Error(t, err)
require.Contains(t, err.Error(), "multiple worksmobile users matched external key")
require.Len(t, transport.requests, 5)
}
func TestWorksmobileHTTPClientAddUserAliasEmailPostsDirectoryAliasEndpoint(t *testing.T) {
transport := &captureRoundTripper{
statusCode: http.StatusCreated,
@@ -637,6 +830,45 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
}
func TestWorksmobileRelayWorkerProcessesAutomaticUserUpdateOnlyWithoutCreate(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: "moved@samaneng.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Moved User"},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 300285955,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{OrgUnitID: "externalKey:new-tenant", Primary: true},
},
},
},
}),
},
},
}
repo.ready[0].Payload["provisioningMode"] = "update_only"
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"job-1"}, repo.processedIDs)
require.Empty(t, client.createdUsers)
require.Len(t, client.updatedUsers, 1)
require.Equal(t, "moved@samaneng.com", client.updatedUsers[0].Email)
}
func TestWorksmobileRelayWorkerRegistersAliasEmailsAfterUserUpsert(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
@@ -1482,6 +1714,7 @@ type fakeWorksmobileDirectoryClient struct {
createdOrgUnits []WorksmobileOrgUnitPayload
deletedOrgUnits []string
createdUsers []WorksmobileUserPayload
updatedUsers []WorksmobileUserPayload
deletedUsers []string
activeUsers []string
suspendedUsers []string
@@ -1610,6 +1843,11 @@ func (f *fakeWorksmobileDirectoryClient) UpsertUser(ctx context.Context, payload
return nil
}
func (f *fakeWorksmobileDirectoryClient) UpdateUserOnly(ctx context.Context, payload WorksmobileUserPayload) error {
f.updatedUsers = append(f.updatedUsers, payload)
return nil
}
func (f *fakeWorksmobileDirectoryClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error {
f.aliasEmails = append(f.aliasEmails, userID+":"+email)
return nil

View File

@@ -202,6 +202,14 @@ func BuildWorksmobileUserPayloadForDomainTenant(user domain.User, tenant domain.
}
func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
return buildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, rootConfig, true)
}
func BuildWorksmobileUserPayloadForScopedDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
return buildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, rootConfig, false)
}
func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap, includeFallbackTenant bool) (WorksmobileUserPayload, error) {
if err := ValidateWorksmobileExternalKey(user.ID); err != nil {
return WorksmobileUserPayload{}, err
}
@@ -211,7 +219,9 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
if tenantByID == nil {
tenantByID = map[string]domain.Tenant{}
}
tenantByID[tenant.ID] = tenant
if includeFallbackTenant {
tenantByID[tenant.ID] = tenant
}
domainID, err := ResolveWorksmobileAccountDomainIDFromEmail(user.Email, tenant, rootConfig)
if err != nil {
return WorksmobileUserPayload{}, err
@@ -253,7 +263,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
if len(appointments) == 0 {
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
} else if !worksmobileAppointmentsContainTenant(appointments, tenant.ID) && !worksmobileAppointmentsHavePrimary(appointments) {
} else if !worksmobileAppointmentsContainSyncableOrgUnit(appointments, tenantByID) && !worksmobileAppointmentsContainTenant(appointments, tenant.ID) {
appointments = append([]worksmobileAppointment{{
TenantID: tenant.ID,
IsPrimary: true,
@@ -284,6 +294,10 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
if !ok {
continue
}
if worksmobileTenantExcludedFromSync(appointmentTenant, tenantByID) {
seen[appointment.TenantID] = true
continue
}
if worksmobileShouldSkipEmailDomainRootAppointment(appointment, appointmentTenant, appointments, tenantByID) {
seen[appointment.TenantID] = true
continue
@@ -303,8 +317,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
if err != nil {
return nil, "", err
}
isAccountDomain := worksmobileTenantDomainIDEnvKey(domainTenant) == accountDomainEnvKey
isPrimaryOrganization := isAccountDomain && !worksmobileOrganizationsHavePrimary(organizations)
isPrimaryOrganization := !worksmobileOrganizationsHavePrimary(organizations)
organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
orgUnit := WorksmobileUserOrgUnit{
OrgUnitID: "externalKey:" + appointmentTenant.ID,
@@ -361,6 +374,23 @@ func worksmobileAppointmentsContainTenant(appointments []worksmobileAppointment,
return false
}
func worksmobileAppointmentsContainSyncableOrgUnit(appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant) bool {
for _, appointment := range appointments {
tenant, ok := tenantByID[strings.TrimSpace(appointment.TenantID)]
if !ok {
continue
}
if worksmobileTenantExcludedFromSync(tenant, tenantByID) {
continue
}
if isWorksmobileDomainRootTenant(tenant) {
continue
}
return true
}
return false
}
func worksmobileAppointmentsHavePrimary(appointments []worksmobileAppointment) bool {
for _, appointment := range appointments {
if appointment.IsPrimary {
@@ -376,6 +406,9 @@ func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment,
if !ok {
continue
}
if worksmobileTenantExcludedFromSync(tenant, tenantByID) {
continue
}
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
if worksmobileTenantDomainIDEnvKey(domainTenant) == envKey {
return true
@@ -384,6 +417,26 @@ func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment,
return false
}
func worksmobileTenantExcludedFromSync(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
visited := map[string]bool{}
current := tenant
for {
if WorksmobileExcluded(current.Config) {
return true
}
parentID := worksmobileTenantParentID(current)
if parentID == "" || visited[parentID] {
return false
}
visited[parentID] = true
parent, ok := tenantByID[parentID]
if !ok {
return false
}
current = parent
}
}
func worksmobileShouldSkipEmailDomainRootAppointment(appointment worksmobileAppointment, tenant domain.Tenant, appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant) bool {
if strings.TrimSpace(appointment.Source) != "email_domain" || !isWorksmobileDomainRootTenant(tenant) {
return false

View File

@@ -315,19 +315,164 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
)
require.NoError(t, err)
require.Equal(t, "Engineering", payload.Task)
require.Equal(t, "PM", payload.Task)
require.Len(t, payload.Organizations, 2)
require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
require.Equal(t, "externalKey:"+secondaryTenantID, 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.True(t, *payload.Organizations[0].OrgUnits[0].IsManager)
require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
require.Nil(t, payload.Organizations[1].OrgUnits[0].IsManager)
}
func TestBuildWorksmobileUserPayloadUsesFirstSyncableAppointmentAsWorksmobilePrimary(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
hanmacRootID := "11111111-1111-1111-1111-111111111111"
samanRootID := "22222222-2222-2222-2222-222222222222"
firstTenantID := "33333333-3333-3333-3333-333333333333"
secondTenantID := "44444444-4444-4444-4444-444444444444"
user := domain.User{
ID: "55555555-5555-5555-5555-555555555555",
Email: "first-order@samaneng.com",
Name: "First Order User",
TenantID: &secondTenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": firstTenantID,
"isPrimary": false,
},
map[string]any{
"tenantId": secondTenantID,
"isPrimary": true,
},
},
},
}
hanmacRoot := domain.Tenant{
ID: hanmacRootID,
Slug: "hanmac",
Name: "한맥기술",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
}
samanRoot := domain.Tenant{
ID: samanRootID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
firstTenant := domain.Tenant{
ID: firstTenantID,
Slug: "first-team",
Name: "First Team",
Type: domain.TenantTypeOrganization,
ParentID: &hanmacRootID,
}
secondTenant := domain.Tenant{
ID: secondTenantID,
Slug: "second-team",
Name: "Second Team",
Type: domain.TenantTypeOrganization,
ParentID: &samanRootID,
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
secondTenant,
map[string]domain.Tenant{
hanmacRootID: hanmacRoot,
samanRootID: samanRoot,
firstTenantID: firstTenant,
secondTenantID: secondTenant,
},
nil,
)
require.NoError(t, err)
require.Len(t, payload.Organizations, 2)
require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
require.Equal(t, "externalKey:"+firstTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
}
func TestBuildWorksmobileUserPayloadSkipsExcludedAppointmentWhenChoosingWorksmobilePrimary(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
excludedRootID := "11111111-1111-1111-1111-111111111111"
includedRootID := "22222222-2222-2222-2222-222222222222"
excludedTenantID := "33333333-3333-3333-3333-333333333333"
includedTenantID := "44444444-4444-4444-4444-444444444444"
user := domain.User{
ID: "55555555-5555-5555-5555-555555555555",
Email: "skip-excluded@samaneng.com",
Name: "Skip Excluded User",
TenantID: &includedTenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{"tenantId": excludedTenantID},
map[string]any{"tenantId": includedTenantID},
},
},
}
excludedRoot := domain.Tenant{
ID: excludedRootID,
Slug: "hanmac",
Name: "한맥기술",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
Config: domain.JSONMap{"worksmobileExcluded": true},
}
includedRoot := domain.Tenant{
ID: includedRootID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
excludedTenant := domain.Tenant{
ID: excludedTenantID,
Slug: "excluded-team",
Name: "Excluded Team",
Type: domain.TenantTypeOrganization,
ParentID: &excludedRootID,
}
includedTenant := domain.Tenant{
ID: includedTenantID,
Slug: "included-team",
Name: "Included Team",
Type: domain.TenantTypeOrganization,
ParentID: &includedRootID,
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
includedTenant,
map[string]domain.Tenant{
excludedRootID: excludedRoot,
includedRootID: includedRoot,
excludedTenantID: excludedTenant,
includedTenantID: includedTenant,
},
nil,
)
require.NoError(t, err)
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:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.Equal(t, "externalKey:"+includedTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
require.Nil(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.True(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 TestBuildWorksmobileUserPayloadKeepsPrimaryTenantWhenEmailDomainAppointmentExists(t *testing.T) {

View File

@@ -141,7 +141,11 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
}
aliasEmails := append([]string(nil), payload.AliasEmails...)
payload.AliasEmails = nil
if err := w.client.UpsertUser(ctx, payload); err != nil {
if stringValue(job.Payload[worksmobileProvisioningModeKey]) == worksmobileProvisioningUpdateOnly {
if err := w.client.UpdateUserOnly(ctx, payload); err != nil {
return fmt.Errorf("worksmobile user update failed: %w", err)
}
} else if err := w.client.UpsertUser(ctx, payload); err != nil {
return fmt.Errorf("worksmobile user upsert failed: %w", err)
}
for _, aliasEmail := range aliasEmails {

View File

@@ -16,14 +16,18 @@ import (
)
const (
HanmacFamilyTenantSlug = "hanmac-family"
worksmobileExcludedConfigKey = "worksmobileExcluded"
HanmacFamilyTenantSlug = "hanmac-family"
worksmobileExcludedConfigKey = "worksmobileExcluded"
worksmobileIdentityMirrorVersion = "kratos-full-pagination-v1"
worksmobileProvisioningModeKey = "provisioningMode"
worksmobileProvisioningUpdateOnly = "update_only"
)
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
EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error
EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error
}
@@ -103,55 +107,62 @@ type WorksmobileComparison struct {
}
type WorksmobileComparisonItem struct {
ResourceType string `json:"resourceType"`
BaronID string `json:"baronId,omitempty"`
BaronSlug string `json:"baronSlug,omitempty"`
BaronName string `json:"baronName,omitempty"`
BaronEmail string `json:"baronEmail,omitempty"`
BaronPhone string `json:"baronPhone,omitempty"`
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
BaronParentID string `json:"baronParentId,omitempty"`
BaronParentSlug string `json:"baronParentSlug,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"`
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
WorksmobileTask string `json:"worksmobileTask,omitempty"`
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"`
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
Status string `json:"status"`
ResourceType string `json:"resourceType"`
BaronID string `json:"baronId,omitempty"`
BaronSlug string `json:"baronSlug,omitempty"`
BaronName string `json:"baronName,omitempty"`
BaronEmail string `json:"baronEmail,omitempty"`
BaronPhone string `json:"baronPhone,omitempty"`
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
BaronParentID string `json:"baronParentId,omitempty"`
BaronParentSlug string `json:"baronParentSlug,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"`
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
WorksmobileTask string `json:"worksmobileTask,omitempty"`
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"`
BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
UpdateReasons []string `json:"updateReasons,omitempty"`
Status string `json:"status"`
}
type worksmobileSyncService struct {
tenantService TenantService
userRepo repository.UserRepository
outboxRepo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient
tenantService TenantService
userRepo repository.UserRepository
outboxRepo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient
identityMirror WorksmobileIdentityMirror
}
type WorksmobileIdentityMirror interface {
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
ListIdentityMirrors(ctx context.Context) ([]KratosIdentity, error)
}
func NewWorksmobileSyncService(tenantService TenantService, userRepo repository.UserRepository, outboxRepo repository.WorksmobileOutboxRepository, client WorksmobileDirectoryClient) *worksmobileSyncService {
@@ -163,6 +174,13 @@ func NewWorksmobileSyncService(tenantService TenantService, userRepo repository.
}
}
func (s *worksmobileSyncService) SetIdentityMirror(source WorksmobileIdentityMirror) {
if s == nil {
return
}
s.identityMirror = source
}
func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) {
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
if err != nil {
@@ -344,7 +362,7 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
tenantIDs = append(tenantIDs, tenant.ID)
}
}
users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs)
users, err := s.comparisonUsers(ctx, tenantIDs)
if err != nil {
return WorksmobileComparison{}, err
}
@@ -360,11 +378,96 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000)
return WorksmobileComparison{
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID, worksmobileUserJobSummaries(recentJobs)),
Users: compareWorksmobileUsersWithRemoteGroups(users, remoteUsers, includeMatched, tenantByID, remoteGroups, worksmobileUserJobSummaries(recentJobs)),
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
}, nil
}
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
if s.identityMirror != nil {
status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
if err == nil &&
status.RedisReady &&
status.Status == "ready" &&
status.MirrorVersion == worksmobileIdentityMirrorVersion {
identities, err := s.identityMirror.ListIdentityMirrors(ctx)
if err != nil {
return nil, err
}
return worksmobileUsersFromIdentityMirror(identities, tenantIDs), nil
}
}
return s.userRepo.FindByTenantIDs(ctx, tenantIDs)
}
func worksmobileUsersFromIdentityMirror(identities []KratosIdentity, tenantIDs []string) []domain.User {
allowed := make(map[string]bool, len(tenantIDs))
for _, tenantID := range tenantIDs {
allowed[strings.TrimSpace(tenantID)] = true
}
users := make([]domain.User, 0, len(identities))
for _, identity := range identities {
tenantID := traitString(identity.Traits, "tenant_id")
if tenantID == "" || !allowed[tenantID] {
continue
}
user := worksmobileUserFromIdentity(identity)
users = append(users, user)
}
return users
}
func worksmobileUserFromIdentity(identity KratosIdentity) domain.User {
metadata := domain.JSONMap{}
for key, value := range identity.Traits {
metadata[key] = value
}
tenantID := traitString(identity.Traits, "tenant_id")
status := domain.UserStatusActive
if identity.State == "inactive" {
status = domain.UserStatusArchived
}
if traitStatus := traitString(identity.Traits, "status"); traitStatus != "" {
status = domain.NormalizeUserStatus(traitStatus)
}
user := domain.User{
ID: strings.TrimSpace(identity.ID),
Email: traitString(identity.Traits, "email"),
Name: traitString(identity.Traits, "name"),
Phone: traitString(identity.Traits, "phone_number"),
Role: domain.NormalizeRole(traitString(identity.Traits, "role")),
AffiliationType: traitString(identity.Traits, "affiliationType"),
Department: traitString(identity.Traits, "department"),
Grade: traitString(identity.Traits, "grade"),
Position: traitString(identity.Traits, "position"),
JobTitle: traitString(identity.Traits, "jobTitle"),
Metadata: metadata,
Status: status,
CreatedAt: identity.CreatedAt,
UpdatedAt: identity.UpdatedAt,
}
if tenantID != "" {
user.TenantID = &tenantID
}
return user
}
func traitString(traits map[string]any, key string) string {
if traits == nil {
return ""
}
value, ok := traits[key]
if !ok || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
@@ -545,35 +648,32 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
}
_, tenantInScope := tenantByID[tenant.ID]
if domain.IsWorksDeprovisionUserStatus(user.Status) {
return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID)
}
if !domain.IsWorksProvisionedUserStatus(user.Status) {
return nil, errors.New("target user status is excluded from Worksmobile sync")
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
*user,
*tenant,
tenantByID,
root.Config,
)
if err != nil {
err := errors.New("target user status is excluded from Worksmobile sync")
if recordErr := s.recordRejectedUserSync(ctx, root.ID, *user, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
initialPassword = strings.TrimSpace(initialPassword)
if initialPassword != "" {
payload.PasswordConfig = WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: initialPassword,
}
buildPayload := BuildWorksmobileUserPayloadForDomainTenants
if !tenantInScope {
buildPayload = BuildWorksmobileUserPayloadForScopedDomainTenants
}
payload, err := buildPayload(*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)
if action == domain.WorksmobileActionUpsert {
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword)
}
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
@@ -594,10 +694,48 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
return item, nil
}
func (s *worksmobileSyncService) recordRejectedUserSync(ctx context.Context, rootID string, user domain.User, tenant domain.Tenant, reason error) error {
payload := WorksmobileUserPayload{
Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: metadataEmployeeNumber(user.Metadata),
Locale: "ko_KR",
Task: strings.TrimSpace(user.JobTitle),
}
outboxPayload := worksmobileUserOutboxPayload(rootID, payload, user.Status)
outboxPayload["displayName"] = strings.TrimSpace(user.Name)
outboxPayload["primaryLeafOrgName"] = strings.TrimSpace(tenant.Name)
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: WorksmobileUserStatusAction(user.Status),
DedupeKey: worksmobileUserSyncDedupeKey("rejected", user.ID),
Payload: outboxPayload,
Status: domain.WorksmobileOutboxStatusFailed,
LastError: reason.Error(),
}
return s.outboxRepo.Create(ctx, item)
}
func worksmobileUserSyncDedupeKey(action, userID string) string {
return "user:" + strings.ToLower(action) + ":" + userID + ":" + uuid.NewString()
}
func worksmobileAdminInitialPasswordConfig(password string) WorksmobilePasswordConfig {
password = strings.TrimSpace(password)
if password == "" {
password = GenerateWorksmobileInitialPassword()
}
changePasswordAtNextLogin := true
return WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: password,
ChangePasswordAtNextLogin: &changePasswordAtNextLogin,
}
}
func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
@@ -629,10 +767,11 @@ func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, t
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
buildPayload := BuildWorksmobileUserPayloadForDomainTenants
if _, ok := tenantByID[tenant.ID]; !ok {
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
buildPayload = BuildWorksmobileUserPayloadForScopedDomainTenants
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
payload, err := buildPayload(*user, *tenant, tenantByID, root.Config)
if err != nil {
return nil, err
}
@@ -848,6 +987,14 @@ func (s *worksmobileSyncService) EnqueueTenantDeleteIfInScope(ctx context.Contex
}
func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error {
return s.enqueueUserUpsertIfInScope(ctx, user, false)
}
func (s *worksmobileSyncService) EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error {
return s.enqueueUserUpsertIfInScope(ctx, user, true)
}
func (s *worksmobileSyncService) enqueueUserUpsertIfInScope(ctx context.Context, user domain.User, updateOnly bool) error {
if user.TenantID == nil || *user.TenantID == "" {
return nil
}
@@ -887,12 +1034,19 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
return err
}
action := WorksmobileUserStatusAction(user.Status)
if action == domain.WorksmobileActionUpsert && !updateOnly {
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig("")
}
outboxPayload := worksmobileUserOutboxPayload(root.ID, payload, user.Status)
if action == domain.WorksmobileActionUpsert && updateOnly {
outboxPayload[worksmobileProvisioningModeKey] = worksmobileProvisioningUpdateOnly
}
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: action,
DedupeKey: worksmobileUserSyncDedupeKey(action, user.ID),
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
Payload: outboxPayload,
})
}
@@ -1429,10 +1583,15 @@ func worksmobileUserJobSummaries(jobs []domain.WorksmobileOutbox) map[string]wor
}
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
return compareWorksmobileUsersWithRemoteGroups(localUsers, remoteUsers, includeMatched, localTenants, nil, jobSummaries...)
}
func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, remoteGroups []WorksmobileRemoteGroup, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
jobSummaryByUserID := map[string]worksmobileUserJobSummary{}
if len(jobSummaries) > 0 && jobSummaries[0] != nil {
jobSummaryByUserID = jobSummaries[0]
}
remoteOrgUnitByExternalID := worksmobileRemoteOrgUnitByExternalID(remoteGroups)
remoteByExternalID := map[string]WorksmobileRemoteUser{}
remoteByEmail := map[string]WorksmobileRemoteUser{}
for _, remote := range remoteUsers {
@@ -1462,7 +1621,11 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
if !matched {
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
}
needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote, localTenants)
updateReasons := []string(nil)
if matched {
updateReasons = worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)
}
needsUpdate := len(updateReasons) > 0
if matched && !includeMatched && !needsUpdate {
matchedRemoteIDs[remote.ID] = true
continue
@@ -1491,6 +1654,7 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
item.Status = "matched"
if needsUpdate {
item.Status = "needs_update"
item.UpdateReasons = updateReasons
}
item.WorksmobileID = remote.ID
item.ExternalKey = remote.ExternalID
@@ -1571,6 +1735,18 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
return result
}
func worksmobileRemoteOrgUnitByExternalID(remoteGroups []WorksmobileRemoteGroup) map[string]WorksmobileRemoteGroup {
result := make(map[string]WorksmobileRemoteGroup, len(remoteGroups))
for _, remote := range remoteGroups {
externalID := strings.TrimSpace(remote.ExternalID)
if externalID == "" {
continue
}
result[externalID] = remote
}
return result
}
func worksmobileRemoteAccountStatus(remote WorksmobileRemoteUser) string {
return normalizeWorksmobileAccountStatus(
remote.AccountStatus,
@@ -1582,29 +1758,40 @@ func worksmobileRemoteAccountStatus(remote WorksmobileRemoteUser) string {
)
}
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool {
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
return len(worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)) > 0
}
func worksmobileUserUpdateReasons(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []string {
reasons := []string{}
if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) {
return true
reasons = append(reasons, "external_key")
}
if strings.TrimSpace(remote.DisplayName) != strings.TrimSpace(user.Name) {
return true
reasons = append(reasons, "name")
}
if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) {
return true
reasons = append(reasons, "email")
}
if worksmobileUserPhoneNeedsUpdate(user, remote) {
return true
reasons = append(reasons, "phone")
}
if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) {
return true
reasons = append(reasons, "employee_number")
}
return false
if worksmobileUserOrganizationsNeedUpdate(user, remote, localTenants, remoteOrgUnitByExternalID) {
reasons = append(reasons, "organization")
}
if worksmobileUserManagerNeedsUpdate(user, remote) {
reasons = append(reasons, "manager")
}
return reasons
}
func worksmobileUserPhoneNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
localPhone := normalizeWorksmobilePhoneForCompare(user.Phone)
remotePhone := normalizeWorksmobilePhoneForCompare(remote.CellPhone)
if localPhone == "" && remotePhone == "" {
if localPhone == "" {
return false
}
if localPhone != remotePhone {
@@ -1636,11 +1823,11 @@ func worksmobileUserEmployeeNumberNeedsUpdate(user domain.User, remote Worksmobi
return localEmployeeNumber != remoteEmployeeNumber
}
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool {
if len(remote.Organizations) == 0 || user.TenantID == nil || localTenants == nil {
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
if localTenants == nil {
return false
}
tenantID := strings.TrimSpace(*user.TenantID)
tenantID := worksmobileUserComparisonTenantID(user, localTenants)
if tenantID == "" {
return false
}
@@ -1648,11 +1835,34 @@ func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote Worksmobile
if !ok {
return false
}
expected, err := BuildWorksmobileUserPayloadForDomainTenants(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
if err != nil {
expectedOrganizations, _, err := buildWorksmobileUserOrganizations(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
if err != nil || len(expectedOrganizations) == 0 {
return worksmobileUserPrimaryOrganizationNeedsUpdate(user, remote, localTenants, remoteOrgUnitByExternalID)
}
remoteOrganizations := remote.Organizations
if len(remoteOrganizations) == 0 {
remoteOrganizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
} else {
remoteOrganizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
}
return !worksmobileUserOrganizationsEqual(expectedOrganizations, remoteOrganizations)
}
func worksmobileUserPrimaryOrganizationNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
tenantID := worksmobileUserComparisonPrimaryTenantID(user)
if tenantID == "" {
return false
}
return !worksmobileUserOrganizationsEqual(expected.Organizations, remote.Organizations)
tenant, ok := localTenants[tenantID]
if !ok || isWorksmobileDomainRootTenant(tenant) {
return false
}
expectedPrimary := "externalKey:" + tenantID
if remoteOrgUnit, ok := remoteOrgUnitByExternalID[tenantID]; ok && strings.TrimSpace(remoteOrgUnit.ID) != "" {
expectedPrimary = strings.TrimSpace(remoteOrgUnit.ID)
}
remotePrimaryOrgUnits := worksmobileRemotePrimaryOrgUnitIDs(remote)
return !worksmobileOrgUnitIDContains(remotePrimaryOrgUnits, expectedPrimary)
}
func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) domain.JSONMap {
@@ -1664,9 +1874,131 @@ func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) doma
return nil
}
func worksmobileUserComparisonPrimaryTenantID(user domain.User) string {
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
if appointment.IsPrimary && strings.TrimSpace(appointment.TenantID) != "" {
return strings.TrimSpace(appointment.TenantID)
}
}
if user.TenantID == nil {
return ""
}
return strings.TrimSpace(*user.TenantID)
}
func worksmobileUserComparisonTenantID(user domain.User, localTenants map[string]domain.Tenant) string {
if user.TenantID != nil {
tenantID := strings.TrimSpace(*user.TenantID)
if _, ok := localTenants[tenantID]; ok {
return tenantID
}
}
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
tenantID := strings.TrimSpace(appointment.TenantID)
if tenantID == "" {
continue
}
if _, ok := localTenants[tenantID]; ok {
return tenantID
}
}
return ""
}
func worksmobileRemoteUserLegacyOrganizations(remote WorksmobileRemoteUser, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []WorksmobileUserOrganization {
if strings.TrimSpace(remote.PrimaryOrgUnitID) == "" {
return nil
}
return []WorksmobileUserOrganization{
{
DomainID: remote.DomainID,
Email: strings.TrimSpace(remote.Email),
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: worksmobileCanonicalRemoteOrgUnitID(strings.TrimSpace(remote.PrimaryOrgUnitID), remoteOrgUnitByExternalID),
Primary: true,
PositionID: strings.TrimSpace(remote.PrimaryOrgUnitPositionID),
IsManager: remote.PrimaryOrgUnitIsManager,
},
},
},
}
}
func worksmobileRemoteUserOrganizationsForCompare(remote WorksmobileRemoteUser, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []WorksmobileUserOrganization {
if len(remote.Organizations) == 0 {
return nil
}
primaryOrgUnitID := strings.TrimSpace(remote.PrimaryOrgUnitID)
result := make([]WorksmobileUserOrganization, len(remote.Organizations))
for i, organization := range remote.Organizations {
result[i] = organization
if result[i].DomainID == 0 {
result[i].DomainID = remote.DomainID
}
result[i].OrgUnits = make([]WorksmobileUserOrgUnit, len(organization.OrgUnits))
copy(result[i].OrgUnits, organization.OrgUnits)
for j, orgUnit := range result[i].OrgUnits {
result[i].OrgUnits[j].OrgUnitID = worksmobileCanonicalRemoteOrgUnitID(orgUnit.OrgUnitID, remoteOrgUnitByExternalID)
}
if primaryOrgUnitID == "" {
continue
}
for j, orgUnit := range result[i].OrgUnits {
if strings.TrimSpace(orgUnit.OrgUnitID) != worksmobileCanonicalRemoteOrgUnitID(primaryOrgUnitID, remoteOrgUnitByExternalID) {
continue
}
result[i].Primary = true
result[i].OrgUnits[j].Primary = true
}
}
return result
}
func worksmobileCanonicalRemoteOrgUnitID(orgUnitID string, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) string {
orgUnitID = strings.TrimSpace(orgUnitID)
if orgUnitID == "" || strings.HasPrefix(orgUnitID, "externalKey:") {
return orgUnitID
}
for externalID, remoteOrgUnit := range remoteOrgUnitByExternalID {
if strings.TrimSpace(remoteOrgUnit.ID) == orgUnitID && strings.TrimSpace(externalID) != "" {
return "externalKey:" + strings.TrimSpace(externalID)
}
}
return orgUnitID
}
func worksmobileRemotePrimaryOrgUnitIDs(remote WorksmobileRemoteUser) []string {
result := make([]string, 0, 1)
for _, organization := range remote.Organizations {
if !organization.Primary {
continue
}
for _, orgUnit := range organization.OrgUnits {
if orgUnit.Primary {
result = append(result, orgUnit.OrgUnitID)
}
}
}
if len(result) == 0 && strings.TrimSpace(remote.PrimaryOrgUnitID) != "" {
result = append(result, remote.PrimaryOrgUnitID)
}
return result
}
func worksmobileOrgUnitIDContains(values []string, expected string) bool {
expected = strings.TrimSpace(expected)
for _, value := range values {
if strings.TrimSpace(value) == expected {
return true
}
}
return false
}
type worksmobileComparableOrgUnit struct {
organizationPrimary bool
organizationEmail string
unitPrimary bool
positionID string
comparePosition bool
@@ -1688,9 +2020,6 @@ func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, r
if expectedUnit.organizationPrimary != remoteUnit.organizationPrimary {
return false
}
if strings.ToLower(expectedUnit.organizationEmail) != strings.ToLower(remoteUnit.organizationEmail) {
return false
}
if expectedUnit.unitPrimary != remoteUnit.unitPrimary {
return false
}
@@ -1704,6 +2033,23 @@ func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, r
return true
}
func worksmobilePrimaryOrgUnitCompareKey(organizations []WorksmobileUserOrganization) string {
for _, organization := range organizations {
if !organization.Primary {
continue
}
for _, orgUnit := range organization.OrgUnits {
if !orgUnit.Primary {
continue
}
if key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID); key != "" {
return key
}
}
}
return ""
}
func flattenExpectedWorksmobileUserOrganizations(organizations []WorksmobileUserOrganization) map[string]worksmobileComparableOrgUnit {
result := map[string]worksmobileComparableOrgUnit{}
for _, organization := range organizations {
@@ -1714,7 +2060,6 @@ func flattenExpectedWorksmobileUserOrganizations(organizations []WorksmobileUser
}
result[key] = worksmobileComparableOrgUnit{
organizationPrimary: organization.Primary,
organizationEmail: strings.TrimSpace(organization.Email),
unitPrimary: orgUnit.Primary,
positionID: strings.TrimSpace(orgUnit.PositionID),
comparePosition: strings.TrimSpace(orgUnit.PositionID) != "",
@@ -1736,7 +2081,6 @@ func flattenRemoteWorksmobileUserOrganizations(organizations []WorksmobileUserOr
}
result[key] = worksmobileComparableOrgUnit{
organizationPrimary: organization.Primary,
organizationEmail: strings.TrimSpace(organization.Email),
unitPrimary: orgUnit.Primary,
positionID: strings.TrimSpace(orgUnit.PositionID),
manager: orgUnit.IsManager,
@@ -1766,22 +2110,43 @@ func worksmobileUserManagerNeedsUpdate(user domain.User, remote WorksmobileRemot
if len(localManagers) == 0 {
return false
}
remoteManagers := remote.OrgUnitManagers
if len(remoteManagers) == 0 && remote.PrimaryOrgUnitID != "" {
remoteManagers = map[string]*bool{remote.PrimaryOrgUnitID: remote.PrimaryOrgUnitIsManager}
}
for remoteOrgUnitID, remoteManager := range remoteManagers {
if remoteManager == nil {
continue
remoteManagers := worksmobileRemoteOrgUnitManagerMap(remote)
for localOrgUnitID, localManager := range localManagers {
remoteManager := false
if value, ok := remoteManagers[localOrgUnitID]; ok && value != nil {
remoteManager = *value
}
localManager, ok := localManagers[worksmobileOrgUnitLocalExternalKey(remoteOrgUnitID)]
if ok && localManager != *remoteManager {
if localManager != remoteManager {
return true
}
}
return false
}
func worksmobileRemoteOrgUnitManagerMap(remote WorksmobileRemoteUser) map[string]*bool {
result := map[string]*bool{}
for orgUnitID, isManager := range remote.OrgUnitManagers {
normalized := worksmobileOrgUnitLocalExternalKey(orgUnitID)
if normalized == "" {
continue
}
result[normalized] = isManager
}
for _, organization := range remote.Organizations {
for _, orgUnit := range organization.OrgUnits {
normalized := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
if normalized == "" {
continue
}
result[normalized] = orgUnit.IsManager
}
}
if strings.TrimSpace(remote.PrimaryOrgUnitID) != "" {
result[worksmobileOrgUnitLocalExternalKey(remote.PrimaryOrgUnitID)] = remote.PrimaryOrgUnitIsManager
}
return result
}
func worksmobileUserExplicitOrgUnitManagers(user domain.User) map[string]bool {
managers := map[string]bool{}
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {

View File

@@ -160,9 +160,11 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
require.True(t, ok)
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
require.Equal(t, "InputPass1!", request.PasswordConfig.Password)
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
}
func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T) {
func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
tenantID := "saman-tenant"
@@ -198,10 +200,14 @@ func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T)
require.NoError(t, err)
require.NotNil(t, item)
require.NotContains(t, outboxRepo.created[0].Payload, "initialPassword")
initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"])
require.NotEmpty(t, initialPassword)
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.Empty(t, request.PasswordConfig.Password)
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
require.Equal(t, initialPassword, request.PasswordConfig.Password)
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
}
func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) {
@@ -286,6 +292,48 @@ func TestWorksmobileSyncServiceCreatesDistinctAutomaticUserSyncHistoryJobs(t *te
require.NotEqual(t, outboxRepo.created[0].DedupeKey, outboxRepo.created[1].DedupeKey)
}
func TestWorksmobileSyncServiceUserUpdateIsUpdateOnlyWithoutInitialPassword(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
tenantID := "saman-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "Hanmac Family",
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "saman",
Name: "Saman",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
target := domain.User{
ID: "target-user",
Email: "target@samaneng.com",
Name: "Target",
Status: domain.UserStatusActive,
TenantID: &tenantID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
outboxRepo,
nil,
)
require.NoError(t, service.EnqueueUserUpdateIfInScope(context.Background(), target))
require.Len(t, outboxRepo.created, 1)
require.Equal(t, "update_only", outboxRepo.created[0].Payload["provisioningMode"])
require.NotContains(t, outboxRepo.created[0].Payload, "initialPassword")
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.True(t, request.PasswordConfig.IsZero())
}
func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
@@ -333,6 +381,49 @@ func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testi
require.NotEmpty(t, outboxRepo.created[0].Payload["initialPassword"])
}
func TestWorksmobileSyncServicePasswordResetAllowsExcludedPrimaryTenant(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
excludedOrgID := "excluded-org"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "Hanmac Family",
}
excludedOrg := domain.Tenant{
ID: excludedOrgID,
Slug: "excluded-team",
Name: "Excluded Team",
Type: domain.TenantTypeOrganization,
ParentID: &rootID,
Config: domain.JSONMap{"worksmobileExcluded": true},
}
target := domain.User{
ID: "target-user",
Email: "target@samaneng.com",
Name: "Target",
Status: domain.UserStatusActive,
TenantID: &excludedOrgID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, excludedOrgID: excludedOrg}, list: []domain.Tenant{root, excludedOrg}},
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
outboxRepo,
nil,
)
item, err := service.EnqueueUserPasswordReset(context.Background(), rootID, target.ID, "reset-batch-1")
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileActionPasswordReset, outboxRepo.created[0].Action)
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
require.Empty(t, outboxRepo.created[0].Payload["primaryLeafOrgName"])
}
func TestWorksmobileSyncServiceFiltersInitialPasswordsByCredentialBatchID(t *testing.T) {
rootID := "root-tenant"
root := domain.Tenant{
@@ -1650,6 +1741,69 @@ func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T)
require.ElementsMatch(t, []string{companyID, userGroupID}, userRepo.requestedTenantIDs)
}
func TestWorksmobileSyncServiceGetComparisonUsesIdentityMirrorWhenReady(t *testing.T) {
rootID := "root-tenant"
companyID := "company-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
company := domain.Tenant{
ID: companyID,
Name: "계열사",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
}
userRepo := &fakeWorksmobileUserRepo{byTenant: []domain.User{{
ID: "local-only-user",
Email: "local-only@example.com",
Name: "Local Only",
TenantID: &companyID,
Status: domain.UserStatusActive,
}}}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}},
userRepo,
&fakeWorksmobileOutboxRepo{},
&fakeWorksmobileDirectoryClient{users: []WorksmobileRemoteUser{{
ID: "works-user",
ExternalID: "mirror-user",
Email: "mirror@example.com",
DisplayName: "Mirror User",
PrimaryOrgUnitID: "externalKey:" + companyID,
}}},
)
service.SetIdentityMirror(&fakeWorksmobileIdentityMirror{
status: domain.IdentityCacheStatus{
Status: "ready",
RedisReady: true,
MirrorVersion: "kratos-full-pagination-v1",
ObservedCount: 1,
},
identities: []KratosIdentity{{
ID: "mirror-user",
State: "active",
Traits: map[string]any{
"email": "mirror@example.com",
"name": "Mirror User",
"tenant_id": companyID,
"role": domain.RoleUser,
"affiliationType": "internal",
},
}},
})
comparison, err := service.GetComparison(context.Background(), rootID, true)
require.NoError(t, err)
require.Empty(t, userRepo.requestedTenantIDs)
require.Len(t, comparison.Users, 1)
require.Equal(t, "matched", comparison.Users[0].Status)
require.Equal(t, "mirror-user", comparison.Users[0].BaronID)
require.Equal(t, "mirror-user", comparison.Users[0].ExternalKey)
}
func TestWorksmobileSyncServiceSkipsArchivedUsersInComparison(t *testing.T) {
rootID := "root-tenant"
companyID := "company-tenant"
@@ -1898,6 +2052,7 @@ func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
}
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
excludedCompanyID := "excluded-company"
excludedOrgID := "excluded-org"
@@ -1944,12 +2099,26 @@ func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T)
require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), user))
item, err := service.EnqueueUserSync(context.Background(), rootID, user.ID, "", "")
require.Nil(t, item)
require.ErrorContains(t, err, "excluded from Worksmobile sync")
require.Empty(t, outboxRepo.created)
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileResourceUser, outboxRepo.created[0].ResourceType)
require.Equal(t, user.ID, outboxRepo.created[0].ResourceID)
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
require.Empty(t, outboxRepo.created[0].Status)
require.Empty(t, outboxRepo.created[0].LastError)
require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"])
require.Equal(t, user.Email, outboxRepo.created[0].Payload["loginEmail"])
require.Equal(t, user.Name, outboxRepo.created[0].Payload["displayName"])
require.Empty(t, outboxRepo.created[0].Payload["primaryLeafOrgName"])
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.Empty(t, request.Organizations)
}
func TestCompareWorksmobileUsersIgnoresManagerChange(t *testing.T) {
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-saman"
tenantID := "tenant-leaf"
user := domain.User{
ID: "user-manager",
@@ -1977,20 +2146,37 @@ func TestCompareWorksmobileUsersIgnoresManagerChange(t *testing.T) {
DisplayName: user.Name,
PrimaryOrgUnitID: "externalKey:" + tenantID,
PrimaryOrgUnitIsManager: &remoteManager,
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:" + tenantID,
Primary: true,
IsManager: &remoteManager,
},
},
},
},
}},
true,
map[string]domain.Tenant{
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization},
rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &rootID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersIgnoresSecondaryManagerChange(t *testing.T) {
primaryTenantID := "tenant-company"
secondaryTenantID := "tenant-gpdtdc-leaf"
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-saman"
primaryTenantID := "tenant-primary"
secondaryTenantID := "tenant-secondary"
user := domain.User{
ID: "user-secondary-manager",
Email: "secondary-manager@samaneng.com",
@@ -2026,19 +2212,39 @@ func TestCompareWorksmobileUsersIgnoresSecondaryManagerChange(t *testing.T) {
"externalKey:" + primaryTenantID: &remotePrimaryManager,
"externalKey:" + secondaryTenantID: &remoteSecondaryManager,
},
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:" + primaryTenantID,
Primary: true,
IsManager: &remotePrimaryManager,
},
{
OrgUnitID: "externalKey:" + secondaryTenantID,
Primary: false,
IsManager: &remoteSecondaryManager,
},
},
},
},
}},
true,
map[string]domain.Tenant{
primaryTenantID: {ID: primaryTenantID, Name: "Company", Type: domain.TenantTypeCompany},
secondaryTenantID: {ID: secondaryTenantID, Name: "GPDTDC Leaf", Type: domain.TenantTypeOrganization},
rootID: {ID: rootID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
primaryTenantID: {ID: primaryTenantID, Name: "Primary", Type: domain.TenantTypeOrganization, ParentID: &rootID},
secondaryTenantID: {ID: secondaryTenantID, Name: "Secondary", Type: domain.TenantTypeOrganization, ParentID: &rootID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersIgnoresMissingSecondaryOrganization(t *testing.T) {
func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
@@ -2097,10 +2303,336 @@ func TestCompareWorksmobileUsersIgnoresMissingSecondaryOrganization(t *testing.T
},
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersIgnoresOrganizationEmailWhenMembershipMatches(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
companyID := "tenant-saman"
tenantID := "tenant-leaf"
user := domain.User{
ID: "user-org-email",
Email: "org-email@samaneng.com",
Name: "Org Email User",
TenantID: &tenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{"tenantId": tenantID},
},
},
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-org-email",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:" + tenantID,
Primary: true,
},
},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersUsesRemoteUserDomainWhenOrganizationDomainIsMissing(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
companyID := "tenant-saman"
tenantID := "tenant-leaf"
user := domain.User{
ID: "user-org-domain",
Email: "org-domain@samaneng.com",
Name: "Org Domain User",
TenantID: &tenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{"tenantId": tenantID},
},
},
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-org-domain",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
DomainID: 1001,
Organizations: []WorksmobileUserOrganization{
{
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:" + tenantID,
Primary: true,
},
},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersUsesRemotePrimaryOrgUnitWhenOrganizationPrimaryFlagsAreMissing(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
companyID := "tenant-saman"
tenantID := "tenant-leaf"
user := domain.User{
ID: "user-org-primary",
Email: "org-primary@samaneng.com",
Name: "Org Primary User",
TenantID: &tenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{"tenantId": tenantID},
},
},
}
items := compareWorksmobileUsersWithRemoteGroups(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-org-primary",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
DomainID: 1001,
PrimaryOrgUnitID: "works-leaf",
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "works-leaf",
},
},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
},
[]WorksmobileRemoteGroup{{
ID: "works-leaf",
ExternalID: tenantID,
}},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersMatchesRemoteOrganizationExternalKey(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
companyID := "tenant-saman"
tenantID := "tenant-leaf"
user := domain.User{
ID: "user-org-external-key",
Email: "org-external-key@samaneng.com",
Name: "Org External Key User",
TenantID: &tenantID,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{"tenantId": tenantID},
},
},
}
items := compareWorksmobileUsersWithRemoteGroups(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-org-external-key",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
DomainID: 1001,
PrimaryOrgUnitID: "works-leaf",
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1001,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:" + tenantID,
Primary: true,
},
},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
tenantID: {ID: tenantID, Slug: "leaf", Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &companyID},
},
[]WorksmobileRemoteGroup{{
ID: "works-leaf",
ExternalID: tenantID,
}},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersMarksMissingPrimaryOrganizationNeedsUpdate(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
gpdtdcID := "tenant-gpdtdc"
peopleGrowthID := "tenant-people-growth"
user := domain.User{
ID: "user-people-growth",
Email: "people-growth@baroncs.co.kr",
Name: "People Growth User",
TenantID: &peopleGrowthID,
Status: domain.UserStatusActive,
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-people-growth",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
DomainID: 1003,
PrimaryOrgUnitID: "externalKey:another-team",
Organizations: []WorksmobileUserOrganization{
{
DomainID: 1003,
Email: user.Email,
Primary: true,
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: "externalKey:another-team",
Primary: true,
},
},
},
},
}},
true,
map[string]domain.Tenant{
rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
peopleGrowthID: {ID: peopleGrowthID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
},
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
require.Equal(t, peopleGrowthID, items[0].BaronPrimaryOrgID)
require.Equal(t, "externalKey:another-team", items[0].WorksmobilePrimaryOrgID)
}
func TestCompareWorksmobileUsersMatchesPrimaryOrganizationByWorksmobileResourceID(t *testing.T) {
peopleGrowthID := "tenant-people-growth"
user := domain.User{
ID: "user-people-growth",
Email: "people-growth@baroncs.co.kr",
Name: "People Growth User",
TenantID: &peopleGrowthID,
Status: domain.UserStatusActive,
}
items := compareWorksmobileUsersWithRemoteGroups(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-people-growth",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
PrimaryOrgUnitID: "works-current-people-growth",
}},
true,
map[string]domain.Tenant{
peopleGrowthID: {ID: peopleGrowthID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization},
},
[]WorksmobileRemoteGroup{{
ID: "works-current-people-growth",
ExternalID: peopleGrowthID,
}},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersMarksStalePrimaryOrganizationResourceIDNeedsUpdate(t *testing.T) {
peopleGrowthID := "tenant-people-growth"
user := domain.User{
ID: "user-people-growth",
Email: "people-growth@baroncs.co.kr",
Name: "People Growth User",
TenantID: &peopleGrowthID,
Status: domain.UserStatusActive,
}
items := compareWorksmobileUsersWithRemoteGroups(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-people-growth",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
PrimaryOrgUnitID: "works-deleted-people-growth",
}},
true,
map[string]domain.Tenant{
peopleGrowthID: {ID: peopleGrowthID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization},
},
[]WorksmobileRemoteGroup{{
ID: "works-current-people-growth",
ExternalID: peopleGrowthID,
}},
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t *testing.T) {
tenantID := "tenant-saman"
user := domain.User{
@@ -2167,6 +2699,63 @@ func TestCompareWorksmobileUsersMarksMalformedRemoteKoreanPhoneNeedsUpdate(t *te
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersIgnoresRemotePhoneWhenBaronPhoneIsEmpty(t *testing.T) {
tenantID := "tenant-halla"
user := domain.User{
ID: "edb8e4f6-3dfd-44d4-a8aa-87332f8b2b38",
Email: "cyhan4@hallasanup.com",
Name: "네이버웍스관리자",
TenantID: &tenantID,
Status: domain.UserStatusActive,
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "fe9449d1-1671-44e4-1848-033779dddbaf",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
CellPhone: "+82 01041585840",
}},
true,
map[string]domain.Tenant{
tenantID: {ID: tenantID, Name: "한라산업개발", Type: domain.TenantTypeCompany},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersTreatsSpacedKoreanCountryCodePhoneAsMatched(t *testing.T) {
tenantID := "tenant-saman"
user := domain.User{
ID: "user-phone-spaced",
Email: "phone-spaced@samaneng.com",
Name: "Phone Spaced User",
Phone: "+821041585840",
TenantID: &tenantID,
Status: domain.UserStatusActive,
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-phone-spaced",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
CellPhone: "+82 1041585840",
}},
true,
map[string]domain.Tenant{
tenantID: {ID: tenantID, Name: "삼안", Type: domain.TenantTypeCompany},
},
)
require.Len(t, items, 1)
require.Equal(t, "matched", items[0].Status)
}
type fakeWorksmobileTenantService struct {
tenants map[string]domain.Tenant
list []domain.Tenant
@@ -2229,6 +2818,19 @@ type fakeWorksmobileUserRepo struct {
requestedTenantIDs []string
}
type fakeWorksmobileIdentityMirror struct {
status domain.IdentityCacheStatus
identities []KratosIdentity
}
func (f *fakeWorksmobileIdentityMirror) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
return f.status, nil
}
func (f *fakeWorksmobileIdentityMirror) ListIdentityMirrors(ctx context.Context) ([]KratosIdentity, error) {
return f.identities, nil
}
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) {

View File

@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { dateTimeInputToUnixSeconds } from "../src/features/clients/rpClaimDateTime";
import {
type Consent,
installDevApiMock,
@@ -7,7 +8,6 @@ import {
} from "./helpers/devfront-fixtures";
import { captureEvidence } from "./helpers/evidence";
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
import { dateTimeInputToUnixSeconds } from "../src/features/clients/rpClaimDateTime";
test.describe("DevFront consents", () => {
test.afterEach(async ({ page }, testInfo) => {

View File

@@ -12,7 +12,6 @@ services:
- IDP_PROVIDER=ory
- OATHKEEPER_API_URL=http://oathkeeper:4456
- PROFILE_CACHE_TTL="${PROFILE_CACHE_TTL:-30m}"
- ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${ORGFRONT_ORGCHART_CACHE_TTL_SECONDS:-3600}
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
ports:
- "${BACKEND_PORT:-3000}:3000"

View File

@@ -381,7 +381,6 @@ services:
- NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER}
- USERFRONT_URL=${USERFRONT_URL}
- REDIS_ADDR=${REDIS_ADDR}
- ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${ORGFRONT_ORGCHART_CACHE_TTL_SECONDS:-3600}
- IDP_PROVIDER=${IDP_PROVIDER:-ory}
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}

View File

@@ -0,0 +1,49 @@
# adminfront 깜빡임 trace 분석
작성일: 2026-06-15
## 입력 자료
- `adminfront/Trace-20260615T113806.json.gz`
## 관찰 결과
- trace 구간은 약 4.18초이다.
- DevTools screenshot은 250장 포함되어 있다.
- 화면 전체 shell이 사라지는 현상은 아니고, 본문 영역이 반복적으로 repaint되는 형태이다.
- `Animation` 이벤트에서 `enter` 애니메이션이 147회 반복 시작되었다.
- 반복 대상은 다음 nodeName으로 확인되었다.
- `DIV class='space-y-4 animate-in fade-in duration-500'`
- 같은 nodeName의 `Paint` 이벤트도 147회 발생했고, span은 약 4.1초였다.
## 원인
장기 유지되는 admin page/tab container에 `animate-in fade-in duration-500` 진입 애니메이션이 붙어 있었다.
이 클래스가 query/refetch, tab content 갱신, 렌더 상태 변화와 결합되면서 본문 영역이 반복 진입 애니메이션을 수행했고, 사용자는 이를 간헐적인 깜빡임으로 보게 된다.
## 수정
다음 장기 컨테이너에서 페이지 레벨 진입 애니메이션을 제거했다.
- `TenantWorksmobilePage` tab panels
- `GlobalOverviewPage` root container
- `DataIntegrityPage` tab panels
- `TenantDetailPage` nested outlet wrapper
Dialog, dropdown, toast처럼 짧게 열리고 닫히는 transient UI의 state-based animation은 유지한다.
## 회귀 방지
- Worksmobile tab panel에 trace 원인 클래스가 다시 들어오지 않도록 테스트를 추가했다.
- adminfront 전체 `.tsx`에서 `animate-in fade-in duration-500` 페이지 레벨 패턴이 재도입되지 않도록 정책 테스트를 추가했다.
## 검증
```bash
pnpm --dir adminfront exec vitest run src/features/coverage/adminPageAnimationPolicy.test.ts --bail 1
pnpm --dir adminfront exec vitest run src/features/tenants/routes/TenantWorksmobilePage.test.ts --bail 1
pnpm --dir adminfront exec vitest run src/features/overview/GlobalOverviewPage.test.tsx --bail 1
pnpm --dir adminfront exec vitest run src/features/integrity/DataIntegrityPage.test.tsx --bail 1
pnpm --dir adminfront exec vitest run src/features/tenants/routes/TenantDetailPage.worksmobile.test.tsx --bail 1
```

View File

@@ -88,7 +88,7 @@
테스트:
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/service -run 'TestWorksmobileSyncServiceSkipsSoftDeletedUsersInComparison' -count=1`
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/handler ./internal/repository -run 'Test.*User|Test.*Projection|Test.*SoftDeleted|Test.*ListUsers' -count=1`
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/handler ./internal/repository -run 'Test.*User|Test.*SoftDeleted|Test.*ListUsers' -count=1`
- `BASE_URL=http://127.0.0.1:5173 npm --prefix adminfront test -- worksmobile.spec.ts --project=chromium`
결과:

View File

@@ -170,12 +170,35 @@ Baron Kratos identity를 Worksmobile user로 보냅니다.
- `passwordConfig.password`: 구성원 생성 시 숫자, 영문, 기호를 모두 포함한 16자리 난수 초기 비밀번호를 생성합니다.
- `task`: Baron `jobTitle`을 우선 사용
- `organizations`
- 원직: 대표 tenant 또는 `additionalAppointments` 중 primary로 선택된 tenant
- 겸직: `metadata.additionalAppointments` 또는 Keto `joinedTenants`
- 원직: Worksmobile 연동 제외 테넌트를 제외한 뒤 `additionalAppointments`에 가장 먼저 등록된 tenant
- 겸직: `metadata.additionalAppointments` 또는 Keto `joinedTenants` 중 Worksmobile 연동 제외가 아닌 tenant
- `orgUnits[].orgUnitId`: `externalKey:{tenant.ID}`
- `levelId`, `positionId`, `userTypeId`: 이번 scope에서는 External Key mapping을 사용하지 않고 사용자 정보 업데이트 필드로 최대한 커버
- `isManager`: `additionalAppointments[].isOwner == true` 또는 Keto owners/admins relation을 기준으로 변환
### 구성원 소속 변경 정책
Worksmobile 연동 화면의 변경 범위는 다음과 같이 분리합니다.
- 조직/그룹 탭은 Worksmobile `orgunits`/`groups` 리소스 자체의 생성, 수정, 삭제, 이동을 관리합니다.
- 구성원 탭은 Worksmobile `users` 리소스의 조직 소속(`organizations[].orgUnits[]`) 변경을 관리합니다.
- 구성원 소속 변경 중 Worksmobile에 대상 org unit이 없거나 부모 구조가 최신이 아니면, 먼저 조직/그룹 탭의 조직 sync로 구조 변경을 반영한 뒤 구성원 sync를 수행합니다.
- `worksmobileExcluded=true`인 테넌트와 그 하위 조직은 조직 sync 및 구성원 소속 sync의 대상에서 제외합니다.
개인 사용자 상세 변경은 대표 조직 1개만 수정하는 동작으로 제한하지 않습니다. Baron의 `metadata.additionalAppointments`에 들어 있는 모든 소속 조직을 기준으로 Worksmobile payload의 `organizations[].orgUnits[]`를 구성합니다. 다만 Worksmobile 연동 제외 테넌트, Worksmobile domain ID를 해석할 수 없는 테넌트, 조직 연동 설정이 켜져 있지 않은 겸직 도메인은 payload에서 제외하고 비교 결과에는 skipped reason 또는 warning을 남깁니다.
WORKS Developers 문서 확인 결과, 구성원 추가/수정 payload는 `organizations[]`와 하위 `orgUnits[]` 배열을 제공하고 `orgUnits`는 최대 30개까지 허용합니다. 또한 대표 도메인과 대표 조직은 각각 하나가 필요합니다. Directory API의 조직 연동 설명에는 구성원 회사 겸직을 설정하려면 겸직 도메인도 조직 연동 사용 설정을 켜야 한다는 제약이 있습니다.
따라서 구현 원칙은 다음과 같습니다.
- 동일 Worksmobile domain 안에서는 Worksmobile 연동 제외가 아닌 조직 소속을 모두 동기화합니다.
- 여러 Worksmobile domain을 넘는 겸직은 해당 domain의 조직 연동 설정이 켜져 있고 Baron에서 domain ID를 해석할 수 있을 때만 동기화합니다.
- Worksmobile primary는 Baron 대표 테넌트 플래그나 `additionalAppointments[].isPrimary=true`를 기준으로 삼지 않습니다. Worksmobile 연동 제외 테넌트를 제거한 뒤 `additionalAppointments`에 가장 먼저 등록된 소속을 최초 반영 소속이자 primary로 둡니다.
- `representative`, `isPrimary`, `primary` 같은 Baron 대표 소속 플래그는 Baron 내부 대표 소속 정책에만 사용하고 Worksmobile 동기화 판단 필드로 사용하지 않습니다.
- 조직장 여부(`isManager`)는 Worksmobile 동기화 대상입니다. 비교 로직은 remote와 Baron의 조직장 여부 차이를 `needs_update`로 판단해야 합니다.
- 비교 로직도 대표 조직 1개가 아니라 `organizations[].orgUnits[]` 전체 set을 기준으로 `org_unit_added`, `org_unit_removed`, `org_unit_moved`, `org_unit_primary_changed` 같은 diff reason을 산출합니다.
- Worksmobile 공식 문서 근거: https://developers.worksmobile.com/kr/docs/user-create, https://developers.worksmobile.com/kr/docs/directory
초기 비밀번호는 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로 해석해 Ory/Keto 관계, 허용된 Backend read model, Worksmobile enqueue가 누락되지 않게 합니다.

View File

@@ -0,0 +1,35 @@
# WORKS 전화번호 송신 포맷 정책
작성일: 2026-06-15
## 목적
Baron 내부 전화번호 표준과 WORKS API 송신 포맷을 분리한다.
## 정책
- Baron 내부 저장 및 비교 표준은 공백 없는 E.164 형태를 유지한다.
- 예: `+821041585840`
- WORKS 계정 생성 및 업데이트 요청으로 전화번호를 보낼 때는 국가번호와 국내 번호 사이에 공백을 둔다.
- 예: `+82 01041585840`
- WORKS 송신용 국내 번호는 `0`으로 시작해야 한다.
- 내부값 `+821041585840`은 WORKS 송신 시 `+82 01041585840`으로 변환한다.
- WORKS 응답 비교는 기존처럼 공백 포함/미포함 형식을 같은 전화번호로 정규화해서 비교한다.
## 적용 범위
- WORKS Directory 사용자 생성 요청
- WORKS Directory 사용자 업데이트 요청
- WORKS SCIM 사용자 생성 요청
## 비적용 범위
- Baron DB 저장값
- Kratos traits 저장값
- Adminfront 비교 화면의 내부 기준 표시값
## 구현 기준
- 내부 payload 생성 단계에서는 기존 E.164 정규화 값을 유지한다.
- 실제 WORKS API 송신 직전 outbound formatter에서만 `+82 0...` 형식으로 변환한다.
- 한국 번호가 아닌 값은 기존 정규화 결과를 유지한다.

View File

@@ -303,6 +303,7 @@ schema_incompatible = "Fields not in target schema may be lost:"
schema_missing = "Missing required fields for target tenant:"
status_placeholder = "Select status"
permission_placeholder = "Select permission"
update_partial_error = "Failed to update {{count}} users."
update_success = "User info updated successfully."
[msg.admin.users.create]
@@ -2839,20 +2840,8 @@ description = "Checks whether user_login_ids.user_id points to a missing or soft
[msg.admin.integrity.check.orphan_user_tenant_memberships]
description = "Checks whether users.tenant_id points to a missing or soft-deleted tenant."
[msg.admin.user_projection]
action_error = "Projection operation failed."
action_success = "Refreshed the projection for {{count}} users."
forbidden_description = "This screen is only available to super_admin users."
load_error = "Failed to load projection status."
reset_confirm = "Rebuild user projection from the Kratos source of truth?"
subtitle = "Review and sync the Kratos user read model."
[msg.admin.user_projection.forbidden]
description = "This screen is only available to super_admin users."
[ui.admin.integrity]
tab_checks = "Integrity Checks"
tab_user_projection = "User Projection"
fetch_error = "Unable to load the final integrity check result."
kicker = "System"
loading = "Loading data integrity report..."
@@ -2934,33 +2923,6 @@ rotate_secret = "Rotate secret"
rotate_secret_done = "Secret rotated"
save_scopes = "Save scopes"
[ui.admin.user_projection]
loading = "Loading user projection data..."
subtitle = "Review and sync the Kratos user read model."
title = "User Projection Management"
[ui.admin.user_projection.actions]
reconcile = "Re-sync"
reset = "Reset and rebuild"
[ui.admin.user_projection.card]
description = "Current user read model state referenced by backend DB statistics."
title = "Kratos users projection"
[ui.admin.user_projection.forbidden]
title = "Access denied"
[ui.admin.user_projection.status]
failed = "failed"
not_ready = "not ready"
ready = "ready"
[ui.admin.user_projection.summary]
last_synced = "Last synced"
projected_users = "Projected users"
status = "Status"
updated_at = "Updated at"
[ui.admin.auth_guard]
subtitle = "Verify admin privileges and ReBAC relationships against the policy engine."
title = "Auth Guard"

View File

@@ -851,6 +851,7 @@ schema_incompatible = "대상 테넌트 스키마에 없는 필드는 유실될
schema_missing = "대상 테넌트의 필수 필드가 누락되어 있습니다:"
status_placeholder = "상태 선택"
permission_placeholder = "권한 선택"
update_partial_error = "{{count}}명의 사용자 정보 수정에 실패했습니다."
update_success = "사용자 정보가 일괄 업데이트되었습니다."
[msg.admin.users.create]
@@ -3284,23 +3285,11 @@ description = "사용자와 로그인 ID 참조의 고아 레코드를 확인합
[msg.admin.integrity]
subtitle = "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다."
[msg.admin.user_projection]
action_error = "사용자 동기화 작업에 실패했습니다."
action_success = "{{count}}명 기준으로 사용자 동기화를 갱신했습니다."
forbidden_description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
load_error = "사용자 동기화 상태를 불러오지 못했습니다."
reset_confirm = "사용자 동기화를 Kratos 기준으로 다시 구축하시겠습니까?"
subtitle = "Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다."
[msg.admin.user_projection.forbidden]
description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
[msg.admin.integrity]
subtitle = "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다."
[ui.admin.integrity]
tab_checks = "정합성 검사"
tab_user_projection = "사용자 동기화"
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
kicker = "시스템"
loading = "불러오는 중"
@@ -3382,32 +3371,6 @@ rotate_secret = "Secret 재발급"
rotate_secret_done = "Secret 재발급 완료"
save_scopes = "권한 저장"
[ui.admin.user_projection]
loading = "불러오는 중"
title = "사용자 동기화 관리"
[ui.admin.user_projection.actions]
reconcile = "재동기화"
reset = "초기화 후 재구축"
[ui.admin.user_projection.card]
description = "Backend DB 통계가 참조하는 사용자 read model 상태입니다."
title = "Kratos 사용자 동기화"
[ui.admin.user_projection.forbidden]
title = "접근 권한이 없습니다"
[ui.admin.user_projection.status]
failed = "실패"
not_ready = "준비되지 않음"
ready = "준비됨"
[ui.admin.user_projection.summary]
last_synced = "마지막 동기화"
projected_users = "동기화 사용자"
status = "상태"
updated_at = "상태 갱신"
[ui.admin.auth_guard]
subtitle = "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다."
title = "인증 가드"

View File

@@ -687,6 +687,7 @@ schema_incompatible = ""
schema_missing = ""
status_placeholder = ""
permission_placeholder = ""
update_partial_error = ""
update_success = ""
[msg.admin.users.create]
@@ -3134,7 +3135,6 @@ description = ""
[ui.admin.integrity]
tab_checks = ""
tab_user_projection = ""
subtitle = ""
[ui.admin.tenants.profile]
@@ -3156,7 +3156,6 @@ kicker = ""
loading = ""
subtitle = ""
tab_checks = ""
tab_user_projection = ""
title = ""
[ui.admin.tenants.profile]
@@ -3168,17 +3167,6 @@ worksmobile_sync = ""
[msg.admin.integrity.section.user_integrity]
description = ""
[msg.admin.user_projection]
action_error = ""
action_success = ""
forbidden_description = ""
load_error = ""
reset_confirm = ""
subtitle = ""
[msg.admin.user_projection.forbidden]
description = ""
[ui.admin.integrity]
fetch_error = ""
kicker = ""
@@ -3260,32 +3248,6 @@ rotate_secret = ""
rotate_secret_done = ""
save_scopes = ""
[ui.admin.user_projection]
loading = ""
title = ""
[ui.admin.user_projection.actions]
reconcile = ""
reset = ""
[ui.admin.user_projection.card]
description = ""
title = ""
[ui.admin.user_projection.forbidden]
title = ""
[ui.admin.user_projection.status]
failed = ""
not_ready = ""
ready = ""
[ui.admin.user_projection.summary]
last_synced = ""
projected_users = ""
status = ""
updated_at = ""
[ui.admin.auth_guard]
subtitle = ""
title = ""

View File

@@ -618,6 +618,120 @@ describe("org chart layout", () => {
]);
});
it("maps appointment memberships by tenant UUID when the stored slug is stale", () => {
const root = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc");
const division = {
...tenantNode("division", "ORGANIZATION", "총괄기획실", "gpd"),
parentId: "gpdtdc",
};
const migratedLeaf = {
...tenantNode(
"migrated-leaf",
"ORGANIZATION",
"통합시스템",
"intigrated-system",
),
parentId: "division",
};
const rootNode = {
...root,
children: [{ ...division, children: [migratedLeaf] }],
};
const usersMap = buildUsersMap(
[
{
...member("migrated-user"),
companyCode: undefined,
tenantSlug: "gpdtdc",
metadata: {
additionalAppointments: [
{
tenantId: "migrated-leaf",
tenantName: "기술기획",
tenantSlug: "tech-planning",
},
],
},
joinedTenants: undefined,
},
],
[rootNode],
{ activeOnly: true },
);
expect(usersMap.get("gpdtdc")).toBeUndefined();
expect(usersMap.get("tech-planning")).toBeUndefined();
expect(usersMap.get("intigrated-system")?.map((user) => user.id)).toEqual([
"migrated-user",
]);
});
it("maps primary and joined memberships by tenant UUID when stored slugs are stale", () => {
const root = tenantNode("root-company", "COMPANY", "Root", "root-company");
const primaryLeaf = {
...tenantNode("primary-leaf", "ORGANIZATION", "Primary", "primary-new"),
parentId: "root-company",
};
const joinedLeaf = {
...tenantNode("joined-leaf", "ORGANIZATION", "Joined", "joined-new"),
parentId: "root-company",
};
const rootNode = {
...root,
children: [primaryLeaf, joinedLeaf],
};
const usersMap = buildUsersMap(
[
{
...member("primary-user"),
companyCode: undefined,
tenantSlug: undefined,
tenant: {
id: "primary-leaf",
type: "ORGANIZATION",
name: "Primary",
slug: "primary-old",
description: "",
status: "active",
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
},
joinedTenants: undefined,
},
{
...member("joined-user"),
companyCode: undefined,
tenantSlug: undefined,
joinedTenants: [
{
id: "joined-leaf",
type: "ORGANIZATION",
name: "Joined",
slug: "joined-old",
description: "",
status: "active",
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
},
],
},
],
[rootNode],
{ activeOnly: true },
);
expect(usersMap.get("primary-new")?.map((user) => user.id)).toEqual([
"primary-user",
]);
expect(usersMap.get("joined-new")?.map((user) => user.id)).toEqual([
"joined-user",
]);
expect(usersMap.get("primary-old")).toBeUndefined();
expect(usersMap.get("joined-old")).toBeUndefined();
});
it("does not fall back to a visible parent for hidden leaf memberships", () => {
const gpdtdc = tenantNode("gpdtdc", "COMPANY", "GPDTDC", "gpdtdc");
const internalLeaf = {

View File

@@ -1270,14 +1270,27 @@ function getUserOrgAppointmentRefs(user: UserSummary): UserOrgAppointmentRef[] {
}
function addTenantSlugCandidate(
slugs: Set<string>,
tenantIds: Set<string>,
tenantIndexes: TenantIndexes,
slug: string,
) {
const normalizedSlug = normalizeOrgSlug(slug);
if (!normalizedSlug) return;
if (!tenantIndexes.bySlug.has(normalizedSlug)) return;
slugs.add(normalizedSlug);
const tenant = tenantIndexes.bySlug.get(normalizedSlug);
if (!tenant) return;
tenantIds.add(tenant.id);
}
function addTenantIdCandidate(
tenantIds: Set<string>,
tenantIndexes: TenantIndexes,
id: unknown,
) {
if (typeof id !== "string") return;
const normalizedId = id.trim();
if (!normalizedId) return;
if (!tenantIndexes.byId.has(normalizedId)) return;
tenantIds.add(normalizedId);
}
function isDescendantTenant(
@@ -1298,19 +1311,19 @@ function isDescendantTenant(
return false;
}
function getLeafMembershipSlugs(
slugs: Set<string>,
function getLeafMembershipIds(
tenantIds: Set<string>,
tenantIndexes: TenantIndexes,
) {
const memberships = Array.from(slugs);
const memberships = Array.from(tenantIds);
return memberships.filter((slug) => {
const tenant = tenantIndexes.bySlug.get(slug);
return memberships.filter((id) => {
const tenant = tenantIndexes.byId.get(id);
if (!tenant) return true;
return !memberships.some((otherSlug) => {
if (otherSlug === slug) return false;
const otherTenant = tenantIndexes.bySlug.get(otherSlug);
return !memberships.some((otherId) => {
if (otherId === id) return false;
const otherTenant = tenantIndexes.byId.get(otherId);
if (!otherTenant) return false;
return isDescendantTenant(otherTenant, tenant, tenantIndexes.byId);
});
@@ -1332,7 +1345,7 @@ export function buildUsersMap(
if (options.activeOnly && user.status !== "active") continue;
if (!isVisibleOrgChartUser(user)) continue;
const slugs = new Set<string>();
const tenantIds = new Set<string>();
const primarySlug = normalizeOrgSlug(user.tenantSlug);
const legacyCompanySlug = normalizeOrgSlug(user.companyCode);
if (
@@ -1344,7 +1357,7 @@ export function buildUsersMap(
name: primarySlug,
})
) {
addTenantSlugCandidate(slugs, membershipTenantIndexes, primarySlug);
addTenantSlugCandidate(tenantIds, membershipTenantIndexes, primarySlug);
}
if (
legacyCompanySlug &&
@@ -1355,24 +1368,51 @@ export function buildUsersMap(
name: legacyCompanySlug,
})
) {
addTenantSlugCandidate(slugs, membershipTenantIndexes, legacyCompanySlug);
addTenantSlugCandidate(
tenantIds,
membershipTenantIndexes,
legacyCompanySlug,
);
}
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
addTenantSlugCandidate(slugs, membershipTenantIndexes, user.tenant.slug);
addTenantIdCandidate(tenantIds, membershipTenantIndexes, user.tenant.id);
addTenantSlugCandidate(
tenantIds,
membershipTenantIndexes,
user.tenant.slug,
);
}
for (const joinedTenant of user.joinedTenants || []) {
if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) {
addTenantIdCandidate(
tenantIds,
membershipTenantIndexes,
joinedTenant.id,
);
addTenantSlugCandidate(
slugs,
tenantIds,
membershipTenantIndexes,
joinedTenant.slug,
);
}
}
for (const appointment of getUserOrgAppointmentRefs(user)) {
const hasTenantIdCandidate =
appointment.tenantId &&
membershipTenantIndexes.byId.has(appointment.tenantId);
if (hasTenantIdCandidate) {
addTenantIdCandidate(
tenantIds,
membershipTenantIndexes,
appointment.tenantId,
);
continue;
}
if (appointment.tenantSlug) {
addTenantSlugCandidate(
slugs,
tenantIds,
membershipTenantIndexes,
appointment.tenantSlug,
);
@@ -1383,12 +1423,14 @@ export function buildUsersMap(
? membershipTenantIndexes.byId.get(appointment.tenantId)
: undefined;
if (tenantById) {
addTenantSlugCandidate(slugs, membershipTenantIndexes, tenantById.slug);
addTenantIdCandidate(tenantIds, membershipTenantIndexes, tenantById.id);
}
}
for (const slug of getLeafMembershipSlugs(slugs, membershipTenantIndexes)) {
if (!visibleTenantIndexes.bySlug.has(slug)) continue;
for (const id of getLeafMembershipIds(tenantIds, membershipTenantIndexes)) {
const visibleTenant = visibleTenantIndexes.byId.get(id);
if (!visibleTenant) continue;
const slug = visibleTenant.slug.toLowerCase();
const list = map.get(slug) || [];
if (!list.some((existing) => existing.id === user.id)) list.push(user);
map.set(slug, list);
@@ -1444,6 +1486,9 @@ export function TenantOrgChartPage() {
queryKey: ["orgchart-snapshot", { cache: "redis" }],
queryFn: () => fetchOrgChartSnapshot(),
enabled: !shareToken,
staleTime: 0,
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
const { rootNodes, usersMap, sharedWith } = React.useMemo(() => {

View File

@@ -194,6 +194,24 @@ describe("OrgPickerEmbedPage orgchart data source", () => {
});
});
it("forces authenticated picker snapshots to refetch instead of trusting the in-memory query cache", async () => {
adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
const rendered = renderPicker("/embed/picker?select=both");
await waitForExpect(() => {
expect(adminApiMocks.fetchOrgChartSnapshot).toHaveBeenCalledTimes(1);
});
const query = rendered.queryClient.getQueryCache().find({
queryKey: ["org-picker-orgchart", "authenticated", undefined],
});
expect(query?.options.staleTime).toBe(0);
expect(query?.options.refetchOnMount).toBe("always");
expect(query?.options.refetchOnWindowFocus).toBe(true);
});
it("uses the public orgchart snapshot when a share token is present", async () => {
adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
adminApiMocks.fetchPublicOrgChart.mockResolvedValue(snapshot);

View File

@@ -384,6 +384,9 @@ export function OrgPickerEmbedPage() {
],
queryFn: () =>
shareToken ? fetchPublicOrgChart(shareToken) : fetchOrgChartSnapshot(),
staleTime: 0,
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
React.useEffect(() => {

View File

@@ -20,8 +20,6 @@ do
assert_contains "$workflow" 'WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}'
assert_contains "$workflow" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}'
assert_contains "$workflow" 'BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}'
assert_contains "$workflow" 'ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }}'
assert_contains "$workflow" "ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600"
done
assert_contains ".gitea/workflows/staging_release.yml" "scp adminfront/seed-tenant.csv"
@@ -33,7 +31,5 @@ assert_contains "docker/staging_pull_compose.template.yaml" "SEED_TENANT_CSV_PAT
assert_contains "docker/staging_pull_compose.template.yaml" "./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro"
assert_contains "docker/staging_pull_compose.template.yaml" 'WORKS_ADMIN_API_BASE_URL=${WORKS_ADMIN_API_BASE_URL}'
assert_contains "docker/staging_pull_compose.template.yaml" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${WORKS_ADMIN_OAUTH_TOKEN_URL}'
assert_contains "docker/docker-compose.staging.template.yaml" "ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=\${ORGFRONT_ORGCHART_CACHE_TTL_SECONDS:-3600}"
assert_contains "docker/staging_pull_compose.template.yaml" "ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=\${ORGFRONT_ORGCHART_CACHE_TTL_SECONDS:-3600}"
echo "staging workflow env checks passed"

View File

@@ -34,13 +34,53 @@ class AuthProxyService {
}
static Exception _error(String key, String fallback, {String? detail}) {
return Exception(
tr(
key,
fallback: fallback,
params: detail != null ? {'error': detail} : null,
),
);
final params = detail != null ? {'error': detail} : null;
var message = tr(key, fallback: fallback, params: params);
if (message == key && fallback.isNotEmpty) {
message = _interpolateFallback(fallback, params);
}
return Exception(message);
}
static String _interpolateFallback(
String fallback,
Map<String, String>? params,
) {
var message = fallback;
params?.forEach((key, value) {
message = message.replaceAll('{{$key}}', value);
});
return message;
}
static String _responseErrorDetail(http.Response response) {
final body = response.body.trim();
if (body.isEmpty) {
return 'HTTP ${response.statusCode}';
}
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final value = decoded['error'] ?? decoded['message'];
if (value is String && value.trim().isNotEmpty) {
return _formatUserPolicyError(value);
}
}
} catch (_) {
// Fall back to the raw response body.
}
return _formatUserPolicyError(body);
}
static String _formatUserPolicyError(String message) {
final normalized = message.toLowerCase();
if (normalized.contains(
'internal email domain cannot be assigned to personal tenant',
) ||
normalized.contains('내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다')) {
return '내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다. 대표소속을 회사 또는 조직 소속으로 지정해 주세요.';
}
return message;
}
static http.Client _createClient({bool withCredentials = false}) {
@@ -735,8 +775,8 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_create',
'Failed to create the user: {{error}}',
detail: response.body,
'사용자 생성 실패: {{error}}',
detail: _responseErrorDetail(response),
);
}
}
@@ -844,8 +884,8 @@ class AuthProxyService {
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.user_update',
'Failed to update the user: {{error}}',
detail: response.body,
'사용자 수정 실패: {{error}}',
detail: _responseErrorDetail(response),
);
}
}

View File

@@ -242,18 +242,6 @@ const Map<String, String> koStrings = {
"msg.admin.tenants.sub.empty": "하위 테넌트가 없습니다.",
"msg.admin.tenants.sub.subtitle": "현재 테넌트 하위에 생성된 조직입니다.",
"msg.admin.tenants.subtitle": "현재 등록된 테넌트를 확인하고 상태를 관리합니다.",
"msg.admin.user_projection.action_error": "사용자 동기화 작업에 실패했습니다.",
"msg.admin.user_projection.action_success":
"{{count}}명 기준으로 사용자 동기화를 갱신했습니다.",
"msg.admin.user_projection.forbidden.description":
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
"msg.admin.user_projection.forbidden_description":
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
"msg.admin.user_projection.load_error": "사용자 동기화 상태를 불러오지 못했습니다.",
"msg.admin.user_projection.reset_confirm":
"사용자 동기화를 Kratos 기준으로 다시 구축하시겠습니까?",
"msg.admin.user_projection.subtitle":
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
"msg.admin.users.bulk.delete_confirm": "선택한 {{count}}명의 사용자를 정말로 삭제하시겠습니까?",
"msg.admin.users.bulk.delete_success": "{{count}}명의 사용자가 삭제되었습니다.",
"msg.admin.users.bulk.description": "CSV 파일을 통해 사용자를 일괄 등록하거나 관리합니다.",
@@ -1229,21 +1217,6 @@ const Map<String, String> koStrings = {
"ui.admin.tenants.worksmobile.sync_user": "구성원 Sync",
"ui.admin.tenants.worksmobile.title": "Worksmobile 연동",
"ui.admin.title": "Admin Control",
"ui.admin.user_projection.actions.reconcile": "재동기화",
"ui.admin.user_projection.actions.reset": "초기화 후 재구축",
"ui.admin.user_projection.card.description":
"Backend DB 통계가 참조하는 사용자 read model 상태입니다.",
"ui.admin.user_projection.card.title": "Kratos 사용자 동기화",
"ui.admin.user_projection.forbidden.title": "접근 권한이 없습니다",
"ui.admin.user_projection.loading": "불러오는 중",
"ui.admin.user_projection.status.failed": "실패",
"ui.admin.user_projection.status.not_ready": "준비되지 않음",
"ui.admin.user_projection.status.ready": "준비됨",
"ui.admin.user_projection.summary.last_synced": "마지막 동기화",
"ui.admin.user_projection.summary.projected_users": "동기화 사용자",
"ui.admin.user_projection.summary.status": "상태",
"ui.admin.user_projection.summary.updated_at": "상태 갱신",
"ui.admin.user_projection.title": "사용자 동기화 관리",
"ui.admin.users.bulk.acknowledge_warning": "경고를 확인했으며 계속 진행합니다.",
"ui.admin.users.bulk.create_missing_tenant": "신규 생성",
"ui.admin.users.bulk.do_move": "이동 실행",
@@ -2583,18 +2556,6 @@ const Map<String, String> enStrings = {
"Review and manage child tenants linked under this tenant.",
"msg.admin.tenants.subtitle":
"Review registered tenants and manage their current status.",
"msg.admin.user_projection.action_error": "Projection operation failed.",
"msg.admin.user_projection.action_success":
"Refreshed the projection for {{count}} users.",
"msg.admin.user_projection.forbidden.description":
"This screen is only available to super_admin users.",
"msg.admin.user_projection.forbidden_description":
"This screen is only available to super_admin users.",
"msg.admin.user_projection.load_error": "Failed to load projection status.",
"msg.admin.user_projection.reset_confirm":
"Rebuild user projection from the Kratos source of truth?",
"msg.admin.user_projection.subtitle":
"Review and sync the Kratos user read model.",
"msg.admin.users.bulk.delete_confirm":
"Are you sure you want to delete the selected {{count}} users?",
"msg.admin.users.bulk.delete_success": "{{count}} users have been deleted.",
@@ -3718,23 +3679,6 @@ const Map<String, String> enStrings = {
"ui.admin.tenants.worksmobile.sync_user": "User Sync",
"ui.admin.tenants.worksmobile.title": "Worksmobile Integration",
"ui.admin.title": "Admin Control",
"ui.admin.user_projection.actions.reconcile": "Re-sync",
"ui.admin.user_projection.actions.reset": "Reset and rebuild",
"ui.admin.user_projection.card.description":
"Current user read model state referenced by backend DB statistics.",
"ui.admin.user_projection.card.title": "Kratos users projection",
"ui.admin.user_projection.forbidden.title": "Access denied",
"ui.admin.user_projection.loading": "Loading user projection data...",
"ui.admin.user_projection.status.failed": "failed",
"ui.admin.user_projection.status.not_ready": "not ready",
"ui.admin.user_projection.status.ready": "ready",
"ui.admin.user_projection.subtitle":
"Review and sync the Kratos user read model.",
"ui.admin.user_projection.summary.last_synced": "Last synced",
"ui.admin.user_projection.summary.projected_users": "Projected users",
"ui.admin.user_projection.summary.status": "Status",
"ui.admin.user_projection.summary.updated_at": "Updated at",
"ui.admin.user_projection.title": "User Projection Management",
"ui.admin.users.bulk.acknowledge_warning":
"I acknowledge the warning and will proceed.",
"ui.admin.users.bulk.create_missing_tenant": "Create new",

View File

@@ -203,6 +203,49 @@ void main() {
},
);
test('createUser는 내부 도메인 personal 정책 오류를 한국어 안내로 표시한다', () async {
client.enqueueJson({
'error':
'internal email domain cannot be assigned to personal tenant: user@hanmaceng.co.kr',
}, statusCode: 400);
await expectLater(
AuthProxyService.createUser(
loginId: 'user@hanmaceng.co.kr',
adminPassword: 'admin-pass',
email: 'user@hanmaceng.co.kr',
),
throwsA(
isA<Exception>().having(
(error) => error.toString(),
'message',
contains('대표소속을 회사 또는 조직 소속으로 지정해 주세요'),
),
),
);
});
test('updateUserDetails는 내부 도메인 personal 정책 오류를 한국어 안내로 표시한다', () async {
client.enqueueJson({
'error': '내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다: user@brsw.kr',
}, statusCode: 400);
await expectLater(
AuthProxyService.updateUserDetails(
adminPassword: 'admin-pass',
loginId: 'user@brsw.kr',
email: 'user@brsw.kr',
),
throwsA(
isA<Exception>().having(
(error) => error.toString(),
'message',
contains('대표소속을 회사 또는 조직 소속으로 지정해 주세요'),
),
),
);
});
test('sendLog는 민감 정보를 제거한 client log를 전송한다', () async {
client.enqueueJson({'ok': true});