첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"errors"
"fmt"
"net/mail"
"strings"
"time"
"gorm.io/gorm"
)
type SuperAdminIdentityAdmin interface {
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error)
CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
}
type SuperAdminStore interface {
FindUserByEmail(ctx context.Context, email string) (*domain.User, error)
CreateUser(ctx context.Context, user *domain.User) error
UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error)
EnqueueSuperAdminRelation(ctx context.Context, userID string) error
}
type EnsureSuperAdminOptions struct {
Email string
Password string
Name string
Source string
UpdatePassword bool
}
type EnsureSuperAdminResult struct {
Email string
IdentityID string
LocalUserID string
IdentityCreated bool
PasswordUpdated bool
LocalUserCreated bool
LocalUserUpdated bool
KetoRelationQueued bool
}
func EnsureSuperAdmin(ctx context.Context, identityAdmin SuperAdminIdentityAdmin, store SuperAdminStore, opts EnsureSuperAdminOptions) (EnsureSuperAdminResult, error) {
email := strings.ToLower(strings.TrimSpace(opts.Email))
name := strings.TrimSpace(opts.Name)
if name == "" {
name = "System Admin"
}
source := strings.TrimSpace(opts.Source)
if source == "" {
source = "admin_cli"
}
result := EnsureSuperAdminResult{Email: email}
if _, err := mail.ParseAddress(email); err != nil {
return result, fmt.Errorf("invalid admin email: %w", err)
}
if identityAdmin == nil {
return result, errors.New("identity admin is required")
}
if store == nil {
return result, errors.New("super admin store is required")
}
identityID, err := identityAdmin.FindIdentityIDByIdentifier(ctx, email)
if err != nil {
return result, fmt.Errorf("find admin identity: %w", err)
}
if identityID == "" {
if strings.TrimSpace(opts.Password) == "" {
return result, errors.New("admin password is required to create identity")
}
identityID, err = identityAdmin.CreateUser(ctx, buildSuperAdminBrokerUser(email, name), opts.Password)
if err != nil {
return result, fmt.Errorf("create admin identity: %w", err)
}
result.IdentityCreated = true
} else if opts.UpdatePassword {
if strings.TrimSpace(opts.Password) == "" {
return result, errors.New("admin password is required to update identity password")
}
if err := identityAdmin.UpdateIdentityPassword(ctx, identityID, opts.Password); err != nil {
return result, fmt.Errorf("update admin identity password: %w", err)
}
result.PasswordUpdated = true
}
result.IdentityID = identityID
user, err := store.FindUserByEmail(ctx, email)
if err != nil {
return result, fmt.Errorf("find local admin user: %w", err)
}
if user == nil {
if identityID == "" {
return result, errors.New("identity id is required to create local admin user")
}
user = &domain.User{
ID: identityID,
Email: email,
Name: name,
Role: domain.RoleSuperAdmin,
Status: domain.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Metadata: domain.JSONMap{
"source": source,
},
}
if err := store.CreateUser(ctx, user); err != nil {
return result, fmt.Errorf("create local admin user: %w", err)
}
result.LocalUserCreated = true
} else if domain.NormalizeRole(user.Role) != domain.RoleSuperAdmin || user.Status != domain.UserStatusActive || (name != "" && user.Name != name) {
user, err = store.UpdateUserSuperAdmin(ctx, user.ID, name)
if err != nil {
return result, fmt.Errorf("update local admin user: %w", err)
}
result.LocalUserUpdated = true
}
result.LocalUserID = user.ID
if err := store.EnqueueSuperAdminRelation(ctx, user.ID); err != nil {
return result, fmt.Errorf("enqueue super admin keto relation: %w", err)
}
result.KetoRelationQueued = true
return result, nil
}
func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser {
return &domain.BrokerUser{
Email: email,
Name: name,
PhoneNumber: "",
Attributes: map[string]any{
"department": "Admin",
"affiliationType": "internal",
"grade": "",
"role": domain.RoleSuperAdmin,
},
}
}
type gormSuperAdminStore struct {
db *gorm.DB
outbox repository.KetoOutboxRepository
}
func NewGormSuperAdminStore(db *gorm.DB, outbox repository.KetoOutboxRepository) SuperAdminStore {
return &gormSuperAdminStore{db: db, outbox: outbox}
}
func (s *gormSuperAdminStore) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) {
var user domain.User
if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (s *gormSuperAdminStore) CreateUser(ctx context.Context, user *domain.User) error {
return s.db.WithContext(ctx).Create(user).Error
}
func (s *gormSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) {
updates := map[string]any{
"role": domain.RoleSuperAdmin,
"status": domain.UserStatusActive,
"updated_at": time.Now(),
}
if strings.TrimSpace(name) != "" {
updates["name"] = strings.TrimSpace(name)
}
if err := s.db.WithContext(ctx).Model(&domain.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
return nil, err
}
var user domain.User
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (s *gormSuperAdminStore) EnqueueSuperAdminRelation(ctx context.Context, userID string) error {
if s.outbox == nil {
return nil
}
return s.outbox.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
}

View File

@@ -0,0 +1,157 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"testing"
)
func TestEnsureSuperAdminCreatesIdentityLocalUserAndKetoRelation(t *testing.T) {
ctx := context.Background()
identityAdmin := &fakeSuperAdminIdentityAdmin{createdID: "identity-1"}
store := &fakeSuperAdminStore{}
result, err := EnsureSuperAdmin(ctx, identityAdmin, store, EnsureSuperAdminOptions{
Email: "new-admin@example.com",
Password: "Password!123",
Name: "New Admin",
Source: "test",
})
if err != nil {
t.Fatalf("EnsureSuperAdmin returned error: %v", err)
}
if !result.IdentityCreated {
t.Fatal("identity must be created")
}
if !result.LocalUserCreated {
t.Fatal("local user must be created")
}
if result.IdentityID != "identity-1" {
t.Fatalf("identity ID = %q, want identity-1", result.IdentityID)
}
if store.user == nil {
t.Fatal("local user was not stored")
}
if store.user.Email != "new-admin@example.com" {
t.Fatalf("local user email = %q", store.user.Email)
}
if store.user.Role != domain.RoleSuperAdmin {
t.Fatalf("local user role = %q, want %q", store.user.Role, domain.RoleSuperAdmin)
}
if len(store.ketoSubjects) != 1 || store.ketoSubjects[0] != "User:identity-1" {
t.Fatalf("keto subjects = %#v, want User:identity-1", store.ketoSubjects)
}
if identityAdmin.createdUser == nil || identityAdmin.createdUser.Attributes["role"] != domain.RoleSuperAdmin {
t.Fatalf("created identity attributes = %#v", identityAdmin.createdUser)
}
}
func TestEnsureSuperAdminPromotesExistingLocalUser(t *testing.T) {
ctx := context.Background()
identityAdmin := &fakeSuperAdminIdentityAdmin{existingID: "identity-1"}
store := &fakeSuperAdminStore{
user: &domain.User{
ID: "local-user-1",
Email: "existing@example.com",
Name: "Existing",
Role: domain.RoleUser,
Status: domain.UserStatusPreboarding,
},
}
result, err := EnsureSuperAdmin(ctx, identityAdmin, store, EnsureSuperAdminOptions{
Email: "existing@example.com",
Password: "Password!123",
Name: "Existing Admin",
Source: "test",
})
if err != nil {
t.Fatalf("EnsureSuperAdmin returned error: %v", err)
}
if result.IdentityCreated {
t.Fatal("existing identity must not be recreated")
}
if !result.LocalUserUpdated {
t.Fatal("local user must be promoted")
}
if store.user.Role != domain.RoleSuperAdmin {
t.Fatalf("local user role = %q, want %q", store.user.Role, domain.RoleSuperAdmin)
}
if store.user.Status != domain.UserStatusActive {
t.Fatalf("local user status = %q, want %q", store.user.Status, domain.UserStatusActive)
}
if len(store.ketoSubjects) != 1 || store.ketoSubjects[0] != "User:local-user-1" {
t.Fatalf("keto subjects = %#v, want User:local-user-1", store.ketoSubjects)
}
}
func TestEnsureSuperAdminRequiresPasswordForNewIdentity(t *testing.T) {
_, err := EnsureSuperAdmin(context.Background(), &fakeSuperAdminIdentityAdmin{}, &fakeSuperAdminStore{}, EnsureSuperAdminOptions{
Email: "new-admin@example.com",
Name: "New Admin",
})
if err == nil {
t.Fatal("expected error")
}
}
type fakeSuperAdminIdentityAdmin struct {
existingID string
createdID string
createdUser *domain.BrokerUser
createdSecret string
}
func (f *fakeSuperAdminIdentityAdmin) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
return f.existingID, nil
}
func (f *fakeSuperAdminIdentityAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
if f.createdID == "" {
return "", errors.New("created id is not configured")
}
f.createdUser = user
f.createdSecret = password
return f.createdID, nil
}
func (f *fakeSuperAdminIdentityAdmin) UpdateIdentityPassword(ctx context.Context, identityID string, newPassword string) error {
return nil
}
type fakeSuperAdminStore struct {
user *domain.User
ketoSubjects []string
}
func (f *fakeSuperAdminStore) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) {
if f.user == nil {
return nil, nil
}
return f.user, nil
}
func (f *fakeSuperAdminStore) CreateUser(ctx context.Context, user *domain.User) error {
copied := *user
f.user = &copied
return nil
}
func (f *fakeSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) {
if f.user == nil {
return nil, errors.New("user not found")
}
f.user.Role = domain.RoleSuperAdmin
f.user.Status = domain.UserStatusActive
if name != "" {
f.user.Name = name
}
return f.user, nil
}
func (f *fakeSuperAdminStore) EnqueueSuperAdminRelation(ctx context.Context, userID string) error {
f.ketoSubjects = append(f.ketoSubjects, "User:"+userID)
return nil
}

