1
0
forked from baron/baron-sso

패키징 개선

This commit is contained in:
2026-06-22 17:56:20 +09:00
parent 12d8d0e832
commit 9cbc9828e6
27 changed files with 1239 additions and 177 deletions

View File

@@ -89,6 +89,26 @@ func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusAccepted).JSON(job)
}
func (h *WorksmobileHandler) ImportUsersFromWorks(c *fiber.Ctx) error {
var req struct {
WorksmobileUserIDs []string `json:"worksmobileUserIds"`
}
if len(c.Body()) > 0 {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
}
result, err := h.Service.ImportUsersFromWorks(
c.Context(),
strings.TrimSpace(c.Params("tenantId")),
req.WorksmobileUserIDs,
)
if err != nil {
return worksmobileGuardError(c, err, "import_users_from_works")
}
return c.JSON(result)
}
func (h *WorksmobileHandler) ResetUserPassword(c *fiber.Ctx) error {
userID := strings.TrimSpace(c.Params("userId"))
credentialBatchID, err := parseWorksmobileCredentialBatchID(c)

View File

@@ -230,6 +230,10 @@ func (f *fakeWorksmobileAdminService) GetComparison(ctx context.Context, tenantI
return service.WorksmobileComparison{}, nil
}
func (f *fakeWorksmobileAdminService) ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (service.WorksmobileImportUsersResult, error) {
return service.WorksmobileImportUsersResult{UpdatedCount: len(worksmobileUserIDs)}, nil
}
func (f *fakeWorksmobileAdminService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (service.WorksmobileBackfillDryRun, error) {
return service.WorksmobileBackfillDryRun{}, nil
}

View File

@@ -12,6 +12,7 @@ import (
type WorksmobileOutboxRepository interface {
Create(ctx context.Context, item *domain.WorksmobileOutbox) error
ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error)
ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error)
UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error
DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error)
@@ -59,6 +60,20 @@ func (r *worksmobileOutboxRepository) ListRecent(ctx context.Context, limit int)
return rows, err
}
func (r *worksmobileOutboxRepository) ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error) {
if limit <= 0 || limit > 1000 {
limit = 50
}
query := r.db.WithContext(ctx).Where("payload ->> 'tenantRootId' = ?", tenantRootID)
if len(resourceIDs) > 0 {
query = query.Or("resource_id IN ?", resourceIDs)
}
var rows []domain.WorksmobileOutbox
err := query.Order("created_at desc").Limit(limit).Find(&rows).Error
return rows, err
}
func (r *worksmobileOutboxRepository) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
query := r.db.WithContext(ctx).
Where("resource_type = ? AND payload ->> 'tenantRootId' = ? AND coalesce(payload ->> 'credentialBatchId', '') <> ?", domain.WorksmobileResourceUser, tenantRootID, "")

View File

