1
0
forked from baron/baron-sso

feat: improve Worksmobile tenant sync handling

This commit is contained in:
2026-06-02 18:05:36 +09:00
parent d6d39ca300
commit d32ca69eee
58 changed files with 4035 additions and 1400 deletions

View File

@@ -17,6 +17,7 @@ type UserGroupService interface {
List(ctx context.Context, tenantID string) ([]domain.UserGroup, error)
Delete(ctx context.Context, tenantID, groupID string) error
Update(ctx context.Context, tenantID, groupID string, name, description, unitType string, parentID *string) (*domain.UserGroup, error)
SetWorksmobileSyncer(syncer WorksmobileSyncer)
// Member Management with Keto Sync
AddMember(ctx context.Context, groupID, userID string) error
@@ -35,6 +36,7 @@ type userGroupService struct {
ketoService KetoService
outboxRepo repository.KetoOutboxRepository
kratos KratosAdminService
worksmobile WorksmobileSyncer
}
func NewUserGroupService(
@@ -55,6 +57,10 @@ func NewUserGroupService(
}
}
func (s *userGroupService) SetWorksmobileSyncer(syncer WorksmobileSyncer) {
s.worksmobile = syncer
}
func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) {
// For Keto and Tenant hierarchy, if no parent group, the company tenant is the parent.
actualParentID := parentID
@@ -261,6 +267,10 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
localUser.Department = group.Name
if err := s.userRepo.Update(ctx, localUser); err != nil {
slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err)
} else if s.worksmobile != nil {
if err := s.worksmobile.EnqueueUserUpsertIfInScope(ctx, *localUser); err != nil {
slog.Warn("Failed to enqueue Worksmobile user sync during AddMember", "user", userID, "error", err)
}
}
}
}

View File

@@ -139,6 +139,27 @@ func (m *MockUserRepository) DB() *gorm.DB {
return nil
}
type fakeUserGroupWorksmobileSyncer struct {
userUpserts []domain.User
}
func (f *fakeUserGroupWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
return nil
}
func (f *fakeUserGroupWorksmobileSyncer) EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error {
return nil
}
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error {
f.userUpserts = append(f.userUpserts, user)
return nil
}
func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error {
return nil
}
type MockKetoOutboxRepository struct {
mock.Mock
}
@@ -337,6 +358,57 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
mockKratos.AssertExpectations(t)
}
func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
mockOutbox := new(MockKetoOutboxRepositoryShared)
mockUserGroupRepo := new(MockUserGroupRepository)
mockUserRepo := new(MockUserRepository)
mockTenantRepo := new(MockTenantRepository)
mockKratos := new(MockKratosAdminServiceShared)
worksmobile := &fakeUserGroupWorksmobileSyncer{}
svc := NewUserGroupService(mockUserGroupRepo, mockUserRepo, mockTenantRepo, nil, mockOutbox, mockKratos)
svc.SetWorksmobileSyncer(worksmobile)
groupID := "group-1"
userID := "user-1"
tenantID := "tenant-1"
mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, TenantID: tenantID, Name: "Sales"}, nil)
mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{
ID: userID,
Email: "user@test.com",
Name: "User Test",
Status: "active",
}, nil)
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: "tenant-slug"}, nil)
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
ID: userID,
Traits: map[string]any{"email": "user@test.com"},
State: "active",
}, nil)
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{
ID: userID,
Traits: map[string]any{"email": "user@test.com", "tenant_id": tenantID, "department": "Sales"},
State: "active",
}, nil)
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
})).Return(nil).Once()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID
})).Return(nil).Once()
err := svc.AddMember(context.Background(), groupID, userID)
assert.NoError(t, err)
assert.Len(t, worksmobile.userUpserts, 1)
assert.Equal(t, userID, worksmobile.userUpserts[0].ID)
assert.NotNil(t, worksmobile.userUpserts[0].TenantID)
assert.Equal(t, tenantID, *worksmobile.userUpserts[0].TenantID)
assert.Equal(t, "Sales", worksmobile.userUpserts[0].Department)
mockOutbox.AssertExpectations(t)
mockKratos.AssertExpectations(t)
}
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
mockOutbox := new(MockKetoOutboxRepositoryShared)
mockUserGroupRepo := new(MockUserGroupRepository)

