package main import ( "baron-sso-backend/internal/bootstrap" "baron-sso-backend/internal/domain" "baron-sso-backend/internal/idp" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "context" "encoding/json" "flag" "fmt" "log" "log/slog" "maps" "os" "strings" "time" "github.com/joho/godotenv" "gorm.io/driver/postgres" "gorm.io/gorm" gormLogger "gorm.io/gorm/logger" ) type createSuperAdminConfig struct { Email string Password string Name string UpdatePassword bool } type clearOrphanUserTenantMembershipsConfig struct { DryRun bool } type repairDeletedTenantIdentitiesConfig struct { DryRun bool } type repairUserTenantConfig struct { UserID string TenantSlug string RemoveTenantSlug string } func main() { loadEnv() logger.Init(logger.Config{ ServiceName: "baron-sso-adminctl", Environment: getenv("APP_ENV", getenv("GO_ENV", "dev")), LevelOverride: getenv("BACKEND_LOG_LEVEL", ""), }) if len(os.Args) < 2 { printUsage() os.Exit(2) } switch os.Args[1] { case "create-super-admin": if err := runCreateSuperAdmin(os.Args[2:]); err != nil { slog.Error("create-super-admin failed", "error", err) os.Exit(1) } case "clear-orphan-user-tenant-memberships": if err := runClearOrphanUserTenantMemberships(os.Args[2:]); err != nil { slog.Error("clear-orphan-user-tenant-memberships failed", "error", err) os.Exit(1) } case "repair-deleted-tenant-identities": if err := runRepairDeletedTenantIdentities(os.Args[2:]); err != nil { slog.Error("repair-deleted-tenant-identities failed", "error", err) os.Exit(1) } case "repair-user-tenant": if err := runRepairUserTenant(os.Args[2:]); err != nil { slog.Error("repair-user-tenant failed", "error", err) os.Exit(1) } case "worksmobile-sync": if err := runWorksmobileSync(os.Args[2:]); err != nil { slog.Error("worksmobile-sync failed", "error", err) os.Exit(1) } default: printUsage() os.Exit(2) } } func runCreateSuperAdmin(args []string) error { config, err := resolveCreateSuperAdminConfig(args) if err != nil { return err } db, err := openDB() if err != nil { return err } if err := bootstrap.Run(db); err != nil { return err } provider, err := idp.InitializeProvider() if err != nil { return err } if provider == nil { return fmt.Errorf("idp provider is required") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() result, err := bootstrap.EnsureSuperAdmin( ctx, service.NewKratosAdminService(), bootstrap.NewGormSuperAdminStore(db, repository.NewKetoOutboxRepository(db)), bootstrap.EnsureSuperAdminOptions{ Email: config.Email, Password: config.Password, Name: config.Name, Source: "adminctl", UpdatePassword: config.UpdatePassword, }, ) if err != nil { return err } fmt.Printf("super admin ensured: email=%s identity_id=%s user_id=%s identity_created=%t local_created=%t local_updated=%t password_updated=%t keto_relation_queued=%t\n", result.Email, result.IdentityID, result.LocalUserID, result.IdentityCreated, result.LocalUserCreated, result.LocalUserUpdated, result.PasswordUpdated, result.KetoRelationQueued, ) return nil } func runRepairUserTenant(args []string) error { config, err := resolveRepairUserTenantConfig(args) if err != nil { return err } db, err := openDB() if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var tenant domain.Tenant if err := db.WithContext(ctx).First(&tenant, "slug = ?", config.TenantSlug).Error; err != nil { return fmt.Errorf("target tenant not found slug=%s: %w", config.TenantSlug, err) } var removeTenant *domain.Tenant if config.RemoveTenantSlug != "" { var found domain.Tenant if err := db.WithContext(ctx).First(&found, "slug = ?", config.RemoveTenantSlug).Error; err != nil { return fmt.Errorf("remove tenant not found slug=%s: %w", config.RemoveTenantSlug, err) } removeTenant = &found } kratos := service.NewKratosAdminService() identity, err := kratos.GetIdentity(ctx, config.UserID) if err != nil { return err } if identity == nil { return fmt.Errorf("identity not found: %s", config.UserID) } traits := adminctlCloneIdentityTraits(identity.Traits) adminctlSetPrimaryTenantTraits(traits, tenant, removeTenant) updated, err := kratos.UpdateIdentity(ctx, config.UserID, traits, identity.State) if err != nil { return err } if updated == nil { return fmt.Errorf("kratos update returned empty identity") } if err := db.WithContext(ctx). Model(&domain.User{}). Where("id = ?", config.UserID). Updates(map[string]any{ "tenant_id": tenant.ID, "metadata": domain.JSONMap(updated.Traits), "updated_at": time.Now(), }).Error; err != nil { return err } if redisService, err := service.NewRedisService(); err == nil { _, _ = redisService.FlushIdentityCache(ctx) } else { slog.Warn("identity mirror flush skipped", "error", err) } fmt.Printf("user tenant repaired: user=%s tenant=%s<%s> removed=%s\n", config.UserID, tenant.Name, tenant.Slug, config.RemoveTenantSlug) return nil } func runClearOrphanUserTenantMemberships(args []string) error { config, err := resolveClearOrphanUserTenantMembershipsConfig(args) if err != nil { return err } db, err := openDB() if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if config.DryRun { count, err := repository.CountOrphanUserTenantMemberships(ctx, db) if err != nil { return err } fmt.Printf("orphan user tenant memberships dry-run: count=%d\n", count) return nil } affected, err := repository.ClearOrphanUserTenantMemberships(ctx, db) if err != nil { return err } fmt.Printf("orphan user tenant memberships cleared: count=%d\n", affected) return nil } func runRepairDeletedTenantIdentities(args []string) error { config, err := resolveRepairDeletedTenantIdentitiesConfig(args) if err != nil { return err } db, err := openDB() if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() var tenants []domain.Tenant if err := db.WithContext(ctx).Unscoped().Find(&tenants).Error; err != nil { return err } tenantByID, deletedBySlug := adminctlTenantIndexes(tenants) kratos := service.NewKratosAdminService() identities, err := kratos.ListIdentities(ctx) if err != nil { return err } scanned := 0 candidates := 0 updated := 0 localUpdated := int64(0) for _, identity := range identities { scanned++ deletedTenant, targetTenant, ok := adminctlDeletedTenantPromotion(identity.Traits, tenantByID, deletedBySlug) if !ok { continue } candidates++ nextTraits, changed := adminctlPromoteIdentityTraits(identity.Traits, deletedTenant, targetTenant) if !changed { continue } fmt.Printf("repair candidate: user=%s email=%s deleted=%s<%s> target=%s<%s>\n", identity.ID, adminctlTraitString(identity.Traits["email"]), deletedTenant.Name, adminctlLegacyTenantSlug(deletedTenant), targetTenant.Name, targetTenant.Slug, ) if config.DryRun { continue } if _, err := kratos.UpdateIdentity(ctx, identity.ID, nextTraits, identity.State); err != nil { return fmt.Errorf("update kratos identity user=%s: %w", identity.ID, err) } result := db.WithContext(ctx). Model(&domain.User{}). Where("id = ?", identity.ID). Updates(map[string]any{"tenant_id": targetTenant.ID, "updated_at": time.Now()}) if result.Error != nil { return result.Error } localUpdated += result.RowsAffected updated++ } orphanUpdated := int64(0) if !config.DryRun { affected, err := repository.ClearOrphanUserTenantMemberships(ctx, db) if err != nil { return err } orphanUpdated = affected if redisService, err := service.NewRedisService(); err == nil { if _, err := redisService.FlushIdentityCache(ctx); err != nil { return err } } else { slog.Warn("identity mirror flush skipped", "error", err) } } fmt.Printf("deleted tenant identity repair: scanned=%d candidates=%d kratos_updated=%d local_users_updated=%d orphan_memberships_updated=%d dry_run=%t\n", scanned, candidates, updated, localUpdated, orphanUpdated, config.DryRun) return nil } func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error) { fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError) fs.SetOutput(os.Stderr) config := createSuperAdminConfig{} fs.StringVar(&config.Email, "email", getenv("ADMIN_EMAIL", ""), "admin email") fs.StringVar(&config.Password, "password", getenv("ADMIN_PASSWORD", ""), "admin password") fs.StringVar(&config.Name, "name", getenv("ADMIN_NAME", "System Admin"), "admin display name") fs.BoolVar(&config.UpdatePassword, "update-password", false, "update password when identity already exists") if err := fs.Parse(args); err != nil { return config, err } config.Email = strings.TrimSpace(config.Email) config.Name = strings.TrimSpace(config.Name) if config.Email == "" { return config, fmt.Errorf("admin email is required; pass --email or set ADMIN_EMAIL") } if strings.TrimSpace(config.Password) == "" { return config, fmt.Errorf("admin password is required; pass --password or set ADMIN_PASSWORD") } if config.Name == "" { config.Name = "System Admin" } return config, nil } func resolveClearOrphanUserTenantMembershipsConfig(args []string) (clearOrphanUserTenantMembershipsConfig, error) { fs := flag.NewFlagSet("clear-orphan-user-tenant-memberships", flag.ContinueOnError) fs.SetOutput(os.Stderr) config := clearOrphanUserTenantMembershipsConfig{} fs.BoolVar(&config.DryRun, "dry-run", false, "count orphan memberships without updating users") if err := fs.Parse(args); err != nil { return config, err } return config, nil } func resolveRepairDeletedTenantIdentitiesConfig(args []string) (repairDeletedTenantIdentitiesConfig, error) { fs := flag.NewFlagSet("repair-deleted-tenant-identities", flag.ContinueOnError) fs.SetOutput(os.Stderr) config := repairDeletedTenantIdentitiesConfig{} fs.BoolVar(&config.DryRun, "dry-run", false, "print identities that reference deleted tenants without updating Kratos or local DB") if err := fs.Parse(args); err != nil { return config, err } return config, nil } func resolveRepairUserTenantConfig(args []string) (repairUserTenantConfig, error) { fs := flag.NewFlagSet("repair-user-tenant", flag.ContinueOnError) fs.SetOutput(os.Stderr) config := repairUserTenantConfig{} fs.StringVar(&config.UserID, "user-id", "", "identity/user id to repair") fs.StringVar(&config.TenantSlug, "tenant-slug", "", "target representative tenant slug") fs.StringVar(&config.RemoveTenantSlug, "remove-tenant-slug", "", "appointment tenant slug to remove") if err := fs.Parse(args); err != nil { return config, err } config.UserID = strings.TrimSpace(config.UserID) config.TenantSlug = strings.TrimSpace(config.TenantSlug) config.RemoveTenantSlug = strings.TrimSpace(config.RemoveTenantSlug) if config.UserID == "" { return config, fmt.Errorf("--user-id is required") } if config.TenantSlug == "" { return config, fmt.Errorf("--tenant-slug is required") } return config, nil } func adminctlSetPrimaryTenantTraits(traits map[string]any, target domain.Tenant, removeTenant *domain.Tenant) { traits["tenant_id"] = target.ID traits["primaryTenantId"] = target.ID traits["primaryTenantSlug"] = target.Slug traits["primaryTenantName"] = target.Name delete(traits, "companyCode") delete(traits, "companyCodes") rawAppointments, _ := adminctlPromoteIdentityAppointments(traits["additionalAppointments"], target, target) if rawAppointments == nil { rawAppointments = []any{} } next := make([]any, 0, len(rawAppointments)+1) targetSeen := false for _, raw := range rawAppointments { appointment, ok := raw.(map[string]any) if !ok { next = append(next, raw) continue } if removeTenant != nil && adminctlAppointmentMatchesTenant(appointment, *removeTenant) { continue } copied := maps.Clone(appointment) if adminctlAppointmentMatchesTenant(copied, target) { copied["tenantId"] = target.ID copied["tenantSlug"] = target.Slug copied["tenantName"] = target.Name copied["isPrimary"] = true targetSeen = true } else { copied["isPrimary"] = false } next = append(next, copied) } if !targetSeen { next = append(next, map[string]any{ "tenantId": target.ID, "tenantSlug": target.Slug, "tenantName": target.Name, "isPrimary": true, }) } traits["additionalAppointments"] = next } func adminctlAppointmentMatchesTenant(appointment map[string]any, tenant domain.Tenant) bool { return adminctlTraitMatchesTenant(appointment["tenantId"], tenant) || adminctlTraitMatchesTenant(appointment["tenantSlug"], tenant) } func adminctlTenantIndexes(tenants []domain.Tenant) (map[string]domain.Tenant, map[string]domain.Tenant) { tenantByID := make(map[string]domain.Tenant, len(tenants)) deletedBySlug := map[string]domain.Tenant{} for _, tenant := range tenants { tenantByID[tenant.ID] = tenant if tenant.DeletedAt.Valid { if slug := strings.ToLower(strings.TrimSpace(tenant.Slug)); slug != "" { deletedBySlug[slug] = tenant } if legacy := adminctlLegacyTenantSlug(tenant); legacy != "" { deletedBySlug[strings.ToLower(legacy)] = tenant } } } return tenantByID, deletedBySlug } func adminctlDeletedTenantPromotion(traits map[string]any, tenantByID map[string]domain.Tenant, deletedBySlug map[string]domain.Tenant) (domain.Tenant, domain.Tenant, bool) { deleted, ok := adminctlFindDeletedTenantInTraits(traits, tenantByID, deletedBySlug) if !ok { return domain.Tenant{}, domain.Tenant{}, false } target, ok := adminctlNearestActiveAncestor(deleted, tenantByID) return deleted, target, ok } func adminctlFindDeletedTenantInTraits(traits map[string]any, tenantByID map[string]domain.Tenant, deletedBySlug map[string]domain.Tenant) (domain.Tenant, bool) { for _, key := range []string{"tenant_id", "primaryTenantId", "primaryTenantSlug", "companyCode", "company_code"} { if tenant, ok := adminctlDeletedTenantFromValue(traits[key], tenantByID, deletedBySlug); ok { return tenant, true } } switch appointments := traits["additionalAppointments"].(type) { case []any: for _, raw := range appointments { appointment, ok := raw.(map[string]any) if !ok { continue } for _, key := range []string{"tenantId", "tenantSlug"} { if tenant, ok := adminctlDeletedTenantFromValue(appointment[key], tenantByID, deletedBySlug); ok { return tenant, true } } } case []map[string]any: for _, appointment := range appointments { for _, key := range []string{"tenantId", "tenantSlug"} { if tenant, ok := adminctlDeletedTenantFromValue(appointment[key], tenantByID, deletedBySlug); ok { return tenant, true } } } } return domain.Tenant{}, false } func adminctlDeletedTenantFromValue(value any, tenantByID map[string]domain.Tenant, deletedBySlug map[string]domain.Tenant) (domain.Tenant, bool) { raw := strings.TrimSpace(fmt.Sprint(value)) if raw == "" || raw == "" { return domain.Tenant{}, false } if tenant, ok := tenantByID[raw]; ok && tenant.DeletedAt.Valid { return tenant, true } tenant, ok := deletedBySlug[strings.ToLower(raw)] return tenant, ok } func adminctlNearestActiveAncestor(deleted domain.Tenant, tenantByID map[string]domain.Tenant) (domain.Tenant, bool) { seen := map[string]bool{} parentID := deleted.ParentID for parentID != nil { id := strings.TrimSpace(*parentID) if id == "" || seen[id] { return domain.Tenant{}, false } seen[id] = true parent, ok := tenantByID[id] if !ok { return domain.Tenant{}, false } if !parent.DeletedAt.Valid { return parent, true } parentID = parent.ParentID } return domain.Tenant{}, false } func adminctlPromoteIdentityTraits(traits map[string]any, deletedTenant domain.Tenant, targetTenant domain.Tenant) (map[string]any, bool) { next := adminctlCloneIdentityTraits(traits) changed := false if adminctlTraitMatchesTenant(next["tenant_id"], deletedTenant) || strings.TrimSpace(adminctlTraitString(next["tenant_id"])) == "" { next["tenant_id"] = targetTenant.ID changed = true } if adminctlTraitMatchesTenant(next["primaryTenantId"], deletedTenant) || adminctlTraitMatchesTenant(next["primaryTenantSlug"], deletedTenant) { next["primaryTenantId"] = targetTenant.ID next["primaryTenantSlug"] = targetTenant.Slug next["primaryTenantName"] = targetTenant.Name changed = true } if adminctlTraitMatchesTenant(next["companyCode"], deletedTenant) { next["companyCode"] = targetTenant.Slug changed = true } if adminctlTraitMatchesTenant(next["company_code"], deletedTenant) { next["company_code"] = targetTenant.Slug changed = true } if appointments, appointmentsChanged := adminctlPromoteIdentityAppointments(next["additionalAppointments"], deletedTenant, targetTenant); appointmentsChanged { next["additionalAppointments"] = appointments changed = true } return next, changed } func adminctlPromoteIdentityAppointments(raw any, deletedTenant domain.Tenant, targetTenant domain.Tenant) ([]any, bool) { switch appointments := raw.(type) { case []any: next := make([]any, 0, len(appointments)) changed := false for _, rawAppointment := range appointments { appointment, ok := rawAppointment.(map[string]any) if !ok { next = append(next, rawAppointment) continue } copied := maps.Clone(appointment) if adminctlTraitMatchesTenant(copied["tenantId"], deletedTenant) || adminctlTraitMatchesTenant(copied["tenantSlug"], deletedTenant) { copied["tenantId"] = targetTenant.ID copied["tenantSlug"] = targetTenant.Slug copied["tenantName"] = targetTenant.Name changed = true } next = append(next, copied) } return next, changed case []map[string]any: next := make([]any, 0, len(appointments)) changed := false for _, appointment := range appointments { copied := maps.Clone(appointment) if adminctlTraitMatchesTenant(copied["tenantId"], deletedTenant) || adminctlTraitMatchesTenant(copied["tenantSlug"], deletedTenant) { copied["tenantId"] = targetTenant.ID copied["tenantSlug"] = targetTenant.Slug copied["tenantName"] = targetTenant.Name changed = true } next = append(next, copied) } return next, changed default: return nil, false } } func adminctlTraitMatchesTenant(value any, tenant domain.Tenant) bool { raw := strings.TrimSpace(adminctlTraitString(value)) if raw == "" { return false } if strings.EqualFold(raw, tenant.ID) || strings.EqualFold(raw, tenant.Slug) { return true } return strings.EqualFold(raw, adminctlLegacyTenantSlug(tenant)) } func adminctlLegacyTenantSlug(tenant domain.Tenant) string { slug := strings.TrimSpace(tenant.Slug) idx := strings.LastIndex(slug, "-deleted-") if idx <= 0 { return slug } return slug[:idx] } func adminctlTraitString(value any) string { if value == nil { return "" } return strings.TrimSpace(fmt.Sprint(value)) } func adminctlCloneIdentityTraits(traits map[string]any) map[string]any { if traits == nil { return map[string]any{} } raw, err := json.Marshal(traits) if err != nil { return maps.Clone(traits) } var next map[string]any if err := json.Unmarshal(raw, &next); err != nil { return maps.Clone(traits) } return next } func openDB() (*gorm.DB, error) { dsn := fmt.Sprintf( "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul", getenv("DB_HOST", "localhost"), getenv("DB_USER", "baron"), getenv("DB_PASSWORD", "password"), getenv("DB_NAME", "baron_sso"), getenv("DB_PORT", "5432"), ) return gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: gormLogger.New( log.New(os.Stdout, "\r\n", log.LstdFlags), gormLogger.Config{ SlowThreshold: time.Second, LogLevel: gormLogger.Warn, IgnoreRecordNotFoundError: true, Colorful: true, }, ), }) } func loadEnv() { _ = godotenv.Load(".env") _ = godotenv.Load("../.env") _ = godotenv.Load("../../.env") } func getenv(key string, fallback string) string { if value, ok := os.LookupEnv(key); ok { return value } return fallback } func printUsage() { fmt.Fprintln(os.Stderr, "usage:") fmt.Fprintln(os.Stderr, " adminctl create-super-admin [--email EMAIL] [--password PASSWORD] [--name NAME] [--update-password]") fmt.Fprintln(os.Stderr, " adminctl clear-orphan-user-tenant-memberships [--dry-run]") fmt.Fprintln(os.Stderr, " adminctl repair-deleted-tenant-identities [--dry-run]") fmt.Fprintln(os.Stderr, " adminctl repair-user-tenant --user-id ID --tenant-slug SLUG [--remove-tenant-slug SLUG]") fmt.Fprintln(os.Stderr, " adminctl worksmobile-sync [--orgunits] [--users-csv PATH] [--credential-batch-id ID] [--process] [--serialize-orgunits] [--serialize-users-batch ID] [--batch-size N] [--delay DURATION]") }