View File

@@ -0,0 +1,116 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"fmt"
"log/slog"
"gorm.io/gorm"
)
// Run executes the application bootstrap logic (migrations, seeding, etc.)
func Run(db *gorm.DB) error {
slog.Info("[Bootstrap] Starting application bootstrap...")
// 1. Auto Migration
if err := migrateSchemas(db); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
// 2. Seed Tenants
if err := SeedTenants(db); err != nil {
return fmt.Errorf("tenant seeding failed: %w", err)
}
// 3. Normalize staging seed/read-model data
if err := CanonicalizeLegacyUserStatuses(db); err != nil {
return fmt.Errorf("legacy user status canonicalization failed: %w", err)
}
if err := SanitizeLegacyUserMetadata(db); err != nil {
return fmt.Errorf("legacy user metadata sanitize failed: %w", err)
}
slog.Info("[Bootstrap] User seed skipped (Kratos is SoT)")
slog.Info("[Bootstrap] Bootstrap completed successfully.")
return nil
}
func migrateSchemas(db *gorm.DB) error {
slog.Info("[Bootstrap] Migrating database schemas...")
if err := dropLegacyTenantDomainUniqueIndex(db); err != nil {
return err
}
if err := dropLegacyUserCompanyColumns(db); err != nil {
return err
}
// Add all domain models here
return db.AutoMigrate(
&domain.Tenant{},
&domain.TenantDomain{},
&domain.User{},
&domain.UserLoginID{},
&domain.UserProjectionState{},
&domain.UserGroup{},
&domain.ApiKey{},
&domain.IdentityProviderConfig{},
&domain.ClientSecret{},
&domain.ClientConsent{},
&domain.KetoOutbox{},
&domain.RPUsageEvent{},
&domain.WorksmobileOutbox{},
&domain.WorksmobileResourceMapping{},
&domain.SharedLink{},
&domain.DeveloperRequest{},
&domain.RPUserMetadata{},
&domain.SystemSetting{},
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
)
}
func CanonicalizeLegacyUserStatuses(db *gorm.DB) error {
if db == nil || !db.Migrator().HasTable(&domain.User{}) {
return nil
}
updates := map[string]string{
"inactive": domain.UserStatusPreboarding,
"leave_of_absence": domain.UserStatusTemporaryLeave,
"baron_only": domain.UserStatusBaronGuest,
}
for legacy, canonical := range updates {
if err := db.Model(&domain.User{}).
Where("status = ?", legacy).
Update("status", canonical).Error; err != nil {
return fmt.Errorf("failed to canonicalize users.status %s to %s: %w", legacy, canonical, err)
}
}
return nil
}
func dropLegacyUserCompanyColumns(db *gorm.DB) error {
if !db.Migrator().HasTable(&domain.User{}) {
return nil
}
for _, column := range []string{"company_code", "company_codes"} {
if !db.Migrator().HasColumn(&domain.User{}, column) {
continue
}
if err := db.Migrator().DropColumn(&domain.User{}, column); err != nil {
return fmt.Errorf("failed to drop legacy users.%s column: %w", column, err)
}
}
return nil
}
func dropLegacyTenantDomainUniqueIndex(db *gorm.DB) error {
if !db.Migrator().HasTable(&domain.TenantDomain{}) {
return nil
}
if !db.Migrator().HasIndex(&domain.TenantDomain{}, "idx_tenant_domains_domain") {
return nil
}
if err := db.Migrator().DropIndex(&domain.TenantDomain{}, "idx_tenant_domains_domain"); err != nil {
return fmt.Errorf("failed to drop legacy tenant domain unique index: %w", err)
}
return nil
}

