1
0
forked from baron/baron-sso

chore: consolidate local integration changes

This commit is contained in:
2026-06-09 21:03:05 +09:00
parent aa2848c3b6
commit 1341f07ef9
158 changed files with 10995 additions and 1490 deletions

View File

@@ -9,6 +9,7 @@ import (
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -68,27 +69,76 @@ func NewKratosAdminService() KratosAdminService {
func (s *kratosAdminService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) {
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("kratos admin list identities failed status=%d body=%s", resp.StatusCode, string(body))
}
var identities []KratosIdentity
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
return nil, err
pageToken := ""
seenTokens := make(map[string]bool)
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
query := req.URL.Query()
query.Set("page_size", "250")
if pageToken != "" {
query.Set("page_token", pageToken)
}
req.URL.RawQuery = query.Encode()
resp, err := s.httpClient().Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
_ = resp.Body.Close()
return nil, fmt.Errorf("kratos admin list identities failed status=%d body=%s", resp.StatusCode, string(body))
}
var page []KratosIdentity
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
_ = resp.Body.Close()
return nil, err
}
_ = resp.Body.Close()
identities = append(identities, page...)
nextToken := kratosNextPageToken(resp.Header.Values("Link"))
if nextToken == "" {
return identities, nil
}
if seenTokens[nextToken] {
return nil, fmt.Errorf("kratos admin list identities pagination loop detected page_token=%s", nextToken)
}
seenTokens[nextToken] = true
pageToken = nextToken
}
return identities, nil
}
func kratosNextPageToken(linkHeaders []string) string {
for _, header := range linkHeaders {
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if !strings.Contains(part, `rel="next"`) && !strings.Contains(part, `rel=next`) {
continue
}
start := strings.Index(part, "<")
end := strings.Index(part, ">")
if start < 0 || end <= start+1 {
continue
}
rawURL := part[start+1 : end]
parsed, err := url.Parse(rawURL)
if err != nil {
continue
}
if token := strings.TrimSpace(parsed.Query().Get("page_token")); token != "" {
return token
}
}
}
return ""
}
func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {

View File

@@ -0,0 +1,63 @@
package service
import (
"bytes"
"context"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestKratosAdminService_ListIdentitiesFollowsNextPagination(t *testing.T) {
var requestedTokens []string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
require.Equal(t, "/admin/identities", r.URL.Path)
token := r.URL.Query().Get("page_token")
requestedTokens = append(requestedTokens, token)
header := make(http.Header)
header.Set("Content-Type", "application/json")
status := http.StatusOK
body := "[]"
switch token {
case "":
header.Set(
"Link",
`</admin/identities?page_size=2&page_token=identity-2>; rel="next"`,
)
body = `[{"id":"identity-1","traits":{"email":"one@example.com"}},{"id":"identity-2","traits":{"email":"two@example.com"}}]`
case "identity-2":
body = `[{"id":"identity-3","traits":{"email":"three@example.com"}}]`
default:
t.Fatalf("unexpected page_token %q", token)
}
return &http.Response{
StatusCode: status,
Header: header,
Body: io.NopCloser(bytes.NewBufferString(body)),
Request: r,
}, nil
})}
service := &kratosAdminService{
AdminURL: "http://kratos.example",
HTTPClient: client,
}
identities, err := service.ListIdentities(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"", "identity-2"}, requestedTokens)
require.Len(t, identities, 3)
require.Equal(t, "identity-1", identities[0].ID)
require.Equal(t, "identity-2", identities[1].ID)
require.Equal(t, "identity-3", identities[2].ID)
}

View File

