1
0
forked from baron/baron-sso

org chart 자동로그인 보완. seed-tenant 삭제불가 조치

This commit is contained in:
2026-04-30 17:02:24 +09:00
parent 6eb4c293ff
commit 3dcdd97882
13 changed files with 490 additions and 32 deletions

View File

@@ -57,6 +57,7 @@ import {
parseTenantCSV,
serializeTenantImportCSV,
} from "../utils/tenantCsvImport";
import { isSeedTenant } from "../utils/protectedTenants";
const tenantCSVTemplate =
"name,type,parent_tenant_slug,slug,memo,email_domain\n";
@@ -206,36 +207,48 @@ function TenantListPage() {
);
}, [allTenants, search]);
const deletableTenants = React.useMemo(
() => tenants.filter((tenant) => !isSeedTenant(tenant)),
[tenants],
);
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(tenants.map((t) => t.id));
setSelectedIds(deletableTenants.map((t) => t.id));
} else {
setSelectedIds([]);
}
};
const handleSelect = (id: string, checked: boolean) => {
const handleSelect = (tenant: TenantSummary, checked: boolean) => {
if (isSeedTenant(tenant)) {
return;
}
if (checked) {
setSelectedIds((prev) => [...prev, id]);
setSelectedIds((prev) => [...prev, tenant.id]);
} else {
setSelectedIds((prev) => prev.filter((i) => i !== id));
setSelectedIds((prev) => prev.filter((i) => i !== tenant.id));
}
};
const handleDeleteBulk = () => {
if (selectedIds.length === 0) return;
const deletableIds = selectedIds.filter((id) =>
deletableTenants.some((tenant) => tenant.id === id),
);
if (deletableIds.length === 0) return;
if (
!window.confirm(
t(
"msg.admin.tenants.delete_bulk_confirm",
"선택한 {{count}}개 테넌트를 삭제할까요?",
{ count: selectedIds.length },
{ count: deletableIds.length },
),
)
) {
return;
}
deleteBulkMutation.mutate(selectedIds);
deleteBulkMutation.mutate(deletableIds);
};
const handleTemplateDownload = () => {
@@ -312,6 +325,10 @@ function TenantListPage() {
};
const handleDelete = (tenantId: string, tenantName: string) => {
const tenant = allTenants.find((item) => item.id === tenantId);
if (tenant && isSeedTenant(tenant)) {
return;
}
if (
!window.confirm(
t(
@@ -473,7 +490,8 @@ function TenantListPage() {
<Checkbox
checked={
tenants.length > 0 &&
selectedIds.length === tenants.length
deletableTenants.length > 0 &&
selectedIds.length === deletableTenants.length
}
onCheckedChange={(checked) =>
handleSelectAll(!!checked)
@@ -529,13 +547,17 @@ function TenantListPage() {
)}
{tenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(tenant.id)}
onCheckedChange={(checked) =>
handleSelect(tenant.id, !!checked)
}
/>
<TableCell className="text-center">
{isSeedTenant(tenant) ? (
<span className="inline-block h-4 w-4" />
) : (
<Checkbox
checked={selectedIds.includes(tenant.id)}
onCheckedChange={(checked) =>
handleSelect(tenant, !!checked)
}
/>
)}
</TableCell>
<TableCell
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
@@ -544,7 +566,17 @@ function TenantListPage() {
{tenant.id}
</TableCell>
<TableCell className="font-semibold">
{tenant.name}
<div className="flex flex-wrap items-center gap-2">
<span>{tenant.name}</span>
{isSeedTenant(tenant) && (
<Badge variant="secondary" className="text-[10px]">
{t(
"ui.admin.tenants.seed_badge",
"초기 설정",
)}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<Badge
@@ -598,7 +630,17 @@ function TenantListPage() {
variant="outline"
size="sm"
onClick={() => handleDelete(tenant.id, tenant.name)}
disabled={deleteMutation.isPending}
disabled={
deleteMutation.isPending || isSeedTenant(tenant)
}
title={
isSeedTenant(tenant)
? t(
"msg.admin.tenants.seed_delete_blocked",
"초기 설정 테넌트는 삭제할 수 없습니다.",
)
: undefined
}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}

View File

@@ -28,6 +28,7 @@ import {
formatDomainConflictMessage,
type ServerDomainConflict,
} from "../utils/domainTags";
import { isSeedTenant } from "../utils/protectedTenants";
export function TenantProfilePage() {
const { tenantId } = useParams<{ tenantId: string }>();
@@ -154,8 +155,14 @@ export function TenantProfilePage() {
?.response?.data?.error;
const loadError = (tenantQuery.error as AxiosError<{ error?: string }>)
?.response?.data?.error;
const isProtectedSeedTenant = tenantQuery.data
? isSeedTenant(tenantQuery.data)
: false;
const handleDelete = () => {
if (isProtectedSeedTenant) {
return;
}
if (
window.confirm(
t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", {
@@ -335,7 +342,15 @@ export function TenantProfilePage() {
<Button
variant="outline"
onClick={handleDelete}
disabled={deleteMutation.isPending}
disabled={deleteMutation.isPending || isProtectedSeedTenant}
title={
isProtectedSeedTenant
? t(
"msg.admin.tenants.seed_delete_blocked",
"초기 설정 테넌트는 삭제할 수 없습니다.",
)
: undefined
}
>
<Trash2 size={16} />
{t("ui.common.delete", "삭제")}

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { getSeedTenantSlugs, isSeedTenant } from "./protectedTenants";
describe("protectedTenants", () => {
it("marks tenants from seed-tenant.csv as protected", () => {
expect(getSeedTenantSlugs()).toEqual(
expect.arrayContaining(["hanmac-family", "personal"]),
);
expect(isSeedTenant({ slug: "hanmac-family" })).toBe(true);
expect(isSeedTenant({ slug: "normal-tenant" })).toBe(false);
});
});

View File

@@ -0,0 +1,19 @@
import type { TenantSummary } from "../../../lib/adminApi";
import { parseTenantCSV } from "./tenantCsvImport";
// Vite ?raw import는 seed CSV를 빌드 타임 상수로 번들합니다.
// eslint-disable-next-line import/no-unresolved
import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
const seedTenantSlugs = new Set(
parseTenantCSV(seedTenantCSVRaw)
.map((row) => row.slug.trim().toLowerCase())
.filter(Boolean),
);
export function isSeedTenant(tenant: Pick<TenantSummary, "slug">): boolean {
return seedTenantSlugs.has(tenant.slug.trim().toLowerCase());
}
export function getSeedTenantSlugs(): string[] {
return Array.from(seedTenantSlugs);
}

View File

@@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
const tenants = [
{
id: "seed-hanmac",
name: "한맥가족",
slug: "hanmac-family",
type: "COMPANY_GROUP",
description: "한맥가족 기본 루트 테넌트",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "normal-tenant",
name: "일반 테넌트",
slug: "normal-tenant",
type: "COMPANY",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
test.describe("Seed tenant protection", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
window.localStorage.setItem("RoleSwitcher-Collapsed", "true");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
}),
);
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
await page.route("**/api/v1/**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (url.includes("/admin/tenants/seed-hanmac")) {
return route.fulfill({ json: tenants[0], headers });
}
if (url.includes("/admin/tenants")) {
return route.fulfill({
json: {
items: tenants,
total: tenants.length,
limit: 1000,
offset: 0,
},
headers,
});
}
return route.fulfill({ json: {}, headers });
});
});
test("removes selection and disables delete action for seed tenants in the list", async ({
page,
}) => {
await page.goto("/tenants");
const seedRow = page.getByRole("row", { name: /한맥가족/ });
await expect(seedRow.getByRole("checkbox")).toHaveCount(0);
await expect(seedRow.getByText("초기 설정")).toBeVisible();
await expect(seedRow.getByRole("button", { name: /삭제/ })).toBeDisabled();
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
await expect(
normalRow.getByRole("button", { name: /삭제/ }),
).toBeEnabled();
});
test("disables delete action on seed tenant profile", async ({ page }) => {
await page.goto("/tenants/seed-hanmac");
await expect(page.getByRole("heading", { name: "한맥가족" })).toBeVisible();
await expect(page.getByRole("button", { name: "삭제" })).toBeDisabled();
});
});

View File

@@ -129,6 +129,36 @@ func loadSeedTenantConfigs() ([]InitialTenantConfig, error) {
return configs, nil
}
func SeedTenantSlugSet() (map[string]bool, error) {
configs, err := loadSeedTenantConfigs()
if err != nil {
return nil, err
}
slugs := make(map[string]bool, len(configs))
for _, config := range configs {
slug := strings.TrimSpace(strings.ToLower(config.Slug))
if slug != "" {
slugs[slug] = true
}
}
return slugs, nil
}
func IsSeedTenantSlug(slug string) bool {
normalized := strings.TrimSpace(strings.ToLower(slug))
if normalized == "" {
return false
}
slugs, err := SeedTenantSlugSet()
if err != nil {
slog.Warn("[Bootstrap] Failed to load seed tenant slug set", "error", err)
return false
}
return slugs[normalized]
}
func findSeedTenantCSVPath() (string, error) {
if configured := strings.TrimSpace(os.Getenv(seedTenantCSVPathEnv)); configured != "" {
return configured, nil

View File

@@ -82,3 +82,21 @@ func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
t.Fatalf("child domains = %#v, want child.example.com", configs[1].Domains)
}
}
func TestIsSeedTenantSlugUsesConfiguredCSVPath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "seed-tenant.csv")
csv := "name,type,parent_tenant_slug,slug,memo,email_domain\n" +
"Root,COMPANY_GROUP,,protected-root,Root memo,\n"
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
t.Fatalf("failed to write seed csv: %v", err)
}
t.Setenv(seedTenantCSVPathEnv, path)
if !IsSeedTenantSlug("protected-root") {
t.Fatal("protected-root must be detected as seed tenant")
}
if IsSeedTenantSlug("normal-tenant") {
t.Fatal("normal-tenant must not be detected as seed tenant")
}
}

View File

@@ -7420,6 +7420,9 @@ func resolveLinkedRPAutoLoginSupported(clientID string, metadata map[string]inte
func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]interface{}) string {
clientID = strings.TrimSpace(clientID)
if metadataURL := readMetadataStringValue(metadata, domain.MetadataAutoLoginURL); metadataURL != "" {
if clientID == "orgfront" {
return ensureOrgfrontAutoLoginURL(metadataURL)
}
return metadataURL
}
@@ -7434,13 +7437,29 @@ func resolveLinkedRPAutoLoginURL(clientID string, metadata map[string]interface{
}
case "orgfront":
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("ORGFRONT_URL")), "/"); value != "" {
return value + "/login"
return value + "/login?auto=1"
}
}
return ""
}
func ensureOrgfrontAutoLoginURL(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
if strings.TrimRight(parsed.Path, "/") != "/login" {
return rawURL
}
query := parsed.Query()
if query.Get("auto") != "1" {
query.Set("auto", "1")
parsed.RawQuery = query.Encode()
}
return parsed.String()
}
func resolveLinkedRPInitURL(clientID string, metadata map[string]interface{}) string {
if !resolveLinkedRPAutoLoginSupported(clientID, metadata) {
return ""

View File

@@ -197,7 +197,7 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
}
}
assert.True(t, orgfrontItem.AutoLoginSupported)
assert.Equal(t, "http://localhost:5175/login", orgfrontItem.AutoLoginURL)
assert.Equal(t, "http://localhost:5175/login?auto=1", orgfrontItem.AutoLoginURL)
assert.Equal(t, orgfrontItem.AutoLoginURL, orgfrontItem.InitURL)
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"baron-sso-backend/internal/bootstrap"
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
@@ -28,6 +29,23 @@ type TenantHandler struct {
SharedLink service.SharedLinkService
}
func seedTenantDeleteError(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusConflict, "seed tenants cannot be deleted")
}
func seedTenantSlugsForDeleteGuard() []string {
slugs, err := bootstrap.SeedTenantSlugSet()
if err != nil {
return nil
}
result := make([]string, 0, len(slugs))
for slug := range slugs {
result = append(result, slug)
}
return result
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService) *TenantHandler {
return &TenantHandler{
DB: db,
@@ -1045,7 +1063,6 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
var req struct {
Name *string `json:"name"`
Type *string `json:"type"`
@@ -1192,6 +1209,9 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if bootstrap.IsSeedTenantSlug(tenant.Slug) {
return seedTenantDeleteError(c)
}
// Rename slug to release it for reuse before soft delete
deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405")
@@ -1502,6 +1522,20 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusForbidden, "only super admin can perform bulk deletion")
}
protectedSlugs := seedTenantSlugsForDeleteGuard()
if len(protectedSlugs) > 0 {
var protectedCount int64
if err := h.DB.Model(&domain.Tenant{}).
Where("id IN ?", req.IDs).
Where("slug IN ?", protectedSlugs).
Count(&protectedCount).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if protectedCount > 0 {
return seedTenantDeleteError(c)
}
}
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}

View File

@@ -0,0 +1,159 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/testsupport"
"bytes"
"context"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/testcontainers/testcontainers-go"
postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
gorm_postgres "gorm.io/driver/postgres"
"gorm.io/gorm"
)
func newTenantHandlerSeedDeleteDB(t *testing.T) *gorm.DB {
t.Helper()
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
ctx := context.Background()
postgresContainer, err := postgres_module.Run(ctx,
"postgres:16-alpine",
postgres_module.WithDatabase("testdb"),
postgres_module.WithUsername("user"),
postgres_module.WithPassword("password"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second)),
)
if err != nil {
t.Fatalf("failed to start postgres container: %v", err)
}
t.Cleanup(func() {
if err := postgresContainer.Terminate(ctx); err != nil {
log.Printf("failed to terminate postgres container: %v", err)
}
})
connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("failed to get postgres connection string: %v", err)
}
db, err := gorm.Open(gorm_postgres.Open(connStr), &gorm.Config{})
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)
}
return db
}
func setSeedTenantCSVForDeleteGuard(t *testing.T, slug string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "seed-tenant.csv")
csv := "name,type,parent_tenant_slug,slug,memo,email_domain\n" +
"Protected,COMPANY_GROUP,," + slug + ",Protected seed,\n"
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
t.Fatalf("failed to write seed csv: %v", err)
}
t.Setenv("SEED_TENANT_CSV_PATH", path)
}
func TestTenantHandlerDeleteTenantRejectsSeedTenant(t *testing.T) {
setSeedTenantCSVForDeleteGuard(t, "protected-root")
db := newTenantHandlerSeedDeleteDB(t)
tenant := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000001",
Name: "Protected",
Slug: "protected-root",
Type: domain.TenantTypeCompanyGroup,
Status: domain.TenantStatusActive,
}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("failed to create tenant: %v", err)
}
app := fiber.New()
app.Delete("/tenants/:id", (&TenantHandler{DB: db}).DeleteTenant)
req := httptest.NewRequest(http.MethodDelete, "/tenants/"+tenant.ID, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusConflict)
}
var count int64
if err := db.Model(&domain.Tenant{}).Where("id = ?", tenant.ID).Count(&count).Error; err != nil {
t.Fatalf("count tenant: %v", err)
}
if count != 1 {
t.Fatalf("seed tenant count = %d, want 1", count)
}
}
func TestTenantHandlerDeleteTenantsBulkRejectsSeedTenant(t *testing.T) {
setSeedTenantCSVForDeleteGuard(t, "protected-root")
db := newTenantHandlerSeedDeleteDB(t)
seed := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000011",
Name: "Protected",
Slug: "protected-root",
Type: domain.TenantTypeCompanyGroup,
Status: domain.TenantStatusActive,
}
normal := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000012",
Name: "Normal",
Slug: "normal",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
if err := db.Create(&seed).Error; err != nil {
t.Fatalf("failed to create seed tenant: %v", err)
}
if err := db.Create(&normal).Error; err != nil {
t.Fatalf("failed to create normal tenant: %v", err)
}
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}).DeleteTenantsBulk)
body, _ := json.Marshal(map[string][]string{"ids": []string{seed.ID, normal.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.StatusConflict {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusConflict)
}
var count int64
if err := db.Model(&domain.Tenant{}).Where("id IN ?", []string{seed.ID, normal.ID}).Count(&count).Error; err != nil {
t.Fatalf("count tenants: %v", err)
}
if count != 2 {
t.Fatalf("remaining tenant count = %d, want 2", count)
}
}

View File

@@ -18,7 +18,7 @@ function LoginPage() {
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const returnTo = searchParams.get("returnTo") || "/chart";
const shouldAutoLogin = searchParams.get("auto") !== "0";
const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => {
if (auth.isAuthenticated) {

View File

@@ -1,4 +1,4 @@
import { expect, test, type Page } from "@playwright/test";
import { type Page, expect, test } from "@playwright/test";
async function stubOidcAuthorization(page: Page) {
let authorizationURL = "";
@@ -35,20 +35,13 @@ async function stubOidcAuthorization(page: Page) {
};
}
test("orgfront login defaults to OIDC authorization", async ({ page }) => {
test("orgfront login waits for explicit auto parameter", async ({ page }) => {
const oidc = await stubOidcAuthorization(page);
await page.goto("/login");
await page.waitForTimeout(500);
await expect.poll(oidc.authorizationURL).toContain("/oauth2/auth");
const parsed = new URL(oidc.authorizationURL());
expect(parsed.searchParams.get("client_id")).toBe("orgfront");
expect(parsed.searchParams.get("redirect_uri")).toBe(
"http://localhost:5175/auth/callback",
);
expect(parsed.searchParams.get("response_type")).toBe("code");
expect(parsed.searchParams.get("scope") ?? "").toContain("openid");
expect(oidc.authorizationURL()).toBe("");
});
test("orgfront login auto parameter starts OIDC authorization", async ({