View File

@@ -658,6 +658,84 @@ func TestWorksmobileRelayWorkerProcessesOrgUnitDeleteAndMarksProcessed(t *testin
require.Equal(t, []string{"works-org-1"}, client.deletedOrgUnits)
}
func TestWorksmobileRelayWorkerProcessesOrgUnitParentsBeforeChildren(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
{
ID: "job-child",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "child-tenant",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
Payload: domain.JSONMap{
"request": map[string]any{
"domainId": 300293726,
"orgUnitExternalKey": "child-tenant",
"orgUnitName": "child",
"parentOrgUnitId": "externalKey:parent-tenant",
},
},
},
{
ID: "job-parent",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "parent-tenant",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
Payload: domain.JSONMap{
"request": map[string]any{
"domainId": 300293726,
"orgUnitExternalKey": "parent-tenant",
"orgUnitName": "parent",
},
},
},
},
}
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"job-parent", "job-child"}, repo.processingIDs)
require.Equal(t, []string{"parent-tenant", "child-tenant"}, []string{
client.createdOrgUnits[0].OrgUnitExternalKey,
client.createdOrgUnits[1].OrgUnitExternalKey,
})
}
func TestWorksmobileRelayWorkerSkipsDispatchWhenJobClaimFails(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
markProcessingClaims: map[string]bool{"job-claimed-by-other-worker": false},
ready: []domain.WorksmobileOutbox{
{
ID: "job-claimed-by-other-worker",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "org-1",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
Payload: domain.JSONMap{
"request": map[string]any{
"domainId": 300293726,
"orgUnitExternalKey": "org-1",
"orgUnitName": "org",
},
},
},
},
}
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Empty(t, repo.processingIDs)
require.Empty(t, repo.processedIDs)
require.Empty(t, client.createdOrgUnits)
}
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
jobs := []domain.WorksmobileOutbox{
{
@@ -1094,14 +1172,17 @@ func boolPtr(value bool) *bool {
}
type fakeWorksmobileOutboxRepo struct {
recent []domain.WorksmobileOutbox
ready []domain.WorksmobileOutbox
created []domain.WorksmobileOutbox
credentialBatchJobs []domain.WorksmobileOutbox
payloadUpdates []domain.JSONMap
processingIDs []string
processedIDs []string
failedIDs []string
recent []domain.WorksmobileOutbox
ready []domain.WorksmobileOutbox
created []domain.WorksmobileOutbox
credentialBatchJobs []domain.WorksmobileOutbox
payloadUpdates []domain.JSONMap
deletedPendingTenantRootID string
deletedPendingCount int
markProcessingClaims map[string]bool
processingIDs []string
processedIDs []string
failedIDs []string
}
func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error {
@@ -1137,6 +1218,11 @@ func (f *fakeWorksmobileOutboxRepo) UpdatePayload(ctx context.Context, id string
return nil
}
func (f *fakeWorksmobileOutboxRepo) DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error) {
f.deletedPendingTenantRootID = tenantRootID
return int64(f.deletedPendingCount), nil
}
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
return f.ready, nil
}
@@ -1149,9 +1235,12 @@ func (f *fakeWorksmobileOutboxRepo) MarkRetry(ctx context.Context, id string) er
return nil
}
func (f *fakeWorksmobileOutboxRepo) MarkProcessing(ctx context.Context, id string) error {
func (f *fakeWorksmobileOutboxRepo) MarkProcessing(ctx context.Context, id string) (bool, error) {
if f.markProcessingClaims != nil && !f.markProcessingClaims[id] {
return false, nil
}
f.processingIDs = append(f.processingIDs, id)
return nil
return true, nil
}
func (f *fakeWorksmobileOutboxRepo) MarkProcessed(ctx context.Context, id string) error {

View File

@@ -126,6 +126,9 @@ func buildWorksmobileOrgUnitEmail(tenant domain.Tenant, domainTenant domain.Tena
func worksmobileTenantMailDomain(tenant domain.Tenant) string {
envKey := strings.TrimSuffix(worksmobileTenantDomainIDEnvKey(tenant), "_DOMAIN_ID")
if domainName := strings.ToLower(strings.TrimSpace(os.Getenv("WORKS_DEFAULT_DOMAIN_" + envKey))); domainName != "" {
return domainName
}
if domainName := strings.ToLower(strings.TrimSpace(os.Getenv(envKey + "_MAIL_DOMAIN"))); domainName != "" {
return domainName
}
@@ -136,6 +139,8 @@ func worksmobileTenantMailDomain(tenant domain.Tenant) string {
return "hanmaceng.co.kr"
case "GPDTDC":
return "baroncs.co.kr"
case "HALLA":
return "hallasanup.com"
case "BARONGROUP":
return "brsw.kr"
default:
@@ -493,6 +498,10 @@ func ResolveWorksmobileAccountDomainIDFromEmail(email string, fallbackTenant dom
if domainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID"); ok {
return domainID, nil
}
case "hallasanup.com":
if domainID, ok := worksmobileDomainIDFromEnv("HALLA_DOMAIN_ID"); ok {
return domainID, nil
}
case "brsw.kr":
if domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID"); ok {
return domainID, nil
@@ -524,6 +533,8 @@ func worksmobileDomainIDEnvKeyFromEmail(email string) string {
return "HANMAC_DOMAIN_ID"
case "baroncs.co.kr":
return "GPDTDC_DOMAIN_ID"
case "hallasanup.com":
return "HALLA_DOMAIN_ID"
case "brsw.kr":
return "BARONGROUP_DOMAIN_ID"
default:
@@ -574,6 +585,9 @@ func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string {
if tenantMatchesAny(tenant, "gpdtdc", "총괄", "기술개발센터", "기술개발") {
return "GPDTDC_DOMAIN_ID"
}
if isHallaWorksmobileTenant(tenant) {
return "HALLA_DOMAIN_ID"
}
return "BARONGROUP_DOMAIN_ID"
}
@@ -595,6 +609,7 @@ func worksmobileDomainEnvMappings() []worksmobileDomainEnvMapping {
{Key: "SAMAN_DOMAIN_ID", Label: "삼안"},
{Key: "HANMAC_DOMAIN_ID", Label: "한맥기술"},
{Key: "GPDTDC_DOMAIN_ID", Label: "총괄기획&기술개발센터"},
{Key: "HALLA_DOMAIN_ID", Label: "한라산업개발"},
{Key: "BARONGROUP_DOMAIN_ID", Label: "바론그룹"},
}
}
@@ -625,6 +640,10 @@ func isHanmacWorksmobileTenant(tenant domain.Tenant) bool {
return tenantHasDomain(tenant, "hanmaceng.co.kr") || tenantMatchesAny(tenant, "hanmac", "한맥")
}
func isHallaWorksmobileTenant(tenant domain.Tenant) bool {
return tenantHasDomain(tenant, "hallasanup.com") || tenantMatchesAny(tenant, "halla", "hanlla", "한라산업개발")
}
func tenantHasDomain(tenant domain.Tenant, domainName string) bool {
domainName = strings.ToLower(strings.TrimSpace(domainName))
for _, d := range tenant.Domains {

View File

@@ -446,6 +446,7 @@ func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
t.Setenv("HALLA_DOMAIN_ID", "1005")
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
tests := []struct {
@@ -468,6 +469,16 @@ func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
tenant: domain.Tenant{Slug: "gpdtdc", Name: "총괄기획&기술개발센터"},
want: 1003,
},
{
name: "halla",
tenant: domain.Tenant{Slug: "halla", Name: "한라산업개발", Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}}},
want: 1005,
},
{
name: "hanlla legacy slug",
tenant: domain.Tenant{Slug: "hanlla", Name: "한라산업개발"},
want: 1005,
},
{
name: "barongroup fallback",
tenant: domain.Tenant{Slug: "family-company", Name: "기타 가족사"},
@@ -484,6 +495,58 @@ func TestResolveWorksmobileDomainIDUsesEnvFamilyFallbacks(t *testing.T) {
}
}
func TestResolveWorksmobileAccountDomainIDUsesHallaEmailDomain(t *testing.T) {
t.Setenv("HALLA_DOMAIN_ID", "1005")
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
tenant := domain.Tenant{
Slug: "halla",
Name: "한라산업개발",
Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}},
}
got, err := ResolveWorksmobileAccountDomainIDFromEmail("user@hallasanup.com", tenant, nil)
require.NoError(t, err)
require.Equal(t, int64(1005), got)
}
func TestWorksmobileDomainIDsFromEnvIncludesHallaBeforeFallback(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
t.Setenv("HALLA_DOMAIN_ID", "1005")
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
got := WorksmobileDomainIDsFromEnv()
require.Equal(t, []int64{1001, 1002, 1003, 1005, 1004}, got)
require.Equal(t, "한라산업개발", WorksmobileDomainLabelForID(1005))
}
func TestBuildWorksmobileUserPayloadUsesHallaDomain(t *testing.T) {
t.Setenv("HALLA_DOMAIN_ID", "1005")
t.Setenv("WORKS_DEFAULT_DOMAIN_HALLA", "hallasanup.com")
tenantID := "33333333-3333-3333-3333-333333333333"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "main@hallasanup.com",
Name: "Halla User",
TenantID: &tenantID,
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "halla",
Name: "한라산업개발",
Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}},
}
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
require.NoError(t, err)
require.Equal(t, int64(1005), payload.DomainID)
require.Equal(t, "main@hallasanup.com", payload.Email)
}
func TestBuildWorksmobileUserPayloadAddsHanmacEmployeeAlias(t *testing.T) {
t.Setenv("HANMAC_DOMAIN_ID", "1002")
tenantID := "33333333-3333-3333-3333-333333333333"

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"log/slog"
"sort"
"strings"
"time"
)
@@ -53,6 +54,7 @@ func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) error {
if err != nil {
return err
}
jobs = sortWorksmobileReadyJobs(jobs)
for _, job := range jobs {
if err := w.processJob(ctx, job); err != nil {
slog.Warn("Worksmobile relay job failed", "jobID", job.ID, "resourceType", job.ResourceType, "resourceID", job.ResourceID, "error", err)
@@ -62,11 +64,15 @@ func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) error {
}
func (w *WorksmobileRelayWorker) processJob(ctx context.Context, job domain.WorksmobileOutbox) error {
if err := w.repo.MarkProcessing(ctx, job.ID); err != nil {
claimed, err := w.repo.MarkProcessing(ctx, job.ID)
if err != nil {
return err
}
if !claimed {
return nil
}
err := w.dispatch(ctx, job)
err = w.dispatch(ctx, job)
if err != nil {
nextAttempt := time.Now().Add(worksmobileRetryDelay(job.RetryCount))
_ = w.repo.MarkFailed(ctx, job.ID, err.Error(), nextAttempt)
@@ -136,6 +142,91 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
}
}
func sortWorksmobileReadyJobs(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
sorted := append([]domain.WorksmobileOutbox(nil), jobs...)
depthByID := worksmobileOrgUnitDepths(sorted)
sort.SliceStable(sorted, func(i, j int) bool {
leftClass := worksmobileRelayOrderClass(sorted[i])
rightClass := worksmobileRelayOrderClass(sorted[j])
if leftClass != rightClass {
return leftClass < rightClass
}
leftDepth := depthByID[sorted[i].ID]
rightDepth := depthByID[sorted[j].ID]
if leftDepth != rightDepth {
return leftDepth < rightDepth
}
return sorted[i].CreatedAt.Before(sorted[j].CreatedAt)
})
return sorted
}
func worksmobileRelayOrderClass(job domain.WorksmobileOutbox) int {
if job.ResourceType == domain.WorksmobileResourceOrgUnit && job.Action == domain.WorksmobileActionUpsert {
return 0
}
if job.ResourceType == domain.WorksmobileResourceUser {
return 1
}
return 2
}
func worksmobileOrgUnitDepths(jobs []domain.WorksmobileOutbox) map[string]int {
type orgUnitJob struct {
jobID string
parentKey string
}
byExternalKey := map[string]orgUnitJob{}
for _, job := range jobs {
externalKey, parentKey := worksmobileOrgUnitExternalKeys(job)
if externalKey == "" {
continue
}
byExternalKey[externalKey] = orgUnitJob{jobID: job.ID, parentKey: parentKey}
}
depthByExternalKey := map[string]int{}
var depth func(externalKey string, seen map[string]bool) int
depth = func(externalKey string, seen map[string]bool) int {
if value, ok := depthByExternalKey[externalKey]; ok {
return value
}
job, ok := byExternalKey[externalKey]
if !ok || job.parentKey == "" || seen[externalKey] {
depthByExternalKey[externalKey] = 0
return 0
}
seen[externalKey] = true
value := depth(job.parentKey, seen) + 1
delete(seen, externalKey)
depthByExternalKey[externalKey] = value
return value
}
depthByJobID := map[string]int{}
for externalKey, job := range byExternalKey {
depthByJobID[job.jobID] = depth(externalKey, map[string]bool{})
}
return depthByJobID
}
func worksmobileOrgUnitExternalKeys(job domain.WorksmobileOutbox) (string, string) {
if job.ResourceType != domain.WorksmobileResourceOrgUnit || job.Action != domain.WorksmobileActionUpsert {
return "", ""
}
var payload WorksmobileOrgUnitPayload
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
return "", ""
}
parentKey := strings.TrimSpace(payload.ParentOrgUnitID)
if strings.HasPrefix(parentKey, "externalKey:") {
parentKey = strings.TrimSpace(strings.TrimPrefix(parentKey, "externalKey:"))
} else {
parentKey = ""
}
return strings.TrimSpace(payload.OrgUnitExternalKey), parentKey
}
func worksmobileOutboxUserIdentifier(job domain.WorksmobileOutbox) string {
userID := stringValue(job.Payload["loginEmail"])
if userID == "" {

View File

@@ -14,6 +14,7 @@ import (
)
const HanmacFamilyTenantSlug = "hanmac-family"
const worksmobileExcludedConfigKey = "worksmobileExcluded"
type WorksmobileSyncer interface {
EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error
@@ -31,6 +32,7 @@ type WorksmobileAdminService interface {
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)
DeletePendingJobs(ctx context.Context, tenantID string) (WorksmobilePendingJobDeleteResult, error)
ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error)
ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error)
DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error)
@@ -54,6 +56,10 @@ type WorksmobileBackfillDryRun struct {
UserCount int `json:"userCount"`
}
type WorksmobilePendingJobDeleteResult struct {
DeletedCount int `json:"deletedCount"`
}
type WorksmobileInitialPasswordCredential struct {
Email string `json:"email"`
Name string `json:"name,omitempty"`
@@ -178,6 +184,21 @@ func worksmobileDirectoryAuthConfigured() bool {
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "")
}
func WorksmobileExcluded(config domain.JSONMap) bool {
rawValue, ok := config[worksmobileExcludedConfigKey]
if !ok {
return false
}
switch value := rawValue.(type) {
case bool:
return value
case string:
return strings.EqualFold(strings.TrimSpace(value), "true")
default:
return false
}
}
func redactWorksmobileOutboxPayloads(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
for i := range jobs {
jobs[i].Payload = safeWorksmobileOutboxPayload(jobs[i].Payload)
@@ -394,6 +415,9 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
return nil, errors.New("target tenant is excluded from Worksmobile sync")
}
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
}
@@ -511,13 +535,16 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
if err != nil {
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
}
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,
@@ -582,6 +609,9 @@ func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, t
return nil, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
return nil, errors.New("target user tenant is excluded from Worksmobile sync")
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config)
if err != nil {
return nil, err
@@ -722,6 +752,18 @@ func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID s
return s.outboxRepo.FindByID(ctx, jobID)
}
func (s *worksmobileSyncService) DeletePendingJobs(ctx context.Context, tenantID string) (WorksmobilePendingJobDeleteResult, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return WorksmobilePendingJobDeleteResult{}, err
}
deleted, err := s.outboxRepo.DeletePendingByTenantRoot(ctx, root.ID)
if err != nil {
return WorksmobilePendingJobDeleteResult{}, err
}
return WorksmobilePendingJobDeleteResult{DeletedCount: int(deleted)}, nil
}
func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error {
root, ok, err := s.rootForTenant(ctx, tenant)
if err != nil || !ok {
@@ -732,6 +774,9 @@ func (s *worksmobileSyncService) EnqueueTenantUpsertIfInScope(ctx context.Contex
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
return nil
}
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
return nil
}
@@ -767,6 +812,9 @@ func (s *worksmobileSyncService) EnqueueTenantDeleteIfInScope(ctx context.Contex
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
return nil
}
if !isWorksmobileOrgUnitTenant(tenant, tenantByID) {
return nil
}
@@ -795,6 +843,10 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
if err != nil {
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[*user.TenantID]; !ok {
return nil
}
if domain.IsWorksDeprovisionUserStatus(user.Status) {
_, err := s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, root.ID)
return err
@@ -802,7 +854,6 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
if !domain.IsWorksProvisionedUserStatus(user.Status) {
return nil
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
*tenant,
@@ -833,10 +884,18 @@ func (s *worksmobileSyncService) EnqueueUserDeleteIfInScope(ctx context.Context,
if err != nil {
return err
}
_, ok, err := s.rootForTenant(ctx, *tenant)
root, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil || !ok {
return err
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[*user.TenantID]; !ok {
return nil
}
_, err = s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, "")
return err
}
@@ -891,6 +950,9 @@ func (s *worksmobileSyncService) hanmacSubtree(ctx context.Context, rootID strin
var visit func(id string)
visit = func(id string) {
for _, child := range byParent[id] {
if WorksmobileExcluded(child.Config) {
continue
}
result = append(result, child)
visit(child.ID)
}
@@ -1011,6 +1073,9 @@ func normalizeWorksmobileSlugLocalPart(value string) string {
}
func isWorksmobileOrgUnitTenant(tenant domain.Tenant, tenantByID map[string]domain.Tenant) bool {
if isWorksmobileDomainRootTenant(tenant) {
return false
}
if tenant.Type == domain.TenantTypeOrganization {
return true
}
@@ -1048,12 +1113,13 @@ func worksmobileDomainClassificationTenant(tenant domain.Tenant, tenantByID map[
func isWorksmobileDomainRootTenant(tenant domain.Tenant) bool {
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
switch slug {
case "saman", "hanmac", "gpdtdc", "baron-group":
case "saman", "hanmac", "gpdtdc", "halla", "hanlla", "baron-group":
return true
}
if tenantHasDomain(tenant, "samaneng.com") ||
tenantHasDomain(tenant, "hanmaceng.co.kr") ||
tenantHasDomain(tenant, "baroncs.co.kr") ||
tenantHasDomain(tenant, "hallasanup.com") ||
tenantHasDomain(tenant, "brsw.kr") {
return true
}
@@ -1061,6 +1127,7 @@ func isWorksmobileDomainRootTenant(tenant domain.Tenant) bool {
return name == "삼안" ||
name == "한맥기술" ||
name == "총괄기획&기술개발센터" ||
name == "한라산업개발" ||
name == "바론그룹"
}

View File

@@ -494,6 +494,70 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
}, orgPayload["requestSummary"])
}
func TestWorksmobileSyncServiceEnqueueTenantUpsertReflectsChangedParentOrgUnit(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
companyID := "saman-tenant"
newParentID := "new-parent-org"
childID := "child-org"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
Type: domain.TenantTypeCompanyGroup,
}
company := domain.Tenant{
ID: companyID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
newParent := domain.Tenant{
ID: newParentID,
Slug: "planning",
Name: "총괄기획",
Type: domain.TenantTypeOrganization,
ParentID: &companyID,
}
child := domain.Tenant{
ID: childID,
Slug: "people-growth",
Name: "인재성장",
Type: domain.TenantTypeOrganization,
ParentID: &newParentID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{
rootID: root,
companyID: company,
newParentID: newParent,
childID: child,
},
list: []domain.Tenant{root, company, newParent, child},
},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
err := service.EnqueueTenantUpsertIfInScope(context.Background(), child)
require.NoError(t, err)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType)
require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
require.Equal(t, childID, outboxRepo.created[0].ResourceID)
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
require.True(t, ok)
require.Equal(t, childID, request.OrgUnitExternalKey)
require.Equal(t, "externalKey:"+newParentID, request.ParentOrgUnitID)
require.Equal(t, "people-growth", outboxRepo.created[0].Payload["matchLocalPart"])
}
func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) {
parentID := "root-tenant"
root := domain.Tenant{
@@ -1085,10 +1149,34 @@ func TestWorksmobileSyncServiceRejectsProtectedDomainRootOrgUnitDelete(t *testin
require.Empty(t, outboxRepo.created)
}
func TestWorksmobileSyncServiceDeletesPendingJobsForTenantRoot(t *testing.T) {
rootID := "root-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
outboxRepo := &fakeWorksmobileOutboxRepo{deletedPendingCount: 2}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
result, err := service.DeletePendingJobs(context.Background(), rootID)
require.NoError(t, err)
require.Equal(t, 2, result.DeletedCount)
require.Equal(t, rootID, outboxRepo.deletedPendingTenantRootID)
}
func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
t.Setenv("HALLA_DOMAIN_ID", "1005")
t.Setenv("WORKS_DEFAULT_DOMAIN_HALLA", "hallasanup.com")
t.Setenv("BARONGROUP_DOMAIN_ID", "1004")
rootID := "root-tenant"
root := domain.Tenant{
@@ -1177,6 +1265,43 @@ func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *
wantDomainID: 1004,
wantEmail: "baron-planning@brsw.kr",
},
{
name: "halla",
company: domain.Tenant{
ID: "company-halla",
Slug: "halla",
Name: "한라산업개발",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}},
},
organization: domain.Tenant{
ID: "org-halla-planning",
Slug: "halla-planning",
Name: "한라 기획팀",
Type: domain.TenantTypeOrganization,
},
wantDomainID: 1005,
wantEmail: "halla-planning@hallasanup.com",
},
{
name: "hanlla legacy slug",
company: domain.Tenant{
ID: "company-hanlla",
Slug: "hanlla",
Name: "한라산업개발",
Type: domain.TenantTypeOrganization,
ParentID: &rootID,
},
organization: domain.Tenant{
ID: "org-hanlla-construction-sites",
Slug: "hanlla-construction-sites",
Name: "시공현장",
Type: domain.TenantTypeOrganization,
},
wantDomainID: 1005,
wantEmail: "hanlla-construction-sites@hallasanup.com",
},
}
for _, tt := range tests {
@@ -1467,6 +1592,181 @@ func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) {
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
}
func TestWorksmobileSyncServiceBackfillDryRunSkipsWorksmobileExcludedSubtree(t *testing.T) {
rootID := "root-tenant"
excludedCompanyID := "excluded-company"
excludedOrgID := "excluded-org"
includedCompanyID := "included-company"
includedOrgID := "included-org"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
excludedCompany := domain.Tenant{
ID: excludedCompanyID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Config: domain.JSONMap{"worksmobileExcluded": true},
}
excludedOrg := domain.Tenant{
ID: excludedOrgID,
Slug: "excluded-team",
Name: "제외팀",
Type: domain.TenantTypeOrganization,
ParentID: &excludedCompanyID,
}
includedCompany := domain.Tenant{
ID: includedCompanyID,
Slug: "halla",
Name: "한라산업개발",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
}
includedOrg := domain.Tenant{
ID: includedOrgID,
Slug: "included-team",
Name: "연동팀",
Type: domain.TenantTypeOrganization,
ParentID: &includedCompanyID,
}
excludedUser := domain.User{
ID: "excluded-user",
Email: "excluded@samaneng.com",
Name: "Excluded User",
TenantID: &excludedOrgID,
Status: domain.UserStatusActive,
}
includedUser := domain.User{
ID: "included-user",
Email: "included@hallasanup.com",
Name: "Included User",
TenantID: &includedOrgID,
Status: domain.UserStatusActive,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{
rootID: root,
excludedCompanyID: excludedCompany,
excludedOrgID: excludedOrg,
includedCompanyID: includedCompany,
includedOrgID: includedOrg,
},
list: []domain.Tenant{root, excludedCompany, excludedOrg, includedCompany, includedOrg},
},
&fakeWorksmobileUserRepo{byTenant: []domain.User{excludedUser, includedUser}},
outboxRepo,
nil,
)
dryRun, err := service.EnqueueBackfillDryRun(context.Background(), rootID)
require.NoError(t, err)
require.Equal(t, 1, dryRun.OrgUnitCount)
require.Equal(t, 1, dryRun.UserCount)
require.Len(t, outboxRepo.created, 1)
require.ElementsMatch(t, []string{includedOrgID}, outboxRepo.created[0].Payload["tenantIds"])
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
}
func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
rootID := "root-tenant"
excludedCompanyID := "excluded-company"
excludedOrgID := "excluded-org"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
excludedCompany := domain.Tenant{
ID: excludedCompanyID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Config: domain.JSONMap{"worksmobileExcluded": true},
}
excludedOrg := domain.Tenant{
ID: excludedOrgID,
Slug: "excluded-team",
Name: "제외팀",
Type: domain.TenantTypeOrganization,
ParentID: &excludedCompanyID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{rootID: root, excludedCompanyID: excludedCompany, excludedOrgID: excludedOrg},
list: []domain.Tenant{root, excludedCompany, excludedOrg},
},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, excludedOrgID)
require.Nil(t, item)
require.ErrorContains(t, err, "excluded from Worksmobile sync")
require.Empty(t, outboxRepo.created)
}
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {
rootID := "root-tenant"
excludedCompanyID := "excluded-company"
excludedOrgID := "excluded-org"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
excludedCompany := domain.Tenant{
ID: excludedCompanyID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Config: domain.JSONMap{"worksmobileExcluded": true},
}
excludedOrg := domain.Tenant{
ID: excludedOrgID,
Slug: "excluded-team",
Name: "제외팀",
Type: domain.TenantTypeOrganization,
ParentID: &excludedCompanyID,
}
user := domain.User{
ID: "excluded-user",
Email: "excluded@samaneng.com",
Name: "Excluded User",
TenantID: &excludedOrgID,
Status: domain.UserStatusActive,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{rootID: root, excludedCompanyID: excludedCompany, excludedOrgID: excludedOrg},
list: []domain.Tenant{root, excludedCompany, excludedOrg},
},
&fakeWorksmobileUserRepo{byID: map[string]domain.User{user.ID: user}},
outboxRepo,
nil,
)
require.NoError(t, service.EnqueueTenantUpsertIfInScope(context.Background(), excludedOrg))
require.NoError(t, service.EnqueueTenantDeleteIfInScope(context.Background(), excludedOrg))
require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), user))
item, err := service.EnqueueUserSync(context.Background(), rootID, user.ID, "")
require.Nil(t, item)
require.ErrorContains(t, err, "excluded from Worksmobile sync")
require.Empty(t, outboxRepo.created)
}
func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
tenantID := "tenant-leaf"
user := domain.User{
@@ -1751,7 +2051,23 @@ func (f *fakeWorksmobileUserRepo) CountByCompanyCodes(ctx context.Context, codes
func (f *fakeWorksmobileUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
f.requestedTenantIDs = append([]string(nil), tenantIDs...)
return f.byTenant, nil
if len(tenantIDs) == 0 {
return nil, nil
}
allowed := make(map[string]bool, len(tenantIDs))
for _, tenantID := range tenantIDs {
allowed[tenantID] = true
}
users := make([]domain.User, 0, len(f.byTenant))
for _, user := range f.byTenant {
if user.TenantID == nil {
continue
}
if allowed[*user.TenantID] {
users = append(users, user)
}
}
return users, nil
}
func (f *fakeWorksmobileUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {