1
0
forked from baron/baron-sso

권한부여 및 정합성 검사 추가

This commit is contained in:
2026-05-14 08:45:48 +09:00
parent f6f8e88342
commit 9ca73e8774
36 changed files with 1772 additions and 105 deletions

View File

@@ -32,6 +32,7 @@ type WorksmobileDirectoryClient interface {
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
DeleteUser(ctx context.Context, userID string) error
SetUserActive(ctx context.Context, userID string, active bool) error
ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error)
ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error)
}
@@ -330,6 +331,33 @@ func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) e
return c.sendJSON(ctx, http.MethodDelete, "/scim/v2/Users/"+url.PathEscape(remote.ID), nil)
}
func (c *WorksmobileHTTPClient) SetUserActive(ctx context.Context, userID string, active bool) error {
userID = strings.TrimSpace(userID)
if userID == "" {
return fmt.Errorf("worksmobile user id is required")
}
if strings.TrimSpace(c.SCIMToken) == "" {
return fmt.Errorf("worksmobile scim token is not configured")
}
remote, err := c.findSCIMUser(ctx, userID)
if err != nil {
return err
}
if remote == nil {
return nil
}
return c.sendJSON(ctx, http.MethodPatch, "/scim/v2/Users/"+url.PathEscape(remote.ID), map[string]any{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]any{
{
"op": "replace",
"path": "active",
"value": active,
},
},
})
}
func (c *WorksmobileHTTPClient) FindUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) {
users, err := c.ListUsers(ctx)
if err != nil {
@@ -344,6 +372,21 @@ func (c *WorksmobileHTTPClient) FindUser(ctx context.Context, identifier string)
return nil, nil
}
func (c *WorksmobileHTTPClient) findSCIMUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) {
identifier = strings.TrimSpace(identifier)
var matched *WorksmobileRemoteUser
err := c.listSCIM(ctx, "/scim/v2/Users", func(resource map[string]any) {
if matched != nil {
return
}
user := parseWorksmobileRemoteUser(resource)
if strings.EqualFold(user.UserName, identifier) || user.ExternalID == identifier || strings.EqualFold(user.Email, identifier) {
matched = &user
}
})
return matched, err
}
func (c *WorksmobileHTTPClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) {
if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 {
users, err := c.listDirectoryUsers(ctx, c.DomainIDs)

View File

@@ -235,6 +235,42 @@ func TestWorksmobileHTTPClientListUsersFallsBackToSCIMWhenDirectoryFails(t *test
require.Equal(t, "/scim/v2/Users", transport.requests[1].URL.Path)
}
func TestWorksmobileHTTPClientSetUserActivePatchesSCIMActiveFlag(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusOK, body: `{"totalResults":1,"Resources":[{"id":"scim-user-1","externalId":"user-1","userName":"tester@samaneng.com","active":true,"emails":[{"value":"tester@samaneng.com","primary":true}]}]}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
SCIMToken: "scim-token-1",
HTTPClient: &http.Client{Transport: transport},
}
err := client.SetUserActive(context.Background(), "tester@samaneng.com", false)
require.NoError(t, err)
require.Len(t, transport.requests, 2)
require.Equal(t, http.MethodGet, transport.requests[0].Method)
require.Equal(t, "/scim/v2/Users", transport.requests[0].URL.Path)
require.Equal(t, http.MethodPatch, transport.requests[1].Method)
require.Equal(t, "/scim/v2/Users/scim-user-1", transport.requests[1].URL.Path)
require.Equal(t, "Bearer scim-token-1", transport.requests[1].Header.Get("Authorization"))
var patchPayload map[string]any
require.Len(t, transport.requestBodies, 1)
require.NoError(t, json.Unmarshal(transport.requestBodies[0], &patchPayload))
operations, ok := patchPayload["Operations"].([]any)
require.True(t, ok)
require.Len(t, operations, 1)
operation, ok := operations[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "replace", operation["op"])
require.Equal(t, "active", operation["path"])
require.Equal(t, false, operation["value"])
}
func TestWorksmobileHTTPClientListGroupsUsesDirectoryAPIFirst(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "300285955")
transport := &captureRoundTripper{
@@ -373,6 +409,60 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
}
func TestWorksmobileRelayWorkerProcessesUserSuspendAndMarksProcessed(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
{
ID: "job-1",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-1",
Action: domain.WorksmobileActionSuspend,
Status: domain.WorksmobileOutboxStatusPending,
Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
Email: "tester@samaneng.com",
UserExternalKey: "user-1",
}),
},
},
}
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"job-1"}, repo.processingIDs)
require.Equal(t, []string{"job-1"}, repo.processedIDs)
require.Equal(t, []string{"tester@samaneng.com"}, client.suspendedUsers)
}
func TestWorksmobileRelayWorkerProcessesActiveUserUpsertAndReactivates(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",
}, domain.UserStatusActive),
},
},
}
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"job-1"}, repo.processedIDs)
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
require.Equal(t, []string{"tester@samaneng.com"}, client.activeUsers)
}
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
jobs := []domain.WorksmobileOutbox{
{
@@ -714,6 +804,8 @@ type fakeWorksmobileDirectoryClient struct {
createdOrgUnits []WorksmobileOrgUnitPayload
createdUsers []WorksmobileUserPayload
deletedUsers []string
activeUsers []string
suspendedUsers []string
orgUnitMatchKeys []string
}
@@ -803,6 +895,15 @@ func (f *fakeWorksmobileDirectoryClient) DeleteUser(ctx context.Context, userID
return nil
}
func (f *fakeWorksmobileDirectoryClient) SetUserActive(ctx context.Context, userID string, active bool) error {
if active {
f.activeUsers = append(f.activeUsers, userID)
} else {
f.suspendedUsers = append(f.suspendedUsers, userID)
}
return nil
}
func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) {
return nil, nil
}

View File

@@ -97,13 +97,17 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
return err
}
return w.client.UpsertUser(ctx, payload)
case domain.WorksmobileActionDelete:
userID := stringValue(job.Payload["loginEmail"])
if userID == "" {
userID = stringValue(job.Payload["userExternalKey"])
if err := w.client.UpsertUser(ctx, payload); err != nil {
return err
}
return w.client.DeleteUser(ctx, userID)
if stringValue(job.Payload["baronStatus"]) == domain.UserStatusActive {
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true)
}
return nil
case domain.WorksmobileActionDelete:
return w.client.DeleteUser(ctx, worksmobileOutboxUserIdentifier(job))
case domain.WorksmobileActionSuspend:
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), false)
default:
return nil
}
@@ -112,6 +116,14 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
}
}
func worksmobileOutboxUserIdentifier(job domain.WorksmobileOutbox) string {
userID := stringValue(job.Payload["loginEmail"])
if userID == "" {
userID = stringValue(job.Payload["userExternalKey"])
}
return userID
}
func decodeWorksmobileRequest(payload domain.JSONMap, target any) error {
raw := payload["request"]
if raw == nil {

View File

@@ -315,7 +315,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
ResourceID: user.ID,
Action: action,
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
Payload: worksmobileUserOutboxPayload(root.ID, payload),
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
}
if err := s.outboxRepo.Create(ctx, item); err != nil {
return nil, err
@@ -459,7 +459,7 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
ResourceID: user.ID,
Action: action,
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
Payload: worksmobileUserOutboxPayload(root.ID, payload),
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
})
}
@@ -649,13 +649,19 @@ func normalizeWorksmobileOrgUnitParent(payload WorksmobileOrgUnitPayload, tenant
return payload
}
func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload) domain.JSONMap {
return domain.JSONMap{
func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload, statuses ...string) domain.JSONMap {
outboxPayload := domain.JSONMap{
"request": payload,
"tenantRootId": rootID,
"loginEmail": payload.Email,
"initialPassword": payload.PasswordConfig.Password,
}
if len(statuses) > 0 {
if status := strings.TrimSpace(statuses[0]); status != "" {
outboxPayload["baronStatus"] = status
}
}
return outboxPayload
}
func stringValue(value any) string {

View File

@@ -56,6 +56,51 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
require.Empty(t, outboxRepo.created)
}
func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(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.UserStatusSuspended,
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)
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileActionSuspend, outboxRepo.created[0].Action)
require.Equal(t, domain.UserStatusSuspended, outboxRepo.created[0].Payload["baronStatus"])
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.NotEmpty(t, request.Organizations)
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
}
func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) {
t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1")
root := domain.Tenant{