forked from baron/baron-sso
441 lines
12 KiB
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)
|
|
}
|