@@ -69,6 +69,56 @@ func TestWorksmobileOutboxRepositoryDeletePendingByTenantRoot(t *testing.T) {
require.Equal(t, "00000000-0000-0000-0000-000000000104", remaining[2].ID)
}
func TestWorksmobileOutboxRepositoryListRecentByTenantRoot(t *testing.T) {
repo := NewWorksmobileOutboxRepository(testDB)
ctx := context.Background()
require.NoError(t, testDB.Exec("DELETE FROM worksmobile_outboxes").Error)
rows := []domain.WorksmobileOutbox{
{
ID: "00000000-0000-0000-0000-000000000151",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-root",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusFailed,
DedupeKey: "recent-root-user",
Payload: domain.JSONMap{"tenantRootId": "root-1"},
CreatedAt: time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC),
},
{
ID: "00000000-0000-0000-0000-000000000152",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "child-tenant",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusFailed,
DedupeKey: "recent-root-org-legacy",
Payload: domain.JSONMap{},
CreatedAt: time.Date(2026, 6, 1, 11, 0, 0, 0, time.UTC),
},
{
ID: "00000000-0000-0000-0000-000000000153",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-other",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusFailed,
DedupeKey: "recent-other-root",
Payload: domain.JSONMap{"tenantRootId": "root-2"},
CreatedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
},
}
for i := range rows {
require.NoError(t, testDB.Create(&rows[i]).Error)
}
recent, err := repo.ListRecentByTenantRoot(ctx, "root-1", []string{"child-tenant"}, 50)
require.NoError(t, err)
require.Len(t, recent, 2)
require.Equal(t, "00000000-0000-0000-0000-000000000152", recent[0].ID)
require.Equal(t, "00000000-0000-0000-0000-000000000151", recent[1].ID)
}
func TestWorksmobileOutboxRepositoryListReadyWaitsForPendingOrgUnitParent(t *testing.T) {
repo := NewWorksmobileOutboxRepository(testDB)
ctx := context.Background()

View File

@@ -1924,6 +1924,20 @@ func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) (
return f.recent, nil
}
func (f *fakeWorksmobileOutboxRepo) ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error) {
resourceIDSet := map[string]bool{}
for _, id := range resourceIDs {
resourceIDSet[id] = true
}
rows := make([]domain.WorksmobileOutbox, 0)
for _, row := range f.recent {
if stringValue(row.Payload["tenantRootId"]) == tenantRootID || resourceIDSet[row.ResourceID] {
rows = append(rows, row)
}
}
return rows, nil
}
func (f *fakeWorksmobileOutboxRepo) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
rows := make([]domain.WorksmobileOutbox, 0)
for _, row := range f.credentialBatchJobs {

View File

@@ -46,7 +46,8 @@ type WorksmobileUserPayload struct {
}
type WorksmobileUserName struct {
LastName string `json:"lastName,omitempty"`
LastName string `json:"lastName,omitempty"`
FirstName string `json:"firstName,omitempty"`
}
type WorksmobilePasswordConfig struct {
@@ -61,6 +62,26 @@ func (c WorksmobilePasswordConfig) IsZero() bool {
c.ChangePasswordAtNextLogin == nil
}
func worksmobileUserNameFromDisplayName(name string) WorksmobileUserName {
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, " \t\r\n") {
return WorksmobileUserName{LastName: name}
}
runes := []rune(name)
if len(runes) < 2 || len(runes) > 4 {
return WorksmobileUserName{LastName: name}
}
for _, r := range runes {
if r < '가' || r > '힣' {
return WorksmobileUserName{LastName: name}
}
}
return WorksmobileUserName{
LastName: string(runes[:1]),
FirstName: string(runes[1:]),
}
}
func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
type payloadJSON struct {
DomainID int64 `json:"domainId"`
@@ -299,7 +320,7 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
DomainID: domainID,
Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
UserName: worksmobileUserNameFromDisplayName(user.Name),
CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: employeeNumber,
Locale: "ko_KR",

View File

@@ -34,6 +34,7 @@ type WorksmobileSyncer interface {
type WorksmobileAdminService interface {
GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error)
GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error)
ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (WorksmobileImportUsersResult, error)
EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error)
@@ -68,6 +69,27 @@ type WorksmobilePendingJobDeleteResult struct {
DeletedCount int `json:"deletedCount"`
}
type WorksmobileImportUsersResult struct {
UpdatedCount int `json:"updatedCount"`
CreatedCount int `json:"createdCount"`
ExternalKeyUpdates int `json:"externalKeyUpdates"`
Failures []WorksmobileImportUsersFailure `json:"failures,omitempty"`
Items []WorksmobileImportUsersResultItem `json:"items,omitempty"`
}
type WorksmobileImportUsersFailure struct {
WorksmobileID string `json:"worksmobileId,omitempty"`
Email string `json:"email,omitempty"`
Error string `json:"error"`
}
type WorksmobileImportUsersResultItem struct {
WorksmobileID string `json:"worksmobileId,omitempty"`
BaronID string `json:"baronId,omitempty"`
Email string `json:"email,omitempty"`
Action string `json:"action"`
}
type WorksmobileInitialPasswordCredential struct {
Email string `json:"email"`
Name string `json:"name,omitempty"`
@@ -178,6 +200,8 @@ type worksmobileSyncService struct {
outboxRepo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient
identityMirror WorksmobileIdentityMirror
identityWriter IdentityWriteService
kratos KratosAdminService
}
type WorksmobileIdentityMirror interface {
@@ -201,18 +225,30 @@ func (s *worksmobileSyncService) SetIdentityMirror(source WorksmobileIdentityMir
s.identityMirror = source
}
func (s *worksmobileSyncService) SetIdentityServices(writer IdentityWriteService, kratos KratosAdminService) {
if s == nil {
return
}
s.identityWriter = writer
s.kratos = kratos
}
func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) {
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return WorksmobileTenantOverview{}, err
}
jobs, _ := s.outboxRepo.ListRecent(ctx, 50)
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return WorksmobileTenantOverview{}, err
}
jobs, _ := s.outboxRepo.ListRecentByTenantRoot(ctx, root.ID, worksmobileRecentResourceIDs(root.ID, scopeTenants), 50)
jobs = redactWorksmobileOutboxPayloads(jobs)
return WorksmobileTenantOverview{
Tenant: *tenant,
Tenant: *root,
Config: WorksmobileConfigSummary{
Enabled: WorksmobileEnabled(tenant.Config),
DomainMappings: WorksmobileDomainMappings(tenant.Config),
Enabled: WorksmobileEnabled(root.Config),
DomainMappings: WorksmobileDomainMappings(root.Config),
TokenConfigured: worksmobileDirectoryAuthConfigured(),
AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")),
},
@@ -231,6 +267,15 @@ func worksmobileDirectoryAuthConfigured() bool {
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "")
}
func worksmobileRecentResourceIDs(rootID string, tenants []domain.Tenant) []string {
ids := make([]string, 0, len(tenants)+1)
ids = append(ids, rootID)
for _, tenant := range tenants {
ids = append(ids, tenant.ID)
}
return ids
}
func WorksmobileExcluded(config domain.JSONMap) bool {
rawValue, ok := config[worksmobileExcludedConfigKey]
if !ok {
@@ -403,6 +448,273 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
}, nil
}
func (s *worksmobileSyncService) ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (WorksmobileImportUsersResult, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
if s.client == nil {
return WorksmobileImportUsersResult{}, errors.New("worksmobile client is not configured")
}
if len(worksmobileUserIDs) == 0 {
return WorksmobileImportUsersResult{}, errors.New("worksmobile user ids are required")
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
remoteUsers, err := s.client.ListUsers(ctx)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
remoteGroups, err := s.client.ListGroups(ctx)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
remoteByID := make(map[string]WorksmobileRemoteUser, len(remoteUsers))
for _, remote := range remoteUsers {
if id := strings.TrimSpace(remote.ID); id != "" {
remoteByID[id] = remote
}
}
groupByID := make(map[string]WorksmobileRemoteGroup, len(remoteGroups))
for _, group := range remoteGroups {
if id := strings.TrimSpace(group.ID); id != "" {
groupByID[id] = group
}
}
result := WorksmobileImportUsersResult{}
seen := map[string]bool{}
for _, rawID := range worksmobileUserIDs {
worksmobileID := strings.TrimSpace(rawID)
if worksmobileID == "" || seen[worksmobileID] {
continue
}
seen[worksmobileID] = true
remote, ok := remoteByID[worksmobileID]
if !ok {
result.Failures = append(result.Failures, WorksmobileImportUsersFailure{WorksmobileID: worksmobileID, Error: "worksmobile user not found"})
continue
}
user, created, externalKeyUpdated, err := s.importSingleWorksmobileUser(ctx, root.ID, remote, tenantByID, groupByID)
if err != nil {
result.Failures = append(result.Failures, WorksmobileImportUsersFailure{WorksmobileID: worksmobileID, Email: remote.Email, Error: err.Error()})
continue
}
action := "updated"
if created {
action = "created"
result.CreatedCount++
} else {
result.UpdatedCount++
}
if externalKeyUpdated {
result.ExternalKeyUpdates++
}
result.Items = append(result.Items, WorksmobileImportUsersResultItem{
WorksmobileID: worksmobileID,
BaronID: user.ID,
Email: user.Email,
Action: action,
})
}
return result, nil
}
func (s *worksmobileSyncService) importSingleWorksmobileUser(ctx context.Context, rootID string, remote WorksmobileRemoteUser, tenantByID map[string]domain.Tenant, groupByID map[string]WorksmobileRemoteGroup) (domain.User, bool, bool, error) {
email := strings.ToLower(strings.TrimSpace(remote.Email))
if email == "" {
return domain.User{}, false, false, errors.New("worksmobile user email is required")
}
tenantID := worksmobileTenantIDForRemoteUser(remote, groupByID)
tenant, ok := tenantByID[tenantID]
if !ok || !isWorksmobileUserScopeTenant(tenant) {
return domain.User{}, false, false, fmt.Errorf("worksmobile primary org is outside import scope: %s", tenantID)
}
var existing *domain.User
if externalKey := strings.TrimSpace(remote.ExternalID); externalKey != "" {
if user, err := s.userRepo.FindByID(ctx, externalKey); err == nil {
existing = user
} else {
return domain.User{}, false, false, fmt.Errorf("worksmobile external key does not match a Baron user: %s", externalKey)
}
} else if user, err := s.userRepo.FindByEmail(ctx, email); err == nil {
existing = user
}
if existing != nil {
user := *existing
applyWorksmobileRemoteToUser(&user, remote, tenant.ID)
if err := s.updateImportedWorksmobileUserIdentity(ctx, user); err != nil {
return domain.User{}, false, false, err
}
if err := s.userRepo.Update(ctx, &user); err != nil {
return domain.User{}, false, false, err
}
updatedExternalKey := false
if strings.TrimSpace(remote.ExternalID) == "" {
if err := s.patchWorksmobileUserExternalKey(ctx, remote, user.ID); err != nil {
return domain.User{}, false, false, err
}
updatedExternalKey = true
}
return user, false, updatedExternalKey, nil
}
if strings.TrimSpace(remote.ExternalID) != "" {
return domain.User{}, false, false, errors.New("creating Baron user from non-empty unmatched worksmobile external key is not supported")
}
if s.kratos == nil {
return domain.User{}, false, false, errors.New("kratos admin service is required")
}
identityID, err := s.kratos.CreateUser(ctx, &domain.BrokerUser{
Email: email,
Name: strings.TrimSpace(remote.DisplayName),
PhoneNumber: strings.TrimSpace(remote.CellPhone),
Attributes: map[string]any{
"tenant_id": tenant.ID,
"role": domain.RoleUser,
"status": domain.UserStatusActive,
"grade": strings.TrimSpace(remote.LevelName),
"jobTitle": strings.TrimSpace(remote.Task),
},
}, GenerateWorksmobileInitialPassword())
if err != nil {
return domain.User{}, false, false, err
}
now := time.Now().UTC()
user := domain.User{
ID: identityID,
Email: email,
Name: strings.TrimSpace(remote.DisplayName),
Phone: strings.TrimSpace(remote.CellPhone),
Role: domain.RoleUser,
Status: domain.UserStatusActive,
TenantID: &tenant.ID,
Grade: strings.TrimSpace(remote.LevelName),
JobTitle: strings.TrimSpace(remote.Task),
Metadata: worksmobileImportedUserMetadata(remote, tenant),
CreatedAt: now,
UpdatedAt: now,
}
if err := s.userRepo.Update(ctx, &user); err != nil {
return domain.User{}, false, false, err
}
if err := s.patchWorksmobileUserExternalKey(ctx, remote, user.ID); err != nil {
return domain.User{}, false, false, err
}
return user, true, true, nil
}
func (s *worksmobileSyncService) updateImportedWorksmobileUserIdentity(ctx context.Context, user domain.User) error {
if s.identityWriter == nil {
return nil
}
identity, err := s.identityWriter.GetIdentity(ctx, user.ID)
if err != nil {
return err
}
traits := map[string]any{}
for key, value := range identity.Traits {
traits[key] = value
}
traits["email"] = user.Email
traits["name"] = user.Name
if phone := strings.TrimSpace(user.Phone); phone != "" {
traits["phone_number"] = phone
}
traits["tenant_id"] = strings.TrimSpace(stringPtrValue(user.TenantID))
traits["role"] = user.Role
traits["status"] = user.Status
traits["grade"] = user.Grade
traits["jobTitle"] = user.JobTitle
_, err = s.identityWriter.UpdateIdentity(ctx, IdentityUpdateRequest{
IdentityID: user.ID,
Traits: traits,
State: strings.TrimSpace(identity.State),
Reason: "worksmobile_import_from_works",
Source: "admin_worksmobile",
})
return err
}
func (s *worksmobileSyncService) patchWorksmobileUserExternalKey(ctx context.Context, remote WorksmobileRemoteUser, userID string) error {
return s.client.UpdateUserOnly(ctx, WorksmobileUserPayload{
DomainID: remote.DomainID,
Email: strings.TrimSpace(remote.Email),
UserExternalKey: strings.TrimSpace(userID),
CellPhone: strings.TrimSpace(remote.CellPhone),
EmployeeNumber: strings.TrimSpace(remote.EmployeeNumber),
Locale: "ko_KR",
Task: strings.TrimSpace(remote.Task),
})
}
func applyWorksmobileRemoteToUser(user *domain.User, remote WorksmobileRemoteUser, tenantID string) {
now := time.Now().UTC()
user.Email = strings.ToLower(strings.TrimSpace(remote.Email))
user.Name = strings.TrimSpace(remote.DisplayName)
user.Phone = strings.TrimSpace(remote.CellPhone)
user.Role = domain.NormalizeRole(user.Role)
user.Status = domain.UserStatusActive
user.TenantID = &tenantID
user.Grade = strings.TrimSpace(remote.LevelName)
user.JobTitle = strings.TrimSpace(remote.Task)
user.Metadata = mergeWorksmobileImportedUserMetadata(user.Metadata, remote, tenantID)
user.UpdatedAt = now
}
func worksmobileImportedUserMetadata(remote WorksmobileRemoteUser, tenant domain.Tenant) domain.JSONMap {
return mergeWorksmobileImportedUserMetadata(domain.JSONMap{}, remote, tenant.ID)
}
func mergeWorksmobileImportedUserMetadata(metadata domain.JSONMap, remote WorksmobileRemoteUser, tenantID string) domain.JSONMap {
if metadata == nil {
metadata = domain.JSONMap{}
}
if value := strings.TrimSpace(remote.EmployeeNumber); value != "" {
metadata["employeeNumber"] = value
metadata["employee_id"] = value
}
if value := strings.TrimSpace(remote.LevelName); value != "" {
metadata["grade"] = value
}
if value := strings.TrimSpace(remote.PrimaryOrgUnitName); value != "" {
metadata["department"] = value
}
metadata["worksmobileImportedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
metadata["worksmobileId"] = strings.TrimSpace(remote.ID)
metadata["worksmobileDomainId"] = remote.DomainID
metadata["worksmobilePrimaryOrgUnitId"] = strings.TrimSpace(remote.PrimaryOrgUnitID)
metadata["additionalAppointments"] = []domain.JSONMap{{
"tenantId": tenantID,
"isPrimary": true,
"grade": strings.TrimSpace(remote.LevelName),
}}
return metadata
}
func worksmobileTenantIDForRemoteUser(remote WorksmobileRemoteUser, groupByID map[string]WorksmobileRemoteGroup) string {
primaryOrgUnitID := strings.TrimSpace(remote.PrimaryOrgUnitID)
if tenantID, ok := strings.CutPrefix(primaryOrgUnitID, "externalKey:"); ok {
return strings.TrimSpace(tenantID)
}
if group, ok := groupByID[primaryOrgUnitID]; ok {
return strings.TrimSpace(group.ExternalID)
}
return ""
}
func stringPtrValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string, tenantByID map[string]domain.Tenant) ([]domain.User, error) {
if s.identityMirror != nil {
status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
@@ -586,8 +898,9 @@ func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tena
Action: domain.WorksmobileActionDryRun,
DedupeKey: "backfill:dry-run:" + root.ID,
Payload: domain.JSONMap{
"tenantIds": orgUnitTenantIDs,
"userCount": len(users),
"tenantRootId": root.ID,
"tenantIds": orgUnitTenantIDs,
"userCount": len(users),
},
})
return WorksmobileBackfillDryRun{OrgUnitCount: len(orgUnitTenantIDs), UserCount: len(users)}, nil
@@ -604,10 +917,17 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
}
tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil {
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
if !ok || tenantRoot.ID != root.ID {
return nil, errors.New("target orgunit is outside hanmac-family subtree")
err := errors.New("target orgunit is outside hanmac-family subtree")
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
@@ -615,10 +935,18 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
return nil, errors.New("target tenant is excluded from Worksmobile sync")
err := errors.New("target tenant is excluded from Worksmobile sync")
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
err := errors.New("target tenant is not a worksmobile orgunit tenant")
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
return s.enqueueOrgUnitUpsert(ctx, root, *tenant, scopeTenants)
}
@@ -632,6 +960,9 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
0,
)
if err != nil {
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
@@ -641,6 +972,7 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"request": payload,
"matchLocalPart": tenant.Slug,
},
@@ -651,6 +983,36 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
return item, nil
}
func (s *worksmobileSyncService) recordRejectedOrgUnitSync(ctx context.Context, rootID string, tenant domain.Tenant, reason error) error {
if s.outboxRepo == nil {
return nil
}
payload := domain.JSONMap{
"tenantRootId": rootID,
"displayName": strings.TrimSpace(tenant.Name),
"matchLocalPart": strings.TrimSpace(tenant.Slug),
"tenantSlug": strings.TrimSpace(tenant.Slug),
"requestSummary": domain.JSONMap{
"orgUnitName": strings.TrimSpace(tenant.Name),
"orgUnitExternalKey": tenant.ID,
"tenantSlug": strings.TrimSpace(tenant.Slug),
},
}
if tenant.ParentID != nil {
payload["parentTenantId"] = strings.TrimSpace(*tenant.ParentID)
}
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: tenant.ID,
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:rejected:" + tenant.ID + ":" + uuid.NewString(),
Payload: payload,
Status: domain.WorksmobileOutboxStatusFailed,
LastError: reason.Error(),
}
return s.outboxRepo.Create(ctx, item)
}
func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
@@ -692,6 +1054,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
Action: domain.WorksmobileActionDelete,
DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"worksmobileId": worksmobileOrgUnitID,
"externalKey": target.ExternalID,
"domainId": target.DomainID,
@@ -756,7 +1119,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
return nil, err
}
action := WorksmobileUserStatusAction(user.Status)
if action == domain.WorksmobileActionUpsert {
if action == domain.WorksmobileActionUpsert && strings.TrimSpace(initialPassword) != "" {
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword)
}
item := &domain.WorksmobileOutbox{
@@ -768,7 +1131,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
}
item.Payload["displayName"] = strings.TrimSpace(user.Name)
item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID)
if batchID := strings.TrimSpace(credentialBatchID); batchID != "" {
if batchID := strings.TrimSpace(credentialBatchID); batchID != "" && strings.TrimSpace(payload.PasswordConfig.Password) != "" {
item.Payload["credentialBatchId"] = batchID
item.Payload["credentialOperation"] = "worksmobile_user_sync"
item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
@@ -783,7 +1146,7 @@ func (s *worksmobileSyncService) recordRejectedUserSync(ctx context.Context, roo
payload := WorksmobileUserPayload{
Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
UserName: worksmobileUserNameFromDisplayName(user.Name),
CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: metadataEmployeeNumber(user.Metadata),
Locale: "ko_KR",

View File

@@ -164,7 +164,7 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
}
func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
func TestWorksmobileSyncServiceSkipsAdminInitialPasswordWhenEmpty(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
tenantID := "saman-tenant"
@@ -201,13 +201,13 @@ func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, item)
initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"])
require.NotEmpty(t, initialPassword)
require.Empty(t, initialPassword)
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
require.Equal(t, initialPassword, request.PasswordConfig.Password)
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
require.Empty(t, request.PasswordConfig.PasswordCreationType)
require.Empty(t, request.PasswordConfig.Password)
require.Nil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
require.Empty(t, stringValue(outboxRepo.created[0].Payload["credentialBatchId"]))
}
func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) {
@@ -661,6 +661,7 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"loginEmail": "changed@example.com",
"displayName": "변경 사용자",
"primaryLeafOrgName": "인재성장",
@@ -680,6 +681,7 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"matchLocalPart": "people-growth",
"request": WorksmobileOrgUnitPayload{
OrgUnitName: "인재성장",
@@ -725,6 +727,67 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
}, orgPayload["requestSummary"])
}
func TestWorksmobileSyncServiceOverviewScopesRecentJobsToTenantRoot(t *testing.T) {
rootID := "root-tenant"
childID := "child-org"
otherRootID := "other-root"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
child := domain.Tenant{
ID: childID,
Slug: "structure-planning",
Name: "구조물계획",
Type: domain.TenantTypeUserGroup,
ParentID: &rootID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{
recent: []domain.WorksmobileOutbox{
{
ID: "job-root-user-failed",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-1",
Status: domain.WorksmobileOutboxStatusFailed,
Payload: domain.JSONMap{"tenantRootId": rootID},
LastError: "worksmobile api failed",
},
{
ID: "job-child-org-legacy",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: childID,
Status: domain.WorksmobileOutboxStatusFailed,
LastError: "legacy org job without tenantRootId",
},
{
ID: "job-other-root",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-2",
Status: domain.WorksmobileOutboxStatusFailed,
Payload: domain.JSONMap{"tenantRootId": otherRootID},
},
},
}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{rootID: root, childID: child},
list: []domain.Tenant{child},
},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
overview, err := service.GetTenantOverview(context.Background(), rootID)
require.NoError(t, err)
require.Len(t, overview.RecentJobs, 2)
require.Equal(t, "job-root-user-failed", overview.RecentJobs[0].ID)
require.Equal(t, "job-child-org-legacy", overview.RecentJobs[1].ID)
require.Equal(t, "legacy org job without tenantRootId", overview.RecentJobs[1].LastError)
}
func TestWorksmobileSyncServiceEnqueueTenantUpsertReflectsChangedParentOrgUnit(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
@@ -1041,7 +1104,12 @@ func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) {
require.Nil(t, item)
require.Error(t, err)
require.Contains(t, err.Error(), "worksmobile orgunit tenant")
require.Empty(t, outboxRepo.created)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType)
require.Equal(t, domain.WorksmobileOutboxStatusFailed, outboxRepo.created[0].Status)
require.Equal(t, companyID, outboxRepo.created[0].ResourceID)
require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"])
require.Equal(t, "target tenant is not a worksmobile orgunit tenant", outboxRepo.created[0].LastError)
}
func TestWorksmobileSyncServiceEnqueuesBarongroupChildCompanyOrgUnitSync(t *testing.T) {
@@ -2046,7 +2114,13 @@ func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
require.Nil(t, item)
require.ErrorContains(t, err, "excluded from Worksmobile sync")
require.Empty(t, outboxRepo.created)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType)
require.Equal(t, domain.WorksmobileOutboxStatusFailed, outboxRepo.created[0].Status)
require.Equal(t, excludedOrgID, outboxRepo.created[0].ResourceID)
require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"])
require.Equal(t, "excluded-team", outboxRepo.created[0].Payload["matchLocalPart"])
require.Equal(t, "target tenant is excluded from Worksmobile sync", outboxRepo.created[0].LastError)
}
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {