1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/service/worksmobile_sync_service.go
2026-06-22 17:56:20 +09:00

3286 lines
115 KiB
Go

package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"errors"
"fmt"
"net/mail"
"os"
"sort"
"strings"
"time"
"github.com/google/uuid"
)
const (
HanmacFamilyTenantSlug = "hanmac-family"
worksmobileExcludedConfigKey = "worksmobileExcluded"
worksmobileIdentityMirrorVersion = "kratos-full-pagination-v1"
worksmobileProvisioningModeKey = "provisioningMode"
worksmobileProvisioningUpdateOnly = "update_only"
)
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
EnqueueUserUpdateIfInScope(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)
ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (WorksmobileImportUsersResult, 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, initialPassword 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 WorksmobileImportUsersResult struct {
UpdatedCount int `json:"updatedCount"`
CreatedCount int `json:"createdCount"`
ExternalKeyUpdates int `json:"externalKeyUpdates"`
Failures []WorksmobileImportUsersFailure `json:"failures,omitempty"`
Items []WorksmobileImportUsersResultItem `json:"items,omitempty"`
}
type WorksmobileImportUsersFailure struct {
WorksmobileID string `json:"worksmobileId,omitempty"`
Email string `json:"email,omitempty"`
Error string `json:"error"`
}
type WorksmobileImportUsersResultItem struct {
WorksmobileID string `json:"worksmobileId,omitempty"`
BaronID string `json:"baronId,omitempty"`
Email string `json:"email,omitempty"`
Action string `json:"action"`
}
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"`
BaronPhone string `json:"baronPhone,omitempty"`
BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
BaronGrade string `json:"baronGrade,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"`
WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
WorksmobileAccountStatus string `json:"worksmobileAccountStatus,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"`
UserMemberships []WorksmobileUserMembershipComparison `json:"userMemberships,omitempty"`
UpdateReasons []string `json:"updateReasons,omitempty"`
Status string `json:"status"`
}
type WorksmobileUserMembershipComparison struct {
BaronOrgID string `json:"baronOrgId,omitempty"`
BaronOrgSlug string `json:"baronOrgSlug,omitempty"`
BaronOrgName string `json:"baronOrgName,omitempty"`
BaronGrade string `json:"baronGrade,omitempty"`
BaronPrimary bool `json:"baronPrimary,omitempty"`
WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
WorksmobileOrgID string `json:"worksmobileOrgId,omitempty"`
WorksmobileOrgName string `json:"worksmobileOrgName,omitempty"`
WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
WorksmobileOrgPositionID string `json:"worksmobileOrgPositionId,omitempty"`
WorksmobileOrgIsManager *bool `json:"worksmobileOrgIsManager,omitempty"`
WorksmobilePrimary bool `json:"worksmobilePrimary,omitempty"`
GradeNeedsUpdate bool `json:"gradeNeedsUpdate,omitempty"`
}
type worksmobileSyncService struct {
tenantService TenantService
userRepo repository.UserRepository
outboxRepo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient
identityMirror WorksmobileIdentityMirror
identityWriter IdentityWriteService
kratos KratosAdminService
}
type WorksmobileIdentityMirror interface {
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
ListIdentityMirrors(ctx context.Context) ([]KratosIdentity, error)
}
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) SetIdentityMirror(source WorksmobileIdentityMirror) {
if s == nil {
return
}
s.identityMirror = source
}
func (s *worksmobileSyncService) SetIdentityServices(writer IdentityWriteService, kratos KratosAdminService) {
if s == nil {
return
}
s.identityWriter = writer
s.kratos = kratos
}
func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return WorksmobileTenantOverview{}, err
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return WorksmobileTenantOverview{}, err
}
jobs, _ := s.outboxRepo.ListRecentByTenantRoot(ctx, root.ID, worksmobileRecentResourceIDs(root.ID, scopeTenants), 50)
jobs = redactWorksmobileOutboxPayloads(jobs)
return WorksmobileTenantOverview{
Tenant: *root,
Config: WorksmobileConfigSummary{
Enabled: WorksmobileEnabled(root.Config),
DomainMappings: WorksmobileDomainMappings(root.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 worksmobileRecentResourceIDs(rootID string, tenants []domain.Tenant) []string {
ids := make([]string, 0, len(tenants)+1)
ids = append(ids, rootID)
for _, tenant := range tenants {
ids = append(ids, tenant.ID)
}
return ids
}
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.comparisonUsers(ctx, tenantIDs, tenantByID)
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: compareWorksmobileUsersWithRemoteGroups(users, remoteUsers, includeMatched, tenantByID, remoteGroups, worksmobileUserJobSummaries(recentJobs)),
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
}, nil
}
func (s *worksmobileSyncService) ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (WorksmobileImportUsersResult, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
if s.client == nil {
return WorksmobileImportUsersResult{}, errors.New("worksmobile client is not configured")
}
if len(worksmobileUserIDs) == 0 {
return WorksmobileImportUsersResult{}, errors.New("worksmobile user ids are required")
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
remoteUsers, err := s.client.ListUsers(ctx)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
remoteGroups, err := s.client.ListGroups(ctx)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
remoteByID := make(map[string]WorksmobileRemoteUser, len(remoteUsers))
for _, remote := range remoteUsers {
if id := strings.TrimSpace(remote.ID); id != "" {
remoteByID[id] = remote
}
}
groupByID := make(map[string]WorksmobileRemoteGroup, len(remoteGroups))
for _, group := range remoteGroups {
if id := strings.TrimSpace(group.ID); id != "" {
groupByID[id] = group
}
}
result := WorksmobileImportUsersResult{}
seen := map[string]bool{}
for _, rawID := range worksmobileUserIDs {
worksmobileID := strings.TrimSpace(rawID)
if worksmobileID == "" || seen[worksmobileID] {
continue
}
seen[worksmobileID] = true
remote, ok := remoteByID[worksmobileID]
if !ok {
result.Failures = append(result.Failures, WorksmobileImportUsersFailure{WorksmobileID: worksmobileID, Error: "worksmobile user not found"})
continue
}
user, created, externalKeyUpdated, err := s.importSingleWorksmobileUser(ctx, root.ID, remote, tenantByID, groupByID)
if err != nil {
result.Failures = append(result.Failures, WorksmobileImportUsersFailure{WorksmobileID: worksmobileID, Email: remote.Email, Error: err.Error()})
continue
}
action := "updated"
if created {
action = "created"
result.CreatedCount++
} else {
result.UpdatedCount++
}
if externalKeyUpdated {
result.ExternalKeyUpdates++
}
result.Items = append(result.Items, WorksmobileImportUsersResultItem{
WorksmobileID: worksmobileID,
BaronID: user.ID,
Email: user.Email,
Action: action,
})
}
return result, nil
}
func (s *worksmobileSyncService) importSingleWorksmobileUser(ctx context.Context, rootID string, remote WorksmobileRemoteUser, tenantByID map[string]domain.Tenant, groupByID map[string]WorksmobileRemoteGroup) (domain.User, bool, bool, error) {
email := strings.ToLower(strings.TrimSpace(remote.Email))
if email == "" {
return domain.User{}, false, false, errors.New("worksmobile user email is required")
}
tenantID := worksmobileTenantIDForRemoteUser(remote, groupByID)
tenant, ok := tenantByID[tenantID]
if !ok || !isWorksmobileUserScopeTenant(tenant) {
return domain.User{}, false, false, fmt.Errorf("worksmobile primary org is outside import scope: %s", tenantID)
}
var existing *domain.User
if externalKey := strings.TrimSpace(remote.ExternalID); externalKey != "" {
if user, err := s.userRepo.FindByID(ctx, externalKey); err == nil {
existing = user
} else {
return domain.User{}, false, false, fmt.Errorf("worksmobile external key does not match a Baron user: %s", externalKey)
}
} else if user, err := s.userRepo.FindByEmail(ctx, email); err == nil {
existing = user
}
if existing != nil {
user := *existing
applyWorksmobileRemoteToUser(&user, remote, tenant.ID)
if err := s.updateImportedWorksmobileUserIdentity(ctx, user); err != nil {
return domain.User{}, false, false, err
}
if err := s.userRepo.Update(ctx, &user); err != nil {
return domain.User{}, false, false, err
}
updatedExternalKey := false
if strings.TrimSpace(remote.ExternalID) == "" {
if err := s.patchWorksmobileUserExternalKey(ctx, remote, user.ID); err != nil {
return domain.User{}, false, false, err
}
updatedExternalKey = true
}
return user, false, updatedExternalKey, nil
}
if strings.TrimSpace(remote.ExternalID) != "" {
return domain.User{}, false, false, errors.New("creating Baron user from non-empty unmatched worksmobile external key is not supported")
}
if s.kratos == nil {
return domain.User{}, false, false, errors.New("kratos admin service is required")
}
identityID, err := s.kratos.CreateUser(ctx, &domain.BrokerUser{
Email: email,
Name: strings.TrimSpace(remote.DisplayName),
PhoneNumber: strings.TrimSpace(remote.CellPhone),
Attributes: map[string]any{
"tenant_id": tenant.ID,
"role": domain.RoleUser,
"status": domain.UserStatusActive,
"grade": strings.TrimSpace(remote.LevelName),
"jobTitle": strings.TrimSpace(remote.Task),
},
}, GenerateWorksmobileInitialPassword())
if err != nil {
return domain.User{}, false, false, err
}
now := time.Now().UTC()
user := domain.User{
ID: identityID,
Email: email,
Name: strings.TrimSpace(remote.DisplayName),
Phone: strings.TrimSpace(remote.CellPhone),
Role: domain.RoleUser,
Status: domain.UserStatusActive,
TenantID: &tenant.ID,
Grade: strings.TrimSpace(remote.LevelName),
JobTitle: strings.TrimSpace(remote.Task),
Metadata: worksmobileImportedUserMetadata(remote, tenant),
CreatedAt: now,
UpdatedAt: now,
}
if err := s.userRepo.Update(ctx, &user); err != nil {
return domain.User{}, false, false, err
}
if err := s.patchWorksmobileUserExternalKey(ctx, remote, user.ID); err != nil {
return domain.User{}, false, false, err
}
return user, true, true, nil
}
func (s *worksmobileSyncService) updateImportedWorksmobileUserIdentity(ctx context.Context, user domain.User) error {
if s.identityWriter == nil {
return nil
}
identity, err := s.identityWriter.GetIdentity(ctx, user.ID)
if err != nil {
return err
}
traits := map[string]any{}
for key, value := range identity.Traits {
traits[key] = value
}
traits["email"] = user.Email
traits["name"] = user.Name
if phone := strings.TrimSpace(user.Phone); phone != "" {
traits["phone_number"] = phone
}
traits["tenant_id"] = strings.TrimSpace(stringPtrValue(user.TenantID))
traits["role"] = user.Role
traits["status"] = user.Status
traits["grade"] = user.Grade
traits["jobTitle"] = user.JobTitle
_, err = s.identityWriter.UpdateIdentity(ctx, IdentityUpdateRequest{
IdentityID: user.ID,
Traits: traits,
State: strings.TrimSpace(identity.State),
Reason: "worksmobile_import_from_works",
Source: "admin_worksmobile",
})
return err
}
func (s *worksmobileSyncService) patchWorksmobileUserExternalKey(ctx context.Context, remote WorksmobileRemoteUser, userID string) error {
return s.client.UpdateUserOnly(ctx, WorksmobileUserPayload{
DomainID: remote.DomainID,
Email: strings.TrimSpace(remote.Email),
UserExternalKey: strings.TrimSpace(userID),
CellPhone: strings.TrimSpace(remote.CellPhone),
EmployeeNumber: strings.TrimSpace(remote.EmployeeNumber),
Locale: "ko_KR",
Task: strings.TrimSpace(remote.Task),
})
}
func applyWorksmobileRemoteToUser(user *domain.User, remote WorksmobileRemoteUser, tenantID string) {
now := time.Now().UTC()
user.Email = strings.ToLower(strings.TrimSpace(remote.Email))
user.Name = strings.TrimSpace(remote.DisplayName)
user.Phone = strings.TrimSpace(remote.CellPhone)
user.Role = domain.NormalizeRole(user.Role)
user.Status = domain.UserStatusActive
user.TenantID = &tenantID
user.Grade = strings.TrimSpace(remote.LevelName)
user.JobTitle = strings.TrimSpace(remote.Task)
user.Metadata = mergeWorksmobileImportedUserMetadata(user.Metadata, remote, tenantID)
user.UpdatedAt = now
}
func worksmobileImportedUserMetadata(remote WorksmobileRemoteUser, tenant domain.Tenant) domain.JSONMap {
return mergeWorksmobileImportedUserMetadata(domain.JSONMap{}, remote, tenant.ID)
}
func mergeWorksmobileImportedUserMetadata(metadata domain.JSONMap, remote WorksmobileRemoteUser, tenantID string) domain.JSONMap {
if metadata == nil {
metadata = domain.JSONMap{}
}
if value := strings.TrimSpace(remote.EmployeeNumber); value != "" {
metadata["employeeNumber"] = value
metadata["employee_id"] = value
}
if value := strings.TrimSpace(remote.LevelName); value != "" {
metadata["grade"] = value
}
if value := strings.TrimSpace(remote.PrimaryOrgUnitName); value != "" {
metadata["department"] = value
}
metadata["worksmobileImportedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
metadata["worksmobileId"] = strings.TrimSpace(remote.ID)
metadata["worksmobileDomainId"] = remote.DomainID
metadata["worksmobilePrimaryOrgUnitId"] = strings.TrimSpace(remote.PrimaryOrgUnitID)
metadata["additionalAppointments"] = []domain.JSONMap{{
"tenantId": tenantID,
"isPrimary": true,
"grade": strings.TrimSpace(remote.LevelName),
}}
return metadata
}
func worksmobileTenantIDForRemoteUser(remote WorksmobileRemoteUser, groupByID map[string]WorksmobileRemoteGroup) string {
primaryOrgUnitID := strings.TrimSpace(remote.PrimaryOrgUnitID)
if tenantID, ok := strings.CutPrefix(primaryOrgUnitID, "externalKey:"); ok {
return strings.TrimSpace(tenantID)
}
if group, ok := groupByID[primaryOrgUnitID]; ok {
return strings.TrimSpace(group.ExternalID)
}
return ""
}
func stringPtrValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string, tenantByID map[string]domain.Tenant) ([]domain.User, error) {
if s.identityMirror != nil {
status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
if err == nil &&
status.RedisReady &&
status.Status == "ready" &&
status.MirrorVersion == worksmobileIdentityMirrorVersion {
identities, err := s.identityMirror.ListIdentityMirrors(ctx)
if err != nil {
return nil, err
}
return worksmobileUsersFromIdentityMirror(identities, tenantIDs, tenantByID), nil
}
}
return s.userRepo.FindByTenantIDs(ctx, tenantIDs)
}
func worksmobileUsersFromIdentityMirror(identities []KratosIdentity, tenantIDs []string, tenantMaps ...map[string]domain.Tenant) []domain.User {
allowed := make(map[string]bool, len(tenantIDs))
for _, tenantID := range tenantIDs {
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" {
continue
}
allowed[strings.ToLower(tenantID)] = true
if len(tenantMaps) > 0 {
if tenant, ok := tenantMaps[0][tenantID]; ok {
if slug := strings.TrimSpace(tenant.Slug); slug != "" {
allowed[strings.ToLower(slug)] = true
}
}
}
}
users := make([]domain.User, 0, len(identities))
for _, identity := range identities {
if !worksmobileIdentityMirrorMatchesTenant(identity.Traits, allowed) {
continue
}
user := worksmobileUserFromIdentity(identity)
if user.TenantID == nil || strings.TrimSpace(*user.TenantID) == "" {
if tenantID := worksmobileIdentityMirrorTenantID(identity.Traits, allowed); tenantID != "" {
user.TenantID = &tenantID
}
}
users = append(users, user)
}
return users
}
func worksmobileIdentityMirrorMatchesTenant(traits map[string]any, allowed map[string]bool) bool {
for _, key := range identityMirrorTenantKeys(traits) {
if allowed[strings.ToLower(strings.TrimSpace(key))] {
return true
}
}
return false
}
func worksmobileIdentityMirrorTenantID(traits map[string]any, allowed map[string]bool) string {
appointments := identityMirrorAppointments(traits["additionalAppointments"])
if len(appointments) == 0 {
if metadata, ok := traits["metadata"].(map[string]any); ok {
appointments = identityMirrorAppointments(metadata["additionalAppointments"])
}
}
for _, appointment := range appointments {
if !metadataBool(domain.JSONMap(appointment), "isPrimary", "primary") {
continue
}
if tenantID := worksmobileIdentityMirrorAllowedAppointmentTenantID(appointment, allowed); tenantID != "" {
return tenantID
}
}
for _, appointment := range appointments {
if tenantID := worksmobileIdentityMirrorAllowedAppointmentTenantID(appointment, allowed); tenantID != "" {
return tenantID
}
}
return ""
}
func worksmobileIdentityMirrorAllowedAppointmentTenantID(appointment map[string]any, allowed map[string]bool) string {
tenantID := strings.TrimSpace(identityMirrorAnyString(appointment["tenantId"]))
if tenantID == "" {
tenantID = strings.TrimSpace(identityMirrorAnyString(appointment["tenant_id"]))
}
if tenantID != "" && allowed[strings.ToLower(tenantID)] {
return tenantID
}
tenantSlug := strings.TrimSpace(identityMirrorAnyString(appointment["tenantSlug"]))
if tenantSlug == "" {
tenantSlug = strings.TrimSpace(identityMirrorAnyString(appointment["slug"]))
}
if tenantSlug != "" && allowed[strings.ToLower(tenantSlug)] {
return tenantID
}
return ""
}
func worksmobileUserFromIdentity(identity KratosIdentity) domain.User {
metadata := domain.JSONMap{}
for key, value := range identity.Traits {
if key == "grade" {
continue
}
metadata[key] = value
}
tenantID := traitString(identity.Traits, "tenant_id")
status := domain.UserStatusActive
if identity.State == "inactive" {
status = domain.UserStatusArchived
}
if traitStatus := traitString(identity.Traits, "status"); traitStatus != "" {
status = domain.NormalizeUserStatus(traitStatus)
}
user := domain.User{
ID: strings.TrimSpace(identity.ID),
Email: traitString(identity.Traits, "email"),
Name: traitString(identity.Traits, "name"),
Phone: traitString(identity.Traits, "phone_number"),
Role: domain.NormalizeRole(traitString(identity.Traits, "role")),
AffiliationType: traitString(identity.Traits, "affiliationType"),
Department: traitString(identity.Traits, "department"),
Position: traitString(identity.Traits, "position"),
JobTitle: traitString(identity.Traits, "jobTitle"),
Metadata: metadata,
Status: status,
CreatedAt: identity.CreatedAt,
UpdatedAt: identity.UpdatedAt,
}
if tenantID != "" {
user.TenantID = &tenantID
}
return user
}
func traitString(traits map[string]any, key string) string {
if traits == nil {
return ""
}
value, ok := traits[key]
if !ok || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
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{
"tenantRootId": root.ID,
"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 {
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
if !ok || tenantRoot.ID != root.ID {
err := errors.New("target orgunit is outside hanmac-family subtree")
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
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 {
err := errors.New("target tenant is excluded from Worksmobile sync")
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
err := errors.New("target tenant is not a worksmobile orgunit tenant")
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
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 {
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
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{
"tenantRootId": root.ID,
"request": payload,
"matchLocalPart": tenant.Slug,
},
}
if err := s.outboxRepo.Create(ctx, item); err != nil {
return nil, err
}
return item, nil
}
func (s *worksmobileSyncService) recordRejectedOrgUnitSync(ctx context.Context, rootID string, tenant domain.Tenant, reason error) error {
if s.outboxRepo == nil {
return nil
}
payload := domain.JSONMap{
"tenantRootId": rootID,
"displayName": strings.TrimSpace(tenant.Name),
"matchLocalPart": strings.TrimSpace(tenant.Slug),
"tenantSlug": strings.TrimSpace(tenant.Slug),
"requestSummary": domain.JSONMap{
"orgUnitName": strings.TrimSpace(tenant.Name),
"orgUnitExternalKey": tenant.ID,
"tenantSlug": strings.TrimSpace(tenant.Slug),
},
}
if tenant.ParentID != nil {
payload["parentTenantId"] = strings.TrimSpace(*tenant.ParentID)
}
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: tenant.ID,
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:rejected:" + tenant.ID + ":" + uuid.NewString(),
Payload: payload,
Status: domain.WorksmobileOutboxStatusFailed,
LastError: reason.Error(),
}
return s.outboxRepo.Create(ctx, item)
}
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...))
_, matchedLocalPart := findWorksmobileOrgUnitTenantByRemoteLocalPart(*target, scopeTenants, tenantByID)
if !matchedLocalPart && 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{
"tenantRootId": root.ID,
"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, initialPassword 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...))
_, tenantInScope := tenantByID[tenant.ID]
if domain.IsWorksDeprovisionUserStatus(user.Status) {
return s.enqueueUserDelete(ctx, *user, "user:delete:"+user.ID, root.ID)
}
if !domain.IsWorksProvisionedUserStatus(user.Status) {
err := errors.New("target user status is excluded from Worksmobile sync")
if recordErr := s.recordRejectedUserSync(ctx, root.ID, *user, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
buildPayload := BuildWorksmobileUserPayloadForDomainTenants
if !tenantInScope {
buildPayload = BuildWorksmobileUserPayloadForScopedDomainTenants
}
payload, err := buildPayload(*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)
if action == domain.WorksmobileActionUpsert && strings.TrimSpace(initialPassword) != "" {
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword)
}
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: action,
DedupeKey: worksmobileUserSyncDedupeKey(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 != "" && strings.TrimSpace(payload.PasswordConfig.Password) != "" {
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) recordRejectedUserSync(ctx context.Context, rootID string, user domain.User, tenant domain.Tenant, reason error) error {
payload := WorksmobileUserPayload{
Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID,
UserName: worksmobileUserNameFromDisplayName(user.Name),
CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: metadataEmployeeNumber(user.Metadata),
Locale: "ko_KR",
Task: strings.TrimSpace(user.JobTitle),
}
outboxPayload := worksmobileUserOutboxPayload(rootID, payload, user.Status)
outboxPayload["displayName"] = strings.TrimSpace(user.Name)
outboxPayload["primaryLeafOrgName"] = strings.TrimSpace(tenant.Name)
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: WorksmobileUserStatusAction(user.Status),
DedupeKey: worksmobileUserSyncDedupeKey("rejected", user.ID),
Payload: outboxPayload,
Status: domain.WorksmobileOutboxStatusFailed,
LastError: reason.Error(),
}
return s.outboxRepo.Create(ctx, item)
}
func worksmobileUserSyncDedupeKey(action, userID string) string {
return "user:" + strings.ToLower(action) + ":" + userID + ":" + uuid.NewString()
}
func worksmobileAdminInitialPasswordConfig(password string) WorksmobilePasswordConfig {
password = strings.TrimSpace(password)
if password == "" {
password = GenerateWorksmobileInitialPassword()
}
changePasswordAtNextLogin := true
return WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: password,
ChangePasswordAtNextLogin: &changePasswordAtNextLogin,
}
}
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...))
buildPayload := BuildWorksmobileUserPayloadForDomainTenants
if _, ok := tenantByID[tenant.ID]; !ok {
buildPayload = BuildWorksmobileUserPayloadForScopedDomainTenants
}
payload, err := buildPayload(*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 {
return s.enqueueUserUpsertIfInScope(ctx, user, false)
}
func (s *worksmobileSyncService) EnqueueUserUpdateIfInScope(ctx context.Context, user domain.User) error {
return s.enqueueUserUpsertIfInScope(ctx, user, true)
}
func (s *worksmobileSyncService) enqueueUserUpsertIfInScope(ctx context.Context, user domain.User, updateOnly bool) 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)
if action == domain.WorksmobileActionUpsert && !updateOnly {
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig("")
}
outboxPayload := worksmobileUserOutboxPayload(root.ID, payload, user.Status)
if action == domain.WorksmobileActionUpsert && updateOnly {
outboxPayload[worksmobileProvisioningModeKey] = worksmobileProvisioningUpdateOnly
}
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: action,
DedupeKey: worksmobileUserSyncDedupeKey(action, user.ID),
Payload: outboxPayload,
})
}
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 &current, 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,
}
if password := strings.TrimSpace(payload.PasswordConfig.Password); password != "" {
outboxPayload["initialPassword"] = 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 {
return compareWorksmobileUsersWithRemoteGroups(localUsers, remoteUsers, includeMatched, localTenants, nil, jobSummaries...)
}
func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, remoteGroups []WorksmobileRemoteGroup, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem {
jobSummaryByUserID := map[string]worksmobileUserJobSummary{}
if len(jobSummaries) > 0 && jobSummaries[0] != nil {
jobSummaryByUserID = jobSummaries[0]
}
remoteOrgUnitByExternalID := worksmobileRemoteOrgUnitByExternalID(remoteGroups)
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 user.DeletedAt.Valid || !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))]
}
updateReasons := []string(nil)
gradeComparison := worksmobileUserGradeComparison{}
if matched {
gradeComparison = worksmobileCompareUserGrade(user, remote, localTenants, remoteOrgUnitByExternalID)
updateReasons = worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)
}
needsUpdate := len(updateReasons) > 0
if matched && !includeMatched && !needsUpdate {
matchedRemoteIDs[remote.ID] = true
continue
}
baronGrade, _ := worksmobileUserComparisonGradeWithTenant(user, remote, localTenants, remoteOrgUnitByExternalID)
if strings.TrimSpace(gradeComparison.LocalGrade) != "" {
baronGrade = gradeComparison.LocalGrade
}
item := WorksmobileComparisonItem{
ResourceType: "USER",
BaronID: user.ID,
BaronName: user.Name,
BaronEmail: user.Email,
BaronPhone: user.Phone,
BaronEmployeeNumber: metadataEmployeeNumber(user.Metadata),
BaronGrade: baronGrade,
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.UpdateReasons = updateReasons
}
item.WorksmobileID = remote.ID
item.ExternalKey = remote.ExternalID
item.WorksmobileName = remote.DisplayName
item.WorksmobileEmail = remote.Email
item.WorksmobilePhone = remote.CellPhone
item.WorksmobileEmployeeNumber = remote.EmployeeNumber
item.WorksmobileAccountStatus = worksmobileRemoteAccountStatus(remote)
item.WorksmobileLevelID = remote.LevelID
item.WorksmobileLevelName = remote.LevelName
if gradeComparison.NeedsUpdate {
item.WorksmobileLevelID = gradeComparison.RemoteLevelID
item.WorksmobileLevelName = gradeComparison.RemoteLevelName
}
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
item.UserMemberships = worksmobileUserMembershipComparisons(user, remote, localTenants, remoteOrgUnitByExternalID, gradeComparison)
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,
WorksmobilePhone: remote.CellPhone,
WorksmobileEmployeeNumber: remote.EmployeeNumber,
WorksmobileAccountStatus: worksmobileRemoteAccountStatus(remote),
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,
WorksmobilePhone: remote.CellPhone,
WorksmobileEmployeeNumber: remote.EmployeeNumber,
WorksmobileAccountStatus: worksmobileRemoteAccountStatus(remote),
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 worksmobileRemoteOrgUnitByExternalID(remoteGroups []WorksmobileRemoteGroup) map[string]WorksmobileRemoteGroup {
result := make(map[string]WorksmobileRemoteGroup, len(remoteGroups))
for _, remote := range remoteGroups {
externalID := strings.TrimSpace(remote.ExternalID)
if externalID == "" {
continue
}
result[externalID] = remote
}
return result
}
func worksmobileRemoteAccountStatus(remote WorksmobileRemoteUser) string {
return normalizeWorksmobileAccountStatus(
remote.AccountStatus,
remote.Active,
remote.IsAwaiting,
remote.IsPending,
remote.IsSuspended,
remote.IsDeleted,
)
}
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
return len(worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)) > 0
}
func worksmobileUserUpdateReasons(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []string {
reasons := []string{}
if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) {
reasons = append(reasons, "external_key")
}
if strings.TrimSpace(remote.DisplayName) != strings.TrimSpace(user.Name) {
reasons = append(reasons, "name")
}
if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) {
reasons = append(reasons, "email")
}
if worksmobileUserPhoneNeedsUpdate(user, remote) {
reasons = append(reasons, "phone")
}
if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) {
reasons = append(reasons, "employee_number")
}
if worksmobileUserGradeNeedsUpdate(user, remote, localTenants, remoteOrgUnitByExternalID) {
reasons = append(reasons, "grade")
}
if worksmobileUserOrganizationsNeedUpdate(user, remote, localTenants, remoteOrgUnitByExternalID) {
reasons = append(reasons, "organization")
}
if worksmobileUserManagerNeedsUpdate(user, remote) {
reasons = append(reasons, "manager")
}
return reasons
}
func worksmobileUserPhoneNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {
localPhone := normalizeWorksmobilePhoneForCompare(user.Phone)
remotePhone := normalizeWorksmobilePhoneForCompare(remote.CellPhone)
if localPhone == "" {
return false
}
if localPhone != remotePhone {
return true
}
return localPhone != "" && worksmobilePhoneHasDuplicateKoreanCountryCode(remote.CellPhone)
}
func normalizeWorksmobilePhoneForCompare(value string) string {
return domain.NormalizePhoneNumber(value)
}
func worksmobilePhoneHasDuplicateKoreanCountryCode(value string) bool {
digits := strings.Builder{}
for _, r := range strings.TrimSpace(value) {
if r >= '0' && r <= '9' {
digits.WriteRune(r)
}
}
return strings.HasPrefix(digits.String(), "8282")
}
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 worksmobileUserGradeNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
return worksmobileCompareUserGrade(user, remote, localTenants, remoteOrgUnitByExternalID).NeedsUpdate
}
func worksmobileUserComparisonGradeWithTenant(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) (string, string) {
comparison := worksmobileCompareUserGrade(user, remote, localTenants, remoteOrgUnitByExternalID)
if strings.TrimSpace(comparison.LocalGrade) != "" {
return comparison.LocalGrade, comparison.TenantID
}
tenantID := worksmobileRemotePrimaryTenantID(remote, localTenants, remoteOrgUnitByExternalID)
if tenantID == "" {
tenantID = worksmobileUserComparisonTenantID(user, localTenants)
}
if tenantID == "" {
return "", ""
}
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
if strings.TrimSpace(appointment.TenantID) != tenantID {
continue
}
grade := normalizeWorksmobileGradeForTenant(appointment.Grade, tenantID, localTenants)
if grade == "" {
return "", tenantID
}
return grade, tenantID
}
return "", tenantID
}
type worksmobileUserGradeComparison struct {
NeedsUpdate bool
TenantID string
LocalGrade string
RemoteLevelID string
RemoteLevelName string
}
type worksmobileRemoteOrganizationLevel struct {
levelID string
levelName string
primary bool
}
func worksmobileCompareUserGrade(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) worksmobileUserGradeComparison {
if localTenants == nil {
return worksmobileUserGradeComparison{}
}
remoteLevelsByTenant := worksmobileRemoteOrganizationLevelsByTenant(remote, localTenants, remoteOrgUnitByExternalID)
if len(remoteLevelsByTenant) == 0 {
return worksmobileUserGradeComparison{}
}
fallback := worksmobileUserGradeComparison{}
for _, appointment := range worksmobileGradeComparisonAppointments(user, localTenants) {
tenantID := strings.TrimSpace(appointment.TenantID)
localGrade := normalizeWorksmobileGradeForTenant(appointment.Grade, tenantID, localTenants)
if tenantID == "" || localGrade == "" {
continue
}
if _, ok := localTenants[tenantID]; !ok {
continue
}
remoteLevel, ok := remoteLevelsByTenant[tenantID]
if !ok {
continue
}
comparison := worksmobileUserGradeComparison{
TenantID: tenantID,
LocalGrade: localGrade,
RemoteLevelID: strings.TrimSpace(remoteLevel.levelID),
RemoteLevelName: strings.TrimSpace(remoteLevel.levelName),
}
if fallback.LocalGrade == "" || remoteLevel.primary {
fallback = comparison
}
if comparison.RemoteLevelName == "" && comparison.RemoteLevelID == "" {
comparison.NeedsUpdate = true
return comparison
}
if !worksmobileRemoteLevelMatchesLocalGrade(localGrade, tenantID, comparison.RemoteLevelID, comparison.RemoteLevelName, localTenants) {
comparison.NeedsUpdate = true
return comparison
}
}
return fallback
}
func worksmobileRemoteLevelMatchesLocalGrade(localGrade, tenantID, remoteLevelID, remoteLevelName string, localTenants map[string]domain.Tenant) bool {
localGrade = strings.TrimSpace(localGrade)
remoteLevelID = strings.TrimSpace(remoteLevelID)
remoteLevelName = strings.TrimSpace(remoteLevelName)
if localGrade == "" {
return remoteLevelID == "" && remoteLevelName == ""
}
if remoteLevelName == localGrade || remoteLevelID == localGrade {
return true
}
expectedLevelID := worksmobileLevelIDForTenant(localGrade, tenantID, localTenants)
return WorksmobileLevelIdentifierMatchesRemote(expectedLevelID, remoteLevelID, remoteLevelName)
}
func worksmobileGradeComparisonAppointments(user domain.User, tenantByID map[string]domain.Tenant) []worksmobileAppointment {
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
hasGPDTDCGrade := false
for _, appointment := range appointments {
if strings.TrimSpace(appointment.Grade) == "" {
continue
}
if worksmobileTenantIsGPDTDCDescendant(appointment.TenantID, tenantByID) {
hasGPDTDCGrade = true
break
}
}
if !hasGPDTDCGrade {
return appointments
}
filtered := make([]worksmobileAppointment, 0, len(appointments))
for _, appointment := range appointments {
if worksmobileTenantIsGPDTDCDescendant(appointment.TenantID, tenantByID) {
filtered = append(filtered, appointment)
}
}
return filtered
}
func worksmobileRemoteOrganizationLevelsByTenant(remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) map[string]worksmobileRemoteOrganizationLevel {
result := map[string]worksmobileRemoteOrganizationLevel{}
if localTenants == nil {
return result
}
organizations := remote.Organizations
if len(organizations) == 0 {
organizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
} else {
organizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
}
for _, organization := range organizations {
levelID := strings.TrimSpace(organization.LevelID)
levelName := strings.TrimSpace(organization.LevelName)
if levelID == "" && levelName == "" && (len(organizations) == 1 || organization.Primary) {
levelID = strings.TrimSpace(remote.LevelID)
levelName = strings.TrimSpace(remote.LevelName)
}
for _, orgUnit := range organization.OrgUnits {
tenantID := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
if tenantID == "" {
continue
}
if _, ok := localTenants[tenantID]; !ok {
continue
}
current := worksmobileRemoteOrganizationLevel{
levelID: levelID,
levelName: levelName,
primary: organization.Primary || orgUnit.Primary,
}
existing, ok := result[tenantID]
if !ok || (!existing.primary && current.primary) {
result[tenantID] = current
}
}
}
return result
}
func worksmobileRemotePrimaryTenantID(remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) string {
if localTenants == nil {
return ""
}
for _, orgUnitID := range worksmobileRemotePrimaryOrgUnitIDs(remote) {
canonicalOrgUnitID := worksmobileCanonicalRemoteOrgUnitID(orgUnitID, remoteOrgUnitByExternalID)
tenantID := worksmobileOrgUnitLocalExternalKey(canonicalOrgUnitID)
if tenantID == "" {
continue
}
if _, ok := localTenants[tenantID]; ok {
return tenantID
}
}
return ""
}
func worksmobileIsResearchGrade(values ...string) bool {
for _, value := range values {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
continue
}
if strings.Contains(normalized, "연구원") ||
strings.Contains(normalized, "선임") ||
strings.Contains(normalized, "책임") ||
strings.Contains(normalized, "수석") ||
strings.Contains(normalized, "research") ||
strings.Contains(normalized, "principal") {
return true
}
}
return false
}
func worksmobileTenantIsGPDTDCDescendant(tenantID string, tenantByID map[string]domain.Tenant) bool {
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" || tenantByID == nil {
return false
}
visited := map[string]bool{}
currentID := tenantID
for currentID != "" {
if visited[currentID] {
return false
}
visited[currentID] = true
tenant, ok := tenantByID[currentID]
if !ok {
return false
}
if worksmobileTenantDomainIDEnvKey(tenant) == "GPDTDC_DOMAIN_ID" && isWorksmobileDomainRootTenant(tenant) {
return true
}
currentID = worksmobileTenantParentID(tenant)
}
return false
}
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
if localTenants == nil {
return false
}
tenantID := worksmobileUserComparisonTenantID(user, localTenants)
if tenantID == "" {
return false
}
tenant, ok := localTenants[tenantID]
if !ok {
return false
}
expectedOrganizations, _, err := buildWorksmobileUserOrganizations(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
if err != nil || len(expectedOrganizations) == 0 {
return worksmobileUserPrimaryOrganizationNeedsUpdate(user, remote, localTenants, remoteOrgUnitByExternalID)
}
remoteOrganizations := remote.Organizations
if len(remoteOrganizations) == 0 {
remoteOrganizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
} else {
remoteOrganizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
}
return !worksmobileUserOrganizationsEqual(expectedOrganizations, remoteOrganizations)
}
func worksmobileUserPrimaryOrganizationNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
tenantID := worksmobileUserComparisonPrimaryTenantID(user)
if tenantID == "" {
return false
}
tenant, ok := localTenants[tenantID]
if !ok || isWorksmobileDomainRootTenant(tenant) {
return false
}
expectedPrimary := "externalKey:" + tenantID
if remoteOrgUnit, ok := remoteOrgUnitByExternalID[tenantID]; ok && strings.TrimSpace(remoteOrgUnit.ID) != "" {
expectedPrimary = strings.TrimSpace(remoteOrgUnit.ID)
}
remotePrimaryOrgUnits := worksmobileRemotePrimaryOrgUnitIDs(remote)
return !worksmobileOrgUnitIDContains(remotePrimaryOrgUnits, expectedPrimary)
}
func worksmobileComparisonRootConfig(localTenants map[string]domain.Tenant) domain.JSONMap {
for _, tenant := range localTenants {
if strings.TrimSpace(tenant.Slug) == HanmacFamilyTenantSlug {
return tenant.Config
}
}
return nil
}
func worksmobileUserComparisonPrimaryTenantID(user domain.User) string {
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
if appointment.IsPrimary && strings.TrimSpace(appointment.TenantID) != "" {
return strings.TrimSpace(appointment.TenantID)
}
}
if user.TenantID == nil {
return ""
}
return strings.TrimSpace(*user.TenantID)
}
func worksmobileUserComparisonTenantID(user domain.User, localTenants map[string]domain.Tenant) string {
if user.TenantID != nil {
tenantID := strings.TrimSpace(*user.TenantID)
if _, ok := localTenants[tenantID]; ok {
return tenantID
}
}
for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
tenantID := strings.TrimSpace(appointment.TenantID)
if tenantID == "" {
continue
}
if _, ok := localTenants[tenantID]; ok {
return tenantID
}
}
return ""
}
func worksmobileRemoteUserLegacyOrganizations(remote WorksmobileRemoteUser, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []WorksmobileUserOrganization {
if strings.TrimSpace(remote.PrimaryOrgUnitID) == "" {
return nil
}
return []WorksmobileUserOrganization{
{
DomainID: remote.DomainID,
Email: strings.TrimSpace(remote.Email),
Primary: true,
LevelID: strings.TrimSpace(remote.LevelID),
LevelName: strings.TrimSpace(remote.LevelName),
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: worksmobileCanonicalRemoteOrgUnitID(strings.TrimSpace(remote.PrimaryOrgUnitID), remoteOrgUnitByExternalID),
Primary: true,
PositionID: strings.TrimSpace(remote.PrimaryOrgUnitPositionID),
IsManager: remote.PrimaryOrgUnitIsManager,
},
},
},
}
}
func worksmobileRemoteUserOrganizationsForCompare(remote WorksmobileRemoteUser, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) []WorksmobileUserOrganization {
if len(remote.Organizations) == 0 {
return nil
}
primaryOrgUnitID := strings.TrimSpace(remote.PrimaryOrgUnitID)
result := make([]WorksmobileUserOrganization, len(remote.Organizations))
for i, organization := range remote.Organizations {
result[i] = organization
if result[i].DomainID == 0 {
result[i].DomainID = remote.DomainID
}
result[i].OrgUnits = make([]WorksmobileUserOrgUnit, len(organization.OrgUnits))
copy(result[i].OrgUnits, organization.OrgUnits)
for j, orgUnit := range result[i].OrgUnits {
result[i].OrgUnits[j].OrgUnitID = worksmobileCanonicalRemoteOrgUnitID(orgUnit.OrgUnitID, remoteOrgUnitByExternalID)
}
if primaryOrgUnitID == "" {
continue
}
for j, orgUnit := range result[i].OrgUnits {
if strings.TrimSpace(orgUnit.OrgUnitID) != worksmobileCanonicalRemoteOrgUnitID(primaryOrgUnitID, remoteOrgUnitByExternalID) {
continue
}
result[i].Primary = true
result[i].OrgUnits[j].Primary = true
}
}
return result
}
func worksmobileCanonicalRemoteOrgUnitID(orgUnitID string, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) string {
orgUnitID = strings.TrimSpace(orgUnitID)
if orgUnitID == "" || strings.HasPrefix(orgUnitID, "externalKey:") {
return orgUnitID
}
for externalID, remoteOrgUnit := range remoteOrgUnitByExternalID {
if strings.TrimSpace(remoteOrgUnit.ID) == orgUnitID && strings.TrimSpace(externalID) != "" {
return "externalKey:" + strings.TrimSpace(externalID)
}
}
return orgUnitID
}
func worksmobileRemotePrimaryOrgUnitIDs(remote WorksmobileRemoteUser) []string {
result := make([]string, 0, 1)
for _, organization := range remote.Organizations {
if !organization.Primary {
continue
}
for _, orgUnit := range organization.OrgUnits {
if orgUnit.Primary {
result = append(result, orgUnit.OrgUnitID)
}
}
}
if len(result) == 0 && strings.TrimSpace(remote.PrimaryOrgUnitID) != "" {
result = append(result, remote.PrimaryOrgUnitID)
}
return result
}
func worksmobileOrgUnitIDContains(values []string, expected string) bool {
expected = strings.TrimSpace(expected)
for _, value := range values {
if strings.TrimSpace(value) == expected {
return true
}
}
return false
}
type worksmobileComparableOrgUnit struct {
organizationPrimary bool
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) == 0 {
return len(remoteUnits) == 0
}
if len(remoteUnits) == 0 {
return false
}
for key, remoteUnit := range remoteUnits {
expectedUnit, ok := expectedUnits[key]
if !ok {
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 worksmobilePrimaryOrgUnitCompareKey(organizations []WorksmobileUserOrganization) string {
for _, organization := range organizations {
if !organization.Primary {
continue
}
for _, orgUnit := range organization.OrgUnits {
if !orgUnit.Primary {
continue
}
if key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID); key != "" {
return key
}
}
}
return ""
}
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,
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,
unitPrimary: orgUnit.Primary,
positionID: strings.TrimSpace(orgUnit.PositionID),
manager: orgUnit.IsManager,
}
}
}
return result
}
type worksmobileRemoteMembershipDetail struct {
domainID int64
domainName string
orgUnitID string
orgUnitName string
levelID string
levelName string
positionID string
manager *bool
primary bool
}
func worksmobileUserMembershipComparisons(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup, gradeComparison worksmobileUserGradeComparison) []WorksmobileUserMembershipComparison {
if localTenants == nil {
return nil
}
tenantID := worksmobileUserComparisonTenantID(user, localTenants)
if tenantID == "" {
return nil
}
tenant, ok := localTenants[tenantID]
if !ok {
return nil
}
expectedOrganizations, _, err := buildWorksmobileUserOrganizations(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
if err != nil || len(expectedOrganizations) == 0 {
return nil
}
remoteOrganizations := remote.Organizations
if len(remoteOrganizations) == 0 {
remoteOrganizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
} else {
remoteOrganizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
}
remoteMemberships := worksmobileRemoteMembershipDetailsByKey(remote, remoteOrganizations, remoteOrgUnitByExternalID)
appointments := worksmobileAppointmentsByTenantID(user.Metadata)
result := make([]WorksmobileUserMembershipComparison, 0)
for _, organization := range expectedOrganizations {
for _, orgUnit := range organization.OrgUnits {
baronOrgID := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
if baronOrgID == "" {
continue
}
baronTenant, ok := localTenants[baronOrgID]
if !ok {
continue
}
item := WorksmobileUserMembershipComparison{
BaronOrgID: baronOrgID,
BaronOrgSlug: strings.TrimSpace(baronTenant.Slug),
BaronOrgName: strings.TrimSpace(baronTenant.Name),
BaronPrimary: organization.Primary || orgUnit.Primary,
}
if appointment, ok := appointments[baronOrgID]; ok {
item.BaronGrade = normalizeWorksmobileGradeForTenant(appointment.Grade, baronOrgID, localTenants)
}
key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID)
if remoteMembership, ok := remoteMemberships[key]; ok {
item.WorksmobileDomainID = remoteMembership.domainID
item.WorksmobileDomainName = remoteMembership.domainName
item.WorksmobileOrgID = remoteMembership.orgUnitID
item.WorksmobileOrgName = remoteMembership.orgUnitName
item.WorksmobileLevelID = remoteMembership.levelID
item.WorksmobileLevelName = remoteMembership.levelName
item.WorksmobileOrgPositionID = remoteMembership.positionID
item.WorksmobileOrgIsManager = remoteMembership.manager
item.WorksmobilePrimary = remoteMembership.primary
}
item.GradeNeedsUpdate = gradeComparison.NeedsUpdate && strings.TrimSpace(gradeComparison.TenantID) == baronOrgID
result = append(result, item)
}
}
return result
}
func worksmobileAppointmentsByTenantID(metadata domain.JSONMap) map[string]worksmobileAppointment {
result := map[string]worksmobileAppointment{}
for _, appointment := range worksmobileAppointmentsFromMetadata(metadata) {
tenantID := strings.TrimSpace(appointment.TenantID)
if tenantID == "" {
continue
}
result[tenantID] = appointment
}
return result
}
func worksmobileRemoteMembershipDetailsByKey(remote WorksmobileRemoteUser, organizations []WorksmobileUserOrganization, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) map[string]worksmobileRemoteMembershipDetail {
result := map[string]worksmobileRemoteMembershipDetail{}
for _, organization := range organizations {
domainID := organization.DomainID
if domainID == 0 {
domainID = remote.DomainID
}
domainName := strings.TrimSpace(remote.DomainName)
levelID := strings.TrimSpace(organization.LevelID)
levelName := strings.TrimSpace(organization.LevelName)
if levelID == "" && levelName == "" && (len(organizations) == 1 || organization.Primary) {
levelID = strings.TrimSpace(remote.LevelID)
levelName = strings.TrimSpace(remote.LevelName)
}
for _, orgUnit := range organization.OrgUnits {
key := worksmobileComparableOrgUnitKey(domainID, orgUnit.OrgUnitID)
if key == "" {
continue
}
localExternalKey := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
orgUnitID := strings.TrimSpace(orgUnit.OrgUnitID)
orgUnitName := ""
if localExternalKey != "" {
if remoteGroup, ok := remoteOrgUnitByExternalID[localExternalKey]; ok {
if strings.TrimSpace(remoteGroup.ID) != "" {
orgUnitID = strings.TrimSpace(remoteGroup.ID)
}
orgUnitName = strings.TrimSpace(remoteGroup.DisplayName)
if domainName == "" {
domainName = strings.TrimSpace(remoteGroup.DomainName)
}
}
}
if orgUnitName == "" && worksmobileOrgUnitIDContains([]string{remote.PrimaryOrgUnitID}, orgUnitID) {
orgUnitName = strings.TrimSpace(remote.PrimaryOrgUnitName)
}
result[key] = worksmobileRemoteMembershipDetail{
domainID: domainID,
domainName: domainName,
orgUnitID: orgUnitID,
orgUnitName: orgUnitName,
levelID: levelID,
levelName: levelName,
positionID: strings.TrimSpace(orgUnit.PositionID),
manager: orgUnit.IsManager,
primary: organization.Primary || orgUnit.Primary,
}
}
}
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 := worksmobileRemoteOrgUnitManagerMap(remote)
for localOrgUnitID, localManager := range localManagers {
remoteManager := false
if value, ok := remoteManagers[localOrgUnitID]; ok && value != nil {
remoteManager = *value
}
if localManager != remoteManager {
return true
}
}
return false
}
func worksmobileRemoteOrgUnitManagerMap(remote WorksmobileRemoteUser) map[string]*bool {
result := map[string]*bool{}
for orgUnitID, isManager := range remote.OrgUnitManagers {
normalized := worksmobileOrgUnitLocalExternalKey(orgUnitID)
if normalized == "" {
continue
}
result[normalized] = isManager
}
for _, organization := range remote.Organizations {
for _, orgUnit := range organization.OrgUnits {
normalized := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
if normalized == "" {
continue
}
result[normalized] = orgUnit.IsManager
}
}
if strings.TrimSpace(remote.PrimaryOrgUnitID) != "" {
result[worksmobileOrgUnitLocalExternalKey(remote.PrimaryOrgUnitID)] = remote.PrimaryOrgUnitIsManager
}
return result
}
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 {
return worksmobileUserComparisonPrimaryTenantID(user)
}
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
}