@@ -1,7 +1,9 @@
package service
import (
"baron-sso-backend/internal/domain"
"context"
"encoding/json"
"os"
"time"
@@ -14,6 +16,14 @@ type RedisService struct {
Client *redis.Client
}
type identityMirrorStateStore struct {
Status string `json:"status"`
LastRefreshedAt *time.Time `json:"lastRefreshedAt,omitempty"`
LastError string `json:"lastError,omitempty"`
ObservedCount int64 `json:"observedCount,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
// NewRedisService creates and returns a new RedisService
func NewRedisService() (*RedisService, error) {
redisAddr := os.Getenv("REDIS_ADDR")
@@ -90,3 +100,139 @@ func (s *RedisService) Get(key string) (string, error) {
func (s *RedisService) Delete(key string) error {
return s.Client.Del(ctx, key).Err()
}
func (s *RedisService) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
if s == nil || s.Client == nil {
return domain.IdentityCacheStatus{
Status: "unavailable",
RedisReady: false,
LastError: "redis service unavailable",
}, nil
}
if err := s.Client.Ping(ctx).Err(); err != nil {
return domain.IdentityCacheStatus{
Status: "failed",
RedisReady: false,
LastError: err.Error(),
}, nil
}
keyCount, err := s.countIdentityCacheKeys(ctx)
if err != nil {
return domain.IdentityCacheStatus{
Status: "failed",
RedisReady: true,
LastError: err.Error(),
}, nil
}
raw, err := s.Client.Get(ctx, "identity:mirror:state").Result()
if err == redis.Nil {
return domain.IdentityCacheStatus{
Status: "empty",
RedisReady: true,
KeyCount: keyCount,
}, nil
}
if err != nil {
return domain.IdentityCacheStatus{
Status: "failed",
RedisReady: true,
KeyCount: keyCount,
LastError: err.Error(),
}, nil
}
var stored identityMirrorStateStore
if err := json.Unmarshal([]byte(raw), &stored); err != nil {
return domain.IdentityCacheStatus{
Status: "failed",
RedisReady: true,
KeyCount: keyCount,
LastError: err.Error(),
}, nil
}
status := stored.Status
if status == "" {
status = "unknown"
}
return domain.IdentityCacheStatus{
Status: status,
RedisReady: true,
ObservedCount: stored.ObservedCount,
KeyCount: keyCount,
LastRefreshedAt: stored.LastRefreshedAt,
LastError: stored.LastError,
UpdatedAt: stored.UpdatedAt,
}, nil
}
func (s *RedisService) FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) {
if s == nil || s.Client == nil {
return domain.IdentityCacheFlushResult{}, os.ErrInvalid
}
keys, err := s.identityCacheKeys(ctx)
if err != nil {
return domain.IdentityCacheFlushResult{}, err
}
var deleted int64
for len(keys) > 0 {
chunkSize := len(keys)
if chunkSize > 500 {
chunkSize = 500
}
chunk := keys[:chunkSize]
count, err := s.Client.Del(ctx, chunk...).Result()
if err != nil {
return domain.IdentityCacheFlushResult{}, err
}
deleted += count
keys = keys[chunkSize:]
}
return domain.IdentityCacheFlushResult{
Status: "success",
FlushedKeys: deleted,
UpdatedAt: time.Now().UTC(),
}, nil
}
func (s *RedisService) countIdentityCacheKeys(ctx context.Context) (int64, error) {
keys, err := s.identityCacheKeys(ctx)
if err != nil {
return 0, err
}
return int64(len(keys)), nil
}
func (s *RedisService) identityCacheKeys(ctx context.Context) ([]string, error) {
seen := make(map[string]bool)
patterns := []string{
"identity:mirror:*",
"identity:index:*",
}
for _, pattern := range patterns {
var cursor uint64
for {
keys, next, err := s.Client.Scan(ctx, cursor, pattern, 250).Result()
if err != nil {
return nil, err
}
for _, key := range keys {
seen[key] = true
}
cursor = next
if cursor == 0 {
break
}
}
}
keys := make([]string, 0, len(seen))
for key := range seen {
keys = append(keys, key)
}
return keys, nil
}

View File

@@ -0,0 +1,150 @@
package service
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/require"
)
type redisCommandStub struct {
scans map[string][]string
stateValue string
deleted []string
}
func (h *redisCommandStub) BeforeProcess(ctx context.Context, cmd redis.Cmder) (context.Context, error) {
return ctx, nil
}
func (h *redisCommandStub) AfterProcess(ctx context.Context, cmd redis.Cmder) error {
switch cmd.Name() {
case "ping":
if status, ok := cmd.(*redis.StatusCmd); ok {
status.SetVal("PONG")
}
case "scan":
if scan, ok := cmd.(*redis.ScanCmd); ok {
scan.SetVal(h.scans[scanPattern(cmd.Args())], 0)
}
case "get":
if str, ok := cmd.(*redis.StringCmd); ok {
if h.stateValue == "" {
str.SetErr(redis.Nil)
return nil
}
str.SetVal(h.stateValue)
}
case "del":
args := cmd.Args()
keys := make([]string, 0, len(args)-1)
for _, arg := range args[1:] {
keys = append(keys, arg.(string))
}
h.deleted = append(h.deleted, keys...)
if count, ok := cmd.(*redis.IntCmd); ok {
count.SetVal(int64(len(keys)))
}
}
cmd.SetErr(nil)
return nil
}
func (h *redisCommandStub) BeforeProcessPipeline(ctx context.Context, cmds []redis.Cmder) (context.Context, error) {
return ctx, nil
}
func (h *redisCommandStub) AfterProcessPipeline(ctx context.Context, cmds []redis.Cmder) error {
return nil
}
func scanPattern(args []interface{}) string {
for index := 0; index < len(args)-1; index++ {
value, ok := args[index].(string)
if ok && value == "match" {
if pattern, ok := args[index+1].(string); ok {
return pattern
}
}
}
return ""
}
func newStubbedRedisService(stub *redisCommandStub) *RedisService {
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:1",
MaxRetries: -1,
})
client.AddHook(stub)
return &RedisService{Client: client}
}
func TestRedisServiceGetIdentityCacheStatusReadsStateAndCountsCacheKeys(t *testing.T) {
now := time.Date(2026, 6, 9, 3, 20, 0, 0, time.UTC)
state, err := json.Marshal(identityMirrorStateStore{
Status: "ready",
LastRefreshedAt: &now,
ObservedCount: 42,
UpdatedAt: &now,
})
require.NoError(t, err)
stub := &redisCommandStub{
stateValue: string(state),
scans: map[string][]string{
"identity:mirror:*": {"identity:mirror:state", "identity:mirror:user:1"},
"identity:index:*": {"identity:index:email:a", "identity:mirror:user:1"},
},
}
service := newStubbedRedisService(stub)
status, err := service.GetIdentityCacheStatus(context.Background())
require.NoError(t, err)
require.Equal(t, "ready", status.Status)
require.True(t, status.RedisReady)
require.Equal(t, int64(42), status.ObservedCount)
require.Equal(t, int64(3), status.KeyCount)
require.Equal(t, &now, status.LastRefreshedAt)
require.Equal(t, &now, status.UpdatedAt)
}
func TestRedisServiceFlushIdentityCacheDeletesOnlyIdentityMirrorAndIndexKeys(t *testing.T) {
stub := &redisCommandStub{
scans: map[string][]string{
"identity:mirror:*": {"identity:mirror:state", "identity:mirror:user:1"},
"identity:index:*": {"identity:index:email:a", "identity:mirror:user:1"},
},
}
service := newStubbedRedisService(stub)
result, err := service.FlushIdentityCache(context.Background())
require.NoError(t, err)
require.Equal(t, "success", result.Status)
require.Equal(t, int64(3), result.FlushedKeys)
require.ElementsMatch(t, []string{
"identity:mirror:state",
"identity:mirror:user:1",
"identity:index:email:a",
}, stub.deleted)
}
func TestRedisServiceGetIdentityCacheStatusReturnsUnavailableWithoutClient(t *testing.T) {
status, err := (*RedisService)(nil).GetIdentityCacheStatus(context.Background())
require.NoError(t, err)
require.Equal(t, "unavailable", status.Status)
require.False(t, status.RedisReady)
require.NotEmpty(t, status.LastError)
}
func TestRedisServiceFlushIdentityCacheFailsWithoutClient(t *testing.T) {
_, err := (*RedisService)(nil).FlushIdentityCache(context.Background())
require.ErrorIs(t, err, os.ErrInvalid)
}

View File

@@ -222,44 +222,19 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
tenant, _ = s.tenantRepo.FindByID(ctx, group.TenantID)
}
var updatedIdentity *KratosIdentity
// [Fix] Sync Kratos Traits & Local DB when a user is added to an organization
if s.kratos != nil && tenant != nil {
// Fetch Kratos Identity
identity, err := s.kratos.GetIdentity(ctx, userID)
if err == nil && identity != nil {
traits := identity.Traits
if traits == nil {
traits = make(map[string]any)
}
delete(traits, "companyCode")
delete(traits, "companyCodes")
traits["tenant_id"] = tenant.ID
traits["department"] = group.Name
// Update Kratos
updated, updateErr := s.kratos.UpdateIdentity(ctx, userID, traits, identity.State)
if updateErr != nil {
slog.Error("Failed to update identity traits during AddMember", "user", userID, "error", updateErr)
} else if updated != nil {
updatedIdentity = updated
} else {
identity.Traits = traits
updatedIdentity = identity
}
}
}
// Sync local user repo
// Kratos는 identity SSOT이고 조직/부서 정보의 원장이 아니므로 AddMember에서 traits를 수정하지 않습니다.
if s.userRepo != nil && tenant != nil {
localUser, err := s.userRepo.FindByID(ctx, userID)
if err != nil || localUser == nil {
if updatedIdentity != nil {
localUser = mapUserGroupKratosIdentityToLocalUser(*updatedIdentity)
if s.kratos != nil {
identity, identityErr := s.kratos.GetIdentity(ctx, userID)
if identityErr == nil && identity != nil {
localUser = mapUserGroupKratosIdentityToLocalUser(*identity)
} else {
slog.Warn("Skipping local user sync during AddMember because identity read is unavailable", "user", userID, "error", identityErr)
}
} else {
slog.Warn("Skipping local user sync during AddMember because identity projection is unavailable", "user", userID, "error", err)
localUser = nil
}
}
if localUser != nil {
@@ -326,7 +301,7 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
ID: identity.ID,
Email: userGroupTraitString(traits, "email"),
Name: userGroupTraitString(traits, "name"),
Phone: userGroupTraitString(traits, "phone_number"),
Phone: domain.NormalizePhoneNumber(userGroupTraitString(traits, "phone_number")),
Role: role,
Status: userGroupIdentityStatus(identity.State),
Department: userGroupTraitString(traits, "department"),

View File

@@ -272,14 +272,6 @@ func TestUserGroupService_AddMember(t *testing.T) {
mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{ID: userID}, nil)
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil)
// Mock Kratos
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{}, nil)
// Mock local user repo update (Ignored since Update is hardcoded to return nil without calling m.Called)
// mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool {
// return u.CompanyCode == tenantSlug && *u.TenantID == tenantID && u.Department == "Sales"
@@ -299,6 +291,8 @@ func TestUserGroupService_AddMember(t *testing.T) {
assert.NoError(t, err)
mockOutbox.AssertExpectations(t)
mockKratos.AssertExpectations(t)
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, userID)
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything)
// mockUserRepo.AssertExpectations(t)
}
@@ -326,19 +320,6 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
},
State: "active",
}, nil)
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool {
_, hasCompanyCode := traits["companyCode"]
return !hasCompanyCode && traits["tenant_id"] == tenantID && traits["department"] == "Sales"
}), "active").Return(&KratosIdentity{
ID: userID,
Traits: map[string]any{
"email": "user@test.com",
"name": "User Test",
"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()
@@ -356,6 +337,7 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
assert.Equal(t, "Sales", mockUserRepo.updatedUsers[0].Department)
mockOutbox.AssertExpectations(t)
mockKratos.AssertExpectations(t)
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything)
}
func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
@@ -380,16 +362,6 @@ func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
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()
@@ -407,6 +379,8 @@ func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) {
assert.Equal(t, "Sales", worksmobile.userUpserts[0].Department)
mockOutbox.AssertExpectations(t)
mockKratos.AssertExpectations(t)
mockKratos.AssertNotCalled(t, "GetIdentity", mock.Anything, userID)
mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, userID, mock.Anything, mock.Anything)
}
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {

View File

@@ -75,7 +75,7 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
ID: identity.ID,
Email: kratosProjectionTraitString(traits, "email"),
Name: kratosProjectionTraitString(traits, "name"),
Phone: kratosProjectionTraitString(traits, "phone_number"),
Phone: domain.NormalizePhoneNumber(kratosProjectionTraitString(traits, "phone_number")),
Role: role,
Status: normalizeProjectionStatus(identity.State),
Department: kratosProjectionTraitString(traits, "department"),

View File

@@ -28,6 +28,10 @@ func (f *fakeUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants
return nil, nil
}
func (f *fakeUserProjectionRepo) CountTenantMembersRecursive(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
return nil, nil
}
func (f *fakeUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
f.replacedUsers = append([]domain.User(nil), users...)
return f.replaceErr
@@ -79,6 +83,33 @@ func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *test
kratos.AssertExpectations(t)
}
func TestUserProjectionSyncService_ReconcileDeduplicatesKoreanCountryCodePhone(t *testing.T) {
ctx := context.Background()
kratos := new(MockKratosAdminServiceShared)
repo := &fakeUserProjectionRepo{}
svc := NewUserProjectionSyncService(kratos, repo)
kratos.On("ListIdentities", ctx).Return([]KratosIdentity{
{
ID: "00000000-0000-0000-0000-000000000102",
Traits: map[string]any{
"email": "two@example.com",
"name": "Two",
"phone_number": "+82 +821091917771",
},
State: "active",
},
}, nil).Once()
count, err := svc.Reconcile(ctx)
require.NoError(t, err)
assert.Equal(t, 1, count)
require.Len(t, repo.replacedUsers, 1)
assert.Equal(t, "+821091917771", repo.replacedUsers[0].Phone)
kratos.AssertExpectations(t)
}
func TestUserProjectionSyncService_ReconcileMarksFailedWhenKratosFails(t *testing.T) {
ctx := context.Background()
kratos := new(MockKratosAdminServiceShared)

View File

@@ -1,6 +1,7 @@
package service
import (
"baron-sso-backend/internal/domain"
"bytes"
"context"
"crypto"
@@ -17,11 +18,13 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
)
const (
defaultWorksmobileOAuthScope = "directory"
defaultWorksmobileOAuthScope = "directory"
worksmobileAPIRateLimitPerMinute = 240
)
type WorksmobileDirectoryClient interface {
@@ -43,6 +46,7 @@ type WorksmobileHTTPClient struct {
DirectoryToken string
SCIMToken string
HTTPClient *http.Client
RateLimiter WorksmobileRateLimiter
OAuthConfig WorksmobileOAuthConfig
DomainIDs []int64
OrgUnitWriteDelay time.Duration
@@ -50,6 +54,16 @@ type WorksmobileHTTPClient struct {
now func() time.Time
}
type WorksmobileRateLimiter interface {
Wait(ctx context.Context, key string) error
}
type worksmobileAPIRateLimiter struct {
interval time.Duration
mu sync.Mutex
next map[string]time.Time
}
type WorksmobileOAuthConfig struct {
ClientID string
ClientSecret string
@@ -64,6 +78,46 @@ type worksmobileAccessTokenCache struct {
ExpiresAt time.Time
}
func NewWorksmobileAPIRateLimiter(limit int, window time.Duration) WorksmobileRateLimiter {
if limit <= 0 || window <= 0 {
return &worksmobileAPIRateLimiter{}
}
return &worksmobileAPIRateLimiter{
interval: window / time.Duration(limit),
next: map[string]time.Time{},
}
}
func (l *worksmobileAPIRateLimiter) Wait(ctx context.Context, key string) error {
if l == nil || l.interval <= 0 {
return nil
}
key = strings.TrimSpace(key)
if key == "" {
key = "UNKNOWN"
}
l.mu.Lock()
now := time.Now()
waitUntil := l.next[key]
if waitUntil.Before(now) {
waitUntil = now
}
l.next[key] = waitUntil.Add(l.interval)
l.mu.Unlock()
if delay := time.Until(waitUntil); delay > 0 {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
}
}
return nil
}
func (c WorksmobileOAuthConfig) normalized() WorksmobileOAuthConfig {
c.ClientID = strings.Trim(strings.TrimSpace(c.ClientID), `"`)
c.ClientSecret = strings.Trim(strings.TrimSpace(c.ClientSecret), `"`)
@@ -280,7 +334,10 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
if identifier == "" {
identifier = strings.TrimSpace(payload.UserExternalKey)
}
return c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload))
if patchErr := c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload)); patchErr != nil {
return fmt.Errorf("worksmobile user create conflict: %w; patch after conflict failed: %v", err, patchErr)
}
return nil
}
return err
}
@@ -306,6 +363,23 @@ func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID st
return err
}
func (c *WorksmobileHTTPClient) RemoveUserAliasEmail(ctx context.Context, userID string, email string) error {
userID = strings.TrimSpace(userID)
email = strings.TrimSpace(email)
if userID == "" {
return fmt.Errorf("worksmobile user id is required")
}
if email == "" {
return fmt.Errorf("worksmobile alias email is required")
}
return c.sendDirectoryJSON(
ctx,
http.MethodDelete,
"/v1.0/users/"+url.PathEscape(userID)+"/alias-emails/"+url.PathEscape(email),
nil,
)
}
func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
userID = strings.TrimSpace(userID)
password = strings.TrimSpace(password)
@@ -315,15 +389,38 @@ func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID st
if password == "" {
return fmt.Errorf("worksmobile password is required")
}
changePasswordAtNextLogin := true
payload := map[string]any{
"passwordConfig": WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: password,
PasswordCreationType: "ADMIN",
Password: password,
ChangePasswordAtNextLogin: &changePasswordAtNextLogin,
},
}
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(userID), payload)
}
func (c *WorksmobileHTTPClient) GetUser(ctx context.Context, userID string) (*WorksmobileRemoteUser, error) {
userID = strings.TrimSpace(userID)
if userID == "" {
return nil, fmt.Errorf("worksmobile user id is required")
}
var response map[string]any
if err := c.getDirectoryJSON(ctx, "/v1.0/users/"+url.PathEscape(userID), &response); err != nil {
return nil, err
}
user := parseWorksmobileDirectoryUser(response)
return &user, nil
}
func (c *WorksmobileHTTPClient) UndeleteUser(ctx context.Context, userID string) error {
userID = strings.TrimSpace(userID)
if userID == "" {
return fmt.Errorf("worksmobile user id is required")
}
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users/"+url.PathEscape(userID)+"/undelete", nil)
}
func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
@@ -484,6 +581,9 @@ func (c *WorksmobileHTTPClient) getJSON(ctx context.Context, path string, target
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
return err
}
resp, err := c.httpClient().Do(req)
if err != nil {
return err
@@ -512,6 +612,9 @@ func (c *WorksmobileHTTPClient) getDirectoryJSON(ctx context.Context, path strin
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
return err
}
resp, err := c.httpClient().Do(req)
if err != nil {
return err
@@ -665,6 +768,9 @@ func (c *WorksmobileHTTPClient) requestDirectoryAccessToken(ctx context.Context,
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
return "", time.Time{}, err
}
resp, err := c.httpClient().Do(req)
if err != nil {
return "", time.Time{}, err
@@ -729,6 +835,9 @@ func (c *WorksmobileHTTPClient) sendJSONWithToken(ctx context.Context, method st
req.Header.Set("Content-Type", "application/json")
}
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
return err
}
resp, err := c.httpClient().Do(req)
if err != nil {
return err
@@ -801,6 +910,7 @@ type WorksmobileRemoteUser struct {
ExternalID string `json:"externalId"`
UserName string `json:"userName"`
Email string `json:"email"`
AliasEmails []string `json:"aliasEmails,omitempty"`
DisplayName string `json:"displayName"`
CellPhone string `json:"cellPhone,omitempty"`
EmployeeNumber string `json:"employeeNumber,omitempty"`
@@ -817,6 +927,10 @@ type WorksmobileRemoteUser struct {
OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"`
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
Active bool `json:"active"`
IsAwaiting bool `json:"isAwaiting"`
IsPending bool `json:"isPending"`
IsSuspended bool `json:"isSuspended"`
IsDeleted bool `json:"isDeleted"`
}
type WorksmobileRemoteGroup struct {
@@ -852,18 +966,22 @@ func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSC
},
}
if strings.TrimSpace(payload.CellPhone) != "" {
result.PhoneNumbers = []WorksmobileSCIMPhoneNumber{{Value: strings.TrimSpace(payload.CellPhone), Primary: true, Type: "mobile"}}
result.PhoneNumbers = []WorksmobileSCIMPhoneNumber{{Value: normalizeWorksmobileOutboundCellPhone(payload.CellPhone), Primary: true, Type: "mobile"}}
}
return result
}
func normalizeWorksmobileOutboundCellPhone(value string) string {
return domain.NormalizePhoneNumber(value)
}
func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileUserPatchPayload {
return WorksmobileUserPatchPayload{
DomainID: payload.DomainID,
Email: strings.TrimSpace(payload.Email),
UserExternalKey: strings.TrimSpace(payload.UserExternalKey),
UserName: payload.UserName,
CellPhone: strings.TrimSpace(payload.CellPhone),
CellPhone: normalizeWorksmobileOutboundCellPhone(payload.CellPhone),
EmployeeNumber: strings.TrimSpace(payload.EmployeeNumber),
AliasEmails: payload.AliasEmails,
Locale: strings.TrimSpace(payload.Locale),
@@ -937,6 +1055,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
ExternalID: firstStringFromMap(resource, "userExternalKey", "externalKey", "externalId"),
UserName: email,
Email: email,
AliasEmails: stringListFromMap(resource, "aliasEmails"),
DisplayName: parseWorksmobileDirectoryUserName(resource),
CellPhone: firstStringFromMap(resource, "cellPhone", "phoneNumber", "phone", "mobile", "mobilePhone"),
EmployeeNumber: firstStringFromMap(
@@ -954,6 +1073,10 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
if active, ok := resource["active"].(bool); ok {
user.Active = active
}
user.IsAwaiting = boolFromMap(resource, "isAwaiting")
user.IsPending = boolFromMap(resource, "isPending")
user.IsSuspended = boolFromMap(resource, "isSuspended")
user.IsDeleted = boolFromMap(resource, "isDeleted")
primaryOrgUnit := parseWorksmobilePrimaryOrgUnitDetail(resource)
user.PrimaryOrgUnitID = primaryOrgUnit.ID
user.PrimaryOrgUnitName = primaryOrgUnit.Name
@@ -1285,6 +1408,25 @@ func firstStringFromMap(values map[string]any, keys ...string) string {
return ""
}
func stringListFromMap(values map[string]any, key string) []string {
raw, ok := values[key].([]any)
if !ok {
return nil
}
result := make([]string, 0, len(raw))
for _, item := range raw {
value, ok := item.(string)
if !ok {
continue
}
value = strings.TrimSpace(value)
if value != "" {
result = append(result, value)
}
}
return result
}
func boolFromMap(values map[string]any, key string) bool {
value, _ := values[key].(bool)
return value
@@ -1324,6 +1466,42 @@ func (c *WorksmobileHTTPClient) requestURL(path string) (string, error) {
return strings.TrimRight(baseURL, "/") + path, nil
}
func (c *WorksmobileHTTPClient) waitForWorksmobileAPI(ctx context.Context, method string, requestURL *url.URL) error {
if c.RateLimiter == nil {
return nil
}
return c.RateLimiter.Wait(ctx, worksmobileRateLimitKey(method, requestURL))
}
func worksmobileRateLimitKey(method string, requestURL *url.URL) string {
normalizedMethod := strings.ToUpper(strings.TrimSpace(method))
if normalizedMethod == "" {
normalizedMethod = "GET"
}
return normalizedMethod + " " + normalizeWorksmobileRateLimitPath(requestURL)
}
func normalizeWorksmobileRateLimitPath(requestURL *url.URL) string {
if requestURL == nil {
return "/"
}
path := requestURL.EscapedPath()
if path == "" {
path = "/"
}
segments := strings.Split(strings.Trim(path, "/"), "/")
if len(segments) == 1 && segments[0] == "" {
return "/"
}
for i := 1; i < len(segments); i++ {
switch strings.ToLower(segments[i-1]) {
case "users", "orgunits", "groups", "alias-emails":
segments[i] = "{id}"
}
}
return "/" + strings.Join(segments, "/")
}
func (c *WorksmobileHTTPClient) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient

View File

@@ -64,6 +64,28 @@ func TestWorksmobileHTTPClientCreateUserPostsDirectoryAdminPasswordPayload(t *te
require.Len(t, passwordConfig["password"], 16)
}
func TestNewWorksmobileUserPatchPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
payload := NewWorksmobileUserPatchPayload(WorksmobileUserPayload{
DomainID: 1001,
Email: "phone-canonical@samaneng.com",
CellPhone: "+82+821062836786",
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
})
require.Equal(t, "+821062836786", payload.CellPhone)
}
func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
payload := NewWorksmobileSCIMUserPayload(WorksmobileUserPayload{
Email: "phone-canonical@samaneng.com",
CellPhone: "+82+821062836786",
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
})
require.Len(t, payload.PhoneNumbers, 1)
require.Equal(t, "+821062836786", payload.PhoneNumbers[0].Value)
}
func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOrPrivateEmail(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
@@ -155,6 +177,7 @@ func TestWorksmobileHTTPClientResetUserPasswordPatchesPasswordConfig(t *testing.
passwordConfig := payload["passwordConfig"].(map[string]any)
require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"])
require.Equal(t, "Aa1!Aa1!Aa1!Aa1!", passwordConfig["password"])
require.Equal(t, true, passwordConfig["changePasswordAtNextLogin"])
}
func TestWorksmobileHTTPClientCreateUserRequiresDirectoryToken(t *testing.T) {
@@ -225,6 +248,84 @@ func TestWorksmobileHTTPClientRequestsJWTBearerAccessToken(t *testing.T) {
require.Equal(t, float64(1710003600), payload["exp"])
}
func TestWorksmobileHTTPClientAppliesRateLimitBeforeDirectoryAPICalls(t *testing.T) {
transport := &captureRoundTripper{
statusCode: http.StatusCreated,
body: `{}`,
}
limiter := &captureWorksmobileRateLimiter{}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
DirectoryToken: "directory-token-1",
HTTPClient: &http.Client{Transport: transport},
RateLimiter: limiter,
}
err := client.CreateUser(context.Background(), WorksmobileUserPayload{
Email: "tester@samaneng.com",
PasswordConfig: WorksmobilePasswordConfig{PasswordCreationType: "ADMIN", Password: "Aa1!Aa1!Aa1!Aa1!"},
})
require.NoError(t, err)
require.Equal(t, []string{"POST /v1.0/users"}, limiter.keys)
require.Len(t, transport.requests, 1)
}
func TestWorksmobileHTTPClientAppliesRateLimitBeforeOAuthTokenCalls(t *testing.T) {
privateKey := testRSAPrivateKeyPEM(t)
transport := &captureRoundTripper{
statusCode: http.StatusOK,
body: `{"access_token":"directory-token-from-jwt","token_type":"Bearer","expires_in":3600}`,
}
limiter := &captureWorksmobileRateLimiter{}
client := &WorksmobileHTTPClient{
HTTPClient: &http.Client{Transport: transport},
RateLimiter: limiter,
now: func() time.Time { return time.Unix(1710000000, 0) },
OAuthConfig: WorksmobileOAuthConfig{
ClientID: "client-id-1",
ClientSecret: "client-secret-1",
ServiceAccount: "service-account-1",
PrivateKey: privateKey,
Scope: "directory",
TokenURL: "https://auth.example.test/oauth2/v2.0/token",
},
}
_, _, err := client.requestDirectoryAccessToken(context.Background(), time.Unix(1710000000, 0))
require.NoError(t, err)
require.Equal(t, []string{"POST /oauth2/v2.0/token"}, limiter.keys)
require.Len(t, transport.requests, 1)
}
func TestWorksmobileHTTPClientRateLimitKeyNormalizesResourceIDsAndDropsQuery(t *testing.T) {
parsedURL, err := url.Parse("https://works.example.test/v1.0/users/user%40example.com/alias-emails/alias%40example.com?domainId=1")
require.NoError(t, err)
require.Equal(
t,
"POST /v1.0/users/{id}/alias-emails/{id}",
worksmobileRateLimitKey(http.MethodPost, parsedURL),
)
parsedURL, err = url.Parse("https://works.example.test/scim/v2/Users/works-user-1")
require.NoError(t, err)
require.Equal(t, "PATCH /scim/v2/Users/{id}", worksmobileRateLimitKey(http.MethodPatch, parsedURL))
}
func TestNewWorksmobileHTTPClientDoesNotInstallRateLimiterByDefault(t *testing.T) {
client := NewWorksmobileHTTPClientWithAuth("directory-token", "scim-token", WorksmobileOAuthConfig{})
require.Nil(t, client.RateLimiter)
}
func TestNewWorksmobileAPIRateLimiterCreatesLimiterForWorkerUse(t *testing.T) {
limiter := NewWorksmobileAPIRateLimiter(240, time.Minute)
require.NotNil(t, limiter)
}
func TestWorksmobileHTTPClientRequiresConfiguredAPIBaseURL(t *testing.T) {
client := &WorksmobileHTTPClient{
DirectoryToken: "directory-token-1",
@@ -608,6 +709,18 @@ func TestWorksmobileRelayWorkerProcessesUserSuspendAndMarksProcessed(t *testing.
require.Equal(t, []string{"tester@samaneng.com"}, client.suspendedUsers)
}
func TestWorksmobileRelayWorkerProcessOnceRecoversPanic(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{listReadyPanic: "list ready crashed"}
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.Error(t, err)
require.Contains(t, err.Error(), "worksmobile relay panic")
require.Contains(t, err.Error(), "list ready crashed")
}
func TestWorksmobileRelayWorkerProcessesActiveUserUpsertAndReactivates(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
@@ -736,6 +849,65 @@ func TestWorksmobileRelayWorkerSkipsDispatchWhenJobClaimFails(t *testing.T) {
require.Empty(t, client.createdOrgUnits)
}
func TestWorksmobileRelayWorkerSkipsProcessingWhenLeaderLockIsNotHeld(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
{
ID: "job-1",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-1",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
Email: "tester@samaneng.com",
UserExternalKey: "user-1",
}),
},
},
}
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
worker.SetLeaderLock(&fakeWorksmobileRelayLeaderLock{held: false})
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Zero(t, repo.listReadyCalls)
require.Empty(t, repo.processingIDs)
require.Empty(t, repo.processedIDs)
require.Empty(t, client.createdUsers)
}
func TestWorksmobileRelayWorkerProcessesWhenLeaderLockIsHeld(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
{
ID: "job-1",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-1",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
Email: "tester@samaneng.com",
UserExternalKey: "user-1",
}),
},
},
}
client := &fakeWorksmobileDirectoryClient{}
lock := &fakeWorksmobileRelayLeaderLock{held: true}
worker := NewWorksmobileRelayWorker(repo, client)
worker.SetLeaderLock(lock)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 1, lock.ensureCalls)
require.Equal(t, 1, repo.listReadyCalls)
require.Equal(t, []string{"job-1"}, repo.processedIDs)
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
}
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
jobs := []domain.WorksmobileOutbox{
{
@@ -1179,6 +1351,8 @@ type fakeWorksmobileOutboxRepo struct {
payloadUpdates []domain.JSONMap
deletedPendingTenantRootID string
deletedPendingCount int
listReadyCalls int
listReadyPanic any
markProcessingClaims map[string]bool
processingIDs []string
processedIDs []string
@@ -1224,6 +1398,10 @@ func (f *fakeWorksmobileOutboxRepo) DeletePendingByTenantRoot(ctx context.Contex
}
func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) {
f.listReadyCalls++
if f.listReadyPanic != nil {
panic(f.listReadyPanic)
}
return f.ready, nil
}
@@ -1282,6 +1460,25 @@ type captureResponse struct {
body string
}
type captureWorksmobileRateLimiter struct {
keys []string
}
func (l *captureWorksmobileRateLimiter) Wait(ctx context.Context, key string) error {
l.keys = append(l.keys, key)
return ctx.Err()
}
type fakeWorksmobileRelayLeaderLock struct {
held bool
ensureCalls int
}
func (l *fakeWorksmobileRelayLeaderLock) EnsureLeadership(ctx context.Context) (bool, error) {
l.ensureCalls++
return l.held, ctx.Err()
}
func (t *captureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
t.request = req
t.requests = append(t.requests, req)

View File

@@ -56,7 +56,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) {
require.NoError(t, outboxRepo.MarkProcessed(ctx, job.ID))
continue
}
item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID, "")
item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID, "", "")
require.NoError(t, err)
require.NotEmpty(t, item)
require.NoError(t, outboxRepo.MarkRetry(ctx, job.ID))

