1
0
forked from baron/baron-sso

사용자 상태 세분화

This commit is contained in:
2026-05-20 10:17:15 +09:00
parent 9112c4fb36
commit 42b49674cc
33 changed files with 876 additions and 590 deletions

View File

@@ -24,8 +24,70 @@ const (
UserStatusInactive = "inactive"
UserStatusSuspended = "suspended"
UserStatusLeaveOfAbsence = "leave_of_absence"
UserStatusTemporaryLeave = "temporary_leave"
UserStatusPreboarding = "preboarding"
UserStatusBaronGuest = "baron_guest"
UserStatusExtendedLeave = "extended_leave"
UserStatusArchived = "archived"
)
func NormalizeUserStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "", UserStatusActive:
return UserStatusActive
case "blocked", UserStatusSuspended:
return UserStatusSuspended
case UserStatusInactive, UserStatusPreboarding:
return UserStatusPreboarding
case UserStatusLeaveOfAbsence, UserStatusTemporaryLeave:
return UserStatusTemporaryLeave
case "baron_only", UserStatusBaronGuest:
return UserStatusBaronGuest
case UserStatusExtendedLeave:
return UserStatusExtendedLeave
case UserStatusArchived:
return UserStatusArchived
default:
return strings.ToLower(strings.TrimSpace(status))
}
}
func IsBaronActivityAllowedStatus(status string) bool {
switch NormalizeUserStatus(status) {
case UserStatusActive, UserStatusTemporaryLeave, UserStatusBaronGuest:
return true
default:
return false
}
}
func IsOrgVisibleUserStatus(status string) bool {
switch NormalizeUserStatus(status) {
case UserStatusActive, UserStatusTemporaryLeave, UserStatusSuspended:
return true
default:
return false
}
}
func IsWorksProvisionedUserStatus(status string) bool {
switch NormalizeUserStatus(status) {
case UserStatusActive, UserStatusTemporaryLeave, UserStatusSuspended:
return true
default:
return false
}
}
func IsWorksDeprovisionUserStatus(status string) bool {
switch NormalizeUserStatus(status) {
case UserStatusBaronGuest, UserStatusExtendedLeave, UserStatusArchived:
return true
default:
return false
}
}
// NormalizeRole maps legacy/synonym role values to canonical role keys.
func NormalizeRole(role string) string {
if normalized, ok := NormalizeRoleAlias(role); ok {

View File

@@ -29,3 +29,45 @@ func TestNormalizeRole(t *testing.T) {
})
}
}
func TestUserStatusPolicy(t *testing.T) {
tests := []struct {
status string
normalized string
baronAllowed bool
orgVisible bool
worksProvisioned bool
worksDeprovisioned bool
}{
{status: UserStatusActive, normalized: UserStatusActive, baronAllowed: true, orgVisible: true, worksProvisioned: true},
{status: UserStatusTemporaryLeave, normalized: UserStatusTemporaryLeave, baronAllowed: true, orgVisible: true, worksProvisioned: true},
{status: UserStatusSuspended, normalized: UserStatusSuspended, orgVisible: true, worksProvisioned: true},
{status: UserStatusPreboarding, normalized: UserStatusPreboarding},
{status: UserStatusBaronGuest, normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true},
{status: UserStatusExtendedLeave, normalized: UserStatusExtendedLeave, worksDeprovisioned: true},
{status: UserStatusArchived, normalized: UserStatusArchived, worksDeprovisioned: true},
{status: UserStatusInactive, normalized: UserStatusPreboarding},
{status: UserStatusLeaveOfAbsence, normalized: UserStatusTemporaryLeave, baronAllowed: true, orgVisible: true, worksProvisioned: true},
{status: "BARON_ONLY", normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true},
}
for _, tc := range tests {
t.Run(tc.status, func(t *testing.T) {
if got := NormalizeUserStatus(tc.status); got != tc.normalized {
t.Fatalf("NormalizeUserStatus(%q)=%q, want %q", tc.status, got, tc.normalized)
}
if got := IsBaronActivityAllowedStatus(tc.status); got != tc.baronAllowed {
t.Fatalf("IsBaronActivityAllowedStatus(%q)=%v, want %v", tc.status, got, tc.baronAllowed)
}
if got := IsOrgVisibleUserStatus(tc.status); got != tc.orgVisible {
t.Fatalf("IsOrgVisibleUserStatus(%q)=%v, want %v", tc.status, got, tc.orgVisible)
}
if got := IsWorksProvisionedUserStatus(tc.status); got != tc.worksProvisioned {
t.Fatalf("IsWorksProvisionedUserStatus(%q)=%v, want %v", tc.status, got, tc.worksProvisioned)
}
if got := IsWorksDeprovisionUserStatus(tc.status); got != tc.worksDeprovisioned {
t.Fatalf("IsWorksDeprovisionUserStatus(%q)=%v, want %v", tc.status, got, tc.worksDeprovisioned)
}
})
}
}

