1
0
forked from baron/baron-sso

Implement tenant import and RP auto login policies

This commit is contained in:
2026-04-30 15:45:34 +09:00
parent 24807eab0f
commit f7e4d43b16
76 changed files with 5307 additions and 441 deletions

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
findDomainConflict,
formatDomainConflictMessage,
normalizeDomainTokens,
} from "./domainTags";
describe("domainTags", () => {
it("splits domains by comma and whitespace", () => {
expect(
normalizeDomainTokens("samaneng.com, hanmaceng.co.kr login.hmac.kr"),
).toEqual(["samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"]);
});
it("finds a domain already assigned to another tenant", () => {
const conflict = findDomainConflict("hanmaceng.co.kr", [
{
id: "tenant-1",
name: "한맥기술",
slug: "hanmac",
type: "COMPANY",
description: "",
status: "active",
domains: ["hanmaceng.co.kr"],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
]);
expect(conflict?.tenant.name).toBe("한맥기술");
});
it("ignores the current tenant when checking domain conflicts", () => {
const conflict = findDomainConflict(
"hanmaceng.co.kr",
[
{
id: "tenant-1",
name: "한맥기술",
slug: "hanmac",
type: "COMPANY",
description: "",
status: "active",
domains: ["hanmaceng.co.kr"],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
],
"tenant-1",
);
expect(conflict).toBeNull();
});
it("formats a duplicate domain message with tenant context", () => {
expect(
formatDomainConflictMessage({
domain: "samaneng.com",
tenantName: "한맥가족",
}),
).toBe(
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
);
});
});

View File

@@ -0,0 +1,59 @@
import type { TenantSummary } from "../../../lib/adminApi";
export type DomainConflict = {
domain: string;
tenant: TenantSummary;
};
export type ServerDomainConflict = {
domain: string;
tenantId?: string;
tenantName?: string;
tenantSlug?: string;
};
export function normalizeDomainTokens(value: string): string[] {
const seen = new Set<string>();
const tokens: string[] = [];
for (const raw of value.split(/[,\s;]+/)) {
const token = raw.trim().toLowerCase();
if (!token || seen.has(token)) {
continue;
}
seen.add(token);
tokens.push(token);
}
return tokens;
}
export function findDomainConflict(
domain: string,
tenants: TenantSummary[] = [],
currentTenantId?: string,
): DomainConflict | null {
const normalized = domain.trim().toLowerCase();
if (!normalized) {
return null;
}
for (const tenant of tenants) {
if (tenant.id === currentTenantId) {
continue;
}
const domains = tenant.domains ?? [];
if (domains.some((item) => item.trim().toLowerCase() === normalized)) {
return { domain: normalized, tenant };
}
}
return null;
}
export function formatDomainConflictMessage(
conflict: DomainConflict | ServerDomainConflict,
): string {
const tenantName =
"tenant" in conflict
? conflict.tenant.name
: conflict.tenantName || conflict.tenantSlug || conflict.tenantId || "다른";
return `${conflict.domain} 도메인은 ${tenantName} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
}

View File

@@ -46,6 +46,7 @@ describe("tenantCsvImport", () => {
name: "Hanmac Tech",
type: "COMPANY",
parentTenantId: "",
parentTenantSlug: "",
slug: "hanmac-tech",
memo: "Memo",
emailDomain: "hanmac-tech.example.com",
@@ -89,4 +90,88 @@ describe("tenantCsvImport", () => {
"tenant-1,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com",
);
});
it("serializes create resolutions by resetting external tenant id and conflicting slug", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\nlocal-tenant-id,Hanmac Technology,COMPANY,,hanmac,Memo,hanmac.example.com\n",
);
const preview = buildTenantImportPreview(rows, tenants);
expect(preview[0].conflicts).toEqual(
expect.arrayContaining(["external_tenant_id", "slug_exists"]),
);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-new-tenant-id",
slug: "hanmac-imported",
},
});
expect(csv).toContain(
"staging-new-tenant-id,Hanmac Technology,COMPANY,,hanmac-imported,Memo,hanmac.example.com",
);
expect(csv).not.toContain("local-tenant-id");
});
it("remaps child parent_tenant_id from source ids to selected staging ids", () => {
const rows = parseTenantCSV(
[
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain",
"local-parent-id,Parent Tenant,COMPANY,,parent-local,,",
"local-child-id,Child Tenant,USER_GROUP,local-parent-id,child-local,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-parent-id",
slug: "parent-staging",
},
3: {
mode: "create",
tenantId: "staging-child-id",
slug: "child-staging",
},
});
expect(csv).toContain(
"staging-parent-id,Parent Tenant,COMPANY,,parent-staging,,",
);
expect(csv).toContain(
"staging-child-id,Child Tenant,USER_GROUP,staging-parent-id,child-staging,,",
);
expect(csv).not.toContain("local-parent-id");
expect(csv).not.toContain("local-child-id");
});
it("parses parent_tenant_slug and remaps it to selected staging ids", () => {
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"Parent Tenant,COMPANY,,parent-slug,,",
"Child Tenant,USER_GROUP,parent-slug,child-slug,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-parent-id",
slug: "parent-slug",
},
3: {
mode: "create",
tenantId: "staging-child-id",
slug: "child-slug",
},
});
expect(rows[1].parentTenantSlug).toBe("parent-slug");
expect(csv).toContain(
"staging-child-id,Child Tenant,USER_GROUP,staging-parent-id,child-slug,,",
);
});
});

View File

@@ -6,6 +6,7 @@ export type TenantCSVRow = {
name: string;
type: string;
parentTenantId: string;
parentTenantSlug: string;
slug: string;
memo: string;
emailDomain: string;
@@ -23,8 +24,30 @@ export type TenantImportPreviewRow = {
row: TenantCSVRow;
candidates: TenantImportCandidate[];
defaultTenantId: string;
defaultCreateSlug: string;
conflicts: TenantImportConflict[];
};
export type TenantImportConflict =
| "external_tenant_id"
| "slug_exists"
| "parent_tenant_id_unresolved";
export type TenantImportResolution =
| {
mode: "existing";
tenantId: string;
}
| {
mode: "create";
tenantId?: string;
slug?: string;
parentTenantId?: string;
}
| {
mode: "skip";
};
const importHeaders = [
"tenant_id",
"name",
@@ -45,6 +68,8 @@ const headerAliases: Record<string, keyof TenantCSVRow> = {
parent_id: "parentTenantId",
parenttenantid: "parentTenantId",
parent_tenant_id: "parentTenantId",
parenttenantslug: "parentTenantSlug",
parent_tenant_slug: "parentTenantSlug",
slug: "slug",
memo: "memo",
description: "memo",
@@ -80,6 +105,7 @@ export function parseTenantCSV(text: string): TenantCSVRow[] {
name: value("name"),
type: value("type"),
parentTenantId: value("parentTenantId"),
parentTenantSlug: value("parentTenantSlug"),
slug: value("slug"),
memo: value("memo"),
emailDomain: value("emailDomain"),
@@ -93,14 +119,17 @@ export function buildTenantImportPreview(
): TenantImportPreviewRow[] {
return rows
.map((row) => {
const candidates = row.tenantId ? [] : findTenantCandidates(row, tenants);
const candidates = findTenantCandidates(row, tenants);
const conflicts = findTenantImportConflicts(row, tenants);
return {
row,
candidates,
conflicts,
defaultTenantId:
candidates[0] && candidates[0].score >= 0.95
? candidates[0].tenantId
: "",
defaultCreateSlug: suggestUniqueTenantSlug(row.slug || row.name, tenants),
};
})
.sort((a, b) => {
@@ -113,24 +142,148 @@ export function buildTenantImportPreview(
export function serializeTenantImportCSV(
previewRows: TenantImportPreviewRow[],
selectedTenantIds: Record<number, string>,
selectedTenantIds: Record<number, string | TenantImportResolution>,
) {
const lines = [importHeaders];
for (const preview of [...previewRows].sort(
const sortedRows = [...previewRows].sort(
(a, b) => a.row.rowNumber - b.row.rowNumber,
)) {
const selectedTenantId = selectedTenantIds[preview.row.rowNumber] ?? "";
);
const targetTenantIds = buildTargetTenantIds(
sortedRows,
selectedTenantIds,
);
for (const preview of sortedRows) {
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
if (typeof resolution === "object" && resolution.mode === "skip") {
continue;
}
const selectedTenantId =
typeof resolution === "string"
? resolution
: resolution.mode === "existing"
? resolution.tenantId
: "";
const slug =
typeof resolution === "object" && resolution.mode === "create"
? resolution.slug || preview.defaultCreateSlug
: preview.row.slug;
const parentTenantId =
typeof resolution === "object" && resolution.mode === "create"
? (resolution.parentTenantId ??
remapParentTenantId(
preview.row.parentTenantId,
preview.row.parentTenantSlug,
targetTenantIds,
))
: preview.row.parentTenantId;
const tenantId =
typeof resolution === "object" && resolution.mode === "create"
? (resolution.tenantId ??
targetTenantIds.bySourceId.get(preview.row.tenantId) ??
createTenantImportId())
: selectedTenantId || preview.row.tenantId;
lines.push([
preview.row.tenantId || selectedTenantId,
tenantId,
preview.row.name,
preview.row.type,
preview.row.parentTenantId,
preview.row.slug,
parentTenantId,
slug,
preview.row.memo,
preview.row.emailDomain,
]);
}
return lines.map(formatCSVRecord).join("\n") + "\n";
return `${lines.map(formatCSVRecord).join("\n")}\n`;
}
function buildTargetTenantIds(
previewRows: TenantImportPreviewRow[],
selectedTenantIds: Record<number, string | TenantImportResolution>,
) {
const bySourceId = new Map<string, string>();
const bySourceSlug = new Map<string, string>();
for (const preview of previewRows) {
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
if (typeof resolution === "object" && resolution.mode === "skip") {
continue;
}
const targetTenantId =
typeof resolution === "string"
? resolution || preview.row.tenantId
: resolution.mode === "existing"
? resolution.tenantId
: resolution.tenantId || createTenantImportId();
if (preview.row.tenantId) {
bySourceId.set(preview.row.tenantId, targetTenantId);
}
if (preview.row.slug) {
bySourceSlug.set(preview.row.slug.toLowerCase(), targetTenantId);
}
}
return { bySourceId, bySourceSlug };
}
function remapParentTenantId(
parentTenantId: string,
parentTenantSlug: string,
targetTenantIds: {
bySourceId: Map<string, string>;
bySourceSlug: Map<string, string>;
},
) {
if (parentTenantId) {
return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId;
}
if (parentTenantSlug) {
return targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? "";
}
return "";
}
function createTenantImportId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `00000000-0000-4000-8000-${Math.random()
.toString(16)
.slice(2, 14)
.padEnd(12, "0")}`;
}
function findTenantImportConflicts(
row: TenantCSVRow,
tenants: TenantSummary[],
): TenantImportConflict[] {
const conflicts: TenantImportConflict[] = [];
const matchingId = row.tenantId
? tenants.find((tenant) => tenant.id === row.tenantId)
: undefined;
const matchingSlug = row.slug
? tenants.find(
(tenant) => normalizeToken(tenant.slug) === normalizeToken(row.slug),
)
: undefined;
if (row.tenantId && !matchingId) {
conflicts.push("external_tenant_id");
}
if (matchingSlug && matchingSlug.id !== row.tenantId) {
conflicts.push("slug_exists");
}
if (
row.parentTenantId &&
!tenants.some((tenant) => tenant.id === row.parentTenantId)
) {
conflicts.push("parent_tenant_id_unresolved");
}
return conflicts;
}
function findTenantCandidates(
@@ -230,6 +383,28 @@ function normalizeToken(value: string) {
.replace(/[^\p{L}\p{N}]/gu, "");
}
function suggestUniqueTenantSlug(value: string, tenants: TenantSummary[]) {
const base = slugify(value) || "tenant";
const used = new Set(tenants.map((tenant) => tenant.slug.toLowerCase()));
if (!used.has(base)) {
return base;
}
let index = 2;
while (used.has(`${base}-${index}`)) {
index += 1;
}
return `${base}-${index}`;
}
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9가-힣ㄱ-ㅎㅏ-ㅣ]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function similarity(left: string, right: string) {
const a = normalizeToken(left);
const b = normalizeToken(right);