View File

@@ -0,0 +1,84 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"log/slog"
"gorm.io/gorm"
)
// SyncKetoRelations synchronizes all existing DB users, tenants and RPs to Ory Keto via Outbox.
// This ensures data consistency for existing data when ReBAC is introduced.
func SyncKetoRelations(db *gorm.DB, outbox repository.KetoOutboxRepository) error {
slog.Info("🚀 Starting Keto ReBAC relation synchronization (via Outbox)...")
ctx := context.Background()
// 1. Sync All Tenants
var tenants []domain.Tenant
if err := db.Find(&tenants).Error; err != nil {
return err
}
slog.Info("Syncing tenants to Keto Outbox", "count", len(tenants))
for _, t := range tenants {
// Global Super Admin access to every tenant
_ = outbox.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: t.ID,
Relation: "admins",
Subject: "System:global#super_admins",
Action: domain.KetoOutboxActionCreate,
})
if t.ParentID != nil {
_ = outbox.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: t.ID,
Relation: "parents",
Subject: "Tenant:" + *t.ParentID,
Action: domain.KetoOutboxActionCreate,
})
}
}
// 2. Sync All RelyingParties (if needed)
// Note: We'll need a way to list them from Hydra or local DB if we had them.
// Assuming they are in a table domain.RelyingParty (though it was removed, let's see)
// Actually, the comment said SSOT is Hydra. But we might have them in a local table for metadata.
// If not, we skip for now or fetch from Hydra.
// 3. Sync All Users Roles and Tenant Memberships
var users []domain.User
if err := db.Find(&users).Error; err != nil {
return err
}
slog.Info("Syncing users to Keto Outbox", "count", len(users))
for _, u := range users {
// Tenant Membership
if u.TenantID != nil {
_ = outbox.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: *u.TenantID,
Relation: "members",
Subject: "User:" + u.ID,
Action: domain.KetoOutboxActionCreate,
})
}
// Roles
role := domain.NormalizeRole(u.Role)
if role == domain.RoleSuperAdmin {
_ = outbox.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + u.ID,
Action: domain.KetoOutboxActionCreate,
})
}
}
slog.Info("✅ Keto ReBAC synchronization items added to Outbox.")
return nil
}

