forked from baron/baron-sso
1993 lines
67 KiB
Go
1993 lines
67 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 {
|
|
jobs[i].Payload = safeWorksmobileOutboxPayload(jobs[i].Payload)
|
|
}
|
|
return jobs
|
|
}
|
|
|
|
func safeWorksmobileOutboxPayload(payload domain.JSONMap) domain.JSONMap {
|
|
if payload == nil {
|
|
return nil
|
|
}
|
|
safe := domain.JSONMap{}
|
|
for _, key := range []string{
|
|
"tenantRootId",
|
|
"loginEmail",
|
|
"displayName",
|
|
"primaryLeafOrgName",
|
|
"credentialBatchId",
|
|
"credentialOperation",
|
|
"credentialBatchCreatedAt",
|
|
"worksmobileId",
|
|
"externalKey",
|
|
"domainId",
|
|
"name",
|
|
"email",
|
|
"matchLocalPart",
|
|
"baronStatus",
|
|
} {
|
|
if value, ok := payload[key]; ok && safeWorksmobilePayloadValue(value) != nil {
|
|
safe[key] = value
|
|
}
|
|
}
|
|
if summary := safeWorksmobileRequestSummary(payload["request"]); len(summary) > 0 {
|
|
safe["requestSummary"] = summary
|
|
}
|
|
return safe
|
|
}
|
|
|
|
func safeWorksmobilePayloadValue(value any) any {
|
|
switch v := value.(type) {
|
|
case string:
|
|
if strings.TrimSpace(v) == "" {
|
|
return nil
|
|
}
|
|
return v
|
|
case nil:
|
|
return nil
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func safeWorksmobileRequestSummary(request any) domain.JSONMap {
|
|
switch v := request.(type) {
|
|
case WorksmobileUserPayload:
|
|
summary := domain.JSONMap{}
|
|
safeSetWorksmobileSummary(summary, "email", v.Email)
|
|
safeSetWorksmobileSummary(summary, "displayName", v.UserName.LastName)
|
|
safeSetWorksmobileSummary(summary, "userExternalKey", v.UserExternalKey)
|
|
safeSetWorksmobileSummary(summary, "cellPhone", v.CellPhone)
|
|
safeSetWorksmobileSummary(summary, "employeeNumber", v.EmployeeNumber)
|
|
safeSetWorksmobileSummary(summary, "task", v.Task)
|
|
return summary
|
|
case WorksmobilePasswordResetPayload:
|
|
summary := domain.JSONMap{}
|
|
safeSetWorksmobileSummary(summary, "email", v.Email)
|
|
return summary
|
|
case WorksmobileOrgUnitPayload:
|
|
summary := domain.JSONMap{}
|
|
safeSetWorksmobileSummary(summary, "email", v.Email)
|
|
safeSetWorksmobileSummary(summary, "orgUnitName", v.OrgUnitName)
|
|
safeSetWorksmobileSummary(summary, "orgUnitExternalKey", v.OrgUnitExternalKey)
|
|
safeSetWorksmobileSummary(summary, "parentOrgUnitId", v.ParentOrgUnitID)
|
|
if v.DomainID > 0 {
|
|
summary["domainId"] = v.DomainID
|
|
}
|
|
return summary
|
|
case map[string]any:
|
|
return safeWorksmobileRequestSummaryFromMap(v)
|
|
case domain.JSONMap:
|
|
return safeWorksmobileRequestSummaryFromMap(map[string]any(v))
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func safeWorksmobileRequestSummaryFromMap(request map[string]any) domain.JSONMap {
|
|
summary := domain.JSONMap{}
|
|
for _, key := range []string{
|
|
"email",
|
|
"userExternalKey",
|
|
"cellPhone",
|
|
"employeeNumber",
|
|
"task",
|
|
"orgUnitName",
|
|
"orgUnitExternalKey",
|
|
"parentOrgUnitId",
|
|
"domainId",
|
|
} {
|
|
if value, ok := request[key]; ok && safeWorksmobilePayloadValue(value) != nil {
|
|
summary[key] = value
|
|
}
|
|
}
|
|
if userName, ok := request["userName"].(map[string]any); ok {
|
|
safeSetWorksmobileSummary(summary, "displayName", stringValue(userName["lastName"]))
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func safeSetWorksmobileSummary(summary domain.JSONMap, key string, value string) {
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
summary[key] = value
|
|
}
|
|
}
|
|
|
|
func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error) {
|
|
root, err := s.hanmacRoot(ctx, tenantID)
|
|
if err != nil {
|
|
return WorksmobileComparison{}, err
|
|
}
|
|
if s.client == nil {
|
|
return WorksmobileComparison{}, errors.New("worksmobile client is not configured")
|
|
}
|
|
|
|
tenants, err := s.hanmacSubtree(ctx, root.ID)
|
|
if err != nil {
|
|
return WorksmobileComparison{}, err
|
|
}
|
|
tenantByID := worksmobileTenantByID(tenants)
|
|
tenantByID[root.ID] = *root
|
|
tenantIDs := make([]string, 0, len(tenants))
|
|
for _, tenant := range tenants {
|
|
if isWorksmobileUserScopeTenant(tenant) {
|
|
tenantIDs = append(tenantIDs, tenant.ID)
|
|
}
|
|
}
|
|
users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs)
|
|
if err != nil {
|
|
return WorksmobileComparison{}, err
|
|
}
|
|
remoteUsers, err := s.client.ListUsers(ctx)
|
|
if err != nil {
|
|
return WorksmobileComparison{}, err
|
|
}
|
|
remoteGroups, err := s.client.ListGroups(ctx)
|
|
if err != nil {
|
|
return WorksmobileComparison{}, err
|
|
}
|
|
|
|
recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000)
|
|
|
|
return WorksmobileComparison{
|
|
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID, worksmobileUserJobSummaries(recentJobs)),
|
|
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
|
|
}, nil
|
|
}
|
|
|
|
func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) {
|
|
root, err := s.hanmacRoot(ctx, tenantID)
|
|
if err != nil {
|
|
return WorksmobileBackfillDryRun{}, err
|
|
}
|
|
tenants, err := s.hanmacSubtree(ctx, root.ID)
|
|
if err != nil {
|
|
return WorksmobileBackfillDryRun{}, err
|
|
}
|
|
orgUnitTenantIDs := make([]string, 0, len(tenants))
|
|
userTenantIDs := make([]string, 0, len(tenants))
|
|
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, tenants...))
|
|
for _, tenant := range tenants {
|
|
if isWorksmobileOrgUnitTenant(tenant, tenantByID) {
|
|
orgUnitTenantIDs = append(orgUnitTenantIDs, tenant.ID)
|
|
}
|
|
if isWorksmobileUserScopeTenant(tenant) {
|
|
userTenantIDs = append(userTenantIDs, tenant.ID)
|
|
}
|
|
}
|
|
users, err := s.userRepo.FindByTenantIDs(ctx, userTenantIDs)
|
|
if err != nil {
|
|
return WorksmobileBackfillDryRun{}, err
|
|
}
|
|
users = worksmobileSyncScopeUsers(users)
|
|
_ = s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
|
|
ResourceType: domain.WorksmobileResourceOrgUnit,
|
|
ResourceID: root.ID,
|
|
Action: domain.WorksmobileActionDryRun,
|
|
DedupeKey: "backfill:dry-run:" + root.ID,
|
|
Payload: domain.JSONMap{
|
|
"tenantIds": orgUnitTenantIDs,
|
|
"userCount": len(users),
|
|
},
|
|
})
|
|
return WorksmobileBackfillDryRun{OrgUnitCount: len(orgUnitTenantIDs), UserCount: len(users)}, nil
|
|
}
|
|
|
|
func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) {
|
|
root, err := s.hanmacRoot(ctx, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tenant, err := s.tenantService.GetTenant(ctx, orgUnitID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok || tenantRoot.ID != root.ID {
|
|
return nil, errors.New("target orgunit is outside hanmac-family subtree")
|
|
}
|
|
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
|
|
if !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 ¤t, current.Slug == HanmacFamilyTenantSlug, nil
|
|
}
|
|
|
|
func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context, root *domain.Tenant, user domain.User, payload WorksmobileUserPayload) error {
|
|
if len(payload.AliasEmails) == 0 {
|
|
return nil
|
|
}
|
|
tenants, err := s.hanmacSubtree(ctx, root.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tenantByID := make(map[string]domain.Tenant, len(tenants)+1)
|
|
tenantByID[root.ID] = *root
|
|
tenantIDs := make([]string, 0, len(tenants)+1)
|
|
tenantIDs = append(tenantIDs, root.ID)
|
|
for _, tenant := range tenants {
|
|
tenantByID[tenant.ID] = tenant
|
|
if isWorksmobileUserScopeTenant(tenant) {
|
|
tenantIDs = append(tenantIDs, tenant.ID)
|
|
}
|
|
}
|
|
users, err := s.userRepo.FindByTenantIDs(ctx, tenantIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existing := map[string]string{}
|
|
for _, existingUser := range users {
|
|
if existingUser.ID == user.ID {
|
|
continue
|
|
}
|
|
addWorksmobileEmail(existing, existingUser.Email, existingUser.ID)
|
|
if existingUser.TenantID == nil {
|
|
continue
|
|
}
|
|
tenant, ok := tenantByID[*existingUser.TenantID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, alias := range BuildWorksmobileAliasEmails(existingUser, tenant) {
|
|
addWorksmobileEmail(existing, alias, existingUser.ID)
|
|
}
|
|
}
|
|
return ValidateWorksmobileAliasEmails(payload.Email, payload.AliasEmails, existing)
|
|
}
|
|
|
|
func addWorksmobileEmail(target map[string]string, email string, owner string) {
|
|
normalized := strings.ToLower(strings.TrimSpace(email))
|
|
if _, err := mail.ParseAddress(normalized); err == nil && normalized != "" {
|
|
target[normalized] = owner
|
|
}
|
|
}
|
|
|
|
func findWorksmobileOrgUnitTenantByRemoteLocalPart(remote WorksmobileRemoteGroup, localTenants []domain.Tenant, tenantByID map[string]domain.Tenant) (domain.Tenant, bool) {
|
|
candidates := worksmobileRemoteGroupLocalPartCandidates(remote)
|
|
for _, tenant := range localTenants {
|
|
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
|
|
continue
|
|
}
|
|
if candidates[normalizeWorksmobileSlugLocalPart(tenant.Slug)] {
|
|
return tenant, true
|
|
}
|
|
}
|
|
return domain.Tenant{}, false
|
|
}
|
|
|
|
func isProtectedWorksmobileRemoteOrgUnit(root domain.Tenant, localTenants []domain.Tenant, remote WorksmobileRemoteGroup) bool {
|
|
if strings.TrimSpace(remote.ParentID) == "" {
|
|
return true
|
|
}
|
|
candidates := worksmobileRemoteGroupLocalPartCandidates(remote)
|
|
if len(candidates) == 0 {
|
|
return false
|
|
}
|
|
for _, tenant := range localTenants {
|
|
if tenant.ParentID == nil || *tenant.ParentID != root.ID || tenant.Type != domain.TenantTypeCompany {
|
|
continue
|
|
}
|
|
if candidates[normalizeWorksmobileSlugLocalPart(tenant.Slug)] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func worksmobileRemoteGroupLocalPartCandidates(remote WorksmobileRemoteGroup) map[string]bool {
|
|
result := map[string]bool{}
|
|
if localPart := normalizeWorksmobileSlugLocalPart(remote.MailLocalPart); localPart != "" {
|
|
result[localPart] = true
|
|
}
|
|
if localPart, err := domain.ExtractNormalizedEmailLocalPart(remote.Email); err == nil && localPart != "" {
|
|
result[localPart] = true
|
|
}
|
|
return result
|
|
}
|
|
|
|
func normalizeWorksmobileSlugLocalPart(value string) string {
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
}
|
|
|
|
func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
|
|
if 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
|
|
}
|