첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
524
baron-sso/backend/internal/bootstrap/tenant_seed.go
Normal file
524
baron-sso/backend/internal/bootstrap/tenant_seed.go
Normal file
@@ -0,0 +1,524 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user