View File

@@ -0,0 +1,75 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"log/slog"
"os"
"strings"
"time"
)
// SeedAdminIdentity creates the initial admin identity in the configured IDP.
// Returns the Kratos Identity ID and error.
func SeedAdminIdentity(idp domain.IdentityProvider) (string, error) {
if idp == nil {
return "", nil
}
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
adminPassword := os.Getenv("ADMIN_PASSWORD")
if adminEmail == "" || adminPassword == "" {
slog.Warn("[Bootstrap] ADMIN_EMAIL or ADMIN_PASSWORD not set. Skipping admin identity seed.")
return "", nil
}
adminName := strings.TrimSpace(os.Getenv("ADMIN_NAME"))
if adminName == "" {
adminName = "System Admin"
}
user := &domain.BrokerUser{
Email: adminEmail,
Name: adminName,
PhoneNumber: "",
Attributes: map[string]any{
"department": "Admin",
"affiliationType": "internal",
"grade": "",
"role": "super_admin", // Explicitly set role for Kratos traits
},
}
// Retry logic for Kratos connection
maxRetries := 5
var err error
var identityID string
for i := range maxRetries {
identityID, err = idp.CreateUser(user, adminPassword)
if err == nil {
slog.Info("[Bootstrap] Admin identity created in IDP", "email", adminEmail, "idp", idp.Name(), "id", identityID)
return identityID, nil
}
if strings.Contains(err.Error(), "already exists") {
slog.Info("[Bootstrap] Admin identity already exists in IDP. Attempting to retrieve ID...", "email", adminEmail)
// Try to sign in to get the identity ID
authInfo, err := idp.SignIn(adminEmail, adminPassword)
if err == nil && authInfo != nil {
slog.Info("[Bootstrap] Retrieved existing admin identity ID", "id", authInfo.Subject)
return authInfo.Subject, nil
}
slog.Warn("[Bootstrap] Failed to retrieve existing admin identity ID via SignIn", "error", err)
return "", nil // Return nil error to avoid stopping bootstrap, but ID is missing
}
slog.Warn("[Bootstrap] Failed to seed admin identity (retrying...)",
"attempt", i+1,
"max_retries", maxRetries,
"error", err,
)
time.Sleep(2 * time.Second)
}
return "", err
}

View File

