forked from baron/baron-sso
3104 lines
100 KiB
Go
3104 lines
100 KiB
Go
package main
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"baron-sso-backend/internal/repository"
|
|
"baron-sso-backend/internal/service"
|
|
"context"
|
|
"encoding/csv"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type worksmobileSyncConfig struct {
|
|
TenantSlug string
|
|
SyncOrgUnits bool
|
|
UsersCSV string
|
|
InspectUsersCSV string
|
|
InspectOrgUnitsCSV string
|
|
UpsertOrgUnitID string
|
|
UndeleteUsersCSV string
|
|
RemoveAliasesCSV string
|
|
FindNumberStrippedAliasesOutput string
|
|
DuplicatePhoneCountryCodeOutput string
|
|
FixDuplicatePhoneCountryCode bool
|
|
PendingUsersOutput string
|
|
ResetPendingUsersPassword string
|
|
ResetPendingUsersResultOutput string
|
|
DeletePendingUsersResultOutput string
|
|
ForceDeleteUsersCSV string
|
|
ForceDeleteUsersResultOutput string
|
|
ForceDeleteUsersLimit int
|
|
CreateUsersCSV string
|
|
CreateUsersPassword string
|
|
CreateUsersResultOutput string
|
|
CreateUsersLimit int
|
|
CreateUsersForcePasswordChange bool
|
|
ImportHanmacUsersCSV string
|
|
ImportHanmacUsersResultOutput string
|
|
ImportHanmacUsersPassword string
|
|
ImportHanmacUsersLimit int
|
|
ImportHanmacUsersForcePasswordChange bool
|
|
RecreatePendingUsersPassword string
|
|
RecreatePendingUsersResultOutput string
|
|
RecreatePendingUsersLimit int
|
|
RecreatePendingUsersCreateDelay time.Duration
|
|
ActivateAllUsersOutput string
|
|
ComparisonOutput string
|
|
AlignBaronFromWorksOutput string
|
|
AlignBaronFromWorksExclude string
|
|
InspectOutput string
|
|
CredentialBatchID string
|
|
Process bool
|
|
SerializeOrgUnits bool
|
|
SerializeUsersBatch string
|
|
BatchSize int
|
|
Delay time.Duration
|
|
MaxCycles int
|
|
}
|
|
|
|
func runWorksmobileSync(args []string) error {
|
|
config, err := resolveWorksmobileSyncConfig(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
db, err := openDB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx := context.Background()
|
|
tenantRepo := repository.NewTenantRepository(db)
|
|
userRepo := repository.NewUserRepository(db)
|
|
userGroupRepo := repository.NewUserGroupRepository(db)
|
|
outboxRepo := repository.NewWorksmobileOutboxRepository(db)
|
|
tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, nil)
|
|
syncService := service.NewWorksmobileSyncService(tenantService, userRepo, outboxRepo, newWorksmobileAdminClient())
|
|
|
|
root, err := tenantService.GetTenantBySlug(ctx, config.TenantSlug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if config.SyncOrgUnits {
|
|
enqueued, skipped, failed, err := enqueueWorksmobileOrgUnits(ctx, db, syncService, root.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("worksmobile orgunits enqueued: enqueued=%d skipped=%d failed=%d\n", enqueued, skipped, failed)
|
|
}
|
|
if config.UsersCSV != "" {
|
|
batchID := strings.TrimSpace(config.CredentialBatchID)
|
|
if batchID == "" {
|
|
batchID = "final-sync-" + time.Now().UTC().Format("20060102-150405Z")
|
|
}
|
|
userIDs, err := readWorksmobileUserIDsCSV(config.UsersCSV)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
enqueued, failed := enqueueWorksmobileUsers(ctx, syncService, root.ID, userIDs, batchID)
|
|
fmt.Printf("worksmobile users enqueued: enqueued=%d failed=%d credential_batch_id=%s\n", enqueued, failed, batchID)
|
|
}
|
|
if config.InspectUsersCSV != "" {
|
|
if err := inspectWorksmobileRemoteUsers(ctx, config.InspectUsersCSV, config.InspectOutput, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.InspectOrgUnitsCSV != "" {
|
|
if err := inspectWorksmobileRemoteOrgUnits(ctx, config.InspectOrgUnitsCSV, config.InspectOutput, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.UpsertOrgUnitID != "" {
|
|
if err := upsertSingleWorksmobileOrgUnit(ctx, db, tenantRepo, *root, config.UpsertOrgUnitID, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.UndeleteUsersCSV != "" {
|
|
if err := undeleteWorksmobileUsers(ctx, config.UndeleteUsersCSV, config.InspectOutput, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.RemoveAliasesCSV != "" {
|
|
if err := removeWorksmobileUserAliases(ctx, config.RemoveAliasesCSV, config.InspectOutput, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.FindNumberStrippedAliasesOutput != "" {
|
|
if err := findNumberStrippedWorksmobileAliases(ctx, config.FindNumberStrippedAliasesOutput, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.DuplicatePhoneCountryCodeOutput != "" || config.FixDuplicatePhoneCountryCode {
|
|
if err := auditAndMaybeFixWorksmobileDuplicatePhoneCountryCodes(ctx, config.DuplicatePhoneCountryCodeOutput, config.FixDuplicatePhoneCountryCode, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var pendingUsers []service.WorksmobileRemoteUser
|
|
if config.PendingUsersOutput != "" || config.ResetPendingUsersPassword != "" || config.DeletePendingUsersResultOutput != "" || config.RecreatePendingUsersPassword != "" {
|
|
if err := exportAndMaybeResetPendingWorksmobileUsers(ctx, config.PendingUsersOutput, config.ResetPendingUsersResultOutput, config.ResetPendingUsersPassword, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
if config.DeletePendingUsersResultOutput != "" || config.RecreatePendingUsersPassword != "" {
|
|
pendingUsers, err = readPendingWorksmobileUsersCSV(config.PendingUsersOutput)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if config.DeletePendingUsersResultOutput != "" {
|
|
if err := deletePendingWorksmobileUsers(ctx, config.DeletePendingUsersResultOutput, pendingUsers, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.ForceDeleteUsersCSV != "" {
|
|
if err := forceDeleteWorksmobileUsersFromCSV(ctx, config.ForceDeleteUsersCSV, config.ForceDeleteUsersResultOutput, config.ForceDeleteUsersLimit, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.CreateUsersCSV != "" {
|
|
if err := createWorksmobileUsersFromCSV(ctx, db, tenantRepo, userRepo, *root, config.CreateUsersCSV, config.CreateUsersResultOutput, config.CreateUsersPassword, config.CreateUsersLimit, config.CreateUsersForcePasswordChange, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.ImportHanmacUsersCSV != "" {
|
|
if err := importHanmacUsersAndCreateWorksmobileAccounts(ctx, db, tenantRepo, userRepo, *root, config.ImportHanmacUsersCSV, config.ImportHanmacUsersResultOutput, config.ImportHanmacUsersPassword, config.ImportHanmacUsersLimit, config.ImportHanmacUsersForcePasswordChange, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.RecreatePendingUsersPassword != "" {
|
|
if err := recreatePendingWorksmobileUsers(ctx, db, tenantRepo, userRepo, *root, config.PendingUsersOutput, config.RecreatePendingUsersResultOutput, config.RecreatePendingUsersPassword, config.RecreatePendingUsersLimit, config.RecreatePendingUsersCreateDelay, newWorksmobileAdminClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.ActivateAllUsersOutput != "" {
|
|
if err := activateAllWorksmobileUsers(ctx, config.ActivateAllUsersOutput, newWorksmobileSCIMClient()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.ComparisonOutput != "" {
|
|
if err := exportWorksmobileNeedsUpdateComparison(ctx, syncService, root.ID, config.ComparisonOutput); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.AlignBaronFromWorksOutput != "" {
|
|
identityWriter := service.NewIdentityWriteService(service.NewKratosAdminService(), nil)
|
|
if err := alignBaronNeedsUpdateUsersFromWorks(ctx, db, syncService, userRepo, identityWriter, root.ID, config.AlignBaronFromWorksOutput, config.AlignBaronFromWorksExclude); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if config.Process {
|
|
return processWorksmobileOutbox(ctx, db, outboxRepo, config)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error) {
|
|
fs := flag.NewFlagSet("worksmobile-sync", flag.ContinueOnError)
|
|
fs.SetOutput(os.Stderr)
|
|
config := worksmobileSyncConfig{
|
|
TenantSlug: "hanmac-family",
|
|
BatchSize: 1,
|
|
Delay: 1500 * time.Millisecond,
|
|
MaxCycles: 5000,
|
|
}
|
|
fs.StringVar(&config.TenantSlug, "tenant-slug", config.TenantSlug, "root tenant slug")
|
|
fs.BoolVar(&config.SyncOrgUnits, "orgunits", false, "regenerate and enqueue orgunit upserts")
|
|
fs.StringVar(&config.UsersCSV, "users-csv", "", "CSV containing user_id column to regenerate and enqueue user upserts")
|
|
fs.StringVar(&config.InspectUsersCSV, "inspect-users-csv", "", "CSV containing email or login_email column to compare with remote Worksmobile users")
|
|
fs.StringVar(&config.InspectOrgUnitsCSV, "inspect-orgunits-csv", "", "CSV containing orgunit_external_key or tenant_id column to compare with remote Worksmobile orgUnits")
|
|
fs.StringVar(&config.UpsertOrgUnitID, "upsert-orgunit-id", "", "single Baron tenant id to upsert as a Worksmobile orgUnit")
|
|
fs.StringVar(&config.UndeleteUsersCSV, "undelete-users-csv", "", "CSV containing email or login_email column to undelete Worksmobile users")
|
|
fs.StringVar(&config.RemoveAliasesCSV, "remove-aliases-csv", "", "CSV containing owner_email and alias_email columns to remove Worksmobile aliases")
|
|
fs.StringVar(&config.FindNumberStrippedAliasesOutput, "find-number-stripped-aliases-output", "", "output CSV path for aliases whose local-part equals owner local-part with trailing digits removed")
|
|
fs.StringVar(&config.DuplicatePhoneCountryCodeOutput, "duplicate-phone-country-code-output", "", "output CSV path for Worksmobile users whose cellPhone has duplicate Korean country code")
|
|
fs.BoolVar(&config.FixDuplicatePhoneCountryCode, "fix-duplicate-phone-country-code", false, "patch Worksmobile users whose cellPhone has duplicate Korean country code")
|
|
fs.StringVar(&config.PendingUsersOutput, "pending-users-output", "", "output CSV path for current Worksmobile users whose isPending flag is true")
|
|
fs.StringVar(&config.ResetPendingUsersPassword, "reset-pending-users-password", "", "reset current Worksmobile pending users to this password")
|
|
fs.StringVar(&config.ResetPendingUsersResultOutput, "reset-pending-users-result-output", "", "output CSV path for pending-user password reset results")
|
|
fs.StringVar(&config.DeletePendingUsersResultOutput, "delete-pending-users-result-output", "", "output CSV path for pending-user delete results")
|
|
fs.StringVar(&config.ForceDeleteUsersCSV, "force-delete-users-csv", "", "CSV containing email or user_id column to force-delete Worksmobile users")
|
|
fs.StringVar(&config.ForceDeleteUsersResultOutput, "force-delete-users-result-output", "", "output CSV path for Worksmobile force-delete results")
|
|
fs.IntVar(&config.ForceDeleteUsersLimit, "force-delete-users-limit", 0, "maximum users to force-delete; 0 means all")
|
|
fs.StringVar(&config.CreateUsersCSV, "create-users-csv", "", "CSV containing user_external_key or email column to create Worksmobile users from Baron")
|
|
fs.StringVar(&config.CreateUsersPassword, "create-users-password", "", "initial password for --create-users-csv")
|
|
fs.StringVar(&config.CreateUsersResultOutput, "create-users-result-output", "", "output CSV path for Worksmobile create results")
|
|
fs.IntVar(&config.CreateUsersLimit, "create-users-limit", 0, "maximum users to create; 0 means all")
|
|
fs.BoolVar(&config.CreateUsersForcePasswordChange, "create-users-force-password-change", true, "request password change at next login for --create-users-csv")
|
|
fs.StringVar(&config.ImportHanmacUsersCSV, "import-hanmac-users-csv", "", "CSV containing Hanmac internal users to upsert into Baron and create in Worksmobile")
|
|
fs.StringVar(&config.ImportHanmacUsersResultOutput, "import-hanmac-users-result-output", "", "output CSV path for Hanmac user import and Worksmobile create results")
|
|
fs.StringVar(&config.ImportHanmacUsersPassword, "import-hanmac-users-password", "", "initial password for --import-hanmac-users-csv")
|
|
fs.IntVar(&config.ImportHanmacUsersLimit, "import-hanmac-users-limit", 0, "maximum Hanmac users to import; 0 means all")
|
|
fs.BoolVar(&config.ImportHanmacUsersForcePasswordChange, "import-hanmac-users-force-password-change", true, "request password change at next login for --import-hanmac-users-csv")
|
|
fs.StringVar(&config.RecreatePendingUsersPassword, "recreate-pending-users-password", "", "delete and recreate current Worksmobile pending users with this initial password")
|
|
fs.StringVar(&config.RecreatePendingUsersResultOutput, "recreate-pending-users-result-output", "", "output CSV path for pending-user delete/recreate results")
|
|
fs.IntVar(&config.RecreatePendingUsersLimit, "recreate-pending-users-limit", 0, "maximum pending users to delete/recreate; 0 means all")
|
|
fs.DurationVar(&config.RecreatePendingUsersCreateDelay, "recreate-pending-users-create-delay", 0, "delay between pending-user tombstone patch and recreated user create")
|
|
fs.StringVar(&config.ActivateAllUsersOutput, "activate-all-users-output", "", "output CSV path for activating every non-active Worksmobile user")
|
|
fs.StringVar(&config.ComparisonOutput, "comparison-output", "", "output CSV path for current Worksmobile user comparison rows whose status is needs_update")
|
|
fs.StringVar(&config.AlignBaronFromWorksOutput, "align-baron-from-works-output", "", "output CSV path for one-time Baron user updates from current Worksmobile needs_update rows")
|
|
fs.StringVar(&config.AlignBaronFromWorksExclude, "align-baron-from-works-exclude", "", "comma-separated emails or local-parts to exclude from --align-baron-from-works-output")
|
|
fs.StringVar(&config.InspectOutput, "inspect-output", "", "output CSV path for inspect/undelete commands")
|
|
fs.StringVar(&config.CredentialBatchID, "credential-batch-id", "", "credential batch id for regenerated user password rows")
|
|
fs.BoolVar(&config.Process, "process", false, "process ready Worksmobile outbox jobs")
|
|
fs.BoolVar(&config.SerializeOrgUnits, "serialize-orgunits", false, "process ORGUNIT pending jobs one at a time by releasing next_attempt_at serially")
|
|
fs.StringVar(&config.SerializeUsersBatch, "serialize-users-batch", "", "process USER pending jobs for the given credential batch one at a time by releasing next_attempt_at serially")
|
|
fs.IntVar(&config.BatchSize, "batch-size", config.BatchSize, "worker batch size")
|
|
fs.DurationVar(&config.Delay, "delay", config.Delay, "delay between worker cycles")
|
|
fs.IntVar(&config.MaxCycles, "max-cycles", config.MaxCycles, "maximum worker cycles")
|
|
if err := fs.Parse(args); err != nil {
|
|
return config, err
|
|
}
|
|
if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && !config.Process {
|
|
return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, or --process")
|
|
}
|
|
if config.ResetPendingUsersPassword != "" && config.ResetPendingUsersResultOutput == "" {
|
|
return config, fmt.Errorf("--reset-pending-users-result-output is required with --reset-pending-users-password")
|
|
}
|
|
if config.ResetPendingUsersPassword != "" && config.PendingUsersOutput == "" {
|
|
return config, fmt.Errorf("--pending-users-output is required with --reset-pending-users-password")
|
|
}
|
|
if config.DeletePendingUsersResultOutput != "" && config.PendingUsersOutput == "" {
|
|
return config, fmt.Errorf("--pending-users-output is required with --delete-pending-users-result-output")
|
|
}
|
|
if config.ForceDeleteUsersCSV != "" && config.ForceDeleteUsersResultOutput == "" {
|
|
return config, fmt.Errorf("--force-delete-users-result-output is required with --force-delete-users-csv")
|
|
}
|
|
if config.CreateUsersCSV != "" && config.CreateUsersPassword == "" {
|
|
return config, fmt.Errorf("--create-users-password is required with --create-users-csv")
|
|
}
|
|
if config.CreateUsersCSV != "" && config.CreateUsersResultOutput == "" {
|
|
return config, fmt.Errorf("--create-users-result-output is required with --create-users-csv")
|
|
}
|
|
if config.ImportHanmacUsersCSV != "" && config.ImportHanmacUsersPassword == "" {
|
|
return config, fmt.Errorf("--import-hanmac-users-password is required with --import-hanmac-users-csv")
|
|
}
|
|
if config.ImportHanmacUsersCSV != "" && config.ImportHanmacUsersResultOutput == "" {
|
|
return config, fmt.Errorf("--import-hanmac-users-result-output is required with --import-hanmac-users-csv")
|
|
}
|
|
if config.RecreatePendingUsersPassword != "" && config.RecreatePendingUsersResultOutput == "" {
|
|
return config, fmt.Errorf("--recreate-pending-users-result-output is required with --recreate-pending-users-password")
|
|
}
|
|
if config.RecreatePendingUsersPassword != "" && config.PendingUsersOutput == "" {
|
|
return config, fmt.Errorf("--pending-users-output is required with --recreate-pending-users-password")
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
func enqueueWorksmobileOrgUnits(ctx context.Context, db *gorm.DB, syncService service.WorksmobileAdminService, rootID string) (int, int, int, error) {
|
|
tenantIDs, err := activeTenantSubtreeIDs(ctx, db, rootID)
|
|
if err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
enqueued, skipped, failed := 0, 0, 0
|
|
for _, tenantID := range tenantIDs {
|
|
if tenantID == rootID {
|
|
continue
|
|
}
|
|
_, err := syncService.EnqueueOrgUnitSync(ctx, rootID, tenantID)
|
|
if err == nil {
|
|
enqueued++
|
|
continue
|
|
}
|
|
if strings.Contains(err.Error(), "not a worksmobile orgunit tenant") ||
|
|
strings.Contains(err.Error(), "excluded from Worksmobile sync") {
|
|
skipped++
|
|
continue
|
|
}
|
|
failed++
|
|
fmt.Printf("worksmobile orgunit enqueue failed: tenant_id=%s error=%v\n", tenantID, err)
|
|
}
|
|
return enqueued, skipped, failed, nil
|
|
}
|
|
|
|
func upsertSingleWorksmobileOrgUnit(ctx context.Context, db *gorm.DB, tenantRepo repository.TenantRepository, root domain.Tenant, orgUnitID string, client service.WorksmobileDirectoryClient) error {
|
|
tenantIDs, err := activeTenantSubtreeIDs(ctx, db, root.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenants, err := tenantRepo.FindByIDs(ctx, tenantIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenantByID := map[string]domain.Tenant{root.ID: root}
|
|
for _, tenant := range tenants {
|
|
tenantByID[tenant.ID] = tenant
|
|
}
|
|
targetID := strings.TrimSpace(orgUnitID)
|
|
target, ok := tenantByID[targetID]
|
|
if !ok || targetID == root.ID {
|
|
return fmt.Errorf("--upsert-orgunit-id must be an active tenant inside %s subtree", root.Slug)
|
|
}
|
|
payload, err := buildAdminctlWorksmobileOrgUnitPayload(target, root, tenantByID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := client.UpsertOrgUnit(ctx, payload, strings.TrimSpace(target.Slug)); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("worksmobile orgunit upserted: tenant_id=%s slug=%s name=%s domain_id=%d\n", target.ID, target.Slug, target.Name, payload.DomainID)
|
|
return nil
|
|
}
|
|
|
|
func buildAdminctlWorksmobileOrgUnitPayload(tenant domain.Tenant, root domain.Tenant, tenantByID map[string]domain.Tenant) (service.WorksmobileOrgUnitPayload, error) {
|
|
domainTenant := adminctlWorksmobileDomainTenant(tenant, tenantByID)
|
|
payload, err := service.BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, domainTenant, root.Config, 1)
|
|
if err != nil {
|
|
return service.WorksmobileOrgUnitPayload{}, err
|
|
}
|
|
if adminctlShouldClearWorksmobileOrgUnitParent(tenant, tenantByID, root.ID) {
|
|
payload.ParentOrgUnitID = ""
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func adminctlWorksmobileDomainTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant {
|
|
current := tenant
|
|
for {
|
|
if adminctlIsWorksmobileDomainRootTenant(current) {
|
|
return current
|
|
}
|
|
if current.ParentID == nil || strings.TrimSpace(*current.ParentID) == "" {
|
|
return tenant
|
|
}
|
|
parent, ok := tenantByID[*current.ParentID]
|
|
if !ok {
|
|
return tenant
|
|
}
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
func adminctlShouldClearWorksmobileOrgUnitParent(tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootID string) bool {
|
|
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" {
|
|
return false
|
|
}
|
|
if *tenant.ParentID == rootID {
|
|
return true
|
|
}
|
|
parent, ok := tenantByID[*tenant.ParentID]
|
|
return ok && adminctlIsWorksmobileDomainRootTenant(parent)
|
|
}
|
|
|
|
func adminctlIsWorksmobileDomainRootTenant(tenant domain.Tenant) bool {
|
|
switch strings.ToLower(strings.TrimSpace(tenant.Slug)) {
|
|
case "saman", "hanmac", "gpdtdc", "halla", "hanlla", "baron-group":
|
|
return true
|
|
}
|
|
for _, candidate := range tenant.Domains {
|
|
switch strings.ToLower(strings.TrimSpace(candidate.Domain)) {
|
|
case "samaneng.com", "hanmaceng.co.kr", "baroncs.co.kr", "hallasanup.com", "brsw.kr":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func activeTenantSubtreeIDs(ctx context.Context, db *gorm.DB, rootID string) ([]string, error) {
|
|
var ids []string
|
|
err := db.WithContext(ctx).Raw(`
|
|
WITH RECURSIVE subtree AS (
|
|
SELECT id, parent_id
|
|
FROM tenants
|
|
WHERE id = ? AND deleted_at IS NULL
|
|
UNION ALL
|
|
SELECT t.id, t.parent_id
|
|
FROM tenants t
|
|
JOIN subtree s ON t.parent_id = s.id
|
|
WHERE t.deleted_at IS NULL
|
|
)
|
|
SELECT id::text
|
|
FROM subtree
|
|
ORDER BY id::text
|
|
`, rootID).Scan(&ids).Error
|
|
return ids, err
|
|
}
|
|
|
|
func readWorksmobileUserIDsCSV(path string) ([]string, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
reader := csv.NewReader(file)
|
|
rows, err := reader.ReadAll()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
userIDIndex := slices.Index(rows[0], "user_id")
|
|
if userIDIndex < 0 {
|
|
return nil, fmt.Errorf("CSV must contain user_id column: %s", path)
|
|
}
|
|
seen := map[string]bool{}
|
|
userIDs := make([]string, 0, len(rows)-1)
|
|
for _, row := range rows[1:] {
|
|
if userIDIndex >= len(row) {
|
|
continue
|
|
}
|
|
userID := strings.TrimSpace(row[userIDIndex])
|
|
if userID == "" || seen[userID] {
|
|
continue
|
|
}
|
|
seen[userID] = true
|
|
userIDs = append(userIDs, userID)
|
|
}
|
|
return userIDs, nil
|
|
}
|
|
|
|
type worksmobileInspectTarget struct {
|
|
Email string
|
|
EmployeeNumber string
|
|
}
|
|
|
|
func readWorksmobileInspectTargetsCSV(path string) ([]worksmobileInspectTarget, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
reader := csv.NewReader(file)
|
|
rows, err := reader.ReadAll()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
emailIndex := slices.Index(rows[0], "email")
|
|
if emailIndex < 0 {
|
|
emailIndex = slices.Index(rows[0], "login_email")
|
|
}
|
|
if emailIndex < 0 {
|
|
return nil, fmt.Errorf("CSV must contain email or login_email column: %s", path)
|
|
}
|
|
employeeNumberIndex := slices.Index(rows[0], "employee_id")
|
|
if employeeNumberIndex < 0 {
|
|
employeeNumberIndex = slices.Index(rows[0], "employeeNumber")
|
|
}
|
|
if employeeNumberIndex < 0 {
|
|
employeeNumberIndex = slices.Index(rows[0], "employee_number")
|
|
}
|
|
seen := map[string]bool{}
|
|
targets := make([]worksmobileInspectTarget, 0, len(rows)-1)
|
|
for _, row := range rows[1:] {
|
|
if emailIndex >= len(row) {
|
|
continue
|
|
}
|
|
email := strings.TrimSpace(row[emailIndex])
|
|
if !strings.HasPrefix(email, "externalKey:") {
|
|
email = strings.ToLower(email)
|
|
}
|
|
if email == "" || seen[email] {
|
|
continue
|
|
}
|
|
seen[email] = true
|
|
target := worksmobileInspectTarget{Email: email}
|
|
if employeeNumberIndex >= 0 && employeeNumberIndex < len(row) {
|
|
target.EmployeeNumber = strings.TrimSpace(row[employeeNumberIndex])
|
|
}
|
|
targets = append(targets, target)
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
func inspectWorksmobileRemoteUsers(ctx context.Context, usersCSV string, outputPath string, client service.WorksmobileDirectoryClient) error {
|
|
targets, err := readWorksmobileInspectTargetsCSV(usersCSV)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
remoteUsers, err := client.ListUsers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
remoteByEmail := map[string][]service.WorksmobileRemoteUser{}
|
|
remoteByLocalPart := map[string][]service.WorksmobileRemoteUser{}
|
|
remoteByAliasLocalPart := map[string][]service.WorksmobileRemoteUser{}
|
|
remoteByEmployeeNumber := map[string][]service.WorksmobileRemoteUser{}
|
|
for _, remote := range remoteUsers {
|
|
email := strings.ToLower(strings.TrimSpace(remote.Email))
|
|
if email != "" {
|
|
remoteByEmail[email] = append(remoteByEmail[email], remote)
|
|
}
|
|
employeeNumber := strings.TrimSpace(remote.EmployeeNumber)
|
|
if employeeNumber != "" {
|
|
remoteByEmployeeNumber[employeeNumber] = append(remoteByEmployeeNumber[employeeNumber], remote)
|
|
}
|
|
for _, candidate := range []string{remote.Email, remote.UserName} {
|
|
localPart, err := domain.ExtractNormalizedEmailLocalPart(strings.ToLower(strings.TrimSpace(candidate)))
|
|
if err == nil && localPart != "" {
|
|
remoteByLocalPart[localPart] = append(remoteByLocalPart[localPart], remote)
|
|
}
|
|
}
|
|
for _, alias := range remote.AliasEmails {
|
|
localPart, err := domain.ExtractNormalizedEmailLocalPart(strings.ToLower(strings.TrimSpace(alias)))
|
|
if err == nil && localPart != "" {
|
|
remoteByAliasLocalPart[localPart] = append(remoteByAliasLocalPart[localPart], remote)
|
|
}
|
|
}
|
|
}
|
|
|
|
writer := csv.NewWriter(os.Stdout)
|
|
var file *os.File
|
|
if strings.TrimSpace(outputPath) != "" {
|
|
file, err = os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer = csv.NewWriter(file)
|
|
}
|
|
defer writer.Flush()
|
|
|
|
header := []string{
|
|
"target_email",
|
|
"target_local_part",
|
|
"target_employee_number",
|
|
"exact_email_match_count",
|
|
"local_part_match_count",
|
|
"alias_local_part_match_count",
|
|
"employee_number_match_count",
|
|
"match_kind",
|
|
"remote_email",
|
|
"remote_user_name",
|
|
"remote_display_name",
|
|
"remote_external_id",
|
|
"remote_alias_emails",
|
|
"remote_cell_phone",
|
|
"remote_employee_number",
|
|
"remote_domain_id",
|
|
"remote_domain_name",
|
|
"remote_primary_orgunit_name",
|
|
"remote_active",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
for _, target := range targets {
|
|
email := target.Email
|
|
localPart, _ := domain.ExtractNormalizedEmailLocalPart(email)
|
|
exactMatches := remoteByEmail[email]
|
|
localMatches := remoteByLocalPart[localPart]
|
|
aliasLocalMatches := remoteByAliasLocalPart[localPart]
|
|
employeeNumberMatches := remoteByEmployeeNumber[strings.TrimSpace(target.EmployeeNumber)]
|
|
matchKind := "none"
|
|
matches := localMatches
|
|
if len(exactMatches) > 0 {
|
|
matchKind = "exact_email"
|
|
matches = exactMatches
|
|
} else if len(localMatches) > 0 {
|
|
matchKind = "local_part"
|
|
} else if len(aliasLocalMatches) > 0 {
|
|
matchKind = "alias_local_part"
|
|
matches = aliasLocalMatches
|
|
} else if len(employeeNumberMatches) > 0 {
|
|
matchKind = "employee_number"
|
|
matches = employeeNumberMatches
|
|
}
|
|
if len(matches) == 0 {
|
|
if err := writer.Write([]string{email, localPart, target.EmployeeNumber, "0", "0", "0", "0", matchKind, "", "", "", "", "", "", "", "", "", ""}); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
for _, remote := range matches {
|
|
if err := writer.Write([]string{
|
|
email,
|
|
localPart,
|
|
target.EmployeeNumber,
|
|
fmt.Sprint(len(exactMatches)),
|
|
fmt.Sprint(len(localMatches)),
|
|
fmt.Sprint(len(aliasLocalMatches)),
|
|
fmt.Sprint(len(employeeNumberMatches)),
|
|
matchKind,
|
|
remote.Email,
|
|
remote.UserName,
|
|
remote.DisplayName,
|
|
remote.ExternalID,
|
|
strings.Join(remote.AliasEmails, ";"),
|
|
remote.CellPhone,
|
|
remote.EmployeeNumber,
|
|
fmt.Sprint(remote.DomainID),
|
|
remote.DomainName,
|
|
remote.PrimaryOrgUnitName,
|
|
fmt.Sprint(remote.Active),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(outputPath) != "" {
|
|
fmt.Printf("worksmobile remote user inspection written: %s targets=%d remote_users=%d\n", outputPath, len(targets), len(remoteUsers))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readWorksmobileOrgUnitKeysCSV(path string) ([]string, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
reader := csv.NewReader(file)
|
|
rows, err := reader.ReadAll()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
keyIndex := slices.Index(rows[0], "orgunit_external_key")
|
|
if keyIndex < 0 {
|
|
keyIndex = slices.Index(rows[0], "tenant_id")
|
|
}
|
|
if keyIndex < 0 {
|
|
keyIndex = slices.Index(rows[0], "orgUnitExternalKey")
|
|
}
|
|
if keyIndex < 0 {
|
|
keyIndex = slices.Index(rows[0], "local_part")
|
|
}
|
|
if keyIndex < 0 {
|
|
return nil, fmt.Errorf("CSV must contain orgunit_external_key, tenant_id, or orgUnitExternalKey column: %s", path)
|
|
}
|
|
seen := map[string]bool{}
|
|
keys := make([]string, 0, len(rows)-1)
|
|
for _, row := range rows[1:] {
|
|
if keyIndex >= len(row) {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(row[keyIndex])
|
|
key = strings.TrimPrefix(key, "externalKey:")
|
|
if key == "" || seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
keys = append(keys, key)
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
func inspectWorksmobileRemoteOrgUnits(ctx context.Context, orgUnitsCSV string, outputPath string, client service.WorksmobileDirectoryClient) error {
|
|
keys, err := readWorksmobileOrgUnitKeysCSV(orgUnitsCSV)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
groups, err := client.ListGroups(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
byExternalKey := map[string][]service.WorksmobileRemoteGroup{}
|
|
byLocalPart := map[string][]service.WorksmobileRemoteGroup{}
|
|
for _, group := range groups {
|
|
key := strings.TrimSpace(group.ExternalID)
|
|
if key != "" {
|
|
byExternalKey[key] = append(byExternalKey[key], group)
|
|
}
|
|
localPart := strings.ToLower(strings.TrimSpace(group.MailLocalPart))
|
|
if localPart != "" {
|
|
byLocalPart[localPart] = append(byLocalPart[localPart], group)
|
|
}
|
|
}
|
|
|
|
writer := csv.NewWriter(os.Stdout)
|
|
var file *os.File
|
|
if strings.TrimSpace(outputPath) != "" {
|
|
file, err = os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer = csv.NewWriter(file)
|
|
}
|
|
defer writer.Flush()
|
|
|
|
header := []string{
|
|
"target_external_key",
|
|
"match_count",
|
|
"match_kind",
|
|
"remote_id",
|
|
"remote_external_id",
|
|
"remote_display_name",
|
|
"remote_email",
|
|
"remote_mail_local_part",
|
|
"remote_domain_id",
|
|
"remote_domain_name",
|
|
"remote_parent_id",
|
|
"remote_parent_name",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
for _, key := range keys {
|
|
matches := byExternalKey[key]
|
|
matchKind := "external_key"
|
|
if len(matches) == 0 {
|
|
matches = byLocalPart[strings.ToLower(key)]
|
|
matchKind = "local_part"
|
|
}
|
|
if len(matches) == 0 {
|
|
if err := writer.Write([]string{key, "0", "", "", "", "", "", "", "", "", "", ""}); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
for _, group := range matches {
|
|
if err := writer.Write([]string{
|
|
key,
|
|
fmt.Sprint(len(matches)),
|
|
matchKind,
|
|
group.ID,
|
|
group.ExternalID,
|
|
group.DisplayName,
|
|
group.Email,
|
|
group.MailLocalPart,
|
|
fmt.Sprint(group.DomainID),
|
|
group.DomainName,
|
|
group.ParentID,
|
|
group.ParentName,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(outputPath) != "" {
|
|
fmt.Printf("worksmobile remote orgunit inspection written: %s targets=%d remote_groups=%d\n", outputPath, len(keys), len(groups))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func undeleteWorksmobileUsers(ctx context.Context, usersCSV string, outputPath string, client *service.WorksmobileHTTPClient) error {
|
|
targets, err := readWorksmobileInspectTargetsCSV(usersCSV)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writer := csv.NewWriter(os.Stdout)
|
|
var file *os.File
|
|
if strings.TrimSpace(outputPath) != "" {
|
|
file, err = os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer = csv.NewWriter(file)
|
|
}
|
|
defer writer.Flush()
|
|
|
|
header := []string{
|
|
"email",
|
|
"get_before_status",
|
|
"get_before_user_id",
|
|
"get_before_external_id",
|
|
"get_before_is_deleted",
|
|
"undelete_status",
|
|
"undelete_error",
|
|
"get_after_status",
|
|
"get_after_user_id",
|
|
"get_after_external_id",
|
|
"get_after_is_deleted",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
for _, target := range targets {
|
|
email := strings.TrimSpace(target.Email)
|
|
beforeStatus, beforeUser := worksmobileGetUserStatus(ctx, client, email)
|
|
undeleteStatus := "ok"
|
|
undeleteError := ""
|
|
if err := client.UndeleteUser(ctx, email); err != nil {
|
|
undeleteStatus = "error"
|
|
undeleteError = err.Error()
|
|
}
|
|
afterStatus, afterUser := worksmobileGetUserStatus(ctx, client, email)
|
|
row := []string{
|
|
email,
|
|
beforeStatus,
|
|
beforeUser.ID,
|
|
beforeUser.ExternalID,
|
|
fmt.Sprint(beforeUser.IsDeleted),
|
|
undeleteStatus,
|
|
undeleteError,
|
|
afterStatus,
|
|
afterUser.ID,
|
|
afterUser.ExternalID,
|
|
fmt.Sprint(afterUser.IsDeleted),
|
|
}
|
|
if err := writer.Write(row); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(outputPath) != "" {
|
|
fmt.Printf("worksmobile undelete result written: %s targets=%d\n", outputPath, len(targets))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func worksmobileGetUserStatus(ctx context.Context, client *service.WorksmobileHTTPClient, email string) (string, service.WorksmobileRemoteUser) {
|
|
user, err := client.GetUser(ctx, email)
|
|
if err != nil {
|
|
return err.Error(), service.WorksmobileRemoteUser{}
|
|
}
|
|
if user == nil {
|
|
return "not_found", service.WorksmobileRemoteUser{}
|
|
}
|
|
return "ok", *user
|
|
}
|
|
|
|
type worksmobileAliasRemovalTarget struct {
|
|
OwnerEmail string
|
|
AliasEmail string
|
|
}
|
|
|
|
func readWorksmobileAliasRemovalTargetsCSV(path string) ([]worksmobileAliasRemovalTarget, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
reader := csv.NewReader(file)
|
|
rows, err := reader.ReadAll()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
ownerIndex := slices.Index(rows[0], "owner_email")
|
|
aliasIndex := slices.Index(rows[0], "alias_email")
|
|
if ownerIndex < 0 || aliasIndex < 0 {
|
|
return nil, fmt.Errorf("CSV must contain owner_email and alias_email columns: %s", path)
|
|
}
|
|
targets := make([]worksmobileAliasRemovalTarget, 0, len(rows)-1)
|
|
seen := map[string]bool{}
|
|
for _, row := range rows[1:] {
|
|
if ownerIndex >= len(row) || aliasIndex >= len(row) {
|
|
continue
|
|
}
|
|
target := worksmobileAliasRemovalTarget{
|
|
OwnerEmail: strings.ToLower(strings.TrimSpace(row[ownerIndex])),
|
|
AliasEmail: strings.ToLower(strings.TrimSpace(row[aliasIndex])),
|
|
}
|
|
key := target.OwnerEmail + "\x00" + target.AliasEmail
|
|
if target.OwnerEmail == "" || target.AliasEmail == "" || seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
targets = append(targets, target)
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
func removeWorksmobileUserAliases(ctx context.Context, aliasesCSV string, outputPath string, client *service.WorksmobileHTTPClient) error {
|
|
targets, err := readWorksmobileAliasRemovalTargetsCSV(aliasesCSV)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writer := csv.NewWriter(os.Stdout)
|
|
var file *os.File
|
|
if strings.TrimSpace(outputPath) != "" {
|
|
file, err = os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer = csv.NewWriter(file)
|
|
}
|
|
defer writer.Flush()
|
|
header := []string{"owner_email", "alias_email", "status", "error"}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
for _, target := range targets {
|
|
status := "ok"
|
|
errorMessage := ""
|
|
if err := client.RemoveUserAliasEmail(ctx, target.OwnerEmail, target.AliasEmail); err != nil {
|
|
status = "error"
|
|
errorMessage = err.Error()
|
|
}
|
|
if err := writer.Write([]string{target.OwnerEmail, target.AliasEmail, status, errorMessage}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(outputPath) != "" {
|
|
fmt.Printf("worksmobile alias removal result written: %s targets=%d\n", outputPath, len(targets))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func exportAndMaybeResetPendingWorksmobileUsers(ctx context.Context, pendingOutputPath string, resetResultOutputPath string, password string, client service.WorksmobileDirectoryClient) error {
|
|
remoteUsers, err := client.ListUsers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pendingUsers := make([]service.WorksmobileRemoteUser, 0)
|
|
for _, remote := range remoteUsers {
|
|
if remote.IsPending {
|
|
pendingUsers = append(pendingUsers, remote)
|
|
}
|
|
}
|
|
if strings.TrimSpace(pendingOutputPath) != "" {
|
|
if err := writePendingWorksmobileUsersCSV(pendingOutputPath, pendingUsers); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("worksmobile pending users written: %s pending=%d remote_users=%d\n", pendingOutputPath, len(pendingUsers), len(remoteUsers))
|
|
}
|
|
password = strings.TrimSpace(password)
|
|
if password == "" {
|
|
return nil
|
|
}
|
|
return resetPendingWorksmobileUserPasswords(ctx, resetResultOutputPath, pendingUsers, password, client)
|
|
}
|
|
|
|
func writePendingWorksmobileUsersCSV(outputPath string, pendingUsers []service.WorksmobileRemoteUser) error {
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
header := []string{
|
|
"email",
|
|
"user_id",
|
|
"user_external_key",
|
|
"display_name",
|
|
"employee_number",
|
|
"domain_id",
|
|
"domain_name",
|
|
"is_awaiting",
|
|
"is_pending",
|
|
"is_suspended",
|
|
"is_deleted",
|
|
"active",
|
|
"primary_orgunit_id",
|
|
"primary_orgunit_name",
|
|
"reset_target",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
for _, remote := range pendingUsers {
|
|
resetTarget := strings.TrimSpace(remote.Email) != "" && !remote.IsDeleted
|
|
if err := writer.Write([]string{
|
|
remote.Email,
|
|
remote.ID,
|
|
remote.ExternalID,
|
|
remote.DisplayName,
|
|
remote.EmployeeNumber,
|
|
fmt.Sprint(remote.DomainID),
|
|
remote.DomainName,
|
|
fmt.Sprint(remote.IsAwaiting),
|
|
fmt.Sprint(remote.IsPending),
|
|
fmt.Sprint(remote.IsSuspended),
|
|
fmt.Sprint(remote.IsDeleted),
|
|
fmt.Sprint(remote.Active),
|
|
remote.PrimaryOrgUnitID,
|
|
remote.PrimaryOrgUnitName,
|
|
fmt.Sprint(resetTarget),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return writer.Error()
|
|
}
|
|
|
|
func resetPendingWorksmobileUserPasswords(ctx context.Context, outputPath string, pendingUsers []service.WorksmobileRemoteUser, password string, client service.WorksmobileDirectoryClient) error {
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
header := []string{
|
|
"email",
|
|
"user_id",
|
|
"user_external_key",
|
|
"display_name",
|
|
"employee_number",
|
|
"domain_id",
|
|
"domain_name",
|
|
"is_awaiting",
|
|
"is_pending",
|
|
"is_suspended",
|
|
"is_deleted",
|
|
"active",
|
|
"status",
|
|
"error",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
okCount := 0
|
|
errorCount := 0
|
|
skippedCount := 0
|
|
for _, remote := range pendingUsers {
|
|
status := "ok"
|
|
errorMessage := ""
|
|
if strings.TrimSpace(remote.Email) == "" {
|
|
status = "skipped"
|
|
errorMessage = "email is empty"
|
|
skippedCount++
|
|
} else if remote.IsDeleted {
|
|
status = "skipped"
|
|
errorMessage = "user is deleted"
|
|
skippedCount++
|
|
} else if err := client.ResetUserPassword(ctx, remote.Email, password); err != nil {
|
|
status = "error"
|
|
errorMessage = err.Error()
|
|
errorCount++
|
|
} else {
|
|
okCount++
|
|
}
|
|
if err := writer.Write([]string{
|
|
remote.Email,
|
|
remote.ID,
|
|
remote.ExternalID,
|
|
remote.DisplayName,
|
|
remote.EmployeeNumber,
|
|
fmt.Sprint(remote.DomainID),
|
|
remote.DomainName,
|
|
fmt.Sprint(remote.IsAwaiting),
|
|
fmt.Sprint(remote.IsPending),
|
|
fmt.Sprint(remote.IsSuspended),
|
|
fmt.Sprint(remote.IsDeleted),
|
|
fmt.Sprint(remote.Active),
|
|
status,
|
|
errorMessage,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
writer.Flush()
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Printf("worksmobile pending user password reset result written: %s targets=%d ok=%d skipped=%d errors=%d\n", outputPath, len(pendingUsers), okCount, skippedCount, errorCount)
|
|
return nil
|
|
}
|
|
|
|
func deletePendingWorksmobileUsers(ctx context.Context, outputPath string, pendingUsers []service.WorksmobileRemoteUser, client service.WorksmobileDirectoryClient) error {
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
header := []string{
|
|
"email",
|
|
"user_id",
|
|
"user_external_key",
|
|
"display_name",
|
|
"delete_identifier",
|
|
"status",
|
|
"error",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
okCount := 0
|
|
errorCount := 0
|
|
skippedCount := 0
|
|
for _, remote := range pendingUsers {
|
|
identifier := strings.TrimSpace(remote.Email)
|
|
if identifier == "" {
|
|
identifier = strings.TrimSpace(remote.ID)
|
|
}
|
|
status := "ok"
|
|
errorMessage := ""
|
|
if identifier == "" {
|
|
status = "skipped"
|
|
errorMessage = "delete identifier is empty"
|
|
skippedCount++
|
|
} else if err := client.DeleteUser(ctx, identifier); err != nil {
|
|
status = "error"
|
|
errorMessage = err.Error()
|
|
errorCount++
|
|
} else {
|
|
okCount++
|
|
}
|
|
if err := writer.Write([]string{
|
|
remote.Email,
|
|
remote.ID,
|
|
remote.ExternalID,
|
|
remote.DisplayName,
|
|
identifier,
|
|
status,
|
|
errorMessage,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
writer.Flush()
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Printf("worksmobile pending users delete result written: %s targets=%d ok=%d skipped=%d errors=%d\n", outputPath, len(pendingUsers), okCount, skippedCount, errorCount)
|
|
return nil
|
|
}
|
|
|
|
func forceDeleteWorksmobileUsersFromCSV(ctx context.Context, usersCSV string, outputPath string, limit int, client *service.WorksmobileHTTPClient) error {
|
|
if limit < 0 {
|
|
return fmt.Errorf("--force-delete-users-limit cannot be negative")
|
|
}
|
|
targets, err := readPendingWorksmobileUsersCSV(usersCSV)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if limit > 0 && len(targets) > limit {
|
|
targets = targets[:limit]
|
|
}
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
header := []string{"email", "user_id", "user_external_key", "display_name", "force_delete_identifier", "status", "error"}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
okCount := 0
|
|
skippedCount := 0
|
|
errorCount := 0
|
|
for _, remote := range targets {
|
|
identifier := strings.TrimSpace(remote.Email)
|
|
if identifier == "" {
|
|
identifier = strings.TrimSpace(remote.ID)
|
|
}
|
|
status := "ok"
|
|
errorMessage := ""
|
|
if identifier == "" {
|
|
status = "skipped"
|
|
errorMessage = "force-delete identifier is empty"
|
|
skippedCount++
|
|
} else if err := client.ForceDeleteUser(ctx, identifier); err != nil {
|
|
status = "error"
|
|
errorMessage = err.Error()
|
|
errorCount++
|
|
} else {
|
|
okCount++
|
|
}
|
|
if err := writer.Write([]string{remote.Email, remote.ID, remote.ExternalID, remote.DisplayName, identifier, status, errorMessage}); err != nil {
|
|
return err
|
|
}
|
|
writer.Flush()
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Printf("worksmobile users force-delete result written: %s targets=%d ok=%d skipped=%d errors=%d\n", outputPath, len(targets), okCount, skippedCount, errorCount)
|
|
return nil
|
|
}
|
|
|
|
func createWorksmobileUsersFromCSV(ctx context.Context, db *gorm.DB, tenantRepo repository.TenantRepository, userRepo repository.UserRepository, root domain.Tenant, usersCSV string, outputPath string, password string, limit int, forcePasswordChange bool, client *service.WorksmobileHTTPClient) error {
|
|
if limit < 0 {
|
|
return fmt.Errorf("--create-users-limit cannot be negative")
|
|
}
|
|
targets, err := readPendingWorksmobileUsersCSV(usersCSV)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if limit > 0 && len(targets) > limit {
|
|
targets = targets[:limit]
|
|
}
|
|
tenantIDs, err := activeTenantSubtreeIDs(ctx, db, root.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenants, err := tenantRepo.FindByIDs(ctx, tenantIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenantByID := map[string]domain.Tenant{root.ID: root}
|
|
for _, tenant := range tenants {
|
|
tenantByID[tenant.ID] = tenant
|
|
}
|
|
userIDs := make([]string, 0, len(targets))
|
|
seenUserIDs := map[string]bool{}
|
|
for _, remote := range targets {
|
|
userID := strings.TrimSpace(remote.ExternalID)
|
|
if userID == "" || seenUserIDs[userID] {
|
|
continue
|
|
}
|
|
if _, err := uuid.Parse(userID); err != nil {
|
|
continue
|
|
}
|
|
seenUserIDs[userID] = true
|
|
userIDs = append(userIDs, userID)
|
|
}
|
|
users, err := userRepo.FindByIDs(ctx, userIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userByID := map[string]domain.User{}
|
|
for _, user := range users {
|
|
userByID[user.ID] = user
|
|
}
|
|
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
header := []string{"email", "user_id", "user_external_key", "display_name", "baron_user_id", "baron_status", "status", "error"}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
okCount := 0
|
|
skippedCount := 0
|
|
errorCount := 0
|
|
var changePasswordAtNextLogin *bool
|
|
if forcePasswordChange {
|
|
value := true
|
|
changePasswordAtNextLogin = &value
|
|
}
|
|
for _, remote := range targets {
|
|
status := "ok"
|
|
errorMessage := ""
|
|
baronUserID := ""
|
|
baronStatus := ""
|
|
user, ok := userByID[strings.TrimSpace(remote.ExternalID)]
|
|
if !ok {
|
|
found, err := userRepo.FindByEmail(ctx, strings.TrimSpace(remote.Email))
|
|
if err == nil && found != nil {
|
|
user = *found
|
|
ok = true
|
|
}
|
|
}
|
|
if !ok {
|
|
status = "skipped"
|
|
errorMessage = "baron user not found"
|
|
skippedCount++
|
|
} else {
|
|
baronUserID = user.ID
|
|
baronStatus = user.Status
|
|
if user.TenantID == nil {
|
|
status = "skipped"
|
|
errorMessage = "baron user has no tenant"
|
|
skippedCount++
|
|
} else if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
|
status = "skipped"
|
|
errorMessage = "baron user status is excluded from Worksmobile sync"
|
|
skippedCount++
|
|
} else {
|
|
tenant, ok := tenantByID[*user.TenantID]
|
|
if !ok {
|
|
status = "skipped"
|
|
errorMessage = "baron user tenant is outside Worksmobile sync scope"
|
|
skippedCount++
|
|
} else {
|
|
payload, err := service.BuildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, root.Config)
|
|
if err != nil {
|
|
status = "skipped"
|
|
errorMessage = err.Error()
|
|
skippedCount++
|
|
} else {
|
|
payload.PasswordConfig = service.WorksmobilePasswordConfig{
|
|
PasswordCreationType: "ADMIN",
|
|
Password: strings.TrimSpace(password),
|
|
ChangePasswordAtNextLogin: changePasswordAtNextLogin,
|
|
}
|
|
if err := client.CreateUser(ctx, payload); err != nil {
|
|
status = "error"
|
|
errorMessage = err.Error()
|
|
errorCount++
|
|
} else {
|
|
okCount++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if err := writer.Write([]string{remote.Email, remote.ID, remote.ExternalID, remote.DisplayName, baronUserID, baronStatus, status, errorMessage}); err != nil {
|
|
return err
|
|
}
|
|
writer.Flush()
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Printf("worksmobile users create result written: %s targets=%d ok=%d skipped=%d errors=%d\n", outputPath, len(targets), okCount, skippedCount, errorCount)
|
|
return nil
|
|
}
|
|
|
|
type hanmacWorksmobileImportRow struct {
|
|
Email string
|
|
Name string
|
|
Phone string
|
|
Role string
|
|
TenantSlug string
|
|
Department string
|
|
Grade string
|
|
Position string
|
|
JobTitle string
|
|
EmployeeID string
|
|
SubEmail string
|
|
}
|
|
|
|
type hanmacWorksmobileImportCounts struct {
|
|
Targets int
|
|
OK int
|
|
Skipped int
|
|
Errors int
|
|
BaronCreated int
|
|
BaronUpdated int
|
|
}
|
|
|
|
type hanmacWorksmobileUserStore interface {
|
|
FindByEmail(ctx context.Context, email string) (domain.User, bool, error)
|
|
Save(ctx context.Context, user *domain.User) (created bool, err error)
|
|
}
|
|
|
|
type worksmobileUserCreateClient interface {
|
|
CreateUser(ctx context.Context, payload service.WorksmobileUserPayload) error
|
|
}
|
|
|
|
func importHanmacUsersAndCreateWorksmobileAccounts(ctx context.Context, db *gorm.DB, tenantRepo repository.TenantRepository, userRepo repository.UserRepository, root domain.Tenant, usersCSV string, outputPath string, password string, limit int, forcePasswordChange bool, client service.WorksmobileDirectoryClient) error {
|
|
if limit < 0 {
|
|
return fmt.Errorf("--import-hanmac-users-limit cannot be negative")
|
|
}
|
|
rows, err := readHanmacWorksmobileImportRowsCSV(usersCSV)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenantIDs, err := activeTenantSubtreeIDs(ctx, db, root.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenants, err := tenantRepo.FindByIDs(ctx, tenantIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenantByID := map[string]domain.Tenant{root.ID: root}
|
|
tenantBySlug := map[string]domain.Tenant{strings.ToLower(strings.TrimSpace(root.Slug)): root}
|
|
for _, tenant := range tenants {
|
|
tenantByID[tenant.ID] = tenant
|
|
tenantBySlug[strings.ToLower(strings.TrimSpace(tenant.Slug))] = tenant
|
|
}
|
|
remoteUsers, err := client.ListUsers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
localUsers, err := userRepo.FindByTenantIDs(ctx, append([]string{root.ID}, tenantIDs...))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
localPartOwners := localWorksmobileLocalPartOwners(localUsers, tenantByID)
|
|
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
|
|
counts, err := importHanmacWorksmobileUsersFromRows(
|
|
ctx,
|
|
rows,
|
|
root,
|
|
tenantBySlug,
|
|
tenantByID,
|
|
remoteUsers,
|
|
localPartOwners,
|
|
&repositoryHanmacWorksmobileUserStore{repo: userRepo},
|
|
strings.TrimSpace(password),
|
|
limit,
|
|
forcePasswordChange,
|
|
writer,
|
|
client,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("hanmac users import and Worksmobile create result written: %s targets=%d ok=%d skipped=%d errors=%d baron_created=%d baron_updated=%d\n", outputPath, counts.Targets, counts.OK, counts.Skipped, counts.Errors, counts.BaronCreated, counts.BaronUpdated)
|
|
return nil
|
|
}
|
|
|
|
type repositoryHanmacWorksmobileUserStore struct {
|
|
repo repository.UserRepository
|
|
}
|
|
|
|
func (s *repositoryHanmacWorksmobileUserStore) FindByEmail(ctx context.Context, email string) (domain.User, bool, error) {
|
|
user, err := s.repo.FindByEmail(ctx, email)
|
|
if err == nil {
|
|
return *user, true, nil
|
|
}
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return domain.User{}, false, nil
|
|
}
|
|
return domain.User{}, false, err
|
|
}
|
|
|
|
func (s *repositoryHanmacWorksmobileUserStore) Save(ctx context.Context, user *domain.User) (bool, error) {
|
|
created := false
|
|
if strings.TrimSpace(user.ID) == "" {
|
|
user.ID = uuid.NewString()
|
|
created = true
|
|
if err := s.repo.Create(ctx, user); err != nil {
|
|
return false, err
|
|
}
|
|
return created, nil
|
|
}
|
|
if _, exists, err := s.FindByEmail(ctx, user.Email); err != nil {
|
|
return false, err
|
|
} else if !exists {
|
|
created = true
|
|
}
|
|
if created {
|
|
return true, s.repo.Create(ctx, user)
|
|
}
|
|
return false, s.repo.Update(ctx, user)
|
|
}
|
|
|
|
func readHanmacWorksmobileImportRowsCSV(path string) ([]hanmacWorksmobileImportRow, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
reader := csv.NewReader(file)
|
|
rows, err := reader.ReadAll()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
header := rows[0]
|
|
required := []string{"email", "name", "tenant_slug"}
|
|
for _, name := range required {
|
|
if slices.Index(header, name) < 0 {
|
|
return nil, fmt.Errorf("CSV must contain %s column: %s", name, path)
|
|
}
|
|
}
|
|
value := func(row []string, name string) string {
|
|
index := slices.Index(header, name)
|
|
if index < 0 || index >= len(row) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(row[index])
|
|
}
|
|
result := make([]hanmacWorksmobileImportRow, 0, len(rows)-1)
|
|
for _, row := range rows[1:] {
|
|
email := strings.ToLower(value(row, "email"))
|
|
if email == "" {
|
|
continue
|
|
}
|
|
result = append(result, hanmacWorksmobileImportRow{
|
|
Email: email,
|
|
Name: value(row, "name"),
|
|
Phone: value(row, "phone"),
|
|
Role: value(row, "role"),
|
|
TenantSlug: strings.ToLower(value(row, "tenant_slug")),
|
|
Department: value(row, "department"),
|
|
Grade: value(row, "grade"),
|
|
Position: value(row, "position"),
|
|
JobTitle: value(row, "jobTitle"),
|
|
EmployeeID: value(row, "employee_id"),
|
|
SubEmail: strings.ToLower(value(row, "sub_email")),
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func importHanmacWorksmobileUsersFromRows(ctx context.Context, rows []hanmacWorksmobileImportRow, root domain.Tenant, tenantBySlug map[string]domain.Tenant, tenantByID map[string]domain.Tenant, remoteUsers []service.WorksmobileRemoteUser, localPartOwners map[string]string, store hanmacWorksmobileUserStore, password string, limit int, forcePasswordChange bool, writer *csv.Writer, client worksmobileUserCreateClient) (hanmacWorksmobileImportCounts, error) {
|
|
if limit < 0 {
|
|
return hanmacWorksmobileImportCounts{}, fmt.Errorf("--import-hanmac-users-limit cannot be negative")
|
|
}
|
|
if limit > 0 && len(rows) > limit {
|
|
rows = rows[:limit]
|
|
}
|
|
header := []string{"email", "name", "tenant_slug", "tenant_id", "baron_user_id", "baron_action", "works_status", "local_part_candidates", "conflict_local_part", "conflict_owner", "error"}
|
|
if err := writer.Write(header); err != nil {
|
|
return hanmacWorksmobileImportCounts{}, err
|
|
}
|
|
counts := hanmacWorksmobileImportCounts{Targets: len(rows)}
|
|
remoteLocalPartOwners := remoteWorksmobileLocalPartOwners(remoteUsers)
|
|
if localPartOwners == nil {
|
|
localPartOwners = map[string]string{}
|
|
}
|
|
claimedLocalParts := map[string]string{}
|
|
writeResult := func(row hanmacWorksmobileImportRow, tenantID string, baronUserID string, baronAction string, status string, candidates []string, conflictLocalPart string, conflictOwner string, errorMessage string) error {
|
|
if err := writer.Write([]string{row.Email, row.Name, row.TenantSlug, tenantID, baronUserID, baronAction, status, strings.Join(candidates, ";"), conflictLocalPart, conflictOwner, errorMessage}); err != nil {
|
|
return err
|
|
}
|
|
writer.Flush()
|
|
return writer.Error()
|
|
}
|
|
var changePasswordAtNextLogin *bool
|
|
if forcePasswordChange {
|
|
value := true
|
|
changePasswordAtNextLogin = &value
|
|
}
|
|
for _, row := range rows {
|
|
status := "ok"
|
|
errorMessage := ""
|
|
baronAction := ""
|
|
baronUserID := ""
|
|
tenantID := ""
|
|
conflictLocalPart := ""
|
|
conflictOwner := ""
|
|
|
|
candidates, err := hanmacWorksmobileImportLocalParts(row)
|
|
if err != nil {
|
|
status = "skipped"
|
|
errorMessage = err.Error()
|
|
counts.Skipped++
|
|
if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
tenant, ok := tenantBySlug[strings.ToLower(strings.TrimSpace(row.TenantSlug))]
|
|
if !ok {
|
|
status = "skipped"
|
|
errorMessage = "tenant_slug not found in Worksmobile sync scope"
|
|
counts.Skipped++
|
|
if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
tenantID = tenant.ID
|
|
if conflictLocalPart, conflictOwner = findHanmacImportLocalPartConflict(candidates, remoteLocalPartOwners, localPartOwners, claimedLocalParts, row.Email); conflictLocalPart != "" {
|
|
status = "skipped"
|
|
errorMessage = "local-part already exists"
|
|
counts.Skipped++
|
|
if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
|
|
user, exists, err := store.FindByEmail(ctx, row.Email)
|
|
if err != nil {
|
|
status = "error"
|
|
errorMessage = err.Error()
|
|
counts.Errors++
|
|
if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
if !exists {
|
|
user.ID = uuid.NewString()
|
|
baronAction = "created"
|
|
} else {
|
|
baronAction = "updated"
|
|
}
|
|
applyHanmacWorksmobileImportRowToUser(&user, row, tenant)
|
|
baronUserID = user.ID
|
|
created, err := store.Save(ctx, &user)
|
|
if err != nil {
|
|
status = "error"
|
|
errorMessage = err.Error()
|
|
counts.Errors++
|
|
if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
if created {
|
|
baronAction = "created"
|
|
counts.BaronCreated++
|
|
} else {
|
|
baronAction = "updated"
|
|
counts.BaronUpdated++
|
|
}
|
|
payload, err := service.BuildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, root.Config)
|
|
if err != nil {
|
|
status = "skipped"
|
|
errorMessage = err.Error()
|
|
counts.Skipped++
|
|
if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
payload.PasswordConfig = service.WorksmobilePasswordConfig{
|
|
PasswordCreationType: "ADMIN",
|
|
Password: password,
|
|
ChangePasswordAtNextLogin: changePasswordAtNextLogin,
|
|
}
|
|
if err := client.CreateUser(ctx, payload); err != nil {
|
|
status = "error"
|
|
errorMessage = err.Error()
|
|
counts.Errors++
|
|
} else {
|
|
counts.OK++
|
|
for _, localPart := range candidates {
|
|
claimedLocalParts[localPart] = row.Email
|
|
localPartOwners[localPart] = row.Email
|
|
}
|
|
}
|
|
if err := writeResult(row, tenantID, baronUserID, baronAction, status, candidates, conflictLocalPart, conflictOwner, errorMessage); err != nil {
|
|
return counts, err
|
|
}
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
func applyHanmacWorksmobileImportRowToUser(user *domain.User, row hanmacWorksmobileImportRow, tenant domain.Tenant) {
|
|
user.Email = strings.ToLower(strings.TrimSpace(row.Email))
|
|
user.Name = strings.TrimSpace(row.Name)
|
|
user.Phone = strings.TrimSpace(row.Phone)
|
|
user.Role = domain.NormalizeRole(row.Role)
|
|
user.Status = domain.UserStatusActive
|
|
user.AffiliationType = "internal"
|
|
user.TenantID = &tenant.ID
|
|
user.Department = strings.TrimSpace(row.Department)
|
|
user.Grade = strings.TrimSpace(row.Grade)
|
|
user.Position = strings.TrimSpace(row.Position)
|
|
user.JobTitle = strings.TrimSpace(row.JobTitle)
|
|
if user.Metadata == nil {
|
|
user.Metadata = domain.JSONMap{}
|
|
}
|
|
if employeeID := strings.TrimSpace(row.EmployeeID); employeeID != "" {
|
|
user.Metadata["employee_id"] = employeeID
|
|
}
|
|
delete(user.Metadata, "sub_email")
|
|
delete(user.Metadata, "external_sub_email")
|
|
if subEmail := strings.ToLower(strings.TrimSpace(row.SubEmail)); subEmail != "" {
|
|
if aliasSubEmail := hanmacWorksmobileImportAliasSubEmail(row); aliasSubEmail != "" {
|
|
user.Metadata["sub_email"] = aliasSubEmail
|
|
} else {
|
|
user.Metadata["external_sub_email"] = subEmail
|
|
}
|
|
}
|
|
user.Metadata["additionalAppointments"] = []any{map[string]any{
|
|
"tenantId": tenant.ID,
|
|
"tenantSlug": tenant.Slug,
|
|
"tenantName": tenant.Name,
|
|
"isPrimary": true,
|
|
}}
|
|
}
|
|
|
|
func hanmacWorksmobileImportLocalParts(row hanmacWorksmobileImportRow) ([]string, error) {
|
|
candidates := make([]string, 0, 3)
|
|
addEmailLocalPart := func(email string) error {
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
if email == "" {
|
|
return nil
|
|
}
|
|
localPart, err := domain.ExtractNormalizedEmailLocalPart(email)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !slices.Contains(candidates, localPart) {
|
|
candidates = append(candidates, localPart)
|
|
}
|
|
return nil
|
|
}
|
|
if err := addEmailLocalPart(row.Email); err != nil {
|
|
return candidates, fmt.Errorf("invalid email: %w", err)
|
|
}
|
|
if aliasSubEmail := hanmacWorksmobileImportAliasSubEmail(row); aliasSubEmail != "" {
|
|
if err := addEmailLocalPart(aliasSubEmail); err != nil {
|
|
return candidates, fmt.Errorf("invalid sub_email: %w", err)
|
|
}
|
|
}
|
|
employeeID := strings.ToLower(strings.TrimSpace(row.EmployeeID))
|
|
if employeeID != "" && !slices.Contains(candidates, employeeID) {
|
|
candidates = append(candidates, employeeID)
|
|
}
|
|
if len(candidates) == 0 {
|
|
return candidates, errors.New("email local-part is empty")
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func hanmacWorksmobileImportAliasSubEmail(row hanmacWorksmobileImportRow) string {
|
|
subEmail := strings.ToLower(strings.TrimSpace(row.SubEmail))
|
|
if subEmail == "" {
|
|
return ""
|
|
}
|
|
_, primaryDomain, err := domain.SplitEmailDomain(row.Email)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
_, subDomain, err := domain.SplitEmailDomain(subEmail)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if subDomain != primaryDomain {
|
|
return ""
|
|
}
|
|
return subEmail
|
|
}
|
|
|
|
func remoteWorksmobileLocalPartOwners(remoteUsers []service.WorksmobileRemoteUser) map[string]string {
|
|
owners := map[string]string{}
|
|
for _, remote := range remoteUsers {
|
|
owner := strings.ToLower(strings.TrimSpace(remote.Email))
|
|
if owner == "" {
|
|
owner = strings.TrimSpace(remote.ID)
|
|
}
|
|
for _, value := range []string{remote.Email, remote.UserName} {
|
|
if localPart := normalizedWorksmobileLocalPart(value); localPart != "" {
|
|
owners[localPart] = owner
|
|
}
|
|
}
|
|
for _, alias := range remote.AliasEmails {
|
|
if localPart := normalizedWorksmobileLocalPart(alias); localPart != "" {
|
|
owners[localPart] = owner
|
|
}
|
|
}
|
|
}
|
|
return owners
|
|
}
|
|
|
|
func localWorksmobileLocalPartOwners(users []domain.User, tenantByID map[string]domain.Tenant) map[string]string {
|
|
owners := map[string]string{}
|
|
for _, user := range users {
|
|
owner := strings.ToLower(strings.TrimSpace(user.Email))
|
|
if localPart := normalizedWorksmobileLocalPart(user.Email); localPart != "" {
|
|
owners[localPart] = owner
|
|
}
|
|
if user.TenantID == nil {
|
|
continue
|
|
}
|
|
tenant, ok := tenantByID[*user.TenantID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, alias := range service.BuildWorksmobileAliasEmails(user, tenant) {
|
|
if localPart := normalizedWorksmobileLocalPart(alias); localPart != "" {
|
|
owners[localPart] = owner
|
|
}
|
|
}
|
|
}
|
|
return owners
|
|
}
|
|
|
|
func normalizedWorksmobileLocalPart(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(value, "@") {
|
|
localPart, err := domain.ExtractNormalizedEmailLocalPart(value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return localPart
|
|
}
|
|
return value
|
|
}
|
|
|
|
func findHanmacImportLocalPartConflict(candidates []string, remoteOwners map[string]string, localOwners map[string]string, claimedOwners map[string]string, email string) (string, string) {
|
|
normalizedEmail := strings.ToLower(strings.TrimSpace(email))
|
|
for _, localPart := range candidates {
|
|
if owner := remoteOwners[localPart]; owner != "" {
|
|
return localPart, owner
|
|
}
|
|
if owner := localOwners[localPart]; owner != "" && !strings.EqualFold(owner, normalizedEmail) {
|
|
return localPart, owner
|
|
}
|
|
if owner := claimedOwners[localPart]; owner != "" && !strings.EqualFold(owner, normalizedEmail) {
|
|
return localPart, owner
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
type worksmobileUndeleteClient interface {
|
|
UndeleteUser(ctx context.Context, userID string) error
|
|
}
|
|
|
|
type worksmobilePatchClient interface {
|
|
PatchUser(ctx context.Context, identifier string, payload service.WorksmobileUserPatchPayload) error
|
|
}
|
|
|
|
func recreatePendingWorksmobileUsers(ctx context.Context, db *gorm.DB, tenantRepo repository.TenantRepository, userRepo repository.UserRepository, root domain.Tenant, pendingCSVPath string, outputPath string, password string, limit int, createDelay time.Duration, client service.WorksmobileDirectoryClient) error {
|
|
if limit < 0 {
|
|
return fmt.Errorf("--recreate-pending-users-limit cannot be negative")
|
|
}
|
|
pendingUsers, err := readPendingWorksmobileUsersCSV(pendingCSVPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if limit > 0 && len(pendingUsers) > limit {
|
|
pendingUsers = pendingUsers[:limit]
|
|
}
|
|
tenantIDs, err := activeTenantSubtreeIDs(ctx, db, root.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenants, err := tenantRepo.FindByIDs(ctx, tenantIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenantByID := map[string]domain.Tenant{root.ID: root}
|
|
for _, tenant := range tenants {
|
|
tenantByID[tenant.ID] = tenant
|
|
}
|
|
userIDs := make([]string, 0, len(pendingUsers))
|
|
seenUserIDs := map[string]bool{}
|
|
for _, remote := range pendingUsers {
|
|
userID := strings.TrimSpace(remote.ExternalID)
|
|
if userID == "" || seenUserIDs[userID] {
|
|
continue
|
|
}
|
|
if _, err := uuid.Parse(userID); err != nil {
|
|
continue
|
|
}
|
|
seenUserIDs[userID] = true
|
|
userIDs = append(userIDs, userID)
|
|
}
|
|
users, err := userRepo.FindByIDs(ctx, userIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userByID := map[string]domain.User{}
|
|
for _, user := range users {
|
|
userByID[user.ID] = user
|
|
}
|
|
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
header := []string{
|
|
"email",
|
|
"user_id",
|
|
"user_external_key",
|
|
"display_name",
|
|
"baron_user_id",
|
|
"baron_status",
|
|
"tombstone_email",
|
|
"tombstone_status",
|
|
"tombstone_error",
|
|
"create_status",
|
|
"create_error",
|
|
"cleanup_status",
|
|
"cleanup_error",
|
|
"rollback_status",
|
|
"rollback_error",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
resolveUser := func(ctx context.Context, remote service.WorksmobileRemoteUser) (domain.User, bool) {
|
|
user, ok := userByID[strings.TrimSpace(remote.ExternalID)]
|
|
if !ok {
|
|
found, err := userRepo.FindByEmail(ctx, strings.TrimSpace(remote.Email))
|
|
if err == nil && found != nil {
|
|
user = *found
|
|
ok = true
|
|
}
|
|
}
|
|
return user, ok
|
|
}
|
|
counts, err := recreatePendingWorksmobileUsersFromSnapshot(ctx, pendingUsers, resolveUser, tenantByID, root.Config, strings.TrimSpace(password), createDelay, writer, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("worksmobile pending users recreate result written: %s targets=%d ok=%d skipped=%d errors=%d\n", outputPath, counts.Targets, counts.OK, counts.Skipped, counts.Errors)
|
|
return nil
|
|
}
|
|
|
|
type worksmobilePendingUserResolver func(ctx context.Context, remote service.WorksmobileRemoteUser) (domain.User, bool)
|
|
|
|
type worksmobilePendingRecreateCounts struct {
|
|
Targets int
|
|
OK int
|
|
Skipped int
|
|
Errors int
|
|
}
|
|
|
|
func recreatePendingWorksmobileUsersFromSnapshot(ctx context.Context, pendingUsers []service.WorksmobileRemoteUser, resolveUser worksmobilePendingUserResolver, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap, password string, createDelay time.Duration, writer *csv.Writer, client service.WorksmobileDirectoryClient) (worksmobilePendingRecreateCounts, error) {
|
|
patchClient, ok := client.(worksmobilePatchClient)
|
|
if !ok {
|
|
return worksmobilePendingRecreateCounts{}, errors.New("worksmobile client does not support pending user tombstone patch")
|
|
}
|
|
counts := worksmobilePendingRecreateCounts{Targets: len(pendingUsers)}
|
|
for _, remote := range pendingUsers {
|
|
result := worksmobilePendingRecreateResult{
|
|
Remote: remote,
|
|
TombstoneStatus: "skipped",
|
|
CreateStatus: "skipped",
|
|
CleanupStatus: "skipped",
|
|
RollbackStatus: "skipped",
|
|
}
|
|
user, ok := resolveUser(ctx, remote)
|
|
if !ok {
|
|
result.TombstoneError = "baron user not found"
|
|
counts.Skipped++
|
|
if err := writePendingRecreateResult(writer, result); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
result.BaronUserID = user.ID
|
|
result.BaronStatus = user.Status
|
|
if user.TenantID == nil {
|
|
result.TombstoneError = "baron user has no tenant"
|
|
counts.Skipped++
|
|
if err := writePendingRecreateResult(writer, result); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
tenant, ok := tenantByID[*user.TenantID]
|
|
if !ok {
|
|
result.TombstoneError = "baron user tenant is outside Worksmobile sync scope"
|
|
counts.Skipped++
|
|
if err := writePendingRecreateResult(writer, result); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
if !domain.IsWorksProvisionedUserStatus(user.Status) {
|
|
result.TombstoneError = "baron user status is excluded from Worksmobile sync"
|
|
counts.Skipped++
|
|
if err := writePendingRecreateResult(writer, result); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
payload, err := service.BuildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, rootConfig)
|
|
if err != nil {
|
|
result.TombstoneError = err.Error()
|
|
counts.Skipped++
|
|
if err := writePendingRecreateResult(writer, result); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
payload.PasswordConfig = service.WorksmobilePasswordConfig{
|
|
PasswordCreationType: "ADMIN",
|
|
Password: strings.TrimSpace(password),
|
|
}
|
|
identifier := strings.TrimSpace(remote.Email)
|
|
if identifier == "" {
|
|
identifier = strings.TrimSpace(remote.ID)
|
|
}
|
|
if identifier == "" {
|
|
result.TombstoneError = "patch identifier is empty"
|
|
counts.Skipped++
|
|
if err := writePendingRecreateResult(writer, result); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
tombstoneEmail, tombstoneExternalKey, err := worksmobilePendingTombstoneIdentity(remote, payload)
|
|
if err != nil {
|
|
result.TombstoneError = err.Error()
|
|
counts.Skipped++
|
|
if err := writePendingRecreateResult(writer, result); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
result.TombstoneEmail = tombstoneEmail
|
|
originalPatch := service.NewWorksmobileUserPatchPayload(payload)
|
|
tombstonePatch := originalPatch
|
|
tombstonePatch.Email = tombstoneEmail
|
|
tombstonePatch.UserExternalKey = tombstoneExternalKey
|
|
tombstonePatch.AliasEmails = nil
|
|
tombstonePatch.Organizations = worksmobileOrganizationsWithEmail(tombstonePatch.Organizations, tombstoneEmail)
|
|
if err := patchClient.PatchUser(ctx, identifier, tombstonePatch); err != nil {
|
|
result.TombstoneStatus = "error"
|
|
result.TombstoneError = err.Error()
|
|
counts.Errors++
|
|
if err := writePendingRecreateResult(writer, result); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
result.TombstoneStatus = "ok"
|
|
if createDelay > 0 {
|
|
select {
|
|
case <-time.After(createDelay):
|
|
case <-ctx.Done():
|
|
return counts, ctx.Err()
|
|
}
|
|
}
|
|
if err := client.CreateUser(ctx, payload); err != nil {
|
|
result.CreateStatus = "error"
|
|
result.CreateError = err.Error()
|
|
counts.Errors++
|
|
if rollbackErr := patchClient.PatchUser(ctx, tombstoneEmail, originalPatch); rollbackErr != nil {
|
|
result.RollbackStatus = "error"
|
|
result.RollbackError = rollbackErr.Error()
|
|
} else {
|
|
result.RollbackStatus = "ok"
|
|
}
|
|
if err := writePendingRecreateResult(writer, result); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
result.CreateStatus = "ok"
|
|
if err := client.DeleteUser(ctx, tombstoneEmail); err != nil {
|
|
result.CleanupStatus = "error"
|
|
result.CleanupError = err.Error()
|
|
counts.Errors++
|
|
if err := writePendingRecreateResult(writer, result); err != nil {
|
|
return counts, err
|
|
}
|
|
continue
|
|
}
|
|
result.CleanupStatus = "ok"
|
|
counts.OK++
|
|
if err := writePendingRecreateResult(writer, result); err != nil {
|
|
return counts, err
|
|
}
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
type worksmobilePendingRecreateResult struct {
|
|
Remote service.WorksmobileRemoteUser
|
|
BaronUserID string
|
|
BaronStatus string
|
|
TombstoneEmail string
|
|
TombstoneStatus string
|
|
TombstoneError string
|
|
CreateStatus string
|
|
CreateError string
|
|
CleanupStatus string
|
|
CleanupError string
|
|
RollbackStatus string
|
|
RollbackError string
|
|
}
|
|
|
|
func writePendingRecreateResult(writer *csv.Writer, result worksmobilePendingRecreateResult) error {
|
|
if err := writer.Write([]string{
|
|
result.Remote.Email,
|
|
result.Remote.ID,
|
|
result.Remote.ExternalID,
|
|
result.Remote.DisplayName,
|
|
result.BaronUserID,
|
|
result.BaronStatus,
|
|
result.TombstoneEmail,
|
|
result.TombstoneStatus,
|
|
result.TombstoneError,
|
|
result.CreateStatus,
|
|
result.CreateError,
|
|
result.CleanupStatus,
|
|
result.CleanupError,
|
|
result.RollbackStatus,
|
|
result.RollbackError,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
writer.Flush()
|
|
return writer.Error()
|
|
}
|
|
|
|
func readPendingWorksmobileUsersCSV(path string) ([]service.WorksmobileRemoteUser, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
reader := csv.NewReader(file)
|
|
rows, err := reader.ReadAll()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
header := rows[0]
|
|
emailIndex := slices.Index(header, "email")
|
|
userIDIndex := slices.Index(header, "user_id")
|
|
externalKeyIndex := slices.Index(header, "user_external_key")
|
|
displayNameIndex := slices.Index(header, "display_name")
|
|
domainIDIndex := slices.Index(header, "domain_id")
|
|
if emailIndex < 0 || userIDIndex < 0 || externalKeyIndex < 0 {
|
|
return nil, fmt.Errorf("CSV must contain email, user_id, and user_external_key columns: %s", path)
|
|
}
|
|
users := make([]service.WorksmobileRemoteUser, 0, len(rows)-1)
|
|
for _, row := range rows[1:] {
|
|
if emailIndex >= len(row) || userIDIndex >= len(row) || externalKeyIndex >= len(row) {
|
|
continue
|
|
}
|
|
remote := service.WorksmobileRemoteUser{
|
|
Email: strings.TrimSpace(row[emailIndex]),
|
|
ID: strings.TrimSpace(row[userIDIndex]),
|
|
ExternalID: strings.TrimSpace(row[externalKeyIndex]),
|
|
}
|
|
if domainIDIndex >= 0 && domainIDIndex < len(row) {
|
|
fmt.Sscan(strings.TrimSpace(row[domainIDIndex]), &remote.DomainID)
|
|
}
|
|
if displayNameIndex >= 0 && displayNameIndex < len(row) {
|
|
remote.DisplayName = strings.TrimSpace(row[displayNameIndex])
|
|
}
|
|
users = append(users, remote)
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
func worksmobilePendingTombstoneIdentity(remote service.WorksmobileRemoteUser, payload service.WorksmobileUserPayload) (string, string, error) {
|
|
email := strings.ToLower(strings.TrimSpace(remote.Email))
|
|
if email == "" {
|
|
email = strings.ToLower(strings.TrimSpace(payload.Email))
|
|
}
|
|
localPart, domainPart, ok := strings.Cut(email, "@")
|
|
if !ok || strings.TrimSpace(localPart) == "" || strings.TrimSpace(domainPart) == "" {
|
|
return "", "", fmt.Errorf("pending user email is invalid: %s", email)
|
|
}
|
|
suffix := strings.ReplaceAll(strings.TrimSpace(remote.ID), "-", "")
|
|
if len(suffix) > 12 {
|
|
suffix = suffix[:12]
|
|
}
|
|
if suffix == "" {
|
|
suffix = time.Now().UTC().Format("20060102150405")
|
|
}
|
|
base := strings.ToLower(strings.Trim(localPart, ".-_"))
|
|
if len(base) > 18 {
|
|
base = base[:18]
|
|
}
|
|
tombstoneEmail := fmt.Sprintf("%s.old%s@%s", base, suffix, domainPart)
|
|
tombstoneExternalKey := "pending-replaced-" + suffix
|
|
return tombstoneEmail, tombstoneExternalKey, nil
|
|
}
|
|
|
|
func worksmobileOrganizationsWithEmail(organizations []service.WorksmobileUserOrganization, email string) []service.WorksmobileUserOrganization {
|
|
next := make([]service.WorksmobileUserOrganization, len(organizations))
|
|
copy(next, organizations)
|
|
for i := range next {
|
|
next[i].Email = email
|
|
}
|
|
return next
|
|
}
|
|
|
|
func activateAllWorksmobileUsers(ctx context.Context, outputPath string, client *service.WorksmobileHTTPClient) error {
|
|
remoteUsers, err := client.ListUsers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
header := []string{
|
|
"email",
|
|
"user_id",
|
|
"user_external_key",
|
|
"display_name",
|
|
"domain_id",
|
|
"domain_name",
|
|
"before_status",
|
|
"is_awaiting",
|
|
"is_pending",
|
|
"is_suspended",
|
|
"is_deleted",
|
|
"active",
|
|
"action",
|
|
"error",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
updatedCount := 0
|
|
skippedCount := 0
|
|
errorCount := 0
|
|
for _, remote := range remoteUsers {
|
|
status := strings.TrimSpace(remote.AccountStatus)
|
|
if status == "" {
|
|
status = "unknown"
|
|
}
|
|
action := "skipped"
|
|
errorMessage := ""
|
|
switch {
|
|
case strings.TrimSpace(remote.Email) == "":
|
|
errorMessage = "email is empty"
|
|
skippedCount++
|
|
case remote.IsDeleted || status == "deleted":
|
|
errorMessage = "deleted user cannot be activated by SCIM active patch"
|
|
skippedCount++
|
|
case status == "active":
|
|
skippedCount++
|
|
default:
|
|
if err := client.SetSCIMUserActiveByID(ctx, remote.ID, true); err != nil {
|
|
action = "error"
|
|
errorMessage = err.Error()
|
|
errorCount++
|
|
} else {
|
|
action = "activated"
|
|
updatedCount++
|
|
}
|
|
}
|
|
if err := writer.Write([]string{
|
|
remote.Email,
|
|
remote.ID,
|
|
remote.ExternalID,
|
|
remote.DisplayName,
|
|
fmt.Sprint(remote.DomainID),
|
|
remote.DomainName,
|
|
status,
|
|
fmt.Sprint(remote.IsAwaiting),
|
|
fmt.Sprint(remote.IsPending),
|
|
fmt.Sprint(remote.IsSuspended),
|
|
fmt.Sprint(remote.IsDeleted),
|
|
fmt.Sprint(remote.Active),
|
|
action,
|
|
errorMessage,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
writer.Flush()
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Printf("worksmobile activate-all users result written: %s remote_users=%d activated=%d skipped=%d errors=%d\n", outputPath, len(remoteUsers), updatedCount, skippedCount, errorCount)
|
|
return nil
|
|
}
|
|
|
|
func exportWorksmobileNeedsUpdateComparison(ctx context.Context, syncService service.WorksmobileAdminService, tenantID string, outputPath string) error {
|
|
comparison, err := syncService.GetComparison(ctx, tenantID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
header := []string{
|
|
"baron_id",
|
|
"baron_name",
|
|
"baron_email",
|
|
"baron_primary_org_id",
|
|
"baron_primary_org_slug",
|
|
"baron_primary_org_name",
|
|
"worksmobile_id",
|
|
"external_key",
|
|
"worksmobile_name",
|
|
"worksmobile_email",
|
|
"worksmobile_domain_id",
|
|
"worksmobile_domain_name",
|
|
"worksmobile_primary_org_id",
|
|
"worksmobile_primary_org_name",
|
|
"worksmobile_primary_org_position_id",
|
|
"worksmobile_primary_org_position_name",
|
|
"worksmobile_primary_org_is_manager",
|
|
"worksmobile_level_id",
|
|
"worksmobile_level_name",
|
|
"worksmobile_task",
|
|
"status",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
count := 0
|
|
for _, item := range comparison.Users {
|
|
if item.Status != "needs_update" {
|
|
continue
|
|
}
|
|
count++
|
|
isManager := ""
|
|
if item.WorksmobilePrimaryOrgIsManager != nil {
|
|
isManager = fmt.Sprint(*item.WorksmobilePrimaryOrgIsManager)
|
|
}
|
|
if err := writer.Write([]string{
|
|
item.BaronID,
|
|
item.BaronName,
|
|
item.BaronEmail,
|
|
item.BaronPrimaryOrgID,
|
|
item.BaronPrimaryOrgSlug,
|
|
item.BaronPrimaryOrgName,
|
|
item.WorksmobileID,
|
|
item.ExternalKey,
|
|
item.WorksmobileName,
|
|
item.WorksmobileEmail,
|
|
fmt.Sprint(item.WorksmobileDomainID),
|
|
item.WorksmobileDomainName,
|
|
item.WorksmobilePrimaryOrgID,
|
|
item.WorksmobilePrimaryOrgName,
|
|
item.WorksmobilePrimaryOrgPositionID,
|
|
item.WorksmobilePrimaryOrgPositionName,
|
|
isManager,
|
|
item.WorksmobileLevelID,
|
|
item.WorksmobileLevelName,
|
|
item.WorksmobileTask,
|
|
item.Status,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("worksmobile needs_update comparison written: %s users=%d\n", outputPath, count)
|
|
return nil
|
|
}
|
|
|
|
func alignBaronNeedsUpdateUsersFromWorks(ctx context.Context, db *gorm.DB, syncService service.WorksmobileAdminService, userRepo repository.UserRepository, identityWriter service.IdentityWriteService, tenantID string, outputPath string, excludeRaw string) error {
|
|
comparison, err := syncService.GetComparison(ctx, tenantID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if identityWriter == nil {
|
|
return fmt.Errorf("identity write service is required to align Baron users from WORKS")
|
|
}
|
|
excludes := parseWorksmobileAlignExcludes(excludeRaw)
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
header := []string{
|
|
"baron_id",
|
|
"old_email",
|
|
"new_email",
|
|
"old_name",
|
|
"new_name",
|
|
"worksmobile_id",
|
|
"worksmobile_domain_name",
|
|
"worksmobile_primary_org_name",
|
|
"status",
|
|
"error",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
targets := 0
|
|
updated := 0
|
|
skipped := 0
|
|
errorsCount := 0
|
|
for _, item := range comparison.Users {
|
|
if item.Status != "needs_update" {
|
|
continue
|
|
}
|
|
if worksmobileAlignExcluded(item, excludes) {
|
|
skipped++
|
|
if err := writer.Write([]string{
|
|
item.BaronID,
|
|
item.BaronEmail,
|
|
item.WorksmobileEmail,
|
|
item.BaronName,
|
|
item.WorksmobileName,
|
|
item.WorksmobileID,
|
|
item.WorksmobileDomainName,
|
|
item.WorksmobilePrimaryOrgName,
|
|
"skipped_excluded",
|
|
"",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
classification, alignable := classifyWorksmobileAlignFromWorks(item)
|
|
if !alignable {
|
|
skipped++
|
|
if err := writer.Write([]string{
|
|
item.BaronID,
|
|
item.BaronEmail,
|
|
item.WorksmobileEmail,
|
|
item.BaronName,
|
|
item.WorksmobileName,
|
|
item.WorksmobileID,
|
|
item.WorksmobileDomainName,
|
|
item.WorksmobilePrimaryOrgName,
|
|
classification,
|
|
"",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
targets++
|
|
status := classification
|
|
errorMessage := ""
|
|
oldEmail := item.BaronEmail
|
|
oldName := item.BaronName
|
|
newEmail := strings.TrimSpace(item.WorksmobileEmail)
|
|
newName := strings.TrimSpace(item.WorksmobileName)
|
|
user, findErr := userRepo.FindByID(ctx, item.BaronID)
|
|
if findErr != nil {
|
|
status = "error"
|
|
errorMessage = findErr.Error()
|
|
errorsCount++
|
|
} else {
|
|
if newEmail == "" {
|
|
newEmail = strings.TrimSpace(user.Email)
|
|
}
|
|
if newName == "" {
|
|
newName = strings.TrimSpace(user.Name)
|
|
}
|
|
identity, identityErr := identityWriter.GetIdentity(ctx, user.ID)
|
|
if identityErr != nil {
|
|
status = "error"
|
|
errorMessage = identityErr.Error()
|
|
errorsCount++
|
|
} else {
|
|
traits := copyKratosTraits(identity.Traits)
|
|
traits["email"] = newEmail
|
|
traits["name"] = newName
|
|
if _, updateErr := identityWriter.UpdateIdentity(ctx, service.IdentityUpdateRequest{
|
|
IdentityID: user.ID,
|
|
Traits: traits,
|
|
State: strings.TrimSpace(identity.State),
|
|
Reason: "worksmobile_align_baron_from_works",
|
|
Source: "adminctl_worksmobile_sync",
|
|
}); updateErr != nil {
|
|
status = "error"
|
|
errorMessage = updateErr.Error()
|
|
errorsCount++
|
|
}
|
|
}
|
|
}
|
|
if status != "error" {
|
|
updates := map[string]any{
|
|
"email": newEmail,
|
|
"name": newName,
|
|
"updated_at": time.Now().UTC(),
|
|
}
|
|
err = db.WithContext(ctx).Model(&domain.User{}).Where("id = ?", user.ID).Updates(updates).Error
|
|
if err != nil {
|
|
status = "error"
|
|
errorMessage = err.Error()
|
|
errorsCount++
|
|
} else {
|
|
updated++
|
|
}
|
|
}
|
|
if err := writer.Write([]string{
|
|
item.BaronID,
|
|
oldEmail,
|
|
newEmail,
|
|
oldName,
|
|
newName,
|
|
item.WorksmobileID,
|
|
item.WorksmobileDomainName,
|
|
item.WorksmobilePrimaryOrgName,
|
|
status,
|
|
errorMessage,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("baron users aligned from worksmobile needs_update rows: %s targets=%d updated=%d skipped=%d errors=%d\n", outputPath, targets, updated, skipped, errorsCount)
|
|
return nil
|
|
}
|
|
|
|
func parseWorksmobileAlignExcludes(raw string) map[string]bool {
|
|
excludes := map[string]bool{}
|
|
for _, part := range strings.Split(raw, ",") {
|
|
value := strings.ToLower(strings.TrimSpace(part))
|
|
if value == "" {
|
|
continue
|
|
}
|
|
excludes[value] = true
|
|
if localPart, ok := emailLocalPart(value); ok {
|
|
excludes[localPart] = true
|
|
}
|
|
}
|
|
return excludes
|
|
}
|
|
|
|
func worksmobileAlignExcluded(item service.WorksmobileComparisonItem, excludes map[string]bool) bool {
|
|
for _, value := range []string{item.BaronEmail, item.WorksmobileEmail, item.BaronName, item.WorksmobileName, item.BaronID, item.WorksmobileID, item.ExternalKey} {
|
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
|
if normalized == "" {
|
|
continue
|
|
}
|
|
if excludes[normalized] {
|
|
return true
|
|
}
|
|
if localPart, ok := emailLocalPart(normalized); ok && excludes[localPart] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func classifyWorksmobileAlignFromWorks(item service.WorksmobileComparisonItem) (string, bool) {
|
|
oldEmail := strings.ToLower(strings.TrimSpace(item.BaronEmail))
|
|
newEmail := strings.ToLower(strings.TrimSpace(item.WorksmobileEmail))
|
|
if oldEmail == "" || newEmail == "" {
|
|
return "skipped_email_empty", false
|
|
}
|
|
if oldEmail == newEmail {
|
|
return "skipped_email_already_matched", false
|
|
}
|
|
oldLocalPart, oldOK := emailLocalPart(oldEmail)
|
|
newLocalPart, newOK := emailLocalPart(newEmail)
|
|
if !oldOK || !newOK {
|
|
return "skipped_email_invalid", false
|
|
}
|
|
if oldLocalPart != newLocalPart {
|
|
return "skipped_email_local_part_changed", false
|
|
}
|
|
return "updated", true
|
|
}
|
|
|
|
func copyKratosTraits(source map[string]any) map[string]any {
|
|
copied := make(map[string]any, len(source)+2)
|
|
for key, value := range source {
|
|
copied[key] = value
|
|
}
|
|
return copied
|
|
}
|
|
|
|
func findNumberStrippedWorksmobileAliases(ctx context.Context, outputPath string, client service.WorksmobileDirectoryClient) error {
|
|
remoteUsers, err := client.ListUsers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
header := []string{
|
|
"owner_email",
|
|
"owner_display_name",
|
|
"owner_external_id",
|
|
"owner_employee_number",
|
|
"owner_domain_id",
|
|
"owner_domain_name",
|
|
"owner_local_part",
|
|
"stripped_local_part",
|
|
"alias_email",
|
|
"alias_local_part",
|
|
"reason",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
count := 0
|
|
for _, remote := range remoteUsers {
|
|
ownerEmail := strings.ToLower(strings.TrimSpace(remote.Email))
|
|
ownerLocalPart, ok := emailLocalPart(ownerEmail)
|
|
if !ok {
|
|
continue
|
|
}
|
|
strippedLocalPart, ok := stripTrailingDigitsFromASCIIName(ownerLocalPart)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, alias := range remote.AliasEmails {
|
|
aliasEmail := strings.ToLower(strings.TrimSpace(alias))
|
|
aliasLocalPart, ok := emailLocalPart(aliasEmail)
|
|
if !ok || aliasEmail == ownerEmail || aliasLocalPart != strippedLocalPart {
|
|
continue
|
|
}
|
|
count++
|
|
if err := writer.Write([]string{
|
|
ownerEmail,
|
|
remote.DisplayName,
|
|
remote.ExternalID,
|
|
remote.EmployeeNumber,
|
|
fmt.Sprint(remote.DomainID),
|
|
remote.DomainName,
|
|
ownerLocalPart,
|
|
strippedLocalPart,
|
|
aliasEmail,
|
|
aliasLocalPart,
|
|
"alias local-part equals owner local-part with trailing digits removed",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if err := writer.Error(); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("worksmobile number-stripped alias candidates written: %s candidates=%d remote_users=%d\n", outputPath, count, len(remoteUsers))
|
|
return nil
|
|
}
|
|
|
|
type worksmobilePhoneAuditClient interface {
|
|
ListUsers(ctx context.Context) ([]service.WorksmobileRemoteUser, error)
|
|
PatchUser(ctx context.Context, identifier string, payload service.WorksmobileUserPatchPayload) error
|
|
}
|
|
|
|
func auditAndMaybeFixWorksmobileDuplicatePhoneCountryCodes(ctx context.Context, outputPath string, fix bool, client worksmobilePhoneAuditClient) error {
|
|
writer := io.Writer(os.Stdout)
|
|
var file *os.File
|
|
var err error
|
|
if strings.TrimSpace(outputPath) != "" {
|
|
file, err = os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer = file
|
|
}
|
|
count, err := auditWorksmobileDuplicatePhoneCountryCodes(ctx, writer, fix, client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(outputPath) != "" {
|
|
fmt.Printf("duplicate Worksmobile phone country-code rows written: %d path=%s fix=%t\n", count, outputPath, fix)
|
|
} else {
|
|
fmt.Printf("duplicate Worksmobile phone country-code rows written: %d fix=%t\n", count, fix)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func auditWorksmobileDuplicatePhoneCountryCodes(ctx context.Context, output io.Writer, fix bool, client worksmobilePhoneAuditClient) (int, error) {
|
|
remoteUsers, err := client.ListUsers(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
writer := csv.NewWriter(output)
|
|
defer writer.Flush()
|
|
header := []string{
|
|
"user_id",
|
|
"email",
|
|
"external_id",
|
|
"domain_id",
|
|
"domain_name",
|
|
"current_cell_phone",
|
|
"normalized_cell_phone",
|
|
"action",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return 0, err
|
|
}
|
|
count := 0
|
|
for _, remote := range remoteUsers {
|
|
currentPhone := strings.TrimSpace(remote.CellPhone)
|
|
if !hasDuplicateKoreanCountryCode(currentPhone) {
|
|
continue
|
|
}
|
|
normalizedPhone := domain.NormalizePhoneNumber(currentPhone)
|
|
action := "audit"
|
|
if fix {
|
|
identifier := strings.TrimSpace(remote.ID)
|
|
if identifier == "" {
|
|
identifier = strings.TrimSpace(remote.Email)
|
|
}
|
|
if identifier == "" {
|
|
return count, fmt.Errorf("Worksmobile user identifier is empty for duplicate phone row")
|
|
}
|
|
if err := client.PatchUser(ctx, identifier, service.WorksmobileUserPatchPayload{
|
|
DomainID: remote.DomainID,
|
|
Email: strings.TrimSpace(remote.Email),
|
|
UserExternalKey: strings.TrimSpace(remote.ExternalID),
|
|
UserName: service.WorksmobileUserName{LastName: strings.TrimSpace(remote.DisplayName)},
|
|
CellPhone: normalizedPhone,
|
|
}); err != nil {
|
|
return count, err
|
|
}
|
|
action = "fixed"
|
|
}
|
|
row := []string{
|
|
strings.TrimSpace(remote.ID),
|
|
strings.TrimSpace(remote.Email),
|
|
strings.TrimSpace(remote.ExternalID),
|
|
fmt.Sprint(remote.DomainID),
|
|
strings.TrimSpace(remote.DomainName),
|
|
currentPhone,
|
|
normalizedPhone,
|
|
action,
|
|
}
|
|
if err := writer.Write(row); err != nil {
|
|
return count, err
|
|
}
|
|
count++
|
|
}
|
|
return count, writer.Error()
|
|
}
|
|
|
|
func hasDuplicateKoreanCountryCode(phone string) bool {
|
|
digits := strings.Builder{}
|
|
for _, r := range strings.TrimSpace(phone) {
|
|
if r >= '0' && r <= '9' {
|
|
digits.WriteRune(r)
|
|
}
|
|
}
|
|
return strings.HasPrefix(digits.String(), "8282")
|
|
}
|
|
|
|
func emailLocalPart(email string) (string, bool) {
|
|
localPart, err := domain.ExtractNormalizedEmailLocalPart(strings.ToLower(strings.TrimSpace(email)))
|
|
if err != nil || localPart == "" {
|
|
return "", false
|
|
}
|
|
return localPart, true
|
|
}
|
|
|
|
func stripTrailingDigitsFromASCIIName(localPart string) (string, bool) {
|
|
localPart = strings.ToLower(strings.TrimSpace(localPart))
|
|
if localPart == "" {
|
|
return "", false
|
|
}
|
|
end := len(localPart)
|
|
startDigits := end
|
|
for startDigits > 0 {
|
|
ch := localPart[startDigits-1]
|
|
if ch < '0' || ch > '9' {
|
|
break
|
|
}
|
|
startDigits--
|
|
}
|
|
if startDigits == end || startDigits == 0 {
|
|
return "", false
|
|
}
|
|
base := localPart[:startDigits]
|
|
for i := 0; i < len(base); i++ {
|
|
ch := base[i]
|
|
if ch < 'a' || ch > 'z' {
|
|
return "", false
|
|
}
|
|
}
|
|
return base, true
|
|
}
|
|
|
|
func enqueueWorksmobileUsers(ctx context.Context, syncService service.WorksmobileAdminService, rootID string, userIDs []string, batchID string) (int, int) {
|
|
enqueued, failed := 0, 0
|
|
for _, userID := range userIDs {
|
|
if _, err := syncService.EnqueueUserSync(ctx, rootID, userID, batchID, ""); err != nil {
|
|
failed++
|
|
fmt.Printf("worksmobile user enqueue failed: user_id=%s error=%v\n", userID, err)
|
|
continue
|
|
}
|
|
enqueued++
|
|
}
|
|
return enqueued, failed
|
|
}
|
|
|
|
func processWorksmobileOutbox(ctx context.Context, db *gorm.DB, repo repository.WorksmobileOutboxRepository, config worksmobileSyncConfig) error {
|
|
if config.SerializeOrgUnits {
|
|
return processSerializedWorksmobileOrgUnits(ctx, db, repo, config)
|
|
}
|
|
if strings.TrimSpace(config.SerializeUsersBatch) != "" {
|
|
return processSerializedWorksmobileUsers(ctx, db, repo, config)
|
|
}
|
|
worker := service.NewWorksmobileRelayWorker(repo, newWorksmobileAdminClient())
|
|
worker.SetBatchLimit(config.BatchSize)
|
|
for cycle := 1; cycle <= config.MaxCycles; cycle++ {
|
|
ready, err := countReadyWorksmobileJobs(ctx, db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ready == 0 {
|
|
fmt.Printf("worksmobile outbox processing complete: cycles=%d\n", cycle-1)
|
|
return printWorksmobileOutboxStatus(ctx, db)
|
|
}
|
|
if err := worker.ProcessOnce(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
|
fmt.Printf("worksmobile outbox process cycle failed: cycle=%d error=%v\n", cycle, err)
|
|
}
|
|
if cycle == 1 || cycle%25 == 0 {
|
|
fmt.Printf("worksmobile outbox processing: cycle=%d ready_before=%d\n", cycle, ready)
|
|
}
|
|
if config.Delay > 0 {
|
|
time.Sleep(config.Delay)
|
|
}
|
|
}
|
|
return fmt.Errorf("worksmobile outbox processing hit max cycles: %d", config.MaxCycles)
|
|
}
|
|
|
|
func processSerializedWorksmobileOrgUnits(ctx context.Context, db *gorm.DB, repo repository.WorksmobileOutboxRepository, config worksmobileSyncConfig) error {
|
|
if err := deferPendingWorksmobileOrgUnits(ctx, db); err != nil {
|
|
return err
|
|
}
|
|
worker := service.NewWorksmobileRelayWorker(repo, newWorksmobileAdminClient())
|
|
worker.SetBatchLimit(1)
|
|
for cycle := 1; cycle <= config.MaxCycles; cycle++ {
|
|
released, err := releaseNextWorksmobileOrgUnit(ctx, db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pending, err := countPendingWorksmobileOrgUnits(ctx, db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !released && pending == 0 {
|
|
fmt.Printf("worksmobile orgunit serialized processing complete: cycles=%d\n", cycle-1)
|
|
return printWorksmobileOutboxStatus(ctx, db)
|
|
}
|
|
if released {
|
|
if err := worker.ProcessOnce(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
|
fmt.Printf("worksmobile orgunit process cycle failed: cycle=%d error=%v\n", cycle, err)
|
|
}
|
|
}
|
|
if cycle == 1 || cycle%25 == 0 {
|
|
fmt.Printf("worksmobile orgunit serialized processing: cycle=%d released=%t pending=%d\n", cycle, released, pending)
|
|
}
|
|
if config.Delay > 0 {
|
|
time.Sleep(config.Delay)
|
|
}
|
|
}
|
|
return fmt.Errorf("worksmobile orgunit serialized processing hit max cycles: %d", config.MaxCycles)
|
|
}
|
|
|
|
func processSerializedWorksmobileUsers(ctx context.Context, db *gorm.DB, repo repository.WorksmobileOutboxRepository, config worksmobileSyncConfig) error {
|
|
batchID := strings.TrimSpace(config.SerializeUsersBatch)
|
|
if batchID == "" {
|
|
return fmt.Errorf("serialize users batch id is required")
|
|
}
|
|
if err := deferPendingWorksmobileUsers(ctx, db, batchID); err != nil {
|
|
return err
|
|
}
|
|
worker := service.NewWorksmobileRelayWorker(repo, newWorksmobileAdminClient())
|
|
worker.SetBatchLimit(1)
|
|
for cycle := 1; cycle <= config.MaxCycles; cycle++ {
|
|
released, err := releaseNextWorksmobileUser(ctx, db, batchID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pending, err := countPendingWorksmobileUsers(ctx, db, batchID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !released && pending == 0 {
|
|
fmt.Printf("worksmobile user serialized processing complete: batch_id=%s cycles=%d\n", batchID, cycle-1)
|
|
return printWorksmobileOutboxStatus(ctx, db)
|
|
}
|
|
if released {
|
|
if err := worker.ProcessOnce(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
|
fmt.Printf("worksmobile user process cycle failed: cycle=%d batch_id=%s error=%v\n", cycle, batchID, err)
|
|
}
|
|
}
|
|
if cycle == 1 || cycle%100 == 0 {
|
|
fmt.Printf("worksmobile user serialized processing: batch_id=%s cycle=%d released=%t pending=%d\n", batchID, cycle, released, pending)
|
|
}
|
|
if config.Delay > 0 {
|
|
time.Sleep(config.Delay)
|
|
}
|
|
}
|
|
return fmt.Errorf("worksmobile user serialized processing hit max cycles: batch_id=%s max_cycles=%d", batchID, config.MaxCycles)
|
|
}
|
|
|
|
func deferPendingWorksmobileUsers(ctx context.Context, db *gorm.DB, batchID string) error {
|
|
return db.WithContext(ctx).Exec(`
|
|
UPDATE worksmobile_outboxes
|
|
SET next_attempt_at = now() + interval '2 hours',
|
|
updated_at = now()
|
|
WHERE status = ?
|
|
AND resource_type = ?
|
|
AND payload ->> 'credentialBatchId' = ?
|
|
`, domain.WorksmobileOutboxStatusPending, domain.WorksmobileResourceUser, batchID).Error
|
|
}
|
|
|
|
func releaseNextWorksmobileUser(ctx context.Context, db *gorm.DB, batchID string) (bool, error) {
|
|
result := db.WithContext(ctx).Exec(`
|
|
WITH next_job AS (
|
|
SELECT id
|
|
FROM worksmobile_outboxes
|
|
WHERE status = ?
|
|
AND resource_type = ?
|
|
AND payload ->> 'credentialBatchId' = ?
|
|
ORDER BY created_at ASC
|
|
LIMIT 1
|
|
)
|
|
UPDATE worksmobile_outboxes
|
|
SET next_attempt_at = NULL,
|
|
updated_at = now()
|
|
WHERE id IN (SELECT id FROM next_job)
|
|
`, domain.WorksmobileOutboxStatusPending, domain.WorksmobileResourceUser, batchID)
|
|
if result.Error != nil {
|
|
return false, result.Error
|
|
}
|
|
return result.RowsAffected > 0, nil
|
|
}
|
|
|
|
func countPendingWorksmobileUsers(ctx context.Context, db *gorm.DB, batchID string) (int64, error) {
|
|
var count int64
|
|
err := db.WithContext(ctx).Raw(`
|
|
SELECT count(*)
|
|
FROM worksmobile_outboxes
|
|
WHERE status = ?
|
|
AND resource_type = ?
|
|
AND payload ->> 'credentialBatchId' = ?
|
|
`, domain.WorksmobileOutboxStatusPending, domain.WorksmobileResourceUser, batchID).Scan(&count).Error
|
|
return count, err
|
|
}
|
|
|
|
func deferPendingWorksmobileOrgUnits(ctx context.Context, db *gorm.DB) error {
|
|
return db.WithContext(ctx).Exec(`
|
|
UPDATE worksmobile_outboxes
|
|
SET next_attempt_at = now() + interval '30 minutes',
|
|
updated_at = now()
|
|
WHERE status = ?
|
|
AND resource_type = ?
|
|
`, domain.WorksmobileOutboxStatusPending, domain.WorksmobileResourceOrgUnit).Error
|
|
}
|
|
|
|
func releaseNextWorksmobileOrgUnit(ctx context.Context, db *gorm.DB) (bool, error) {
|
|
result := db.WithContext(ctx).Exec(`
|
|
WITH candidates AS (
|
|
SELECT
|
|
id,
|
|
created_at,
|
|
NULLIF(payload #>> '{request,orgUnitExternalKey}', '') AS org_external_key,
|
|
CASE
|
|
WHEN payload #>> '{request,parentOrgUnitId}' LIKE 'externalKey:%'
|
|
THEN NULLIF(substr(payload #>> '{request,parentOrgUnitId}', length('externalKey:') + 1), '')
|
|
ELSE ''
|
|
END AS parent_external_key
|
|
FROM worksmobile_outboxes
|
|
WHERE status = ?
|
|
AND resource_type = ?
|
|
AND action = ?
|
|
),
|
|
next_job AS (
|
|
SELECT c.id
|
|
FROM candidates c
|
|
WHERE NOT (
|
|
c.parent_external_key <> ''
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM worksmobile_outboxes parent_job
|
|
WHERE parent_job.resource_type = ?
|
|
AND parent_job.action = ?
|
|
AND parent_job.status <> ?
|
|
AND NULLIF(parent_job.payload #>> '{request,orgUnitExternalKey}', '') = c.parent_external_key
|
|
)
|
|
)
|
|
ORDER BY c.created_at ASC
|
|
LIMIT 1
|
|
)
|
|
UPDATE worksmobile_outboxes
|
|
SET next_attempt_at = NULL,
|
|
updated_at = now()
|
|
WHERE id IN (SELECT id FROM next_job)
|
|
`, domain.WorksmobileOutboxStatusPending, domain.WorksmobileResourceOrgUnit, domain.WorksmobileActionUpsert, domain.WorksmobileResourceOrgUnit, domain.WorksmobileActionUpsert, domain.WorksmobileOutboxStatusProcessed)
|
|
if result.Error != nil {
|
|
return false, result.Error
|
|
}
|
|
return result.RowsAffected > 0, nil
|
|
}
|
|
|
|
func countPendingWorksmobileOrgUnits(ctx context.Context, db *gorm.DB) (int64, error) {
|
|
var count int64
|
|
err := db.WithContext(ctx).Raw(`
|
|
SELECT count(*)
|
|
FROM worksmobile_outboxes
|
|
WHERE status = ?
|
|
AND resource_type = ?
|
|
`, domain.WorksmobileOutboxStatusPending, domain.WorksmobileResourceOrgUnit).Scan(&count).Error
|
|
return count, err
|
|
}
|
|
|
|
func countReadyWorksmobileJobs(ctx context.Context, db *gorm.DB) (int64, error) {
|
|
var count int64
|
|
err := db.WithContext(ctx).Raw(`
|
|
SELECT count(*)
|
|
FROM worksmobile_outboxes
|
|
WHERE status = ?
|
|
AND (next_attempt_at IS NULL OR next_attempt_at <= now())
|
|
`, domain.WorksmobileOutboxStatusPending).Scan(&count).Error
|
|
return count, err
|
|
}
|
|
|
|
func printWorksmobileOutboxStatus(ctx context.Context, db *gorm.DB) error {
|
|
type row struct {
|
|
ResourceType string
|
|
Status string
|
|
Count int64
|
|
}
|
|
var rows []row
|
|
if err := db.WithContext(ctx).Raw(`
|
|
SELECT resource_type, status, count(*) AS count
|
|
FROM worksmobile_outboxes
|
|
GROUP BY resource_type, status
|
|
ORDER BY resource_type, status
|
|
`).Scan(&rows).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, row := range rows {
|
|
fmt.Printf("worksmobile outbox status: resource_type=%s status=%s count=%d\n", row.ResourceType, row.Status, row.Count)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newWorksmobileAdminClient() *service.WorksmobileHTTPClient {
|
|
privateKey, _ := getenvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "")
|
|
client := service.NewWorksmobileHTTPClientWithAuth(
|
|
getenv("WORKS_ADMIN_ACCESS_TOKEN", getenv("WORKS_ADMIN_OAUTH_ACCESS_TOKEN", "")),
|
|
getenv("WORKS_ADMIN_SCIM_TOKEN", ""),
|
|
service.WorksmobileOAuthConfig{
|
|
ClientID: getenv("WORKS_ADMIN_OAUTH_CLIENT_ID", ""),
|
|
ClientSecret: getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET", ""),
|
|
ServiceAccount: getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT", ""),
|
|
PrivateKey: privateKey,
|
|
Scope: getenv("WORKS_ADMIN_OAUTH_SCOPE", "directory"),
|
|
TokenURL: getenv("WORKS_ADMIN_OAUTH_TOKEN_URL", ""),
|
|
},
|
|
)
|
|
client.BaseURL = strings.TrimSpace(getenv("WORKS_ADMIN_API_BASE_URL", ""))
|
|
client.RateLimiter = service.NewWorksmobileAPIRateLimiter(180, time.Minute)
|
|
return client
|
|
}
|
|
|
|
func newWorksmobileSCIMClient() *service.WorksmobileHTTPClient {
|
|
client := service.NewWorksmobileHTTPClientWithTokens(
|
|
"",
|
|
getenv("WORKS_ADMIN_SCIM_TOKEN", getenv("SAMAN_SCIM_LONGLIVE_TOKEN", "")),
|
|
)
|
|
client.BaseURL = strings.TrimSpace(getenv("WORKS_ADMIN_API_BASE_URL", ""))
|
|
client.RateLimiter = service.NewWorksmobileAPIRateLimiter(180, time.Minute)
|
|
return client
|
|
}
|
|
|
|
func getenvFileOrValue(fileKey string, valueKey string, fallback string) (string, error) {
|
|
if path := strings.TrimSpace(getenv(fileKey, "")); path != "" {
|
|
data, err := readEnvPath(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|
|
return getenv(valueKey, fallback), nil
|
|
}
|
|
|
|
func readEnvPath(path string) ([]byte, error) {
|
|
candidates := []string{path}
|
|
if !strings.HasPrefix(path, "/") {
|
|
candidates = append(candidates, "../"+path, "../../"+path)
|
|
}
|
|
var lastErr error
|
|
for _, candidate := range candidates {
|
|
data, err := os.ReadFile(candidate)
|
|
if err == nil {
|
|
return data, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
return nil, lastErr
|
|
}
|