1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/bootstrap/tenant_seed.go

441 lines
12 KiB
Go

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
}
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 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)
}
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")),
})
}
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",
}
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 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,
}
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)
}