@@ -0,0 +1,77 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"log/slog"
"os"
"strings"
"time"
"gorm.io/gorm"
)
// SyncAdminRole updates the role of the admin user in the local DB.
// It ensures the admin user exists in the local DB with the correct Kratos ID.
func SyncAdminRole(db *gorm.DB, kratosID string) error {
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
if adminEmail == "" {
slog.Warn("[Bootstrap] ADMIN_EMAIL not set. Skipping admin role sync.")
return nil
}
adminName := strings.TrimSpace(os.Getenv("ADMIN_NAME"))
if adminName == "" {
adminName = "System Admin"
}
// Find user by email
var user domain.User
if err := db.Where("email = ?", adminEmail).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
if kratosID == "" {
slog.Warn("[Bootstrap] Admin user not found in local DB and Kratos ID is missing. Cannot create local user.", "email", adminEmail)
return nil
}
// Create new admin user in local DB
newUser := domain.User{
ID: kratosID,
Email: adminEmail,
Name: adminName,
Role: domain.RoleSuperAdmin,
Status: "active",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Metadata: domain.JSONMap{"source": "bootstrap_seed"},
}
if err := db.Create(&newUser).Error; err != nil {
return err
}
slog.Info("[Bootstrap] Created admin user in local DB", "email", adminEmail, "id", kratosID)
return nil
}
return err
}
// Update role if needed
updates := map[string]any{}
if user.Role != domain.RoleSuperAdmin {
updates["role"] = domain.RoleSuperAdmin
}
// Also ensure ID matches if it was somehow different (though changing PK is hard, at least log it)
if kratosID != "" && user.ID != kratosID {
slog.Warn("[Bootstrap] Admin user exists but ID mismatch with Kratos", "local_id", user.ID, "kratos_id", kratosID)
// We generally don't change UUID PKs, just warn.
}
if len(updates) > 0 {
if err := db.Model(&user).Updates(updates).Error; err != nil {
return err
}
slog.Info("[Bootstrap] Updated admin user role to super_admin", "email", adminEmail)
} else {
slog.Info("[Bootstrap] Admin user already has super_admin role", "email", adminEmail)
}
return nil
}

View 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)
}

View File

