package bootstrap import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "bytes" "context" "encoding/csv" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "strings" "gorm.io/gorm" ) const seedTenantCSVPathEnv = "SEED_TENANT_CSV_PATH" var seedTenantCSVPathCandidates = []string{ "adminfront/seed-tenant.csv", "../adminfront/seed-tenant.csv", "../../adminfront/seed-tenant.csv", "../../../adminfront/seed-tenant.csv", "/app/adminfront/seed-tenant.csv", } type InitialTenantConfig struct { TenantID string Name string Slug string Type string ParentSlug string Description string Domains []string Config domain.JSONMap } func SeedTenants(db *gorm.DB) error { slog.Info("[Bootstrap] Checking initial tenant seed...") configs, err := loadSeedTenantConfigs() if err != nil { return err } if len(configs) == 0 { return errors.New("seed tenant csv has no tenant rows") } existingSlugs, existingIDs, err := loadExistingTenantIdentitySet(db) if err != nil { return err } missingConfigs := filterMissingSeedTenantConfigs(configs, existingSlugs, existingIDs) if len(missingConfigs) == 0 { slog.Info("[Bootstrap] Tenant seed skipped because all seed slugs already exist", "count", len(configs)) return nil } slog.Info( "[Bootstrap] Tenant seed will create missing seed tenants", "total", len(configs), "missing", len(missingConfigs), "existing", len(configs)-len(missingConfigs), ) return seedTenantConfigs(db, missingConfigs) } func loadExistingTenantIdentitySet(db *gorm.DB) (map[string]bool, map[string]bool, error) { var tenants []domain.Tenant if err := db.Select("id", "slug").Find(&tenants).Error; err != nil { return nil, nil, fmt.Errorf("load existing tenants before seed: %w", err) } slugs := make(map[string]bool, len(tenants)) ids := make(map[string]bool, len(tenants)) for _, tenant := range tenants { slug := strings.TrimSpace(strings.ToLower(tenant.Slug)) if slug != "" { slugs[slug] = true } id := strings.TrimSpace(strings.ToLower(tenant.ID)) if id != "" { ids[id] = true } } return slugs, ids, nil } func filterMissingSeedTenantConfigs(configs []InitialTenantConfig, existingSlugs map[string]bool, existingIDs map[string]bool) []InitialTenantConfig { filtered := make([]InitialTenantConfig, 0, len(configs)) for _, config := range configs { slug := strings.TrimSpace(strings.ToLower(config.Slug)) id := strings.TrimSpace(strings.ToLower(config.TenantID)) if slug == "" || existingSlugs[slug] || (id != "" && existingIDs[id]) { continue } filtered = append(filtered, config) existingSlugs[slug] = true if id != "" { existingIDs[id] = true } } return filtered } func seedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error { slog.Info("[Bootstrap] Seeding initial tenants from CSV...", "count", len(configs)) repo := repository.NewTenantRepository(db) userRepo := repository.NewUserRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) outboxRepo := repository.NewKetoOutboxRepository(db) svc := service.NewTenantService(repo, userRepo, userGroupRepo, outboxRepo) ctx := context.Background() for _, config := range orderSeedTenantConfigsByParentSlug(configs) { tenantType := config.Type if tenantType == "" { tenantType = domain.TenantTypeCompany } var parentID *string if config.ParentSlug != "" { parent, err := repo.FindBySlug(ctx, config.ParentSlug) if err != nil || parent == nil { if err == nil { err = errors.New("parent tenant not found") } slog.Error("Failed to resolve parent tenant for seed", "slug", config.Slug, "parentSlug", config.ParentSlug, "error", err) return fmt.Errorf("resolve parent tenant %q for seed %q: %w", config.ParentSlug, config.Slug, err) } parentID = &parent.ID } slog.Info("[Bootstrap] Creating seed tenant", "name", config.Name, "slug", config.Slug) var tenant *domain.Tenant var err error if config.TenantID != "" { tenant, err = createSeedTenant(ctx, repo, outboxRepo, config, tenantType, parentID) } else { tenant, err = svc.RegisterTenant(ctx, config.Name, config.Slug, tenantType, config.Description, config.Domains, parentID, "") } if err != nil { slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err) return err } tenant.Status = domain.TenantStatusActive if len(config.Config) > 0 { tenant.Config = config.Config } if err := db.Save(tenant).Error; err != nil { return err } } return nil } func loadSeedTenantConfigs() ([]InitialTenantConfig, error) { path, err := findSeedTenantCSVPath() if err != nil { return nil, err } file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("open seed tenant csv %q: %w", path, err) } defer file.Close() configs, err := parseSeedTenantCSV(file) if err != nil { return nil, fmt.Errorf("parse seed tenant csv %q: %w", path, err) } 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 } for _, candidate := range seedTenantCSVPathCandidates { cleaned := filepath.Clean(candidate) if _, err := os.Stat(cleaned); err == nil { return cleaned, nil } } return "", fmt.Errorf("seed tenant csv not found; set %s or add adminfront/seed-tenant.csv", seedTenantCSVPathEnv) } func parseSeedTenantCSV(r io.Reader) ([]InitialTenantConfig, error) { data, err := io.ReadAll(r) if err != nil { return nil, errors.New("failed to read csv") } data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) reader := csv.NewReader(bytes.NewReader(data)) reader.FieldsPerRecord = -1 rows, err := reader.ReadAll() if err != nil { return nil, fmt.Errorf("invalid csv: %w", err) } if len(rows) == 0 { return nil, errors.New("csv is empty") } header := seedTenantCSVHeaderIndex(rows[0]) for _, key := range []string{"name", "type", "slug"} { if _, ok := header[key]; !ok { return nil, fmt.Errorf("missing required column: %s", key) } } configs := make([]InitialTenantConfig, 0, len(rows)-1) for i, row := range rows[1:] { if seedTenantCSVRowIsEmpty(row) { continue } name := seedTenantCSVValue(row, header, "name") if name == "" { return nil, fmt.Errorf("row %d: name is required", i+2) } tenantType := normalizeSeedTenantType(seedTenantCSVValue(row, header, "type")) if tenantType == "" { return nil, fmt.Errorf("row %d: invalid tenant type", i+2) } slug := utils.GenerateSlug(seedTenantCSVValue(row, header, "slug")) if slug == "" { return nil, fmt.Errorf("row %d: slug is required", i+2) } config, err := seedTenantCSVRecordConfig(row, header) if err != nil { return nil, fmt.Errorf("row %d: %w", i+2, err) } configs = append(configs, InitialTenantConfig{ TenantID: seedTenantCSVValue(row, header, "tenant_id"), Name: name, Type: tenantType, ParentSlug: seedTenantCSVValue(row, header, "parent_tenant_slug"), Slug: slug, Description: seedTenantCSVValue(row, header, "memo"), Domains: splitSeedTenantCSVDomains(seedTenantCSVValue(row, header, "email_domain")), Config: config, }) } return configs, nil } func seedTenantCSVHeaderIndex(header []string) map[string]int { index := make(map[string]int, len(header)) aliases := map[string]string{ "id": "tenant_id", "tenantid": "tenant_id", "tenant_id": "tenant_id", "name": "name", "type": "type", "parenttenantslug": "parent_tenant_slug", "parent_tenant_slug": "parent_tenant_slug", "parent_slug": "parent_tenant_slug", "slug": "slug", "memo": "memo", "description": "memo", "email-domain": "email_domain", "emaildomain": "email_domain", "email_domain": "email_domain", "domain": "email_domain", "domains": "email_domain", "visibility": "visibility", "public_setting": "visibility", "publicsetting": "visibility", "org_unit_type": "org_unit_type", "orgunittype": "org_unit_type", "organization_type": "org_unit_type", "organizationtype": "org_unit_type", "worksmobile": "worksmobile_sync", "worksmobilesync": "worksmobile_sync", "worksmobile_sync": "worksmobile_sync", "works_sync": "worksmobile_sync", "works": "worksmobile_sync", } for i, column := range header { key := strings.ToLower(strings.TrimSpace(column)) key = strings.ReplaceAll(key, " ", "_") if canonical, ok := aliases[key]; ok { index[canonical] = i } } return index } func seedTenantCSVValue(row []string, header map[string]int, key string) string { idx, ok := header[key] if !ok || idx >= len(row) { return "" } return strings.TrimSpace(row[idx]) } func seedTenantCSVRecordConfig(row []string, header map[string]int) (domain.JSONMap, error) { config := domain.JSONMap{} visibility := strings.TrimSpace(seedTenantCSVValue(row, header, "visibility")) if visibility != "" { normalizedVisibility, err := normalizeSeedTenantVisibility(visibility) if err != nil { return nil, err } config["visibility"] = normalizedVisibility } orgUnitType := strings.TrimSpace(seedTenantCSVValue(row, header, "org_unit_type")) if orgUnitType != "" { if !isAllowedSeedTenantOrgUnitType(orgUnitType) { return nil, errors.New("orgUnitType must be one of 실, 팀, TF, TF팀, 센터, 디비전, 셀, 본부, 지역본부, 부, 임원직속") } config["orgUnitType"] = orgUnitType } if worksmobileSync := strings.TrimSpace(seedTenantCSVValue(row, header, "worksmobile_sync")); worksmobileSync != "" { excluded, err := normalizeSeedTenantWorksmobileExcluded(worksmobileSync) if err != nil { return nil, err } config["worksmobileExcluded"] = excluded } if len(config) == 0 { return nil, nil } return config, nil } func normalizeSeedTenantWorksmobileExcluded(value string) (bool, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "", "yes", "y", "true", "1", "on", "sync", "linked", "연동": return false, nil case "no", "n", "false", "0", "off", "none", "excluded", "exclude", "not_sync", "not-synced", "미연동", "연동안함", "제외": return true, nil default: return false, errors.New("worksmobile_sync must be yes or no") } } func normalizeSeedTenantVisibility(value string) (string, error) { visibility := strings.ToLower(strings.TrimSpace(value)) if visibility == "" || visibility == "public" { return "public", nil } if visibility != "internal" && visibility != "private" { return "", errors.New("visibility must be public, internal, or private") } return visibility, nil } func isAllowedSeedTenantOrgUnitType(value string) bool { switch strings.TrimSpace(value) { case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속": return true default: return false } } func seedTenantCSVRowIsEmpty(row []string) bool { for _, value := range row { if strings.TrimSpace(value) != "" { return false } } return true } func normalizeSeedTenantType(value string) string { switch strings.ToUpper(strings.TrimSpace(value)) { case domain.TenantTypePersonal: return domain.TenantTypePersonal case domain.TenantTypeCompany: return domain.TenantTypeCompany case domain.TenantTypeCompanyGroup: return domain.TenantTypeCompanyGroup case domain.TenantTypeOrganization: return domain.TenantTypeOrganization case domain.TenantTypeUserGroup: return domain.TenantTypeUserGroup default: return "" } } func splitSeedTenantCSVDomains(value string) []string { value = strings.ReplaceAll(value, "\n", ";") value = strings.ReplaceAll(value, ",", ";") parts := strings.Split(value, ";") domains := make([]string, 0, len(parts)) seen := make(map[string]bool, len(parts)) for _, part := range parts { domainName := strings.ToLower(strings.TrimSpace(part)) if domainName == "" || seen[domainName] { continue } seen[domainName] = true domains = append(domains, domainName) } return domains } func orderSeedTenantConfigsByParentSlug(configs []InitialTenantConfig) []InitialTenantConfig { bySlug := make(map[string]InitialTenantConfig, len(configs)) for _, config := range configs { bySlug[strings.ToLower(config.Slug)] = config } ordered := make([]InitialTenantConfig, 0, len(configs)) visited := make(map[string]bool, len(configs)) var visit func(config InitialTenantConfig) visit = func(config InitialTenantConfig) { key := strings.ToLower(config.Slug) if visited[key] { return } if config.ParentSlug != "" { if parent, ok := bySlug[strings.ToLower(config.ParentSlug)]; ok { visit(parent) } } visited[key] = true ordered = append(ordered, config) } for _, config := range configs { visit(config) } return ordered } func createSeedTenant( ctx context.Context, repo repository.TenantRepository, outboxRepo repository.KetoOutboxRepository, config InitialTenantConfig, tenantType string, parentID *string, ) (*domain.Tenant, error) { tenant := &domain.Tenant{ ID: config.TenantID, Type: tenantType, Name: config.Name, Slug: config.Slug, Description: config.Description, Status: domain.TenantStatusActive, ParentID: parentID, Config: config.Config, } if err := repo.Create(ctx, tenant); err != nil { return nil, err } if err := outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: "admins", Subject: "System:global#super_admins", Action: domain.KetoOutboxActionCreate, }); err != nil { return nil, err } if tenant.ParentID != nil { if err := outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + *tenant.ParentID, Action: domain.KetoOutboxActionCreate, }); err != nil { return nil, err } } for _, domainName := range config.Domains { if err := repo.AddDomain(ctx, tenant.ID, domainName, true); err != nil { slog.Error("Failed to add domain to seeded tenant", "tenant", config.Slug, "domain", domainName, "error", err) } } return repo.FindBySlug(ctx, config.Slug) }