1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/service/worksmobile_sync_service.go
Lectom 75f192fb24 merge: integrate origin dev into dev
Includes Worksmobile SSOT sync comparison updates, UUID import conflict resolution, and Playwright route mock stabilization.
2026-06-01 17:48:39 +09:00

1887 lines
64 KiB
Go

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