View File

@@ -3,6 +3,7 @@ package service
import (
"baron-sso-backend/internal/domain"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"math/big"
@@ -30,14 +31,14 @@ type WorksmobileOrgUnitPayload struct {
type WorksmobileUserPayload struct {
DomainID int64 `json:"domainId"`
Email string `json:"email"`
UserExternalKey string `json:"userExternalKey"`
UserExternalKey string `json:"userExternalKey,omitempty"`
UserName WorksmobileUserName `json:"userName"`
CellPhone string `json:"cellPhone,omitempty"`
EmployeeNumber string `json:"employeeNumber,omitempty"`
PrivateEmail string `json:"privateEmail,omitempty"`
AliasEmails []string `json:"aliasEmails,omitempty"`
Locale string `json:"locale,omitempty"`
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"`
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
Task string `json:"task,omitempty"`
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
}
@@ -47,8 +48,52 @@ type WorksmobileUserName struct {
}
type WorksmobilePasswordConfig struct {
PasswordCreationType string `json:"passwordCreationType"`
Password string `json:"password"`
PasswordCreationType string `json:"passwordCreationType"`
Password string `json:"password"`
ChangePasswordAtNextLogin *bool `json:"changePasswordAtNextLogin,omitempty"`
}
func (c WorksmobilePasswordConfig) IsZero() bool {
return strings.TrimSpace(c.PasswordCreationType) == "" &&
strings.TrimSpace(c.Password) == "" &&
c.ChangePasswordAtNextLogin == nil
}
func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
type payloadJSON struct {
DomainID int64 `json:"domainId"`
Email string `json:"email"`
UserExternalKey string `json:"userExternalKey,omitempty"`
UserName WorksmobileUserName `json:"userName"`
CellPhone string `json:"cellPhone,omitempty"`
EmployeeNumber string `json:"employeeNumber,omitempty"`
PrivateEmail string `json:"privateEmail,omitempty"`
AliasEmails []string `json:"aliasEmails,omitempty"`
Locale string `json:"locale,omitempty"`
PasswordConfig *WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
Task string `json:"task,omitempty"`
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
}
var passwordConfig *WorksmobilePasswordConfig
if !p.PasswordConfig.IsZero() {
passwordConfig = &p.PasswordConfig
}
return json.Marshal(payloadJSON{
DomainID: p.DomainID,
Email: p.Email,
UserExternalKey: p.UserExternalKey,
UserName: p.UserName,
CellPhone: p.CellPhone,
EmployeeNumber: p.EmployeeNumber,
PrivateEmail: p.PrivateEmail,
AliasEmails: p.AliasEmails,
Locale: p.Locale,
PasswordConfig: passwordConfig,
Task: p.Task,
Organizations: p.Organizations,
})
}
type WorksmobilePasswordResetPayload struct {
@@ -184,15 +229,11 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
CellPhone: strings.TrimSpace(user.Phone),
CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: employeeNumber,
Locale: "ko_KR",
PasswordConfig: WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: GenerateWorksmobileInitialPassword(),
},
Task: task,
Organizations: organizations,
Task: task,
Organizations: organizations,
}
payload.AliasEmails = BuildWorksmobileAliasEmails(user, tenant)
return payload, nil
@@ -205,12 +246,20 @@ type worksmobileAppointment struct {
HasManager bool
JobTitle string
PositionID string
Source string
}
func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) ([]WorksmobileUserOrganization, string, error) {
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
if len(appointments) == 0 {
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
} else if !worksmobileAppointmentsContainTenant(appointments, tenant.ID) && !worksmobileAppointmentsHavePrimary(appointments) {
appointments = append([]worksmobileAppointment{{
TenantID: tenant.ID,
IsPrimary: true,
JobTitle: strings.TrimSpace(user.JobTitle),
PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"),
}}, appointments...)
}
accountDomainTenant := worksmobileAccountDomainTenantFromEmail(user.Email, tenant, tenantByID)
accountDomainEnvKey := worksmobileTenantDomainIDEnvKey(accountDomainTenant)
@@ -235,6 +284,17 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
if !ok {
continue
}
if worksmobileShouldSkipEmailDomainRootAppointment(appointment, appointmentTenant, appointments, tenantByID) {
seen[appointment.TenantID] = true
continue
}
if isWorksmobileDomainRootTenant(appointmentTenant) {
if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" && task == "" {
task = strings.TrimSpace(appointment.JobTitle)
}
seen[appointment.TenantID] = true
continue
}
if err := ValidateWorksmobileExternalKey(appointmentTenant.ID); err != nil {
return nil, "", err
}
@@ -276,7 +336,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
seen[appointment.TenantID] = true
}
if len(organizations) == 0 {
return nil, "", errors.New("no valid worksmobile organization")
return nil, task, nil
}
if !worksmobileOrganizationsHavePrimary(organizations) {
organizations[0].Primary = true
@@ -288,6 +348,28 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
return organizations, task, nil
}
func worksmobileAppointmentsContainTenant(appointments []worksmobileAppointment, tenantID string) bool {
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" {
return false
}
for _, appointment := range appointments {
if strings.TrimSpace(appointment.TenantID) == tenantID {
return true
}
}
return false
}
func worksmobileAppointmentsHavePrimary(appointments []worksmobileAppointment) bool {
for _, appointment := range appointments {
if appointment.IsPrimary {
return true
}
}
return false
}
func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant, envKey string) bool {
for _, appointment := range appointments {
tenant, ok := tenantByID[appointment.TenantID]
@@ -302,6 +384,26 @@ func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment,
return false
}
func worksmobileShouldSkipEmailDomainRootAppointment(appointment worksmobileAppointment, tenant domain.Tenant, appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant) bool {
if strings.TrimSpace(appointment.Source) != "email_domain" || !isWorksmobileDomainRootTenant(tenant) {
return false
}
envKey := worksmobileTenantDomainIDEnvKey(tenant)
for _, candidate := range appointments {
if strings.TrimSpace(candidate.TenantID) == "" || strings.TrimSpace(candidate.TenantID) == tenant.ID {
continue
}
candidateTenant, ok := tenantByID[candidate.TenantID]
if !ok || isWorksmobileDomainRootTenant(candidateTenant) {
continue
}
if worksmobileTenantDomainIDEnvKey(worksmobileDomainClassificationTenant(candidateTenant, tenantByID)) == envKey {
return true
}
}
return false
}
func worksmobileOrganizationsHavePrimary(organizations []WorksmobileUserOrganization) bool {
for _, organization := range organizations {
if organization.Primary {
@@ -327,6 +429,7 @@ func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileA
IsPrimary: metadataBool(domain.JSONMap(item), "isPrimary", "primary"),
JobTitle: metadataString(domain.JSONMap(item), "jobTitle", "job_title", "task"),
PositionID: metadataString(domain.JSONMap(item), "worksmobilePositionId", "positionId", "position_id"),
Source: metadataString(domain.JSONMap(item), "assignmentSource", "source"),
}
if isManager, ok := metadataOptionalBool(domain.JSONMap(item), "isManager", "lead", "isLead"); ok {
appointment.IsManager = isManager
@@ -416,7 +519,7 @@ func ValidateWorksmobileAliasEmails(primaryEmail string, aliasEmails []string, e
func GenerateWorksmobileInitialPassword() string {
digits := "0123456789"
letters := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
symbols := "!@#$%^&*()-_=+[]{}"
symbols := "!@#$%"
all := digits + letters + symbols
password := []byte{

View File

@@ -84,6 +84,7 @@ func TestNormalizeRootChildWorksmobileOrgUnitParentClearsCrossDomainParent(t *te
func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootTenantID := "11111111-1111-1111-1111-111111111111"
tenantID := "33333333-3333-3333-3333-333333333333"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
@@ -98,9 +99,17 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
},
}
tenant := domain.Tenant{
ID: tenantID,
ID: tenantID,
Slug: "sales",
Name: "Sales",
Type: domain.TenantTypeOrganization,
ParentID: &rootTenantID,
}
rootTenant := domain.Tenant{
ID: rootTenantID,
Slug: "saman",
Name: "Saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
rootConfig := domain.JSONMap{
@@ -111,7 +120,15 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
},
}
payload, err := BuildWorksmobileUserPayload(user, tenant, rootConfig)
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
tenant,
map[string]domain.Tenant{
rootTenantID: rootTenant,
tenantID: tenant,
},
rootConfig,
)
require.NoError(t, err)
require.Equal(t, int64(1001), payload.DomainID)
@@ -124,17 +141,76 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
require.Empty(t, payload.PrivateEmail)
require.Empty(t, payload.AliasEmails)
require.Equal(t, "ko_KR", payload.Locale)
require.Equal(t, "ADMIN", payload.PasswordConfig.PasswordCreationType)
require.Len(t, payload.PasswordConfig.Password, 16)
require.True(t, containsAny(payload.PasswordConfig.Password, "0123456789"))
require.True(t, containsAny(payload.PasswordConfig.Password, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"))
require.True(t, containsAny(payload.PasswordConfig.Password, "!@#$%^&*()-_=+[]{}"))
require.Empty(t, payload.PasswordConfig.PasswordCreationType)
require.Empty(t, payload.PasswordConfig.Password)
require.Len(t, payload.Organizations, 1)
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
require.Equal(t, "externalKey:"+tenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
}
func TestBuildWorksmobileUserPayloadDeduplicatesKoreanCountryCodeInCellPhone(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
tenantID := "33333333-3333-3333-3333-333333333333"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "john1@samaneng.com",
Name: "John Doe",
Phone: "+82 +821091917771",
TenantID: &tenantID,
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "saman",
Name: "Saman",
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
require.NoError(t, err)
require.Equal(t, "+821091917771", payload.CellPhone)
}
func TestWorksmobileUserPayloadJSONOmitsEmptyPasswordConfig(t *testing.T) {
data, err := json.Marshal(WorksmobileUserPayload{
DomainID: 1001,
Email: "target@samaneng.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "Target"},
})
require.NoError(t, err)
require.NotContains(t, string(data), "passwordConfig")
}
func TestBuildWorksmobileUserPayloadOmitsOrganizationsForSamanRootTenant(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
tenantID := "33333333-3333-3333-3333-333333333333"
user := domain.User{
ID: "44444444-4444-4444-4444-444444444444",
Email: "root-user@samaneng.com",
Name: "Root User",
JobTitle: "Advisor",
TenantID: &tenantID,
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
require.NoError(t, err)
require.Equal(t, int64(1001), payload.DomainID)
require.Equal(t, "root-user@samaneng.com", payload.Email)
require.Equal(t, "Advisor", payload.Task)
require.Empty(t, payload.Organizations)
}
func TestBuildWorksmobileUserPayloadNormalizesLegacyCharacterMapEmployeeID(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
tenantID := "33333333-3333-3333-3333-333333333333"
@@ -168,6 +244,8 @@ func TestBuildWorksmobileUserPayloadNormalizesLegacyCharacterMapEmployeeID(t *te
func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
samanRootID := "11111111-1111-1111-1111-111111111111"
hanmacRootID := "22222222-2222-2222-2222-222222222222"
primaryTenantID := "33333333-3333-3333-3333-333333333333"
secondaryTenantID := "55555555-5555-5555-5555-555555555555"
user := domain.User{
@@ -195,23 +273,41 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
},
},
}
primaryTenant := domain.Tenant{
ID: primaryTenantID,
samanRoot := domain.Tenant{
ID: samanRootID,
Slug: "saman",
Name: "Saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
secondaryTenant := domain.Tenant{
ID: secondaryTenantID,
hanmacRoot := domain.Tenant{
ID: hanmacRootID,
Slug: "hanmac",
Name: "Hanmac",
Name: "한맥기술",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}},
}
primaryTenant := domain.Tenant{
ID: primaryTenantID,
Slug: "saman-sales",
Name: "Saman Sales",
Type: domain.TenantTypeOrganization,
ParentID: &samanRootID,
}
secondaryTenant := domain.Tenant{
ID: secondaryTenantID,
Slug: "hanmac-sales",
Name: "Hanmac Sales",
Type: domain.TenantTypeOrganization,
ParentID: &hanmacRootID,
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
primaryTenant,
map[string]domain.Tenant{
samanRootID: samanRoot,
hanmacRootID: hanmacRoot,
primaryTenantID: primaryTenant,
secondaryTenantID: secondaryTenant,
},
@@ -234,9 +330,66 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager)
}
func TestBuildWorksmobileUserPayloadKeepsPrimaryTenantWhenEmailDomainAppointmentExists(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootTenantID := "9caf62e1-297d-4e8f-870b-61780998bbeb"
primaryTenantID := "1edc196d-020c-4519-9ec4-3d23b99076e6"
user := domain.User{
ID: "64231465-d5c0-4085-b4a2-603b90834f86",
Email: "evenlee@samaneng.com",
Name: "이용운",
JobTitle: "부사장",
TenantID: &primaryTenantID,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": rootTenantID,
"tenantSlug": "saman",
"tenantName": "삼안",
"assignmentSource": "email_domain",
"sourceDomain": "samaneng.com",
},
},
},
}
rootTenant := domain.Tenant{
ID: rootTenantID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
primaryTenant := domain.Tenant{
ID: primaryTenantID,
Slug: "asset-management",
Name: "자산관리",
Type: domain.TenantTypeOrganization,
ParentID: &rootTenantID,
}
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
primaryTenant,
map[string]domain.Tenant{
rootTenantID: rootTenant,
primaryTenantID: primaryTenant,
},
nil,
)
require.NoError(t, err)
require.Len(t, payload.Organizations, 1)
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
require.Len(t, payload.Organizations[0].OrgUnits, 1)
require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
}
func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronRepresentativeIsGPDTDC(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
samanRootID := "11111111-1111-1111-1111-111111111111"
gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
firstTenantID := "33333333-3333-3333-3333-333333333333"
secondTenantID := "55555555-5555-5555-5555-555555555555"
@@ -265,12 +418,20 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
Slug: "gpdtdc",
Name: "총괄기획&기술개발센터",
}
firstTenant := domain.Tenant{
ID: firstTenantID,
Slug: "rnd-saman",
Name: "삼안기술개발센터",
samanRoot := domain.Tenant{
ID: samanRootID,
Slug: "saman",
Name: "삼안",
Type: domain.TenantTypeCompany,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
firstTenant := domain.Tenant{
ID: firstTenantID,
Slug: "rnd-center",
Name: "삼안기술개발센터",
Type: domain.TenantTypeOrganization,
ParentID: &samanRootID,
}
secondTenant := domain.Tenant{
ID: secondTenantID,
Slug: "tdc",
@@ -282,6 +443,7 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese
user,
gpdtdcTenant,
map[string]domain.Tenant{
samanRootID: samanRoot,
gpdtdcID: gpdtdcTenant,
firstTenantID: firstTenant,
secondTenantID: secondTenant,
@@ -354,17 +516,12 @@ func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOr
require.NoError(t, err)
require.Equal(t, int64(1001), payload.DomainID)
require.Len(t, payload.Organizations, 2)
require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
require.Len(t, payload.Organizations, 1)
require.Equal(t, int64(1003), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
require.Equal(t, "dhlee@samaneng.com", payload.Organizations[0].Email)
require.Equal(t, "externalKey:"+samanID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.Equal(t, "dhlee@baroncs.co.kr", payload.Organizations[0].Email)
require.Equal(t, "externalKey:"+leafTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
require.Equal(t, int64(1003), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
require.Equal(t, "dhlee@baroncs.co.kr", payload.Organizations[1].Email)
require.Equal(t, "externalKey:"+leafTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
}
func TestWorksmobileUserPayloadJSONIncludesFalsePrimaryFields(t *testing.T) {

View File

@@ -0,0 +1,79 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"time"
"github.com/go-redis/redis/v8"
)
const (
worksmobileRelayLeaderLockKey = "baron:worksmobile:relay:leader"
worksmobileRelayLeaderLockTTL = 30 * time.Second
)
const worksmobileRelayLeaderRenewScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("EXPIRE", KEYS[1], ARGV[2])
end
return 0
`
type WorksmobileRedisRelayLeaderLock struct {
client *redis.Client
key string
ttl time.Duration
ownerID string
}
func NewWorksmobileRedisRelayLeaderLock(redisService *RedisService) *WorksmobileRedisRelayLeaderLock {
if redisService == nil || redisService.Client == nil {
return nil
}
return &WorksmobileRedisRelayLeaderLock{
client: redisService.Client,
key: worksmobileRelayLeaderLockKey,
ttl: worksmobileRelayLeaderLockTTL,
ownerID: newWorksmobileRelayLeaderOwnerID(),
}
}
func (l *WorksmobileRedisRelayLeaderLock) EnsureLeadership(ctx context.Context) (bool, error) {
if l == nil || l.client == nil {
return true, nil
}
acquired, err := l.client.SetNX(ctx, l.key, l.ownerID, l.ttl).Result()
if err != nil {
return false, err
}
if acquired {
return true, nil
}
ttlSeconds := int64(l.ttl / time.Second)
if ttlSeconds <= 0 {
ttlSeconds = 30
}
result, err := l.client.Eval(ctx, worksmobileRelayLeaderRenewScript, []string{l.key}, l.ownerID, ttlSeconds).Int()
if err != nil {
return false, err
}
return result == 1, nil
}
func newWorksmobileRelayLeaderOwnerID() string {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown-host"
}
randomBytes := make([]byte, 8)
if _, err := rand.Read(randomBytes); err != nil {
return fmt.Sprintf("%s:%d:%d", hostname, os.Getpid(), time.Now().UnixNano())
}
return fmt.Sprintf("%s:%d:%s", hostname, os.Getpid(), hex.EncodeToString(randomBytes))
}

View File

@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
@@ -15,10 +16,15 @@ import (
type WorksmobileRelayWorker struct {
repo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient
leaderLock WorksmobileRelayLeaderLock
interval time.Duration
batchLimit int
}
type WorksmobileRelayLeaderLock interface {
EnsureLeadership(ctx context.Context) (bool, error)
}
func NewWorksmobileRelayWorker(repo repository.WorksmobileOutboxRepository, client WorksmobileDirectoryClient) *WorksmobileRelayWorker {
return &WorksmobileRelayWorker{
repo: repo,
@@ -28,6 +34,17 @@ func NewWorksmobileRelayWorker(repo repository.WorksmobileOutboxRepository, clie
}
}
func (w *WorksmobileRelayWorker) SetLeaderLock(lock WorksmobileRelayLeaderLock) {
w.leaderLock = lock
}
func (w *WorksmobileRelayWorker) SetBatchLimit(limit int) {
if limit <= 0 {
return
}
w.batchLimit = limit
}
func (w *WorksmobileRelayWorker) Start(ctx context.Context) {
if w.repo == nil || w.client == nil {
slog.Warn("Worksmobile relay worker disabled")
@@ -49,7 +66,23 @@ func (w *WorksmobileRelayWorker) Start(ctx context.Context) {
}
}
func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) error {
func (w *WorksmobileRelayWorker) ProcessOnce(ctx context.Context) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("worksmobile relay panic: %v", recovered)
}
}()
if w.leaderLock != nil {
isLeader, err := w.leaderLock.EnsureLeadership(ctx)
if err != nil {
return err
}
if !isLeader {
return nil
}
}
jobs, err := w.repo.ListReady(ctx, w.batchLimit)
if err != nil {
return err
@@ -109,15 +142,20 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
aliasEmails := append([]string(nil), payload.AliasEmails...)
payload.AliasEmails = nil
if err := w.client.UpsertUser(ctx, payload); err != nil {
return err
return fmt.Errorf("worksmobile user upsert failed: %w", err)
}
for _, aliasEmail := range aliasEmails {
if err := w.client.AddUserAliasEmail(ctx, payload.Email, aliasEmail); err != nil {
return err
return fmt.Errorf("worksmobile user alias add failed: %w", err)
}
}
if stringValue(job.Payload["baronStatus"]) == domain.UserStatusActive {
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true)
if err := w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true); err != nil {
if isWorksmobileSCIMTokenNotConfiguredError(err) {
return nil
}
return fmt.Errorf("worksmobile user set active failed: %w", err)
}
}
return nil
case domain.WorksmobileActionDelete:
@@ -142,6 +180,10 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
}
}
func isWorksmobileSCIMTokenNotConfiguredError(err error) bool {
return err != nil && strings.Contains(err.Error(), "worksmobile scim token is not configured")
}
func sortWorksmobileReadyJobs(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
sorted := append([]domain.WorksmobileOutbox(nil), jobs...)
depthByID := worksmobileOrgUnitDepths(sorted)

View File

@@ -31,7 +31,7 @@ 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, credentialBatchID string) (*domain.WorksmobileOutbox, error)
EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID, initialPassword 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)
@@ -510,7 +510,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
return item, nil
}
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) {
func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID, initialPassword string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return nil, err
@@ -556,6 +556,13 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
if err != nil {
return nil, err
}
initialPassword = strings.TrimSpace(initialPassword)
if initialPassword != "" {
payload.PasswordConfig = WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: initialPassword,
}
}
if err := s.validateUserAliasLocalParts(ctx, root, *user, payload); err != nil {
return nil, err
}
@@ -1167,10 +1174,12 @@ func normalizeWorksmobileOrgUnitParent(payload WorksmobileOrgUnitPayload, tenant
func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload, statuses ...string) domain.JSONMap {
outboxPayload := domain.JSONMap{
"request": payload,
"tenantRootId": rootID,
"loginEmail": payload.Email,
"initialPassword": payload.PasswordConfig.Password,
"request": payload,
"tenantRootId": rootID,
"loginEmail": payload.Email,
}
if password := strings.TrimSpace(payload.PasswordConfig.Password); password != "" {
outboxPayload["initialPassword"] = password
}
if len(statuses) > 0 {
if status := strings.TrimSpace(statuses[0]); status != "" {
@@ -1428,7 +1437,7 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
excludedLocalIDs := map[string]bool{}
result := make([]WorksmobileComparisonItem, 0)
for _, user := range localUsers {
if !domain.IsWorksProvisionedUserStatus(user.Status) {
if user.DeletedAt.Valid || !domain.IsWorksProvisionedUserStatus(user.Status) {
excludedLocalIDs[user.ID] = true
if remote, ok := remoteByExternalID[user.ID]; ok {
matchedRemoteIDs[remote.ID] = true
@@ -1556,12 +1565,6 @@ func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser,
if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) {
return true
}
if worksmobileUserOrganizationsNeedUpdate(user, remote, localTenants) {
return true
}
if worksmobileUserManagerNeedsUpdate(user, remote) {
return true
}
return false
}
@@ -1571,22 +1574,24 @@ func worksmobileUserPhoneNeedsUpdate(user domain.User, remote WorksmobileRemoteU
if localPhone == "" && remotePhone == "" {
return false
}
return localPhone != remotePhone
if localPhone != remotePhone {
return true
}
return localPhone != "" && worksmobilePhoneHasDuplicateKoreanCountryCode(remote.CellPhone)
}
func normalizeWorksmobilePhoneForCompare(value string) string {
normalized := strings.TrimSpace(value)
normalized = strings.NewReplacer("-", "", " ", "", "(", "", ")", "").Replace(normalized)
if normalized == "" {
return ""
return domain.NormalizePhoneNumber(value)
}
func worksmobilePhoneHasDuplicateKoreanCountryCode(value string) bool {
digits := strings.Builder{}
for _, r := range strings.TrimSpace(value) {
if r >= '0' && r <= '9' {
digits.WriteRune(r)
}
}
if strings.HasPrefix(normalized, "010") {
return "+82" + normalized[1:]
}
if strings.HasPrefix(normalized, "82") {
return "+" + normalized
}
return normalized
return strings.HasPrefix(digits.String(), "8282")
}
func worksmobileUserEmployeeNumberNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool {

View File

@@ -50,7 +50,7 @@ func TestWorksmobileSyncServiceRejectsAliasEmailAlreadyUsedByOtherUser(t *testin
nil,
)
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "", "")
require.Nil(t, item)
require.Error(t, err)
@@ -90,7 +90,7 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
nil,
)
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "", "")
require.NoError(t, err)
require.NotNil(t, item)
@@ -135,7 +135,7 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
nil,
)
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "batch-1")
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "batch-1", "InputPass1!")
require.NoError(t, err)
require.NotNil(t, item)
@@ -144,6 +144,53 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"])
require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"])
require.Equal(t, "Saman", outboxRepo.created[0].Payload["primaryLeafOrgName"])
require.Equal(t, "InputPass1!", outboxRepo.created[0].Payload["initialPassword"])
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
require.Equal(t, "InputPass1!", request.PasswordConfig.Password)
}
func TestWorksmobileSyncServiceDoesNotAutoGenerateInitialPassword(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
tenantID := "saman-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "Hanmac Family",
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "saman",
Name: "Saman",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
target := domain.User{
ID: "target-user",
Email: "target@samaneng.com",
Name: "Target",
Status: domain.UserStatusActive,
TenantID: &tenantID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
outboxRepo,
nil,
)
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "batch-1", "")
require.NoError(t, err)
require.NotNil(t, item)
require.NotContains(t, outboxRepo.created[0].Payload, "initialPassword")
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.Empty(t, request.PasswordConfig.Password)
}
func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) {
@@ -382,7 +429,7 @@ func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) {
nil,
)
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "")
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "", "")
require.NoError(t, err)
require.NotNil(t, item)
@@ -1548,6 +1595,48 @@ func TestWorksmobileSyncServiceSkipsArchivedUsersInComparison(t *testing.T) {
require.Empty(t, comparison.Users)
}
func TestWorksmobileSyncServiceSkipsSoftDeletedUsersInComparison(t *testing.T) {
rootID := "root-tenant"
companyID := "company-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
company := domain.Tenant{
ID: companyID,
Name: "계열사",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
}
deleted := domain.User{
ID: "deleted-user",
Email: "deleted@samaneng.com",
Name: "Deleted",
TenantID: &companyID,
Status: domain.UserStatusActive,
DeletedAt: gorm.DeletedAt{
Time: time.Now(),
Valid: true,
},
}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}},
&fakeWorksmobileUserRepo{byTenant: []domain.User{deleted}},
&fakeWorksmobileOutboxRepo{},
&fakeWorksmobileDirectoryClient{users: []WorksmobileRemoteUser{{
ID: "works-deleted",
ExternalID: deleted.ID,
Email: deleted.Email,
}}},
)
comparison, err := service.GetComparison(context.Background(), rootID, true)
require.NoError(t, err)
require.Empty(t, comparison.Users)
}
func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) {
rootID := "root-tenant"
companyID := "company-tenant"
@@ -1760,14 +1849,14 @@ func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T)
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, "")
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) {
func TestCompareWorksmobileUsersIgnoresManagerChange(t *testing.T) {
tenantID := "tenant-leaf"
user := domain.User{
ID: "user-manager",
@@ -1803,10 +1892,10 @@ func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
func TestCompareWorksmobileUsersIgnoresSecondaryManagerChange(t *testing.T) {
primaryTenantID := "tenant-company"
secondaryTenantID := "tenant-gpdtdc-leaf"
user := domain.User{
@@ -1853,10 +1942,10 @@ func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testin
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *testing.T) {
func TestCompareWorksmobileUsersIgnoresMissingSecondaryOrganization(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
@@ -1916,7 +2005,7 @@ func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
require.Equal(t, "matched", items[0].Status)
}
func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t *testing.T) {
@@ -1952,6 +2041,35 @@ func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t
require.Equal(t, "needs_update", items[0].Status)
}
func TestCompareWorksmobileUsersMarksMalformedRemoteKoreanPhoneNeedsUpdate(t *testing.T) {
tenantID := "tenant-saman"
user := domain.User{
ID: "user-phone-canonical",
Email: "phone-canonical@samaneng.com",
Name: "Phone Canonical User",
Phone: "+821062836786",
TenantID: &tenantID,
Status: domain.UserStatusActive,
}
items := compareWorksmobileUsers(
[]domain.User{user},
[]WorksmobileRemoteUser{{
ID: "works-user-phone-canonical",
ExternalID: user.ID,
Email: user.Email,
DisplayName: user.Name,
CellPhone: "+82+821062836786",
}},
true,
map[string]domain.Tenant{
tenantID: {ID: tenantID, Name: "삼안", Type: domain.TenantTypeCompany},
},
)
require.Len(t, items, 1)
require.Equal(t, "needs_update", items[0].Status)
}
type fakeWorksmobileTenantService struct {
tenants map[string]domain.Tenant
list []domain.Tenant