1
0
forked from baron/baron-sso

feat: update worksmobile sync and restore planning

This commit is contained in:
2026-06-01 17:01:53 +09:00
parent 6574fb54b9
commit 5c8a338085
36 changed files with 3922 additions and 243 deletions

View File

@@ -5,9 +5,11 @@ import (
"baron-sso-backend/internal/repository"
"context"
"errors"
"net/mail"
"os"
"sort"
"strings"
"time"
)
const HanmacFamilyTenantSlug = "hanmac-family"
@@ -25,9 +27,12 @@ type WorksmobileAdminService interface {
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 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 string) ([]WorksmobileInitialPasswordCredential, 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 {
@@ -49,10 +54,36 @@ type WorksmobileBackfillDryRun struct {
}
type WorksmobileInitialPasswordCredential struct {
Email string `json:"email"`
InitialPassword string `json:"initialPassword"`
Status string `json:"status"`
LastError string `json:"lastError,omitempty"`
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 {
@@ -93,6 +124,10 @@ type WorksmobileComparisonItem struct {
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"`
}
@@ -185,8 +220,10 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
return WorksmobileComparison{}, err
}
recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000)
return WorksmobileComparison{
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID),
Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID, worksmobileUserJobSummaries(recentJobs)),
Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched),
}, nil
}
@@ -340,7 +377,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
return item, nil
}
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) {
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
@@ -394,18 +431,104 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
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) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error) {
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
}
jobs, err := s.outboxRepo.ListRecent(ctx, 1000)
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
}
@@ -418,6 +541,9 @@ func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Cont
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] {
@@ -425,15 +551,60 @@ func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Cont
}
seen[email] = true
credentials = append(credentials, WorksmobileInitialPasswordCredential{
Email: email,
InitialPassword: password,
Status: job.Status,
LastError: job.LastError,
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
@@ -663,7 +834,7 @@ func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context
if existingUser.ID == user.ID {
continue
}
addWorksmobileLocalPart(existing, existingUser.Email, existingUser.ID)
addWorksmobileEmail(existing, existingUser.Email, existingUser.ID)
if existingUser.TenantID == nil {
continue
}
@@ -672,16 +843,16 @@ func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context
continue
}
for _, alias := range BuildWorksmobileAliasEmails(existingUser, tenant) {
addWorksmobileLocalPart(existing, alias, existingUser.ID)
addWorksmobileEmail(existing, alias, existingUser.ID)
}
}
return ValidateWorksmobileAliasLocalParts(payload.Email, payload.AliasEmails, existing)
return ValidateWorksmobileAliasEmails(payload.Email, payload.AliasEmails, existing)
}
func addWorksmobileLocalPart(target map[string]string, email string, owner string) {
localPart, err := domain.ExtractNormalizedEmailLocalPart(email)
if err == nil && localPart != "" {
target[localPart] = owner
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
}
}
@@ -833,6 +1004,196 @@ func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload,
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:
@@ -842,7 +1203,40 @@ func stringValue(value any) string {
}
}
func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant) []WorksmobileComparisonItem {
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 {
@@ -872,7 +1266,8 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
if !matched {
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
}
if matched && !includeMatched {
needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote)
if matched && !includeMatched && !needsUpdate {
matchedRemoteIDs[remote.ID] = true
continue
}
@@ -886,8 +1281,19 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
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
@@ -958,6 +1364,62 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
return result
}
func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) 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 worksmobileUserManagerNeedsUpdate(user, remote) {
return true
}
return false
}
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 ""