forked from baron/baron-sso
699 lines
21 KiB
Go
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]")
|
|
}
|