View File

@@ -2580,6 +2580,9 @@ func (h *AuthHandler) authenticatePasswordLogin(ctx context.Context, loginID, pa
slog.Error("Failed to resolve kratos identity after login", "loginID", loginID, "error", resolveErr)
return nil, fmt.Errorf("failed to resolve user identity")
}
if err := h.ensureUserActivityAllowed(ctx, subject); err != nil {
return nil, err
}
authInfo.Subject = subject
return authInfo, nil
@@ -2598,9 +2601,30 @@ func passwordLoginErrorSpec(err error) (int, string, string) {
if strings.Contains(err.Error(), "failed to resolve user identity") {
return fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity"
}
if strings.Contains(err.Error(), "cannot perform Baron activity") {
return fiber.StatusForbidden, "user_status_forbidden", "This user status cannot sign in"
}
return fiber.StatusUnauthorized, "password_or_email_mismatch", "Invalid credentials"
}
func (h *AuthHandler) ensureUserActivityAllowed(ctx context.Context, userID string) error {
if h == nil || h.UserRepo == nil || strings.TrimSpace(userID) == "" {
return nil
}
user, err := h.UserRepo.FindByID(ctx, userID)
if err != nil || user == nil {
return nil
}
if !domain.IsBaronActivityAllowedStatus(user.Status) {
return fmt.Errorf("user status %s cannot perform Baron activity", domain.NormalizeUserStatus(user.Status))
}
return nil
}
func isUserActivityForbiddenError(err error) bool {
return err != nil && strings.Contains(err.Error(), "cannot perform Baron activity")
}
func headlessAssertionAudiences(c *fiber.Ctx) []string {
if c == nil {
return nil
@@ -4522,6 +4546,9 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string {
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
profile, err := h.resolveCurrentProfile(c)
if err != nil {
if isUserActivityForbiddenError(err) {
return errorJSON(c, fiber.StatusForbidden, "This user status cannot perform Baron activity")
}
return errorJSON(c, fiber.StatusUnauthorized, err.Error())
}
return c.JSON(profile)
@@ -6198,6 +6225,9 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
if err != nil || subject == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
}
if err := h.ensureUserActivityAllowed(c.Context(), subject); err != nil {
return fiber.NewError(fiber.StatusForbidden, "This user status cannot sign in")
}
c.Locals("user_id", subject)
approvedSessionID := strings.TrimSpace(req.ApprovedSessionID)
if approvedSessionID == "" {
@@ -7472,6 +7502,9 @@ func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domai
slog.Warn("Hydra token session validation failed", "error", err)
return nil, err
}
if err := h.ensureUserActivityAllowed(ctx, intro.Subject); err != nil {
return nil, err
}
slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID)
@@ -7655,6 +7688,9 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile
if err != nil {
return nil, err
}
if err := h.ensureUserActivityAllowed(context.Background(), identityID); err != nil {
return nil, err
}
return h.applySessionInfoFromWhoami(
h.mapKratosIdentityToProfile(identityID, traits),
authenticatedAt,
@@ -7667,6 +7703,9 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro
if err != nil {
return nil, err
}
if err := h.ensureUserActivityAllowed(context.Background(), identityID); err != nil {
return nil, err
}
return h.applySessionInfoFromWhoami(
h.mapKratosIdentityToProfile(identityID, traits),
authenticatedAt,
@@ -7699,6 +7738,9 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
if err != nil {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
}
if err := h.ensureUserActivityAllowed(c.Context(), identityID); err != nil {
return errorJSON(c, fiber.StatusForbidden, "This user status cannot perform Baron activity")
}
currentPhone, _ := traits["phone_number"].(string)
newPhoneStorage := h.formatPhoneForStorage(req.Phone)

View File

@@ -31,6 +31,7 @@ import (
josejwt "github.com/go-jose/go-jose/v4/jwt"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// --- Mocks ---
@@ -159,6 +160,61 @@ func newHeadlessPasswordLoginTestApp(h *AuthHandler) *fiber.App {
return app
}
type passwordLoginUserRepo struct {
usersByID map[string]domain.User
}
func (r *passwordLoginUserRepo) Create(ctx context.Context, user *domain.User) error { return nil }
func (r *passwordLoginUserRepo) Update(ctx context.Context, user *domain.User) error { return nil }
func (r *passwordLoginUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, errors.New("not found")
}
func (r *passwordLoginUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) {
if r != nil {
if user, ok := r.usersByID[id]; ok {
return &user, nil
}
}
return nil, errors.New("not found")
}
func (r *passwordLoginUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
return nil, nil
}
func (r *passwordLoginUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
return nil, nil
}
func (r *passwordLoginUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
return nil, 0, nil
}
func (r *passwordLoginUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
return 0, nil
}
func (r *passwordLoginUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
}
func (r *passwordLoginUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
return nil, nil
}
func (r *passwordLoginUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
return nil, nil
}
func (r *passwordLoginUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
return nil, nil
}
func (r *passwordLoginUserRepo) Delete(ctx context.Context, id string) error { return nil }
func (r *passwordLoginUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return nil
}
func (r *passwordLoginUserRepo) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) {
return nil, nil
}
func (r *passwordLoginUserRepo) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) {
return false, nil
}
func (r *passwordLoginUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) {
return "", nil
}
func mustHeadlessRSAJWK(t *testing.T) (*rsa.PrivateKey, map[string]any) {
t.Helper()
@@ -1947,6 +2003,88 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) {
}
}
func TestPasswordLogin_ArchivedUserRejected(t *testing.T) {
mockIdp := new(MockIdentityProvider)
mockIdp.On("SignIn", "archived@example.com", "password").Return(&domain.AuthInfo{
SessionToken: &domain.Token{JWT: "archived-jwt"},
Subject: "archived-user-id",
}, nil)
mockKratos := new(MockKratosAdminService)
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "archived@example.com").Return("archived-user-id", nil)
h := &AuthHandler{
IdpProvider: mockIdp,
KratosAdmin: mockKratos,
Hydra: service.NewHydraAdminService(),
UserRepo: &passwordLoginUserRepo{usersByID: map[string]domain.User{
"archived-user-id": {
ID: "archived-user-id",
Email: "archived@example.com",
Name: "Archived User",
Status: domain.UserStatusArchived,
},
}},
}
app := newAuthLoginTestApp(h)
body, _ := json.Marshal(map[string]string{
"loginId": "archived@example.com",
"password": "password",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403, got %d", resp.StatusCode)
}
}
func TestEnsureUserActivityAllowedByStatus(t *testing.T) {
tests := []struct {
name string
status string
wantErr bool
}{
{name: "active allowed", status: domain.UserStatusActive},
{name: "temporary leave allowed", status: domain.UserStatusTemporaryLeave},
{name: "baron guest allowed", status: domain.UserStatusBaronGuest},
{name: "suspended rejected", status: domain.UserStatusSuspended, wantErr: true},
{name: "preboarding rejected", status: domain.UserStatusPreboarding, wantErr: true},
{name: "extended leave rejected", status: domain.UserStatusExtendedLeave, wantErr: true},
{name: "archived rejected", status: domain.UserStatusArchived, wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h := &AuthHandler{
UserRepo: &passwordLoginUserRepo{usersByID: map[string]domain.User{
"user-id": {
ID: "user-id",
Email: "user@example.com",
Name: "User",
Status: tc.status,
},
}},
}
err := h.ensureUserActivityAllowed(context.Background(), "user-id")
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) {
mockIdp := new(MockIdentityProvider)
mockIdp.On("SignIn", "user@example.com", "wrong-password").Return(nil, errors.New("비밀번호가 일치하지 않습니다"))

View File

@@ -13,6 +13,7 @@ import (
"errors"
"fmt"
"io"
"sort"
"strings"
"time"
@@ -415,6 +416,7 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
tenants := filterTenantCSVDescendants(allTenants, parentID)
sortTenantsByInputOrder(tenants)
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
@@ -483,6 +485,15 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
return c.Send(buf.Bytes())
}
func sortTenantsByInputOrder(tenants []domain.Tenant) {
sort.SliceStable(tenants, func(i, j int) bool {
if tenants[i].CreatedAt.Equal(tenants[j].CreatedAt) {
return tenants[i].ID < tenants[j].ID
}
return tenants[i].CreatedAt.Before(tenants[j].CreatedAt)
})
}
func filterTenantCSVDescendants(tenants []domain.Tenant, parentID string) []domain.Tenant {
parentID = strings.TrimSpace(parentID)
if parentID == "" {
@@ -2231,7 +2242,7 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
users := append(usersByID, usersBySlug...)
users = append(users, usersByAppointment...)
for _, user := range users {
if seen[user.ID] || user.Status != domain.UserStatusActive {
if seen[user.ID] || !domain.IsOrgVisibleUserStatus(user.Status) {
continue
}
assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs)

View File

@@ -616,6 +616,66 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
CreatedAt: now,
UpdatedAt: now,
},
{
ID: "user-archived",
Email: "archived@example.com",
Name: "보관 사용자",
Status: domain.UserStatusArchived,
TenantID: parent("dept-platform"),
CompanyCode: "platform",
CreatedAt: now,
UpdatedAt: now,
},
{
ID: "user-suspended",
Email: "suspended@example.com",
Name: "정지 사용자",
Status: domain.UserStatusSuspended,
TenantID: parent("dept-platform"),
CompanyCode: "platform",
CreatedAt: now,
UpdatedAt: now,
},
{
ID: "user-temporary-leave",
Email: "temporary-leave@example.com",
Name: "단기휴무 사용자",
Status: domain.UserStatusTemporaryLeave,
TenantID: parent("dept-platform"),
CompanyCode: "platform",
CreatedAt: now,
UpdatedAt: now,
},
{
ID: "user-preboarding",
Email: "preboarding@example.com",
Name: "입사대기 사용자",
Status: domain.UserStatusPreboarding,
TenantID: parent("dept-platform"),
CompanyCode: "platform",
CreatedAt: now,
UpdatedAt: now,
},
{
ID: "user-baron-guest",
Email: "baron-guest@example.com",
Name: "Baron Guest",
Status: domain.UserStatusBaronGuest,
TenantID: parent("dept-platform"),
CompanyCode: "platform",
CreatedAt: now,
UpdatedAt: now,
},
{
ID: "user-extended-leave",
Email: "extended-leave@example.com",
Name: "장기휴직 사용자",
Status: domain.UserStatusExtendedLeave,
TenantID: parent("dept-platform"),
CompanyCode: "platform",
CreatedAt: now,
UpdatedAt: now,
},
}
usersBySlug := []domain.User{
{ID: "user-sso-member", Email: "member@example.com", Name: "SSO 구성원", Status: domain.UserStatusActive, CompanyCode: "sso", Grade: "선임", CreatedAt: now, UpdatedAt: now},
@@ -668,7 +728,7 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
require.NotContains(t, got, "users")
deptPlatform := tenantsPayload[2].(map[string]any)
platformMembers := deptPlatform["members"].([]any)
require.Len(t, platformMembers, 1)
require.Len(t, platformMembers, 3)
firstUser := platformMembers[0].(map[string]any)
require.NotContains(t, firstUser, "id")
require.NotContains(t, firstUser, "phone")
@@ -703,6 +763,12 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
require.NotContains(t, toJSONString(t, got), "directUserIds")
require.NotContains(t, toJSONString(t, got), "private-team")
require.NotContains(t, toJSONString(t, got), "root-other")
require.NotContains(t, toJSONString(t, got), "archived@example.com")
require.Contains(t, toJSONString(t, got), "suspended@example.com")
require.Contains(t, toJSONString(t, got), "temporary-leave@example.com")
require.NotContains(t, toJSONString(t, got), "preboarding@example.com")
require.NotContains(t, toJSONString(t, got), "baron-guest@example.com")
require.NotContains(t, toJSONString(t, got), "extended-leave@example.com")
}
func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *testing.T) {
@@ -963,6 +1029,37 @@ func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T)
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ExportTenantsCSV_OrdersByInputOrder(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
app.Get("/tenants/export", h.ExportTenantsCSV)
oldest := time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC)
middle := oldest.Add(time.Hour)
newest := oldest.Add(2 * time.Hour)
tenants := []domain.Tenant{
{ID: "newest", Name: "Newest Tenant", Type: domain.TenantTypeCompany, Slug: "newest", CreatedAt: newest},
{ID: "middle", Name: "Middle Tenant", Type: domain.TenantTypeCompany, Slug: "middle", CreatedAt: middle},
{ID: "oldest", Name: "Oldest Tenant", Type: domain.TenantTypeCompany, Slug: "oldest", CreatedAt: oldest},
}
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
resp, _ := app.Test(req)
body, _ := io.ReadAll(resp.Body)
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Len(t, lines, 4)
assert.Contains(t, lines[1], "oldest,Oldest Tenant")
assert.Contains(t, lines[2], "middle,Middle Tenant")
assert.Contains(t, lines[3], "newest,Newest Tenant")
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)

View File

@@ -1644,10 +1644,9 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
state := identity.State
if req.Status != nil {
if *req.Status == "active" {
state = "active"
} else {
state = "inactive"
state = normalizeKratosState(req.Status)
if state == "" {
state = identity.State
}
}
@@ -1667,7 +1666,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
localUser.Role = *req.Role
}
if req.Status != nil {
localUser.Status = *req.Status
localUser.Status = normalizeStatus(*req.Status)
}
if req.Department != nil {
localUser.Department = *req.Department
@@ -2610,20 +2609,7 @@ func formatTime(value time.Time) string {
}
func normalizeStatus(state string) string {
state = strings.ToLower(strings.TrimSpace(state))
if state == "blocked" {
return domain.UserStatusInactive
}
if state == domain.UserStatusInactive ||
state == domain.UserStatusSuspended ||
state == domain.UserStatusLeaveOfAbsence ||
state == domain.UserStatusActive {
return state
}
if state == "" {
return domain.UserStatusActive
}
return state
return domain.NormalizeUserStatus(state)
}
func normalizeKratosState(status *string) string {
@@ -2637,9 +2623,13 @@ func normalizeKratosState(status *string) string {
if value == domain.UserStatusActive {
return domain.UserStatusActive
}
if value == domain.UserStatusInactive ||
value == domain.UserStatusSuspended ||
value == domain.UserStatusLeaveOfAbsence {
normalized := domain.NormalizeUserStatus(value)
if normalized == domain.UserStatusPreboarding ||
normalized == domain.UserStatusSuspended ||
normalized == domain.UserStatusTemporaryLeave ||
normalized == domain.UserStatusBaronGuest ||
normalized == domain.UserStatusExtendedLeave ||
normalized == domain.UserStatusArchived {
return domain.UserStatusInactive
}
return ""

View File

@@ -1017,7 +1017,7 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
assert.True(t, results[0].(map[string]interface{})["success"].(bool))
assert.Len(t, worksmobile.upserts, 1)
assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status)
assert.Equal(t, domain.UserStatusPreboarding, worksmobile.upserts[0].Status)
})
t.Run("Fail - Super admin cannot assign tenant or RP admin roles", func(t *testing.T) {

View File

@@ -3,6 +3,7 @@ package repository
import (
"baron-sso-backend/internal/domain"
"context"
"fmt"
"strings"
"gorm.io/gorm"
@@ -45,24 +46,21 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. Resolve email conflicts: If another user in the local DB has this email but a different ID,
// we must remove the old local record because Kratos is the source of truth for ID <-> Email mapping.
var existing domain.User
if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil {
if existing.ID != user.ID {
// Delete associated login IDs first to prevent FK constraint violation
if strings.EqualFold(strings.TrimSpace(existing.Status), domain.UserStatusArchived) {
return fmt.Errorf("email is reserved by archived user: %s", user.Email)
}
if err := tx.Unscoped().Where("user_id = ?", existing.ID).Delete(&domain.UserLoginID{}).Error; err != nil {
return err
}
// Different ID holds this email locally. Hard delete the old record to avoid constraint violation.
if err := tx.Unscoped().Delete(&domain.User{}, "id = ?", existing.ID).Error; err != nil {
return err
}
}
}
// 2. Perform Upsert based on ID.
// In GORM v2, true upsert requires Create() with OnConflict on the primary key.
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,

View File

@@ -53,6 +53,36 @@ func TestUserRepository(t *testing.T) {
assert.Equal(t, "010-1234-5678", found.Phone)
})
t.Run("Update preserves archived email reservation", func(t *testing.T) {
testDB.Exec("DELETE FROM user_login_ids")
testDB.Exec("DELETE FROM users")
archived := &domain.User{
ID: "00000000-0000-0000-0000-00000000a001",
Email: "reserved@example.com",
Name: "Archived User",
Role: domain.RoleUser,
Status: domain.UserStatusArchived,
}
replacement := &domain.User{
ID: "00000000-0000-0000-0000-00000000a002",
Email: "reserved@example.com",
Name: "Replacement User",
Role: domain.RoleUser,
Status: domain.UserStatusActive,
}
require.NoError(t, repo.Create(ctx, archived))
err := repo.Update(ctx, replacement)
require.Error(t, err)
require.Contains(t, err.Error(), "archived user")
found, err := repo.FindByEmail(ctx, archived.Email)
require.NoError(t, err)
require.Equal(t, archived.ID, found.ID)
require.Equal(t, domain.UserStatusArchived, found.Status)
})
t.Run("List Users with Search", func(t *testing.T) {
// Add some users
_ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"})

View File

@@ -384,14 +384,7 @@ func userGroupTraitStringArray(traits map[string]interface{}, key string) []stri
}
func userGroupIdentityStatus(state string) string {
switch state {
case "", "active":
return domain.UserStatusActive
case "inactive":
return domain.UserStatusInactive
default:
return state
}
return domain.NormalizeUserStatus(state)
}
func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {

View File

@@ -145,14 +145,9 @@ func kratosProjectionTraitStringArray(traits map[string]interface{}, key string)
}
func normalizeProjectionStatus(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) {
case "blocked", domain.UserStatusInactive:
return domain.UserStatusInactive
case domain.UserStatusSuspended:
return domain.UserStatusSuspended
case domain.UserStatusLeaveOfAbsence:
return domain.UserStatusLeaveOfAbsence
default:
normalized := domain.NormalizeUserStatus(state)
if normalized == "" {
return domain.UserStatusActive
}
return normalized
}

View File

@@ -96,3 +96,16 @@ func TestUserProjectionSyncService_ReconcileMarksFailedWhenKratosFails(t *testin
assert.Empty(t, repo.replacedUsers)
kratos.AssertExpectations(t)
}
func TestMapKratosIdentityToLocalUserPreservesArchivedStatus(t *testing.T) {
user := MapKratosIdentityToLocalUser(KratosIdentity{
ID: "00000000-0000-0000-0000-000000000201",
State: domain.UserStatusArchived,
Traits: map[string]interface{}{
"email": "archived@example.com",
"name": "Archived User",
},
})
assert.Equal(t, domain.UserStatusArchived, user.Status)
}

View File

@@ -924,6 +924,7 @@ type fakeWorksmobileDirectoryClient struct {
deletedUsers []string
activeUsers []string
suspendedUsers []string
users []WorksmobileRemoteUser
orgUnitMatchKeys []string
groups []WorksmobileRemoteGroup
}
@@ -1029,7 +1030,7 @@ func (f *fakeWorksmobileDirectoryClient) SetUserActive(ctx context.Context, user
}
func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) {
return nil, nil
return f.users, nil
}
func (f *fakeWorksmobileDirectoryClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) {

View File

@@ -402,8 +402,12 @@ func shuffleBytes(values []byte) {
}
func WorksmobileUserStatusAction(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case domain.UserStatusInactive, domain.UserStatusSuspended, domain.UserStatusLeaveOfAbsence:
normalized := domain.NormalizeUserStatus(status)
if domain.IsWorksDeprovisionUserStatus(normalized) {
return domain.WorksmobileActionDelete
}
switch normalized {
case domain.UserStatusSuspended:
return WorksmobileUserActionSuspend
default:
return WorksmobileUserActionUpsert

View File

@@ -371,9 +371,13 @@ func containsAny(value string, candidates string) bool {
func TestWorksmobileUserStatusAction(t *testing.T) {
require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusActive))
require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusInactive))
require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusTemporaryLeave))
require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusSuspended))
require.Equal(t, WorksmobileUserActionSuspend, WorksmobileUserStatusAction(domain.UserStatusLeaveOfAbsence))
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusExtendedLeave))
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusBaronGuest))
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction(domain.UserStatusArchived))
require.Equal(t, WorksmobileUserActionUpsert, WorksmobileUserStatusAction(domain.UserStatusLeaveOfAbsence))
require.Equal(t, domain.WorksmobileActionDelete, WorksmobileUserStatusAction("baron_only"))
}
func TestValidateWorksmobileExternalKeyRejectsUnsupportedCharacters(t *testing.T) {

View File

@@ -215,6 +215,7 @@ func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tena
if err != nil {
return WorksmobileBackfillDryRun{}, err
}
users = worksmobileSyncScopeUsers(users)
_ = s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: root.ID,
@@ -366,6 +367,12 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
if err != nil {
return nil, err
}
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,
@@ -510,6 +517,13 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
if err != nil {
return err
}
if domain.IsWorksDeprovisionUserStatus(user.Status) {
_, err := s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, root.ID)
return err
}
if !domain.IsWorksProvisionedUserStatus(user.Status) {
return nil
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
payload, err := BuildWorksmobileUserPayloadForDomainTenants(
user,
@@ -545,16 +559,32 @@ func (s *worksmobileSyncService) EnqueueUserDeleteIfInScope(ctx context.Context,
if err != nil || !ok {
return err
}
return s.outboxRepo.Create(ctx, &domain.WorksmobileOutbox{
_, err = s.enqueueUserDelete(ctx, user, "user:delete:"+user.ID, "")
return err
}
func (s *worksmobileSyncService) enqueueUserDelete(ctx context.Context, user domain.User, dedupeKey string, rootID string) (*domain.WorksmobileOutbox, error) {
payload := domain.JSONMap{
"userExternalKey": user.ID,
"loginEmail": user.Email,
}
if rootID != "" {
payload["tenantRootId"] = rootID
}
if status := domain.NormalizeUserStatus(user.Status); status != "" {
payload["baronStatus"] = status
}
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceUser,
ResourceID: user.ID,
Action: domain.WorksmobileActionDelete,
DedupeKey: "user:delete:" + user.ID,
Payload: domain.JSONMap{
"userExternalKey": user.ID,
"loginEmail": user.Email,
},
})
DedupeKey: dedupeKey,
Payload: payload,
}
if err := s.outboxRepo.Create(ctx, item); err != nil {
return nil, err
}
return item, nil
}
func (s *worksmobileSyncService) hanmacRoot(ctx context.Context, tenantID string) (*domain.Tenant, error) {
@@ -803,8 +833,18 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
}
localByID := map[string]domain.User{}
matchedRemoteIDs := map[string]bool{}
excludedLocalIDs := map[string]bool{}
result := make([]WorksmobileComparisonItem, 0)
for _, user := range localUsers {
if !domain.IsWorksProvisionedUserStatus(user.Status) {
excludedLocalIDs[user.ID] = true
if remote, ok := remoteByExternalID[user.ID]; ok {
matchedRemoteIDs[remote.ID] = true
} else if remote, ok := remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]; ok {
matchedRemoteIDs[remote.ID] = true
}
continue
}
localByID[user.ID] = user
remote, matched := remoteByExternalID[user.ID]
if !matched {
@@ -848,6 +888,9 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile
if matchedRemoteIDs[remote.ID] {
continue
}
if excludedLocalIDs[remote.ExternalID] {
continue
}
if remote.ExternalID == "" {
result = append(result, WorksmobileComparisonItem{
ResourceType: "USER",
@@ -1094,3 +1137,17 @@ func worksmobileTenantParentSlug(tenant domain.Tenant, tenantByID map[string]dom
}
return strings.TrimSpace(tenantByID[parentID].Slug)
}
func worksmobileSyncScopeUsers(users []domain.User) []domain.User {
if len(users) == 0 {
return users
}
filtered := make([]domain.User, 0, len(users))
for _, user := range users {
if !domain.IsWorksProvisionedUserStatus(user.Status) {
continue
}
filtered = append(filtered, user)
}
return filtered
}

View File

@@ -101,6 +101,51 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
}
func TestWorksmobileSyncServiceDeprovisionsArchivedUser(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: "archived-user",
Email: "archived@samaneng.com",
Name: "Archived",
Status: domain.UserStatusArchived,
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.Equal(t, domain.WorksmobileActionDelete, item.Action)
require.Len(t, outboxRepo.created, 1)
err = service.EnqueueUserUpsertIfInScope(context.Background(), target)
require.NoError(t, err)
require.Len(t, outboxRepo.created, 2)
require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[1].Action)
}
func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) {
t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1")
root := domain.Tenant{
@@ -759,6 +804,88 @@ func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T)
require.ElementsMatch(t, []string{companyID, userGroupID}, userRepo.requestedTenantIDs)
}
func TestWorksmobileSyncServiceSkipsArchivedUsersInComparison(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,
}
archived := domain.User{
ID: "archived-user",
Email: "archived@samaneng.com",
Name: "Archived",
TenantID: &companyID,
Status: domain.UserStatusArchived,
}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}},
&fakeWorksmobileUserRepo{byTenant: []domain.User{archived}},
&fakeWorksmobileOutboxRepo{},
&fakeWorksmobileDirectoryClient{users: []WorksmobileRemoteUser{{
ID: "works-archived",
ExternalID: archived.ID,
Email: archived.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"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
company := domain.Tenant{
ID: companyID,
Name: "계열사",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
}
active := domain.User{
ID: "active-user",
Email: "active@samaneng.com",
Name: "Active",
TenantID: &companyID,
Status: domain.UserStatusActive,
}
archived := domain.User{
ID: "archived-user",
Email: "archived@samaneng.com",
Name: "Archived",
TenantID: &companyID,
Status: domain.UserStatusArchived,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}},
&fakeWorksmobileUserRepo{byTenant: []domain.User{active, archived}},
outboxRepo,
nil,
)
dryRun, err := service.EnqueueBackfillDryRun(context.Background(), rootID)
require.NoError(t, err)
require.Equal(t, 1, dryRun.UserCount)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"])
}
type fakeWorksmobileTenantService struct {
tenants map[string]domain.Tenant
list []domain.Tenant