@@ -0,0 +1,388 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/testsupport"
"context"
"log"
"os"
"path/filepath"
"testing"
"time"
"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 TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
configs, err := loadSeedTenantConfigs()
if err != nil {
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
}
expected := []struct {
name string
slug string
tenantType string
parentSlug string
domains []string
}{
{
name: "한맥가족",
slug: "hanmac-family",
tenantType: domain.TenantTypeCompanyGroup,
},
{
name: "삼안",
slug: "saman",
tenantType: domain.TenantTypeCompany,
parentSlug: "hanmac-family",
domains: []string{"samaneng.com"},
},
{
name: "한맥기술",
slug: "hanmac",
tenantType: domain.TenantTypeCompany,
parentSlug: "hanmac-family",
domains: []string{"hanmaceng.co.kr"},
},
{
name: "총괄기획&기술개발센터",
slug: "gpdtdc",
tenantType: domain.TenantTypeCompany,
parentSlug: "hanmac-family",
domains: []string{"baroncs.co.kr"},
},
{
name: "바론그룹",
slug: "baron-group",
tenantType: domain.TenantTypeCompanyGroup,
parentSlug: "hanmac-family",
domains: []string{"brsw.kr"},
},
{
name: "(주)장헌",
slug: "jangheon",
tenantType: domain.TenantTypeCompany,
parentSlug: "baron-group",
domains: []string{"jangheon.com"},
},
{
name: "장헌산업",
slug: "jangheon-sanup",
tenantType: domain.TenantTypeCompany,
parentSlug: "baron-group",
domains: []string{"jangheon.co.kr"},
},
{
name: "한라산업개발",
slug: "halla",
tenantType: domain.TenantTypeCompany,
parentSlug: "hanmac-family",
domains: []string{"hallasanup.com"},
},
{
name: "(주)피티씨",
slug: "ptc",
tenantType: domain.TenantTypeCompany,
parentSlug: "baron-group",
domains: []string{"pre-cast.co.kr"},
},
{
name: "Personal",
slug: "personal",
tenantType: domain.TenantTypePersonal,
},
}
if len(configs) < len(expected) {
t.Fatalf("expected at least %d seed tenants, got %d", len(expected), len(configs))
}
wantFamilyChildOrder := []string{
"gpdtdc",
"saman",
"hanmac",
"baron-group",
"halla",
}
policyFamilyChildSlugs := map[string]bool{}
for _, slug := range wantFamilyChildOrder {
policyFamilyChildSlugs[slug] = true
}
gotFamilyChildOrder := make([]string, 0, len(wantFamilyChildOrder))
for _, config := range configs {
if config.ParentSlug == "hanmac-family" && policyFamilyChildSlugs[config.Slug] {
gotFamilyChildOrder = append(gotFamilyChildOrder, config.Slug)
}
}
if len(gotFamilyChildOrder) != len(wantFamilyChildOrder) {
t.Fatalf("hanmac-family child order = %#v, want %#v", gotFamilyChildOrder, wantFamilyChildOrder)
}
for i, wantSlug := range wantFamilyChildOrder {
if gotFamilyChildOrder[i] != wantSlug {
t.Fatalf("hanmac-family child order[%d] = %q, want %q", i, gotFamilyChildOrder[i], wantSlug)
}
}
configBySlug := make(map[string]InitialTenantConfig, len(configs))
for _, config := range configs {
configBySlug[config.Slug] = config
}
for _, want := range expected {
got, ok := configBySlug[want.slug]
if !ok {
t.Fatalf("tenant slug %q not found in seed configs", want.slug)
}
if got.Name != want.name {
t.Fatalf("tenant[%s] name = %q, want %q", want.slug, got.Name, want.name)
}
if got.Slug != want.slug {
t.Fatalf("tenant[%s] slug = %q, want %q", want.slug, got.Slug, want.slug)
}
if got.Type != want.tenantType {
t.Fatalf("tenant[%s] type = %q, want %q", want.slug, got.Type, want.tenantType)
}
if got.ParentSlug != want.parentSlug {
t.Fatalf("tenant[%s] parent slug = %q, want %q", want.slug, got.ParentSlug, want.parentSlug)
}
if len(got.Domains) != len(want.domains) {
t.Fatalf("tenant[%s] domains = %#v, want %#v", want.slug, got.Domains, want.domains)
}
for j, wantDomain := range want.domains {
if got.Domains[j] != wantDomain {
t.Fatalf("tenant[%s] domain[%d] = %q, want %q", want.slug, j, got.Domains[j], wantDomain)
}
}
}
}
func TestNormalizeSeedTenantTypeAllowsOrganization(t *testing.T) {
if got := normalizeSeedTenantType("organization"); got != domain.TenantTypeOrganization {
t.Fatalf("normalizeSeedTenantType(organization) = %q, want %q", got, domain.TenantTypeOrganization)
}
}
func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "seed-tenant.csv")
csv := "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n" +
"Root,COMPANY_GROUP,,root,Root memo,,,,\n" +
"Child,USER_GROUP,root,child,Child memo,child.example.com,private,팀,no\n"
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
t.Fatalf("failed to write seed csv: %v", err)
}
t.Setenv(seedTenantCSVPathEnv, path)
configs, err := loadSeedTenantConfigs()
if err != nil {
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
}
if len(configs) != 2 {
t.Fatalf("expected 2 configs, got %d", len(configs))
}
if configs[1].ParentSlug != "root" {
t.Fatalf("child parent slug = %q, want root", configs[1].ParentSlug)
}
if len(configs[1].Domains) != 1 || configs[1].Domains[0] != "child.example.com" {
t.Fatalf("child domains = %#v, want child.example.com", configs[1].Domains)
}
if configs[1].Config["visibility"] != "private" {
t.Fatalf("child visibility = %#v, want private", configs[1].Config["visibility"])
}
if configs[1].Config["orgUnitType"] != "팀" {
t.Fatalf("child orgUnitType = %#v, want 팀", configs[1].Config["orgUnitType"])
}
if configs[1].Config["worksmobileExcluded"] != true {
t.Fatalf("child worksmobileExcluded = %#v, want true", configs[1].Config["worksmobileExcluded"])
}
}
func TestSeedTenantCSVDefinesMHDAsPrivateUserGroup(t *testing.T) {
configs, err := loadSeedTenantConfigs()
if err != nil {
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
}
configBySlug := make(map[string]InitialTenantConfig, len(configs))
for _, config := range configs {
configBySlug[config.Slug] = config
}
mhd, ok := configBySlug["mhd"]
if !ok {
t.Fatal("mhd seed tenant not found")
}
if mhd.Type != domain.TenantTypeUserGroup {
t.Fatalf("mhd type = %q, want %q", mhd.Type, domain.TenantTypeUserGroup)
}
if mhd.Config["visibility"] != "private" {
t.Fatalf("mhd visibility = %#v, want private", mhd.Config["visibility"])
}
if mhd.Config["worksmobileExcluded"] != true {
t.Fatalf("mhd worksmobileExcluded = %#v, want true", mhd.Config["worksmobileExcluded"])
}
}
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")
}
}
func TestFilterMissingSeedTenantConfigsSkipsExistingSlugs(t *testing.T) {
configs := []InitialTenantConfig{
{TenantID: "existing-root-id", Name: "Existing Root", Slug: "existing-root"},
{Name: "Missing Child", Slug: "missing-child", ParentSlug: "existing-root"},
{TenantID: "existing-child-id", Name: "Existing Child", Slug: "existing-child", ParentSlug: "existing-root"},
{TenantID: "existing-other-id", Name: "Conflicting ID", Slug: "new-slug"},
}
existingSlugs := map[string]bool{
"existing-root": true,
"existing-child": true,
}
existingIDs := map[string]bool{
"existing-root-id": true,
"existing-child-id": true,
"existing-other-id": true,
}
filtered := filterMissingSeedTenantConfigs(configs, existingSlugs, existingIDs)
if len(filtered) != 1 {
t.Fatalf("filtered count = %d, want 1: %#v", len(filtered), filtered)
}
if filtered[0].Slug != "missing-child" {
t.Fatalf("filtered slug = %q, want missing-child", filtered[0].Slug)
}
}
func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testing.T) {
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{}, &domain.TenantDomain{}, &domain.KetoOutbox{}); err != nil {
t.Fatalf("failed to migrate seed test tables: %v", err)
}
existingRoot := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000001",
Name: "Existing Root Name",
Slug: "existing-root",
Type: domain.TenantTypeCompanyGroup,
Description: "manual tenant must not be overwritten",
Status: domain.TenantStatusActive,
}
nonSeedTenant := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000002",
Name: "Manual Tenant",
Slug: "manual-tenant",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
if err := db.Create(&existingRoot).Error; err != nil {
t.Fatalf("failed to create existing root tenant: %v", err)
}
if err := db.Create(&nonSeedTenant).Error; err != nil {
t.Fatalf("failed to create non-seed tenant: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "seed-tenant.csv")
csv := "id,name,type,parent_tenant_slug,slug,memo,email_domain\n" +
"10000000-0000-0000-0000-000000000001,Seed Root Name,COMPANY_GROUP,,existing-root,seed must be skipped,\n" +
"00000000-0000-0000-0000-000000000002,Conflicting ID,COMPANY,existing-root,conflicting-id,seed id must be skipped,\n" +
"10000000-0000-0000-0000-000000000002,Missing Child,COMPANY,existing-root,missing-child,created from seed,child.example.com\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 err := SeedTenants(db); err != nil {
t.Fatalf("SeedTenants returned error: %v", err)
}
var root domain.Tenant
if err := db.First(&root, "slug = ?", "existing-root").Error; err != nil {
t.Fatalf("failed to load existing root after seed: %v", err)
}
if root.ID != existingRoot.ID {
t.Fatalf("existing root ID = %q, want %q", root.ID, existingRoot.ID)
}
if root.Name != existingRoot.Name {
t.Fatalf("existing root name = %q, want untouched %q", root.Name, existingRoot.Name)
}
var child domain.Tenant
if err := db.Preload("Domains").First(&child, "slug = ?", "missing-child").Error; err != nil {
t.Fatalf("missing seed child was not created: %v", err)
}
if child.ParentID == nil || *child.ParentID != existingRoot.ID {
t.Fatalf("child parent ID = %v, want %q", child.ParentID, existingRoot.ID)
}
if len(child.Domains) != 1 || child.Domains[0].Domain != "child.example.com" {
t.Fatalf("child domains = %#v, want child.example.com", child.Domains)
}
var rootCount int64
if err := db.Model(&domain.Tenant{}).Where("slug = ?", "existing-root").Count(&rootCount).Error; err != nil {
t.Fatalf("failed to count existing root rows: %v", err)
}
if rootCount != 1 {
t.Fatalf("existing-root row count = %d, want 1", rootCount)
}
var conflictingIDCount int64
if err := db.Model(&domain.Tenant{}).Where("slug = ?", "conflicting-id").Count(&conflictingIDCount).Error; err != nil {
t.Fatalf("failed to count conflicting-id rows: %v", err)
}
if conflictingIDCount != 0 {
t.Fatalf("conflicting-id row count = %d, want 0", conflictingIDCount)
}
}

