package main import ( "baron-sso-backend/internal/bootstrap" "baron-sso-backend/internal/idp" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "context" "flag" "fmt" "log" "log/slog" "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 } 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 "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 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 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 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 worksmobile-sync [--orgunits] [--users-csv PATH] [--credential-batch-id ID] [--process] [--serialize-orgunits] [--serialize-users-batch ID] [--batch-size N] [--delay DURATION]") }