1
0
forked from baron/baron-sso
Files

699 lines
21 KiB
Go

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 == "<nil>" {
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]")
}