View File

@@ -0,0 +1,34 @@
package bootstrap
import (
"fmt"
"log/slog"
"gorm.io/gorm"
)
const sanitizeLegacyUserMetadataSQL = `
update users
set metadata = metadata - 'hanmacFamily' - 'userType',
updated_at = now()
where metadata ? 'hanmacFamily'
or metadata ? 'userType'
`
// SanitizeLegacyUserMetadata removes legacy UI classification flags from Baron user metadata.
func SanitizeLegacyUserMetadata(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database is not configured")
}
if !db.Migrator().HasTable("users") {
slog.Info("[Bootstrap] Legacy user metadata sanitize skipped because users table does not exist")
return nil
}
result := db.Exec(sanitizeLegacyUserMetadataSQL)
if result.Error != nil {
return fmt.Errorf("sanitize legacy user metadata: %w", result.Error)
}
slog.Info("[Bootstrap] Legacy user metadata sanitized", "rowsAffected", result.RowsAffected)
return nil
}

View File

@@ -0,0 +1,203 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/testsupport"
"context"
"log"
"os"
"path/filepath"
"testing"
"time"
"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 TestSanitizeLegacyUserMetadataRemovesClassificationFlags(t *testing.T) {
db := openBootstrapPostgresTestDB(t)
if err := db.AutoMigrate(&domain.User{}); err != nil {
t.Fatalf("failed to migrate users table: %v", err)
}
user := domain.User{
ID: "10000000-0000-0000-0000-000000000001",
Email: "legacy@example.com",
Name: "Legacy User",
Role: domain.RoleUser,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"hanmacFamily": true,
"userType": "hanmac",
"employeeId": "E001",
"nested": map[string]any{
"userType": "must stay nested",
},
},
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
if err := SanitizeLegacyUserMetadata(db); err != nil {
t.Fatalf("SanitizeLegacyUserMetadata returned error: %v", err)
}
if err := SanitizeLegacyUserMetadata(db); err != nil {
t.Fatalf("SanitizeLegacyUserMetadata must be idempotent: %v", err)
}
var got domain.User
if err := db.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatalf("failed to load sanitized user: %v", err)
}
if _, ok := got.Metadata["hanmacFamily"]; ok {
t.Fatalf("hanmacFamily must be removed from metadata: %#v", got.Metadata)
}
if _, ok := got.Metadata["userType"]; ok {
t.Fatalf("userType must be removed from metadata: %#v", got.Metadata)
}
if got.Metadata["employeeId"] != "E001" {
t.Fatalf("employeeId = %#v, want E001", got.Metadata["employeeId"])
}
nested, ok := got.Metadata["nested"].(map[string]any)
if !ok || nested["userType"] != "must stay nested" {
t.Fatalf("nested metadata must be preserved: %#v", got.Metadata["nested"])
}
}
func TestCanonicalizeLegacyUserStatuses(t *testing.T) {
db := openBootstrapPostgresTestDB(t)
if err := db.AutoMigrate(&domain.User{}); err != nil {
t.Fatalf("failed to migrate users table: %v", err)
}
users := []domain.User{
{ID: "11000000-0000-0000-0000-000000000001", Email: "inactive@example.com", Name: "Inactive", Role: domain.RoleUser, Status: "inactive"},
{ID: "11000000-0000-0000-0000-000000000002", Email: "leave@example.com", Name: "Leave", Role: domain.RoleUser, Status: "leave_of_absence"},
{ID: "11000000-0000-0000-0000-000000000003", Email: "baron-only@example.com", Name: "Baron Only", Role: domain.RoleUser, Status: "baron_only"},
{ID: "11000000-0000-0000-0000-000000000004", Email: "active@example.com", Name: "Active", Role: domain.RoleUser, Status: domain.UserStatusActive},
}
if err := db.Create(&users).Error; err != nil {
t.Fatalf("failed to create users: %v", err)
}
if err := CanonicalizeLegacyUserStatuses(db); err != nil {
t.Fatalf("CanonicalizeLegacyUserStatuses returned error: %v", err)
}
if err := CanonicalizeLegacyUserStatuses(db); err != nil {
t.Fatalf("CanonicalizeLegacyUserStatuses must be idempotent: %v", err)
}
got := map[string]string{}
var loaded []domain.User
if err := db.Find(&loaded).Error; err != nil {
t.Fatalf("failed to load users: %v", err)
}
for _, user := range loaded {
got[user.Email] = user.Status
}
if got["inactive@example.com"] != domain.UserStatusPreboarding {
t.Fatalf("inactive status = %q, want %q", got["inactive@example.com"], domain.UserStatusPreboarding)
}
if got["leave@example.com"] != domain.UserStatusTemporaryLeave {
t.Fatalf("leave status = %q, want %q", got["leave@example.com"], domain.UserStatusTemporaryLeave)
}
if got["baron-only@example.com"] != domain.UserStatusBaronGuest {
t.Fatalf("baron_only status = %q, want %q", got["baron-only@example.com"], domain.UserStatusBaronGuest)
}
if got["active@example.com"] != domain.UserStatusActive {
t.Fatalf("active status = %q, want %q", got["active@example.com"], domain.UserStatusActive)
}
}
func TestRunSanitizesLegacyUserMetadata(t *testing.T) {
db := openBootstrapPostgresTestDB(t)
if err := db.AutoMigrate(&domain.User{}); err != nil {
t.Fatalf("failed to migrate users table: %v", err)
}
user := domain.User{
ID: "20000000-0000-0000-0000-000000000001",
Email: "run-legacy@example.com",
Name: "Run Legacy User",
Role: domain.RoleUser,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"hanmacFamily": true,
"userType": "external",
"employeeId": "E002",
},
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "seed-tenant.csv")
csv := "id,name,type,parent_tenant_slug,slug,memo,email_domain\n" +
"30000000-0000-0000-0000-000000000001,Seed Root,COMPANY_GROUP,,seed-root,seed root,\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 err := Run(db); err != nil {
t.Fatalf("Run returned error: %v", err)
}
var got domain.User
if err := db.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatalf("failed to load sanitized user: %v", err)
}
if _, ok := got.Metadata["hanmacFamily"]; ok {
t.Fatalf("Run must remove hanmacFamily from metadata: %#v", got.Metadata)
}
if _, ok := got.Metadata["userType"]; ok {
t.Fatalf("Run must remove userType from metadata: %#v", got.Metadata)
}
if got.Metadata["employeeId"] != "E002" {
t.Fatalf("employeeId = %#v, want E002", got.Metadata["employeeId"])
}
}
func openBootstrapPostgresTestDB(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)
}
return db
}