package service import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "context" "errors" "fmt" "net/mail" "os" "sort" "strings" "time" ) const HanmacFamilyTenantSlug = "hanmac-family" const worksmobileExcludedConfigKey = "worksmobileExcluded" type WorksmobileSyncer interface { EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error } type WorksmobileAdminService interface { GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) DeletePendingJobs(ctx context.Context, tenantID string) (WorksmobilePendingJobDeleteResult, error) ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error) ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error) DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error) } type WorksmobileConfigSummary struct { Enabled bool `json:"enabled"` DomainMappings map[string]int64 `json:"domainMappings"` TokenConfigured bool `json:"tokenConfigured"` AdminTenantID string `json:"adminTenantId,omitempty"` } type WorksmobileTenantOverview struct { Tenant domain.Tenant `json:"tenant"` Config WorksmobileConfigSummary `json:"config"` RecentJobs []domain.WorksmobileOutbox `json:"recentJobs"` } type WorksmobileBackfillDryRun struct { OrgUnitCount int `json:"orgUnitCount"` UserCount int `json:"userCount"` } type WorksmobilePendingJobDeleteResult struct { DeletedCount int `json:"deletedCount"` } type WorksmobileInitialPasswordCredential struct { Email string `json:"email"` Name string `json:"name,omitempty"` PrimaryLeafOrgName string `json:"primaryLeafOrgName,omitempty"` InitialPassword string `json:"initialPassword"` Status string `json:"status"` LastError string `json:"lastError,omitempty"` } type WorksmobileCredentialBatch struct { BatchID string `json:"batchId"` Operation string `json:"operation,omitempty"` UserCount int `json:"userCount"` PendingCount int `json:"pendingCount"` ProcessingCount int `json:"processingCount"` ProcessedCount int `json:"processedCount"` FailedCount int `json:"failedCount"` HasPasswords bool `json:"hasPasswords"` DeletedAt string `json:"deletedAt,omitempty"` Failures []WorksmobileCredentialBatchFailure `json:"failures,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type WorksmobileCredentialBatchFailure struct { UserID string `json:"userId,omitempty"` Email string `json:"email,omitempty"` Status string `json:"status"` RetryCount int `json:"retryCount"` LastError string `json:"lastError,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` } type WorksmobileComparison struct { Users []WorksmobileComparisonItem `json:"users"` Groups []WorksmobileComparisonItem `json:"groups"` } type WorksmobileComparisonItem struct { ResourceType string `json:"resourceType"` BaronID string `json:"baronId,omitempty"` BaronSlug string `json:"baronSlug,omitempty"` BaronName string `json:"baronName,omitempty"` BaronEmail string `json:"baronEmail,omitempty"` BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"` BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"` BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"` BaronParentID string `json:"baronParentId,omitempty"` BaronParentSlug string `json:"baronParentSlug,omitempty"` BaronParentName string `json:"baronParentName,omitempty"` WorksmobileID string `json:"worksmobileId,omitempty"` ExternalKey string `json:"externalKey,omitempty"` WorksmobileName string `json:"worksmobileName,omitempty"` WorksmobileEmail string `json:"worksmobileEmail,omitempty"` WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"` WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"` WorksmobileTask string `json:"worksmobileTask,omitempty"` WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"` WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"` WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"` WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"` WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"` WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"` WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"` BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"` BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"` BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"` WorksmobileParentID string `json:"worksmobileParentId,omitempty"` WorksmobileParentName string `json:"worksmobileParentName,omitempty"` WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"` WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"` WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"` WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"` WorksmobileLastError string `json:"worksmobileLastError,omitempty"` WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"` Status string `json:"status"` } type worksmobileSyncService struct { tenantService TenantService userRepo repository.UserRepository outboxRepo repository.WorksmobileOutboxRepository client WorksmobileDirectoryClient } func NewWorksmobileSyncService(tenantService TenantService, userRepo repository.UserRepository, outboxRepo repository.WorksmobileOutboxRepository, client WorksmobileDirectoryClient) *worksmobileSyncService { return &worksmobileSyncService{ tenantService: tenantService, userRepo: userRepo, outboxRepo: outboxRepo, client: client, } } func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) { tenant, err := s.tenantService.GetTenant(ctx, tenantID) if err != nil { return WorksmobileTenantOverview{}, err } jobs, _ := s.outboxRepo.ListRecent(ctx, 50) jobs = redactWorksmobileOutboxPayloads(jobs) return WorksmobileTenantOverview{ Tenant: *tenant, Config: WorksmobileConfigSummary{ Enabled: WorksmobileEnabled(tenant.Config), DomainMappings: WorksmobileDomainMappings(tenant.Config), TokenConfigured: worksmobileDirectoryAuthConfigured(), AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")), }, RecentJobs: jobs, }, nil } func worksmobileDirectoryAuthConfigured() bool { if strings.TrimSpace(os.Getenv("WORKS_ADMIN_ACCESS_TOKEN")) != "" || strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_ACCESS_TOKEN")) != "" { return true } return strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_ID")) != "" && strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SECRET")) != "" && strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT")) != "" && (strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY")) != "" || strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "") } func WorksmobileExcluded(config domain.JSONMap) bool { rawValue, ok := config[worksmobileExcludedConfigKey] if !ok { return false } switch value := rawValue.(type) { case bool: return value case string: return strings.EqualFold(strings.TrimSpace(value), "true") default: return false } } func redactWorksmobileOutboxPayloads(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox { for i := range jobs { jobs[i].Payload = safeWorksmobileOutboxPayload(jobs[i].Payload) } return jobs } func safeWorksmobileOutboxPayload(payload domain.JSONMap) domain.JSONMap { if payload == nil { return nil } safe := domain.JSONMap{} for _, key := range []string{ "tenantRootId", "loginEmail", "displayName", "primaryLeafOrgName", "credentialBatchId", "credentialOperation", "credentialBatchCreatedAt", "worksmobileId", "externalKey", "domainId", "name", "email", "matchLocalPart", "baronStatus", } { if value, ok := payload[key]; ok && safeWorksmobilePayloadValue(value) != nil { safe[key] = value } } if summary := safeWorksmobileRequestSummary(payload["request"]); len(summary) > 0 { safe["requestSummary"] = summary } return safe } func safeWorksmobilePayloadValue(value any) any { switch v := value.(type) { case string: if strings.TrimSpace(v) == "" { return nil } return v case nil: return nil default: return value } } func safeWorksmobileRequestSummary(request any) domain.JSONMap { switch v := request.(type) { case WorksmobileUserPayload: summary := domain.JSONMap{} safeSetWorksmobileSummary(summary, "email", v.Email) safeSetWorksmobileSummary(summary, "displayName", v.UserName.LastName) safeSetWorksmobileSummary(summary, "userExternalKey", v.UserExternalKey) safeSetWorksmobileSummary(summary, "cellPhone", v.CellPhone) safeSetWorksmobileSummary(summary, "employeeNumber", v.EmployeeNumber) safeSetWorksmobileSummary(summary, "task", v.Task) return summary case WorksmobilePasswordResetPayload: summary := domain.JSONMap{} safeSetWorksmobileSummary(summary, "email", v.Email) return summary case WorksmobileOrgUnitPayload: summary := domain.JSONMap{} safeSetWorksmobileSummary(summary, "email", v.Email) safeSetWorksmobileSummary(summary, "orgUnitName", v.OrgUnitName) safeSetWorksmobileSummary(summary, "orgUnitExternalKey", v.OrgUnitExternalKey) safeSetWorksmobileSummary(summary, "parentOrgUnitId", v.ParentOrgUnitID) if v.DomainID > 0 { summary["domainId"] = v.DomainID } return summary case map[string]any: return safeWorksmobileRequestSummaryFromMap(v) case domain.JSONMap: return safeWorksmobileRequestSummaryFromMap(map[string]any(v)) default: return nil } } func safeWorksmobileRequestSummaryFromMap(request map[string]any) domain.JSONMap { summary := domain.JSONMap{} for _, key := range []string{ "email", "userExternalKey", "cellPhone", "employeeNumber", "task", "orgUnitName", "orgUnitExternalKey", "parentOrgUnitId", "domainId", } { if value, ok := request[key]; ok && safeWorksmobilePayloadValue(value) != nil { summary[key] = value } } if userName, ok := request["userName"].(map[string]any); ok { safeSetWorksmobileSummary(summary, "displayName", stringValue(userName["lastName"])) } return summary } func safeSetWorksmobileSummary(summary domain.JSONMap, key string, value string) { if value = strings.TrimSpace(value); value != "" { summary[key] = value } } func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return WorksmobileComparison{}, err } if s.client == nil { return WorksmobileComparison{}, errors.New("worksmobile client is not configured") } tenants, err := s.hanmacSubtree(ctx, root.ID) if err != nil { return WorksmobileComparison{}, err } tenantByID := worksmobileTenantByID(tenants) tenantByID[root.ID] = *root tenantIDs := make([]string, 0, len(tenants)) for _, tenant := range tenants { if isWorksmobileUserScopeTenant(tenant) { tenantIDs = append(tenantIDs, tenant.ID) } } users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs) if err != nil { return WorksmobileComparison{}, err } remoteUsers, err := s.client.ListUsers(ctx) if err != nil { return WorksmobileComparison{}, err } remoteGroups, err := s.client.ListGroups(ctx) if err != nil { return WorksmobileComparison{}, err } recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000) return WorksmobileComparison{ Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID, worksmobileUserJobSummaries(recentJobs)), Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched), }, nil } func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return WorksmobileBackfillDryRun{}, err } tenants, err := s.hanmacSubtree(ctx, root.ID) if err != nil { return WorksmobileBackfillDryRun{}, err } orgUnitTenantIDs := make([]string, 0, len(tenants)) userTenantIDs := make([]string, 0, len(tenants)) tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, tenants...)) for _, tenant := range tenants { if isWorksmobileOrgUnitTenant(tenant, tenantByID) { orgUnitTenantIDs = append(orgUnitTenantIDs, tenant.ID) } if isWorksmobileUserScopeTenant(tenant) { userTenantIDs = append(userTenantIDs, tenant.ID) } } users, err := s.userRepo.FindByTenantIDs(ctx, userTenantIDs) if err != nil { return WorksmobileBackfillDryRun{}, err } users = worksmobileSyncScopeUsers(users) _ = s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{ ResourceType: domain.WorksmobileResourceOrgUnit, ResourceID: root.ID, Action: domain.WorksmobileActionDryRun, DedupeKey: "backfill:dry-run:" + root.ID, Payload: domain.JSONMap{ "tenantIds": orgUnitTenantIDs, "userCount": len(users), }, }) return WorksmobileBackfillDryRun{OrgUnitCount: len(orgUnitTenantIDs), UserCount: len(users)}, nil } func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return nil, err } tenant, err := s.tenantService.GetTenant(ctx, orgUnitID) if err != nil { return nil, err } tenantRoot, ok, err := s.rootForTenant(ctx, *tenant) if err != nil { return nil, err } if !ok || tenantRoot.ID != root.ID { return nil, errors.New("target orgunit is outside hanmac-family subtree") } scopeTenants, err := s.hanmacSubtree(ctx, root.ID) if err != nil { return nil, err } tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) if _, ok := tenantByID[tenant.ID]; !ok { return nil, errors.New("target tenant is excluded from Worksmobile sync") } if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) { return nil, errors.New("target tenant is not a worksmobile orgunit tenant") } return s.enqueueOrgUnitUpsert(ctx, root, *tenant, scopeTenants) } func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root *domain.Tenant, tenant domain.Tenant, scopeTenants []domain.Tenant) (*domain.WorksmobileOutbox, error) { tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant( tenant, worksmobileDomainClassificationTenant(tenant, tenantByID), root.Config, 0, ) if err != nil { return nil, err } payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID) item := &domain.WorksmobileOutbox{ ResourceType: domain.WorksmobileResourceOrgUnit, ResourceID: tenant.ID, Action: domain.WorksmobileActionUpsert, DedupeKey: "orgunit:upsert:" + tenant.ID, Payload: domain.JSONMap{ "request": payload, "matchLocalPart": tenant.Slug, }, } if err := s.outboxRepo.Create(ctx, item); err != nil { return nil, err } return item, nil } func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return nil, err } if s.client == nil { return nil, errors.New("worksmobile client is not configured") } scopeTenants, err := s.hanmacSubtree(ctx, root.ID) if err != nil { return nil, err } worksmobileOrgUnitID = strings.TrimSpace(worksmobileOrgUnitID) if worksmobileOrgUnitID == "" { return nil, errors.New("worksmobile orgunit id is required") } groups, err := s.client.ListGroups(ctx) if err != nil { return nil, err } var target *WorksmobileRemoteGroup for i := range groups { if strings.TrimSpace(groups[i].ID) == worksmobileOrgUnitID { target = &groups[i] break } } if target == nil { return nil, errors.New("worksmobile orgunit not found") } tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) if tenant, ok := findWorksmobileOrgUnitTenantByRemoteLocalPart(*target, scopeTenants, tenantByID); ok { return s.enqueueOrgUnitUpsert(ctx, root, tenant, scopeTenants) } if isProtectedWorksmobileRemoteOrgUnit(*root, scopeTenants, *target) { return nil, errors.New("protected worksmobile domain root orgunit cannot be deleted") } item := &domain.WorksmobileOutbox{ ResourceType: domain.WorksmobileResourceOrgUnit, ResourceID: worksmobileOrgUnitID, Action: domain.WorksmobileActionDelete, DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID, Payload: domain.JSONMap{ "worksmobileId": worksmobileOrgUnitID, "externalKey": target.ExternalID, "domainId": target.DomainID, "name": target.DisplayName, "email": target.Email, }, } if err := s.outboxRepo.Create(ctx, item); err != nil { return nil, err } return item, nil } func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return nil, err } user, err := s.userRepo.FindByID(ctx, userID) if err != nil { return nil, err } if user.TenantID == nil { return nil, errors.New("target user has no tenant") } tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID) if err != nil { return nil, err } tenantRoot, ok, err := s.rootForTenant(ctx, *tenant) if err != nil { return nil, err } if !ok || tenantRoot.ID != root.ID { return nil, errors.New("target user is outside hanmac-family subtree") } scopeTenants, err := s.hanmacSubtree(ctx, root.ID) if err != nil { return nil, err } tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) if _, ok := tenantByID[tenant.ID]; !ok { return nil, errors.New("target user tenant is excluded from Worksmobile sync") } if domain.IsWorksDeprovisionUserStatus(user.Status) { return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID) } if !domain.IsWorksProvisionedUserStatus(user.Status) { return nil, errors.New("target user status is excluded from Worksmobile sync") } payload, err := BuildWorksmobileUserPayloadForDomainTenants( *user, *tenant, tenantByID, root.Config, ) if err != nil { return nil, err } if err := s.validateUserAliasLocalParts(ctx, root, *user, payload); err != nil { return nil, err } action := WorksmobileUserStatusAction(user.Status) item := &domain.WorksmobileOutbox{ ResourceType: domain.WorksmobileResourceUser, ResourceID: user.ID, Action: action, DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID, Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status), } item.Payload["displayName"] = strings.TrimSpace(user.Name) item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID) if batchID := strings.TrimSpace(credentialBatchID); batchID != "" { item.Payload["credentialBatchId"] = batchID item.Payload["credentialOperation"] = "worksmobile_user_sync" item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano) } if err := s.outboxRepo.Create(ctx, item); err != nil { return nil, err } return item, nil } func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return nil, err } user, err := s.userRepo.FindByID(ctx, userID) if err != nil { return nil, err } if user.TenantID == nil { return nil, errors.New("target user has no tenant") } tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID) if err != nil { return nil, err } tenantRoot, ok, err := s.rootForTenant(ctx, *tenant) if err != nil { return nil, err } if !ok || tenantRoot.ID != root.ID { return nil, errors.New("target user is outside hanmac-family subtree") } if !domain.IsWorksProvisionedUserStatus(user.Status) { return nil, errors.New("target user status is excluded from Worksmobile password reset") } scopeTenants, err := s.hanmacSubtree(ctx, root.ID) if err != nil { return nil, err } tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) if _, ok := tenantByID[tenant.ID]; !ok { return nil, errors.New("target user tenant is excluded from Worksmobile sync") } payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config) if err != nil { return nil, err } password := GenerateWorksmobileInitialPassword() request := WorksmobilePasswordResetPayload{ Email: strings.TrimSpace(payload.Email), PasswordConfig: WorksmobilePasswordConfig{ PasswordCreationType: "ADMIN", Password: password, }, } batchID := strings.TrimSpace(credentialBatchID) batchCreatedAt := time.Now().UTC().Format(time.RFC3339Nano) dedupeSuffix := batchID if dedupeSuffix == "" { dedupeSuffix = batchCreatedAt } item := &domain.WorksmobileOutbox{ ResourceType: domain.WorksmobileResourceUser, ResourceID: user.ID, Action: domain.WorksmobileActionPasswordReset, DedupeKey: "user:password-reset:" + user.ID + ":" + dedupeSuffix, Payload: domain.JSONMap{ "tenantRootId": root.ID, "loginEmail": request.Email, "userExternalKey": user.ID, "initialPassword": password, "displayName": strings.TrimSpace(user.Name), "primaryLeafOrgName": worksmobileUserPrimaryOrgName(*user, tenantByID), "credentialBatchId": batchID, "credentialOperation": "worksmobile_password_reset", "credentialBatchCreatedAt": batchCreatedAt, "request": request, }, } if err := s.outboxRepo.Create(ctx, item); err != nil { return nil, err } return item, nil } func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return nil, err } credentialBatchID = strings.TrimSpace(credentialBatchID) var jobs []domain.WorksmobileOutbox if credentialBatchID != "" { jobs, err = s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, credentialBatchID) } else { jobs, err = s.outboxRepo.ListRecent(ctx, 1000) } if err != nil { return nil, err } credentials := make([]WorksmobileInitialPasswordCredential, 0) seen := map[string]bool{} for _, job := range jobs { if job.ResourceType != domain.WorksmobileResourceUser { continue } if stringValue(job.Payload["tenantRootId"]) != root.ID { continue } if credentialBatchID != "" && stringValue(job.Payload["credentialBatchId"]) != credentialBatchID { continue } email := stringValue(job.Payload["loginEmail"]) password := stringValue(job.Payload["initialPassword"]) if email == "" || password == "" || seen[email] { continue } seen[email] = true credentials = append(credentials, WorksmobileInitialPasswordCredential{ Email: email, Name: stringValue(job.Payload["displayName"]), PrimaryLeafOrgName: stringValue(job.Payload["primaryLeafOrgName"]), InitialPassword: password, Status: job.Status, LastError: job.LastError, }) } return credentials, nil } func (s *worksmobileSyncService) ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return nil, err } jobs, err := s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, "") if err != nil { return nil, err } return aggregateWorksmobileCredentialBatches(jobs), nil } func (s *worksmobileSyncService) DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return WorksmobileCredentialBatch{}, err } credentialBatchID = strings.TrimSpace(credentialBatchID) if credentialBatchID == "" { return WorksmobileCredentialBatch{}, errors.New("credential batch id is required") } jobs, err := s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, credentialBatchID) if err != nil { return WorksmobileCredentialBatch{}, err } if len(jobs) == 0 { return WorksmobileCredentialBatch{}, errors.New("credential batch not found") } deletedAt := time.Now().UTC().Format(time.RFC3339) for i := range jobs { nextPayload := scrubWorksmobileCredentialPayload(jobs[i].Payload, deletedAt) if err := s.outboxRepo.UpdatePayload(ctx, jobs[i].ID, nextPayload); err != nil { return WorksmobileCredentialBatch{}, err } jobs[i].Payload = nextPayload } batches := aggregateWorksmobileCredentialBatches(jobs) if len(batches) == 0 { return WorksmobileCredentialBatch{}, errors.New("credential batch not found") } return batches[0], nil } func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) { if _, err := s.hanmacRoot(ctx, tenantID); err != nil { return nil, err } if err := s.outboxRepo.MarkRetry(ctx, jobID); err != nil { return nil, err } return s.outboxRepo.FindByID(ctx, jobID) } func (s *worksmobileSyncService) DeletePendingJobs(ctx context.Context, tenantID string) (WorksmobilePendingJobDeleteResult, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return WorksmobilePendingJobDeleteResult{}, err } deleted, err := s.outboxRepo.DeletePendingByTenantRoot(ctx, root.ID) if err != nil { return WorksmobilePendingJobDeleteResult{}, err } return WorksmobilePendingJobDeleteResult{DeletedCount: int(deleted)}, nil } func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error { root, ok, err := s.rootForTenant(ctx, tenant) if err != nil || !ok { return err } scopeTenants, err := s.hanmacSubtree(ctx, root.ID) if err != nil { return err } tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) if _, ok := tenantByID[tenant.ID]; !ok { return nil } if !isWorksmobileOrgUnitTenant(tenant, tenantByID) { return nil } payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant( tenant, worksmobileDomainClassificationTenant(tenant, tenantByID), root.Config, 0, ) if err != nil { return err } payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID) return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{ ResourceType: domain.WorksmobileResourceOrgUnit, ResourceID: tenant.ID, Action: domain.WorksmobileActionUpsert, DedupeKey: "orgunit:upsert:" + tenant.ID, Payload: domain.JSONMap{ "request": payload, "matchLocalPart": tenant.Slug, }, }) } func (s *worksmobileSyncService) EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error { root, ok, err := s.rootForTenant(ctx, tenant) if err != nil || !ok { return err } scopeTenants, err := s.hanmacSubtree(ctx, root.ID) if err != nil { return err } tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) if _, ok := tenantByID[tenant.ID]; !ok { return nil } if !isWorksmobileOrgUnitTenant(tenant, tenantByID) { return nil } return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{ ResourceType: domain.WorksmobileResourceOrgUnit, ResourceID: tenant.ID, Action: domain.WorksmobileActionDelete, DedupeKey: "orgunit:delete:" + tenant.ID, Payload: domain.JSONMap{"orgUnitExternalKey": tenant.ID}, }) } func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error { if user.TenantID == nil || *user.TenantID == "" { return nil } tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID) if err != nil { return err } root, ok, err := s.rootForTenant(ctx, *tenant) if err != nil || !ok { return err } scopeTenants, err := s.hanmacSubtree(ctx, root.ID) if err != nil { return err } tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) if _, ok := tenantByID[*user.TenantID]; !ok { return nil } if domain.IsWorksDeprovisionUserStatus(user.Status) { _, err := s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, root.ID) return err } if !domain.IsWorksProvisionedUserStatus(user.Status) { return nil } payload, err := BuildWorksmobileUserPayloadForDomainTenants( user, *tenant, tenantByID, root.Config, ) if err != nil { return err } if err := s.validateUserAliasLocalParts(ctx, root, user, payload); err != nil { return err } action := WorksmobileUserStatusAction(user.Status) return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{ ResourceType: domain.WorksmobileResourceUser, ResourceID: user.ID, Action: action, DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID, Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status), }) } func (s *worksmobileSyncService) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error { if user.TenantID == nil || *user.TenantID == "" { return nil } tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID) if err != nil { return err } root, ok, err := s.rootForTenant(ctx, *tenant) if err != nil || !ok { return err } scopeTenants, err := s.hanmacSubtree(ctx, root.ID) if err != nil { return err } tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) if _, ok := tenantByID[*user.TenantID]; !ok { return nil } _, err = s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, "") return err } func (s *worksmobileSyncService) enqueueUserDelete(ctx context.Context, user domain.User, dedupeKey string, rootID string) (*domain.WorksmobileOutbox, error) { payload := domain.JSONMap{ "userExternalKey": user.ID, "loginEmail": user.Email, } if rootID != "" { payload["tenantRootId"] = rootID } if status := domain.NormalizeUserStatus(user.Status); status != "" { payload["baronStatus"] = status } item := &domain.WorksmobileOutbox{ ResourceType: domain.WorksmobileResourceUser, ResourceID: user.ID, Action: domain.WorksmobileActionDelete, DedupeKey: dedupeKey, Payload: payload, } if err := s.outboxRepo.Create(ctx, item); err != nil { return nil, err } return item, nil } func (s *worksmobileSyncService) hanmacRoot(ctx context.Context, tenantID string) (*domain.Tenant, error) { tenant, err := s.tenantService.GetTenant(ctx, tenantID) if err != nil { return nil, err } if tenant.Slug != HanmacFamilyTenantSlug || tenant.ParentID != nil { return nil, errors.New("worksmobile is only available for hanmac-family root tenant") } return tenant, nil } func (s *worksmobileSyncService) hanmacSubtree(ctx context.Context, rootID string) ([]domain.Tenant, error) { all, _, err := s.tenantService.ListTenants(ctx, 10000, 0, "") if err != nil { return nil, err } byParent := map[string][]domain.Tenant{} for _, tenant := range all { if tenant.ParentID != nil { byParent[*tenant.ParentID] = append(byParent[*tenant.ParentID], tenant) } } result := []domain.Tenant{} var visit func(id string) visit = func(id string) { for _, child := range byParent[id] { if WorksmobileExcluded(child.Config) { continue } result = append(result, child) visit(child.ID) } } visit(rootID) sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) return result, nil } func (s *worksmobileSyncService) rootForTenant(ctx context.Context, tenant domain.Tenant) (*domain.Tenant, bool, error) { current := tenant for current.ParentID != nil && *current.ParentID != "" { parent, err := s.tenantService.GetTenant(ctx, *current.ParentID) if err != nil { return nil, false, err } current = *parent } return ¤t, current.Slug == HanmacFamilyTenantSlug, nil } func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context, root *domain.Tenant, user domain.User, payload WorksmobileUserPayload) error { if len(payload.AliasEmails) == 0 { return nil } tenants, err := s.hanmacSubtree(ctx, root.ID) if err != nil { return err } tenantByID := make(map[string]domain.Tenant, len(tenants)+1) tenantByID[root.ID] = *root tenantIDs := make([]string, 0, len(tenants)+1) tenantIDs = append(tenantIDs, root.ID) for _, tenant := range tenants { tenantByID[tenant.ID] = tenant if isWorksmobileUserScopeTenant(tenant) { tenantIDs = append(tenantIDs, tenant.ID) } } users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs) if err != nil { return err } existing := map[string]string{} for _, existingUser := range users { if existingUser.ID == user.ID { continue } addWorksmobileEmail(existing, existingUser.Email, existingUser.ID) if existingUser.TenantID == nil { continue } tenant, ok := tenantByID[*existingUser.TenantID] if !ok { continue } for _, alias := range BuildWorksmobileAliasEmails(existingUser, tenant) { addWorksmobileEmail(existing, alias, existingUser.ID) } } return ValidateWorksmobileAliasEmails(payload.Email, payload.AliasEmails, existing) } func addWorksmobileEmail(target map[string]string, email string, owner string) { normalized := strings.ToLower(strings.TrimSpace(email)) if _, err := mail.ParseAddress(normalized); err == nil && normalized != "" { target[normalized] = owner } } func findWorksmobileOrgUnitTenantByRemoteLocalPart(remote WorksmobileRemoteGroup, localTenants []domain.Tenant, tenantByID map[string]domain.Tenant) (domain.Tenant, bool) { candidates := worksmobileRemoteGroupLocalPartCandidates(remote) for _, tenant := range localTenants { if !isWorksmobileOrgUnitTenant(tenant, tenantByID) { continue } if candidates[normalizeWorksmobileSlugLocalPart(tenant.Slug)] { return tenant, true } } return domain.Tenant{}, false } func isProtectedWorksmobileRemoteOrgUnit(root domain.Tenant, localTenants []domain.Tenant, remote WorksmobileRemoteGroup) bool { if strings.TrimSpace(remote.ParentID) == "" { return true } candidates := worksmobileRemoteGroupLocalPartCandidates(remote) if len(candidates) == 0 { return false } for _, tenant := range localTenants { if tenant.ParentID == nil || *tenant.ParentID != root.ID || tenant.Type != domain.TenantTypeCompany { continue } if candidates[normalizeWorksmobileSlugLocalPart(tenant.Slug)] { return true } } return false } func worksmobileRemoteGroupLocalPartCandidates(remote WorksmobileRemoteGroup) map[string]bool { result := map[string]bool{} if localPart := normalizeWorksmobileSlugLocalPart(remote.MailLocalPart); localPart != "" { result[localPart] = true } if localPart, err := domain.ExtractNormalizedEmailLocalPart(remote.Email); err == nil && localPart != "" { result[localPart] = true } return result } func normalizeWorksmobileSlugLocalPart(value string) string { return strings.ToLower(strings.TrimSpace(value)) } func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool { if isWorksmobileDomainRootTenant(tenant) { return false } if tenant.Type == domain.TenantTypeOrganization { return true } if tenant.Type == domain.TenantTypeUserGroup { return true } if tenant.Type == domain.TenantTypeCompany { return isWorksmobileBarongroupChildCompany(tenant, tenantByID) } return false } func isWorksmobileUserScopeTenant(tenant domain.Tenant) bool { return tenant.Type == domain.TenantTypeCompany || tenant.Type == domain.TenantTypeOrganization || tenant.Type == domain.TenantTypeUserGroup } func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant { current := tenant for { if isWorksmobileDomainRootTenant(current) { return current } parentID := worksmobileTenantParentID(current) if parentID == "" { return tenant } parent, ok := tenantByID[parentID] if !ok { return tenant } current = parent } } func isWorksmobileDomainRootTenant(tenant domain.Tenant) bool { slug := strings.ToLower(strings.TrimSpace(tenant.Slug)) switch slug { case "saman", "hanmac", "gpdtdc", "halla", "hanlla", "baron-group": return true } if tenantHasDomain(tenant, "samaneng.com") || tenantHasDomain(tenant, "hanmaceng.co.kr") || tenantHasDomain(tenant, "baroncs.co.kr") || tenantHasDomain(tenant, "hallasanup.com") || tenantHasDomain(tenant, "brsw.kr") { return true } name := strings.TrimSpace(tenant.Name) return name == "삼안" || name == "한맥기술" || name == "총괄기획&기술개발센터" || name == "한라산업개발" || name == "바론그룹" } func isWorksmobileBarongroupChildCompany(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool { if tenant.Type != domain.TenantTypeCompany || tenant.Slug == "baron-group" { return false } parentID := worksmobileTenantParentID(tenant) for parentID != "" { parent, ok := tenantByID[parentID] if !ok { return false } if parent.Slug == "baron-group" { return true } parentID = worksmobileTenantParentID(parent) } return false } func normalizeWorksmobileOrgUnitParent(payload WorksmobileOrgUnitPayload, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootID string) WorksmobileOrgUnitPayload { if tenant.ParentID != nil && *tenant.ParentID == rootID { payload.ParentOrgUnitID = "" } if tenant.ParentID != nil { if parent, ok := tenantByID[*tenant.ParentID]; ok { if parent.Slug == "baron-group" || !isWorksmobileOrgUnitTenant(parent, tenantByID) { payload.ParentOrgUnitID = "" } } } return payload } func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload, statuses ...string) domain.JSONMap { outboxPayload := domain.JSONMap{ "request": payload, "tenantRootId": rootID, "loginEmail": payload.Email, "initialPassword": payload.PasswordConfig.Password, } if len(statuses) > 0 { if status := strings.TrimSpace(statuses[0]); status != "" { outboxPayload["baronStatus"] = status } } return outboxPayload } func aggregateWorksmobileCredentialBatches(jobs []domain.WorksmobileOutbox) []WorksmobileCredentialBatch { byBatchID := map[string]*WorksmobileCredentialBatch{} for _, job := range jobs { batchID := stringValue(job.Payload["credentialBatchId"]) if batchID == "" { continue } batch, ok := byBatchID[batchID] if !ok { createdAt := worksmobileCredentialBatchCreatedAt(job) batch = &WorksmobileCredentialBatch{ BatchID: batchID, Operation: stringValue(job.Payload["credentialOperation"]), CreatedAt: createdAt, UpdatedAt: job.UpdatedAt, } byBatchID[batchID] = batch } batch.UserCount++ if batch.Operation == "" { batch.Operation = stringValue(job.Payload["credentialOperation"]) } jobBatchCreatedAt := worksmobileCredentialBatchCreatedAt(job) if jobBatchCreatedAt.Before(batch.CreatedAt) || batch.CreatedAt.IsZero() { batch.CreatedAt = jobBatchCreatedAt } if job.UpdatedAt.After(batch.UpdatedAt) { batch.UpdatedAt = job.UpdatedAt } switch job.Status { case domain.WorksmobileOutboxStatusPending: batch.PendingCount++ case domain.WorksmobileOutboxStatusProcessing: batch.ProcessingCount++ case domain.WorksmobileOutboxStatusProcessed: batch.ProcessedCount++ case domain.WorksmobileOutboxStatusFailed: batch.FailedCount++ batch.Failures = append(batch.Failures, WorksmobileCredentialBatchFailure{ UserID: job.ResourceID, Email: worksmobileCredentialJobEmail(job), Status: job.Status, RetryCount: job.RetryCount, LastError: strings.TrimSpace(job.LastError), UpdatedAt: job.UpdatedAt.Format(time.RFC3339), }) } if worksmobilePayloadHasPassword(job.Payload) { batch.HasPasswords = true } if deletedAt := stringValue(job.Payload["credentialDeletedAt"]); deletedAt != "" { batch.DeletedAt = deletedAt } } batches := make([]WorksmobileCredentialBatch, 0, len(byBatchID)) for _, batch := range byBatchID { batches = append(batches, *batch) } sort.Slice(batches, func(i, j int) bool { return batches[i].CreatedAt.After(batches[j].CreatedAt) }) return batches } func worksmobileCredentialBatchCreatedAt(job domain.WorksmobileOutbox) time.Time { if value := stringValue(job.Payload["credentialBatchCreatedAt"]); value != "" { if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil { return parsed.UTC() } if parsed, err := time.Parse(time.RFC3339, value); err == nil { return parsed.UTC() } } if !job.UpdatedAt.IsZero() && !job.CreatedAt.IsZero() && job.UpdatedAt.After(job.CreatedAt) { return job.UpdatedAt.UTC() } return job.CreatedAt.UTC() } func worksmobileCredentialJobEmail(job domain.WorksmobileOutbox) string { if email := stringValue(job.Payload["loginEmail"]); email != "" { return email } switch request := job.Payload["request"].(type) { case WorksmobileUserPayload: return strings.TrimSpace(request.Email) case WorksmobilePasswordResetPayload: return strings.TrimSpace(request.Email) case map[string]any: return stringValue(request["email"]) case domain.JSONMap: return stringValue(request["email"]) default: return "" } } func scrubWorksmobileCredentialPayload(payload domain.JSONMap, deletedAt string) domain.JSONMap { nextPayload := make(domain.JSONMap, len(payload)+1) for key, value := range payload { nextPayload[key] = value } delete(nextPayload, "initialPassword") nextPayload["credentialDeletedAt"] = deletedAt nextPayload["request"] = scrubWorksmobileRequestPassword(nextPayload["request"]) return nextPayload } func scrubWorksmobileRequestPassword(request any) any { switch v := request.(type) { case WorksmobileUserPayload: v.PasswordConfig.Password = "" return v case WorksmobilePasswordResetPayload: v.PasswordConfig.Password = "" return v case map[string]any: next := make(map[string]any, len(v)) for key, value := range v { next[key] = value } next["passwordConfig"] = scrubWorksmobilePasswordConfig(next["passwordConfig"]) return next case domain.JSONMap: next := make(domain.JSONMap, len(v)) for key, value := range v { next[key] = value } next["passwordConfig"] = scrubWorksmobilePasswordConfig(next["passwordConfig"]) return next default: return request } } func scrubWorksmobilePasswordConfig(config any) any { switch v := config.(type) { case WorksmobilePasswordConfig: v.Password = "" return v case map[string]any: next := make(map[string]any, len(v)) for key, value := range v { next[key] = value } next["password"] = "" return next case domain.JSONMap: next := make(domain.JSONMap, len(v)) for key, value := range v { next[key] = value } next["password"] = "" return next default: return config } } func worksmobilePayloadHasPassword(payload domain.JSONMap) bool { if stringValue(payload["initialPassword"]) != "" { return true } switch request := payload["request"].(type) { case WorksmobileUserPayload: return strings.TrimSpace(request.PasswordConfig.Password) != "" case WorksmobilePasswordResetPayload: return strings.TrimSpace(request.PasswordConfig.Password) != "" case map[string]any: return worksmobilePasswordConfigHasPassword(request["passwordConfig"]) case domain.JSONMap: return worksmobilePasswordConfigHasPassword(request["passwordConfig"]) default: return false } } func worksmobilePasswordConfigHasPassword(config any) bool { switch v := config.(type) { case WorksmobilePasswordConfig: return strings.TrimSpace(v.Password) != "" case map[string]any: return stringValue(v["password"]) != "" case domain.JSONMap: return stringValue(v["password"]) != "" default: return false } } func stringValue(value any) string { switch v := value.(type) { case string: return strings.TrimSpace(v) default: return "" } } type worksmobileUserJobSummary struct { Status string RetryCount int LastError string LastAttemptAt string } func worksmobileUserJobSummaries(jobs []domain.WorksmobileOutbox) map[string]worksmobileUserJobSummary { result := map[string]worksmobileUserJobSummary{} for _, job := range jobs { if job.ResourceType != domain.WorksmobileResourceUser { continue } if job.ResourceID == "" { continue } if _, exists := result[job.ResourceID]; exists { continue } result[job.ResourceID] = worksmobileUserJobSummary{ Status: job.Status, RetryCount: job.RetryCount, LastError: strings.TrimSpace(job.LastError), LastAttemptAt: job.UpdatedAt.Format(time.RFC3339), } } return result } func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem { jobSummaryByUserID := map[string]worksmobileUserJobSummary{} if len(jobSummaries) > 0 && jobSummaries[0] != nil { jobSummaryByUserID = jobSummaries[0] } remoteByExternalID := map[string]WorksmobileRemoteUser{} remoteByEmail := map[string]WorksmobileRemoteUser{} for _, remote := range remoteUsers { if remote.ExternalID != "" { remoteByExternalID[remote.ExternalID] = remote } if normalizedEmail := strings.ToLower(strings.TrimSpace(remote.Email)); normalizedEmail != "" { remoteByEmail[normalizedEmail] = remote } } localByID := map[string]domain.User{} matchedRemoteIDs := map[string]bool{} excludedLocalIDs := map[string]bool{} result := make([]WorksmobileComparisonItem, 0) for _, user := range localUsers { if !domain.IsWorksProvisionedUserStatus(user.Status) { excludedLocalIDs[user.ID] = true if remote, ok := remoteByExternalID[user.ID]; ok { matchedRemoteIDs[remote.ID] = true } else if remote, ok := remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]; ok { matchedRemoteIDs[remote.ID] = true } continue } localByID[user.ID] = user remote, matched := remoteByExternalID[user.ID] if !matched { remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))] } needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote, localTenants) if matched && !includeMatched && !needsUpdate { matchedRemoteIDs[remote.ID] = true continue } item := WorksmobileComparisonItem{ ResourceType: "USER", BaronID: user.ID, BaronName: user.Name, BaronEmail: user.Email, BaronPrimaryOrgID: worksmobileUserPrimaryOrgID(user), BaronPrimaryOrgSlug: worksmobileUserPrimaryOrgSlug(user, localTenants), BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants), Status: "missing_in_worksmobile", } if summary, ok := jobSummaryByUserID[user.ID]; ok { item.WorksmobileJobStatus = summary.Status item.WorksmobileJobRetryCount = summary.RetryCount item.WorksmobileLastAttemptAt = summary.LastAttemptAt if summary.Status == domain.WorksmobileOutboxStatusFailed { item.WorksmobileLastError = summary.LastError } } if matched { item.Status = "matched" if needsUpdate { item.Status = "needs_update" } item.WorksmobileID = remote.ID item.ExternalKey = remote.ExternalID item.WorksmobileName = remote.DisplayName item.WorksmobileEmail = remote.Email item.WorksmobileLevelID = remote.LevelID item.WorksmobileLevelName = remote.LevelName item.WorksmobileTask = remote.Task item.WorksmobileDomainID = remote.DomainID item.WorksmobileDomainName = remote.DomainName item.WorksmobilePrimaryOrgID = remote.PrimaryOrgUnitID item.WorksmobilePrimaryOrgName = remote.PrimaryOrgUnitName item.WorksmobilePrimaryOrgPositionID = remote.PrimaryOrgUnitPositionID item.WorksmobilePrimaryOrgPositionName = remote.PrimaryOrgUnitPositionName item.WorksmobilePrimaryOrgIsManager = remote.PrimaryOrgUnitIsManager matchedRemoteIDs[remote.ID] = true } result = append(result, item) } for _, remote := range remoteUsers { if matchedRemoteIDs[remote.ID] { continue } if excludedLocalIDs[remote.ExternalID] { continue } if remote.ExternalID == "" { result = append(result, WorksmobileComparisonItem{ ResourceType: "USER", WorksmobileID: remote.ID, ExternalKey: remote.ExternalID, WorksmobileName: remote.DisplayName, WorksmobileEmail: remote.Email, WorksmobileLevelID: remote.LevelID, WorksmobileLevelName: remote.LevelName, WorksmobileTask: remote.Task, WorksmobileDomainID: remote.DomainID, WorksmobileDomainName: remote.DomainName, WorksmobilePrimaryOrgID: remote.PrimaryOrgUnitID, WorksmobilePrimaryOrgName: remote.PrimaryOrgUnitName, WorksmobilePrimaryOrgPositionID: remote.PrimaryOrgUnitPositionID, WorksmobilePrimaryOrgPositionName: remote.PrimaryOrgUnitPositionName, WorksmobilePrimaryOrgIsManager: remote.PrimaryOrgUnitIsManager, Status: "missing_external_key", }) continue } if _, ok := localByID[remote.ExternalID]; !ok { result = append(result, WorksmobileComparisonItem{ ResourceType: "USER", WorksmobileID: remote.ID, ExternalKey: remote.ExternalID, WorksmobileName: remote.DisplayName, WorksmobileEmail: remote.Email, WorksmobileLevelID: remote.LevelID, WorksmobileLevelName: remote.LevelName, WorksmobileTask: remote.Task, WorksmobileDomainID: remote.DomainID, WorksmobileDomainName: remote.DomainName, WorksmobilePrimaryOrgID: remote.PrimaryOrgUnitID, WorksmobilePrimaryOrgName: remote.PrimaryOrgUnitName, WorksmobilePrimaryOrgPositionID: remote.PrimaryOrgUnitPositionID, WorksmobilePrimaryOrgPositionName: remote.PrimaryOrgUnitPositionName, WorksmobilePrimaryOrgIsManager: remote.PrimaryOrgUnitIsManager, Status: "missing_in_baron", }) } } return result } func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool { if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) { return true } if strings.TrimSpace(remote.DisplayName) != strings.TrimSpace(user.Name) { return true } if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) { return true } if worksmobileUserPhoneNeedsUpdate(user, remote) { return true } if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) { return true } if worksmobileUserOrganizationsNeedUpdate(user, remote, localTenants) { return true } if worksmobileUserManagerNeedsUpdate(user, remote) { return true } return false } func worksmobileUserPhoneNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool { localPhone := normalizeWorksmobilePhoneForCompare(user.Phone) remotePhone := normalizeWorksmobilePhoneForCompare(remote.CellPhone) if localPhone == "" && remotePhone == "" { return false } return localPhone != remotePhone } func normalizeWorksmobilePhoneForCompare(value string) string { normalized := strings.TrimSpace(value) normalized = strings.NewReplacer("-", "", " ", "", "(", "", ")", "").Replace(normalized) if normalized == "" { return "" } if strings.HasPrefix(normalized, "010") { return "+82" + normalized[1:] } if strings.HasPrefix(normalized, "82") { return "+" + normalized } return normalized } func worksmobileUserEmployeeNumberNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool { localEmployeeNumber := strings.TrimSpace(metadataEmployeeNumber(user.Metadata)) remoteEmployeeNumber := strings.TrimSpace(remote.EmployeeNumber) if localEmployeeNumber == "" && remoteEmployeeNumber == "" { return false } return localEmployeeNumber != remoteEmployeeNumber } func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant) bool { if len(remote.Organizations) == 0 || user.TenantID == nil || localTenants == nil { return false } tenantID := strings.TrimSpace(*user.TenantID) if tenantID == "" { return false } tenant, ok := localTenants[tenantID] if !ok { return false } expected, err := BuildWorksmobileUserPayloadForDomainTenants(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants)) if err != nil { return false } return !worksmobileUserOrganizationsEqual(expected.Organizations, remote.Organizations) } func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) domain.JSONMap { for _, tenant := range localTenants { if strings.TrimSpace(tenant.Slug) == HanmacFamilyTenantSlug { return tenant.Config } } return nil } type worksmobileComparableOrgUnit struct { organizationPrimary bool organizationEmail string unitPrimary bool positionID string comparePosition bool manager *bool compareManager bool } func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, remote []WorksmobileUserOrganization) bool { expectedUnits := flattenExpectedWorksmobileUserOrganizations(expected) remoteUnits := flattenRemoteWorksmobileUserOrganizations(remote) if len(expectedUnits) != len(remoteUnits) { return false } for key, expectedUnit := range expectedUnits { remoteUnit, ok := remoteUnits[key] if !ok { return false } if expectedUnit.organizationPrimary != remoteUnit.organizationPrimary { return false } if strings.ToLower(expectedUnit.organizationEmail) != strings.ToLower(remoteUnit.organizationEmail) { return false } if expectedUnit.unitPrimary != remoteUnit.unitPrimary { return false } if expectedUnit.comparePosition && strings.TrimSpace(expectedUnit.positionID) != strings.TrimSpace(remoteUnit.positionID) { return false } if expectedUnit.compareManager && !worksmobileBoolPointersEqual(expectedUnit.manager, remoteUnit.manager) { return false } } return true } func flattenExpectedWorksmobileUserOrganizations(organizations []WorksmobileUserOrganization) map[string]worksmobileComparableOrgUnit { result := map[string]worksmobileComparableOrgUnit{} for _, organization := range organizations { for _, orgUnit := range organization.OrgUnits { key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID) if key == "" { continue } result[key] = worksmobileComparableOrgUnit{ organizationPrimary: organization.Primary, organizationEmail: strings.TrimSpace(organization.Email), unitPrimary: orgUnit.Primary, positionID: strings.TrimSpace(orgUnit.PositionID), comparePosition: strings.TrimSpace(orgUnit.PositionID) != "", manager: orgUnit.IsManager, compareManager: orgUnit.IsManager != nil, } } } return result } func flattenRemoteWorksmobileUserOrganizations(organizations []WorksmobileUserOrganization) map[string]worksmobileComparableOrgUnit { result := map[string]worksmobileComparableOrgUnit{} for _, organization := range organizations { for _, orgUnit := range organization.OrgUnits { key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID) if key == "" { continue } result[key] = worksmobileComparableOrgUnit{ organizationPrimary: organization.Primary, organizationEmail: strings.TrimSpace(organization.Email), unitPrimary: orgUnit.Primary, positionID: strings.TrimSpace(orgUnit.PositionID), manager: orgUnit.IsManager, } } } return result } func worksmobileComparableOrgUnitKey(domainID int64, orgUnitID string) string { orgUnitID = strings.TrimSpace(orgUnitID) if domainID == 0 || orgUnitID == "" { return "" } return fmt.Sprintf("%d:%s", domainID, orgUnitID) } func worksmobileBoolPointersEqual(left, right *bool) bool { if left == nil || right == nil { return left == nil && right == nil } return *left == *right } func worksmobileUserManagerNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool { localManagers := worksmobileUserExplicitOrgUnitManagers(user) if len(localManagers) == 0 { return false } remoteManagers := remote.OrgUnitManagers if len(remoteManagers) == 0 && remote.PrimaryOrgUnitID != "" { remoteManagers = map[string]*bool{remote.PrimaryOrgUnitID: remote.PrimaryOrgUnitIsManager} } for remoteOrgUnitID, remoteManager := range remoteManagers { if remoteManager == nil { continue } localManager, ok := localManagers[worksmobileOrgUnitLocalExternalKey(remoteOrgUnitID)] if ok && localManager != *remoteManager { return true } } return false } func worksmobileUserExplicitOrgUnitManagers(user domain.User) map[string]bool { managers := map[string]bool{} for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) { if appointment.TenantID == "" || !appointment.HasManager { continue } managers[appointment.TenantID] = appointment.IsManager } return managers } func worksmobileOrgUnitLocalExternalKey(orgUnitID string) string { normalized := strings.TrimSpace(orgUnitID) if after, ok := strings.CutPrefix(normalized, "externalKey:"); ok { return strings.TrimSpace(after) } return normalized } func worksmobileUserPrimaryOrgID(user domain.User) string { if user.TenantID == nil { return "" } return strings.TrimSpace(*user.TenantID) } func worksmobileUserPrimaryOrgName(user domain.User, localTenants map[string]domain.Tenant) string { tenantID := worksmobileUserPrimaryOrgID(user) if tenantID == "" { return "" } if tenant, ok := localTenants[tenantID]; ok { return strings.TrimSpace(tenant.Name) } if user.Tenant != nil && user.Tenant.ID == tenantID { return strings.TrimSpace(user.Tenant.Name) } return "" } func worksmobileUserPrimaryOrgSlug(user domain.User, localTenants map[string]domain.Tenant) string { tenantID := worksmobileUserPrimaryOrgID(user) if tenantID == "" { return "" } if tenant, ok := localTenants[tenantID]; ok { return strings.TrimSpace(tenant.Slug) } if user.Tenant != nil && user.Tenant.ID == tenantID { return strings.TrimSpace(user.Tenant.Slug) } return "" } func compareWorksmobileGroups(localTenants []domain.Tenant, remoteGroups []WorksmobileRemoteGroup, includeMatched bool) []WorksmobileComparisonItem { remoteByExternalID := map[string][]WorksmobileRemoteGroup{} remoteByID := map[string]WorksmobileRemoteGroup{} for _, remote := range remoteGroups { if remote.ID != "" { remoteByID[remote.ID] = remote } if remote.ExternalID != "" { remoteByExternalID[remote.ExternalID] = append(remoteByExternalID[remote.ExternalID], remote) } } tenantByID := worksmobileTenantByID(localTenants) localByID := map[string]domain.Tenant{} ignoredLocalByID := map[string]bool{} matchedRemoteIDs := map[string]bool{} result := make([]WorksmobileComparisonItem, 0) for _, tenant := range localTenants { if !isWorksmobileOrgUnitTenant(tenant, tenantByID) { ignoredLocalByID[tenant.ID] = true continue } localByID[tenant.ID] = tenant remote, matched := matchingWorksmobileRemoteGroupForTenant(tenant, remoteByExternalID[tenant.ID], tenantByID) item := WorksmobileComparisonItem{ ResourceType: "GROUP", BaronID: tenant.ID, BaronSlug: tenant.Slug, BaronName: tenant.Name, BaronParentID: worksmobileTenantParentID(tenant), BaronParentSlug: worksmobileTenantParentSlug(tenant, tenantByID), BaronParentName: worksmobileTenantParentName(tenant, tenantByID), Status: "missing_in_worksmobile", } if matched { item.Status = "matched" item.WorksmobileID = remote.ID item.ExternalKey = remote.ExternalID item.WorksmobileName = remote.DisplayName item.WorksmobileEmail = remote.Email item.WorksmobileDomainID = remote.DomainID item.WorksmobileDomainName = remote.DomainName item.WorksmobileParentID = remote.ParentID item.WorksmobileParentName = remote.ParentName if parent, ok := tenantByID[item.BaronParentID]; ok { if parentRemote, ok := matchingWorksmobileRemoteGroupForTenant(parent, remoteByExternalID[item.BaronParentID], tenantByID); ok { item.BaronParentWorksmobileID = parentRemote.ID item.BaronParentWorksmobileName = parentRemote.DisplayName item.BaronParentWorksmobileEmail = parentRemote.Email } } else if parentRemote, ok := firstWorksmobileRemoteGroup(remoteByExternalID[item.BaronParentID]); ok { item.BaronParentWorksmobileID = parentRemote.ID item.BaronParentWorksmobileName = parentRemote.DisplayName item.BaronParentWorksmobileEmail = parentRemote.Email } if parentRemote, ok := remoteByID[remote.ParentID]; ok { if item.WorksmobileParentName == "" { item.WorksmobileParentName = parentRemote.DisplayName } item.WorksmobileParentEmail = parentRemote.Email item.WorksmobileParentExternalKey = parentRemote.ExternalID } item = fillWorksmobileParentFromBaronParentMatch(item) if worksmobileGroupNeedsUpdate(tenant, remote, remoteByID, remoteByExternalID, tenantByID) { item.Status = "needs_update" } matchedRemoteIDs[remote.ID] = true } if matched && item.Status == "matched" && !includeMatched { continue } result = append(result, item) } for _, remote := range remoteGroups { if matchedRemoteIDs[remote.ID] { continue } if remote.ExternalID == "" { result = append(result, WorksmobileComparisonItem{ ResourceType: "GROUP", WorksmobileID: remote.ID, ExternalKey: remote.ExternalID, WorksmobileName: remote.DisplayName, WorksmobileEmail: remote.Email, WorksmobileDomainID: remote.DomainID, WorksmobileDomainName: remote.DomainName, WorksmobileParentID: remote.ParentID, WorksmobileParentName: remote.ParentName, Status: "missing_external_key", }) if parentRemote, ok := remoteByID[remote.ParentID]; ok { last := &result[len(result)-1] if last.WorksmobileParentName == "" { last.WorksmobileParentName = parentRemote.DisplayName } last.WorksmobileParentEmail = parentRemote.Email last.WorksmobileParentExternalKey = parentRemote.ExternalID } continue } if ignoredLocalByID[remote.ExternalID] { continue } if _, ok := localByID[remote.ExternalID]; !ok { result = append(result, WorksmobileComparisonItem{ ResourceType: "GROUP", WorksmobileID: remote.ID, ExternalKey: remote.ExternalID, WorksmobileName: remote.DisplayName, WorksmobileEmail: remote.Email, WorksmobileDomainID: remote.DomainID, WorksmobileDomainName: remote.DomainName, WorksmobileParentID: remote.ParentID, WorksmobileParentName: remote.ParentName, Status: "missing_in_baron", }) if parentRemote, ok := remoteByID[remote.ParentID]; ok { last := &result[len(result)-1] if last.WorksmobileParentName == "" { last.WorksmobileParentName = parentRemote.DisplayName } last.WorksmobileParentEmail = parentRemote.Email last.WorksmobileParentExternalKey = parentRemote.ExternalID } } } return result } func matchingWorksmobileRemoteGroupForTenant(tenant domain.Tenant, remotes []WorksmobileRemoteGroup, tenantByID map[string]domain.Tenant) (WorksmobileRemoteGroup, bool) { if len(remotes) == 0 { return WorksmobileRemoteGroup{}, false } expectedDomainID, hasExpectedDomainID := expectedWorksmobileDomainIDForTenant(tenant, tenantByID) if !hasExpectedDomainID { return remotes[0], true } var unknownDomain WorksmobileRemoteGroup hasUnknownDomain := false for i := range remotes { remote := remotes[i] if remote.DomainID == expectedDomainID { return remote, true } if remote.DomainID == 0 && !hasUnknownDomain { unknownDomain = remote hasUnknownDomain = true } } if hasUnknownDomain { return unknownDomain, true } return WorksmobileRemoteGroup{}, false } func firstWorksmobileRemoteGroup(remotes []WorksmobileRemoteGroup) (WorksmobileRemoteGroup, bool) { if len(remotes) == 0 { return WorksmobileRemoteGroup{}, false } return remotes[0], true } func expectedWorksmobileDomainIDForTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) (int64, bool) { domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID) domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, nil) if err != nil || domainID <= 0 { return 0, false } return domainID, true } func worksmobileGroupNeedsUpdate(tenant domain.Tenant, remote WorksmobileRemoteGroup, remoteByID map[string]WorksmobileRemoteGroup, remoteByExternalID map[string][]WorksmobileRemoteGroup, tenantByID map[string]domain.Tenant) bool { if strings.TrimSpace(tenant.Name) != strings.TrimSpace(remote.DisplayName) { return true } expectedParentExternalKey := expectedWorksmobileParentExternalKey(tenant, remoteByExternalID, tenantByID) actualParentExternalKey := "" if remote.ParentID != "" { actualParentExternalKey = strings.TrimSpace(remoteByID[remote.ParentID].ExternalID) } return expectedParentExternalKey != actualParentExternalKey } func expectedWorksmobileParentExternalKey(tenant domain.Tenant, remoteByExternalID map[string][]WorksmobileRemoteGroup, tenantByID map[string]domain.Tenant) string { parentID := worksmobileTenantParentID(tenant) if parentID == "" { return "" } if parent, ok := tenantByID[parentID]; ok && parent.Slug == "baron-group" { return "" } parent, ok := tenantByID[parentID] if !ok { return "" } if _, ok := matchingWorksmobileRemoteGroupForTenant(parent, remoteByExternalID[parentID], tenantByID); !ok { return "" } return parentID } func fillWorksmobileParentFromBaronParentMatch(item WorksmobileComparisonItem) WorksmobileComparisonItem { if item.WorksmobileParentID == "" || item.WorksmobileParentID != item.BaronParentWorksmobileID { return item } if item.WorksmobileParentName == "" { item.WorksmobileParentName = item.BaronParentWorksmobileName } if item.WorksmobileParentEmail == "" { item.WorksmobileParentEmail = item.BaronParentWorksmobileEmail } if item.WorksmobileParentExternalKey == "" { item.WorksmobileParentExternalKey = item.BaronParentID } return item } func worksmobileTenantByID(tenants []domain.Tenant) map[string]domain.Tenant { result := make(map[string]domain.Tenant, len(tenants)) for _, tenant := range tenants { result[tenant.ID] = tenant } return result } func worksmobileTenantParentID(tenant domain.Tenant) string { if tenant.ParentID == nil { return "" } return strings.TrimSpace(*tenant.ParentID) } func worksmobileTenantParentName(tenant domain.Tenant, tenantByID map[string]domain.Tenant) string { parentID := worksmobileTenantParentID(tenant) if parentID == "" { return "" } return strings.TrimSpace(tenantByID[parentID].Name) } func worksmobileTenantParentSlug(tenant domain.Tenant, tenantByID map[string]domain.Tenant) string { parentID := worksmobileTenantParentID(tenant) if parentID == "" { return "" } return strings.TrimSpace(tenantByID[parentID].Slug) } func worksmobileSyncScopeUsers(users []domain.User) []domain.User { if len(users) == 0 { return users } filtered := make([]domain.User, 0, len(users)) for _, user := range users { if !domain.IsWorksProvisionedUserStatus(user.Status) { continue } filtered = append(filtered, user) } return filtered }