1
0
forked from baron/baron-sso

사용자 테넌트 소속 데이터 정리

This commit is contained in:
2026-05-13 18:23:39 +09:00
parent 8a6e41d74c
commit e36a973053
26 changed files with 348 additions and 387 deletions

View File

@@ -28,6 +28,10 @@ type createSuperAdminConfig struct {
UpdatePassword bool
}
type clearOrphanUserTenantMembershipsConfig struct {
DryRun bool
}
func main() {
loadEnv()
logger.Init(logger.Config{
@@ -47,6 +51,11 @@ func main() {
slog.Error("create-super-admin failed", "error", err)
os.Exit(1)
}
case "clear-orphan-user-tenant-memberships":
if err := runClearOrphanUserTenantMemberships(os.Args[2:]); err != nil {
slog.Error("clear-orphan-user-tenant-memberships failed", "error", err)
os.Exit(1)
}
default:
printUsage()
os.Exit(2)
@@ -107,6 +116,37 @@ func runCreateSuperAdmin(args []string) error {
return nil
}
func runClearOrphanUserTenantMemberships(args []string) error {
config, err := resolveClearOrphanUserTenantMembershipsConfig(args)
if err != nil {
return err
}
db, err := openDB()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if config.DryRun {
count, err := repository.CountOrphanUserTenantMemberships(ctx, db)
if err != nil {
return err
}
fmt.Printf("orphan user tenant memberships dry-run: count=%d\n", count)
return nil
}
affected, err := repository.ClearOrphanUserTenantMemberships(ctx, db)
if err != nil {
return err
}
fmt.Printf("orphan user tenant memberships cleared: count=%d\n", affected)
return nil
}
func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error) {
fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
@@ -135,6 +175,19 @@ func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error
return config, nil
}
func resolveClearOrphanUserTenantMembershipsConfig(args []string) (clearOrphanUserTenantMembershipsConfig, error) {
fs := flag.NewFlagSet("clear-orphan-user-tenant-memberships", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
config := clearOrphanUserTenantMembershipsConfig{}
fs.BoolVar(&config.DryRun, "dry-run", false, "count orphan memberships without updating users")
if err := fs.Parse(args); err != nil {
return config, err
}
return config, nil
}
func openDB() (*gorm.DB, error) {
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
@@ -173,4 +226,5 @@ func getenv(key string, fallback string) string {
func printUsage() {
fmt.Fprintln(os.Stderr, "usage:")
fmt.Fprintln(os.Stderr, " adminctl create-super-admin [--email EMAIL] [--password PASSWORD] [--name NAME] [--update-password]")
fmt.Fprintln(os.Stderr, " adminctl clear-orphan-user-tenant-memberships [--dry-run]")
}

View File

@@ -60,3 +60,14 @@ func TestResolveCreateSuperAdminConfigRequiresEmailAndPassword(t *testing.T) {
t.Fatal("expected error")
}
}
func TestResolveClearOrphanUserTenantMembershipsConfig(t *testing.T) {
config, err := resolveClearOrphanUserTenantMembershipsConfig([]string{"--dry-run"})
if err != nil {
t.Fatalf("resolveClearOrphanUserTenantMembershipsConfig returned error: %v", err)
}
if !config.DryRun {
t.Fatal("dry-run flag was not set")
}
}

View File

@@ -139,7 +139,6 @@ func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser {
Attributes: map[string]interface{}{
"department": "Admin",
"affiliationType": "internal",
"companyCode": "",
"grade": "",
"role": domain.RoleSuperAdmin,
},

View File

@@ -37,6 +37,9 @@ func migrateSchemas(db *gorm.DB) error {
if err := dropLegacyTenantDomainUniqueIndex(db); err != nil {
return err
}
if err := dropLegacyUserCompanyColumns(db); err != nil {
return err
}
// Add all domain models here
return db.AutoMigrate(
@@ -61,6 +64,21 @@ func migrateSchemas(db *gorm.DB) error {
)
}
func dropLegacyUserCompanyColumns(db *gorm.DB) error {
if !db.Migrator().HasTable(&domain.User{}) {
return nil
}
for _, column := range []string{"company_code", "company_codes"} {
if !db.Migrator().HasColumn(&domain.User{}, column) {
continue
}
if err := db.Migrator().DropColumn(&domain.User{}, column); err != nil {
return fmt.Errorf("failed to drop legacy users.%s column: %w", column, err)
}
}
return nil
}
func dropLegacyTenantDomainUniqueIndex(db *gorm.DB) error {
if !db.Migrator().HasTable(&domain.TenantDomain{}) {
return nil

View File

@@ -34,7 +34,6 @@ func SeedAdminIdentity(idp domain.IdentityProvider) (string, error) {
Attributes: map[string]interface{}{
"department": "Admin",
"affiliationType": "internal",
"companyCode": "",
"grade": "",
"role": "super_admin", // Explicitly set role for Kratos traits
},

View File

@@ -60,6 +60,7 @@ type SignupRequest struct {
Name string `json:"name"`
Phone string `json:"phone"`
AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL"
TenantSlug string `json:"tenantSlug,omitempty"`
CompanyCode string `json:"companyCode,omitempty"`
Department string `json:"department"`
Metadata JSONMap `json:"metadata,omitempty"`
@@ -117,5 +118,6 @@ type PasswordChangeRequest struct {
type CheckLoginIDRequest struct {
LoginID string `json:"loginId"`
TenantSlug string `json:"tenantSlug,omitempty"`
CompanyCode string `json:"companyCode,omitempty"`
}

View File

@@ -59,8 +59,8 @@ type User struct {
Phone string `gorm:"column:phone" json:"phone"`
Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user
AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
CompanyCode string `gorm:"column:company_code;index" json:"companyCode"`
CompanyCodes pq.StringArray `gorm:"column:company_codes;type:text[]" json:"companyCodes"`
CompanyCode string `gorm:"-" json:"companyCode,omitempty"`
CompanyCodes pq.StringArray `gorm:"-" json:"companyCodes,omitempty"`
TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"`
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용

View File

@@ -704,8 +704,12 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, "Identity provider unavailable")
}
if strings.TrimSpace(req.CompanyCode) != "" {
return errorJSON(c, fiber.StatusBadRequest, "companyCode is deprecated; use tenantSlug")
}
// 소속이 비어 있는 일반 가입자는 PERSONAL tenant를 자동 생성해 대표소속을 보장합니다.
companyCode := ""
tenantSlug := strings.TrimSpace(req.TenantSlug)
var tenantID *string
parts := strings.Split(req.Email, "@")
@@ -726,33 +730,28 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email)
}
// If user provided a CompanyCode, verify it exists and is a family affiliate
if req.CompanyCode != "" {
// [Security] Cross-check: If domain is NOT internal, they cannot provide a CompanyCode
if tenantSlug != "" {
// [Security] Cross-check: If domain is NOT internal, they cannot provide a tenantSlug
if !isInternal {
slog.Warn("[Signup] Security violation: non-internal email providing CompanyCode", "email", req.Email)
slog.Warn("[Signup] Security violation: non-internal email providing tenantSlug", "email", req.Email)
return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.")
}
// Verify the selected company code exists and is indeed a family company
if !affiliateSlugs[strings.ToLower(req.CompanyCode)] {
if !affiliateSlugs[strings.ToLower(tenantSlug)] {
return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.")
}
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode)
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
if err == nil && tenant != nil {
if tenant.Status == domain.TenantStatusActive {
// We no longer strictly cross-check if the chosen tenant owns the email domain.
// Being an 'isInternal' (family) email is enough to join ANY family affiliate.
slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug)
companyCode = tenant.Slug
tenantSlug = tenant.Slug
tenantID = &tenant.ID
} else {
return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.")
}
} else {
slog.Warn("[Signup] Attempted to join non-existent organization", "slug", req.CompanyCode, "email", req.Email)
slog.Warn("[Signup] Attempted to join non-existent organization", "slug", tenantSlug, "email", req.Email)
return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.")
}
} else {
@@ -770,7 +769,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant")
}
companyCode = tenant.Slug
tenantSlug = tenant.Slug
tenantID = &tenant.ID
}
@@ -789,7 +788,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
attributes := map[string]interface{}{
"department": req.Department,
"affiliationType": req.AffiliationType,
"companyCode": companyCode,
"grade": "",
"role": domain.RoleUser,
}
@@ -844,7 +842,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
Phone: normalizedPhone,
Role: "user",
AffiliationType: req.AffiliationType,
CompanyCode: companyCode,
Department: req.Department,
Status: "active",
CreatedAt: time.Now(),
@@ -7508,7 +7505,6 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
phone, _ := traits["phone_number"].(string)
dept, _ := traits["department"].(string)
affType, _ := traits["affiliationType"].(string)
compCode, _ := traits["companyCode"].(string)
role, _ := traits["role"].(string)
tenantID, _ := traits["tenant_id"].(string)
relyingPartyID, _ := traits["relying_party_id"].(string)
@@ -7520,7 +7516,6 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
Phone: h.formatPhoneForDisplay(phone),
Department: dept,
AffiliationType: affType,
CompanyCode: compCode,
Role: domain.NormalizeRole(role),
Metadata: make(map[string]any),
}
@@ -7591,16 +7586,6 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
localUser.AffiliationType = affType
}
companyCode := extractTraitString(traits, "companyCode")
if companyCode == "" {
companyCode = extractTraitString(traits, "company_code")
}
if companyCode != "" {
localUser.CompanyCode = companyCode
}
if companyCodes := extractTraitStringArray(traits, "companyCodes"); len(companyCodes) > 0 {
localUser.CompanyCodes = pq.StringArray(companyCodes)
}
if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
localUser.TenantID = &tenantID
}

View File

@@ -209,7 +209,7 @@ func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
require.Equal(t, "New Name", userRepo.updated.Name)
require.Equal(t, "+821087654321", userRepo.updated.Phone)
require.Equal(t, "New Dept", userRepo.updated.Department)
require.Equal(t, "saman", userRepo.updated.CompanyCode)
require.Empty(t, userRepo.updated.CompanyCode)
require.NotNil(t, userRepo.updated.TenantID)
require.Equal(t, "11111111-1111-1111-1111-111111111111", *userRepo.updated.TenantID)
}

View File

@@ -5,7 +5,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -78,7 +77,7 @@ func (m *MockIdpForSignup) UpdateUserPassword(loginID, newPassword string, r *ht
return nil
}
func TestSignup_CompanyCodeValidation(t *testing.T) {
func TestSignup_TenantSlugValidation(t *testing.T) {
app := fiber.New()
mockTenantSvc := new(MockTenantService)
mockRedis := new(MockRedisForSignup)
@@ -99,7 +98,7 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
})
mockRedis.On("Get", mock.Anything).Return(string(verifiedState), nil)
t.Run("Fail - Tenant not found for CompanyCode", func(t *testing.T) {
t.Run("Rejects legacy CompanyCode", func(t *testing.T) {
reqBody := domain.SignupRequest{
Email: "user@gmail.com",
Password: "StrongPass123!",
@@ -110,25 +109,21 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
}
body, _ := json.Marshal(reqBody)
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil).Once()
mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "gmail.com").Return(nil, errors.New("not found")).Maybe()
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "new-slug").Return(nil, nil).Once()
req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("Active Company Code", func(t *testing.T) {
t.Run("Active Tenant Slug", func(t *testing.T) {
reqBody := domain.SignupRequest{
Email: "user@hanmaceng.co.kr",
Password: "StrongPass123!",
Name: "Test User",
Phone: "010-1234-5678",
TermsAccepted: true,
CompanyCode: "hanmac",
TenantSlug: "hanmac",
}
body, _ := json.Marshal(reqBody)

View File

@@ -2386,14 +2386,6 @@ func mapOrgContextMemberAssignments(user domain.User, tenantByID, tenantBySlug m
tenant := tenantBySlug[strings.ToLower(user.Tenant.Slug)]
addTenant(tenant, tenant.ID != "", nil)
}
if user.CompanyCode != "" {
tenant := tenantBySlug[strings.ToLower(strings.TrimSpace(user.CompanyCode))]
addTenant(tenant, tenant.ID != "", nil)
}
for _, companyCode := range user.CompanyCodes {
tenant := tenantBySlug[strings.ToLower(strings.TrimSpace(companyCode))]
addTenant(tenant, tenant.ID != "", nil)
}
return assignments
}
@@ -2596,7 +2588,6 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
sharedRootID := findRoot(link.TenantID)
var filteredTenants []domain.Tenant
var tenantIDs []string
var slugs []string
for _, t := range allTenants {
if findRoot(t.ID) == sharedRootID {
@@ -2606,16 +2597,15 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
filteredTenants = filterPublicTenants(filteredTenants)
for _, t := range filteredTenants {
tenantIDs = append(tenantIDs, t.ID)
slugs = append(slugs, t.Slug)
}
type publicUserSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
CompanyCode string `json:"companyCode"`
Status string `json:"status"`
ID string `json:"id"`
Name string `json:"name"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
TenantSlug string `json:"tenantSlug"`
Status string `json:"status"`
}
var publicUsers []publicUserSummary
@@ -2629,29 +2619,12 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
continue
}
seen[u.ID] = true
cc := u.CompanyCode
if cc == "" && u.Tenant != nil {
cc = u.Tenant.Slug
tenantSlug := ""
if u.Tenant != nil {
tenantSlug = u.Tenant.Slug
}
publicUsers = append(publicUsers, publicUserSummary{
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status,
})
}
// Fetch users by Slugs
var usersBySlug []domain.User
h.DB.Where("company_code IN ?", slugs).Preload("Tenant").Find(&usersBySlug)
for _, u := range usersBySlug {
if u.Status != "active" || seen[u.ID] {
continue
}
seen[u.ID] = true
cc := u.CompanyCode
if cc == "" && u.Tenant != nil {
cc = u.Tenant.Slug
}
publicUsers = append(publicUsers, publicUserSummary{
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status,
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, TenantSlug: tenantSlug, Status: u.Status,
})
}

View File

@@ -690,8 +690,8 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
require.Equal(t, "기술기획", firstUser["jobTitle"])
teamSSO := tenantsPayload[3].(map[string]any)
ssoMembers := teamSSO["members"].([]any)
require.Len(t, ssoMembers, 2)
appointmentOnly := ssoMembers[1].(map[string]any)
require.Len(t, ssoMembers, 1)
appointmentOnly := ssoMembers[0].(map[string]any)
require.Equal(t, "appointment@example.com", appointmentOnly["email"])
require.Equal(t, false, appointmentOnly["isOwner"])
require.Equal(t, true, appointmentOnly["isLeader"])

View File

@@ -256,6 +256,26 @@ func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string)
return nil, nil
}
func identityTenantAccessKeys(traits map[string]interface{}) []string {
keys := make([]string, 0, 2)
if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" {
keys = append(keys, tenantID)
}
if legacySlug := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "companyCode"))); legacySlug != "" {
keys = append(keys, legacySlug)
}
return keys
}
func anyTenantKeyAllowed(keys []string, allowed map[string]bool) bool {
for _, key := range keys {
if allowed[key] {
return true
}
}
return false
}
type userSummary struct {
ID string `json:"id"`
Email string `json:"email"`
@@ -590,7 +610,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.CompanyCode = tenantSlugFromRequest(req.TenantSlug, req.CompanyCode)
tenantSlug, err := tenantSlugFromRequest(req.TenantSlug, req.CompanyCode)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
req.CompanyCode = tenantSlug
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
email := strings.TrimSpace(req.Email)
@@ -643,7 +667,6 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
"position": req.Position,
"jobTitle": req.JobTitle,
"affiliationType": "internal",
"companyCode": req.CompanyCode,
}
// [Override with explicit LoginID if provided]
@@ -687,7 +710,6 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
attributes["role"] = role
attributes["companyCode"] = req.CompanyCode
if tenantID != "" {
attributes["tenant_id"] = tenantID
}
@@ -969,13 +991,17 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name)
tenantID := strings.TrimSpace(item.TenantID)
tenantSlug := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode)
tenantSlug, tenantSlugErr := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode)
dept := strings.TrimSpace(item.Department)
if email == "" || name == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "email and name are required"})
continue
}
if tenantSlugErr != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()})
continue
}
var tItem tenantCacheItem
var err error
@@ -1156,7 +1182,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
"position": strings.TrimSpace(item.Position),
"jobTitle": strings.TrimSpace(item.JobTitle),
"affiliationType": "internal",
"companyCode": tenantSlug,
"tenant_id": tItem.ID,
"role": role,
}
@@ -1307,7 +1332,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
search := strings.TrimSpace(c.Query("search"))
tenantSlug := tenantSlugFromRequest(c.Query("tenantSlug"), c.Query("companyCode"))
tenantSlug, err := tenantSlugFromRequest(c.Query("tenantSlug"), c.Query("companyCode"))
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
var requesterRole string
var manageableSlugs []string
@@ -1481,7 +1509,11 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.CompanyCode = tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
tenantSlug, err := tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
req.CompanyCode = tenantSlug
if len(req.UserIDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided")
@@ -1539,9 +1571,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
}
// Authorization check
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
if requester.Role == domain.RoleTenantAdmin {
if !manageableSlugs[userComp] {
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"})
continue
}
@@ -1560,7 +1591,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
traits["role"] = *req.Role
}
if req.CompanyCode != nil {
traits["companyCode"] = *req.CompanyCode
delete(traits, "companyCode")
delete(traits, "companyCodes")
// Resolve and update tenant_id in traits if changed
if tItem, exists := tenantCache[*req.CompanyCode]; exists {
@@ -1702,8 +1734,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
// Authorization check
if requester.Role == domain.RoleTenantAdmin {
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
if !manageableSlugs[userComp] {
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"})
continue
}
@@ -1767,8 +1798,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
// [New] Check access scope
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
compCode := extractTraitString(identity.Traits, "companyCode")
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
allowed := map[string]bool{}
if requester.TenantID != nil {
allowed[strings.ToLower(*requester.TenantID)] = true
}
if requester.CompanyCode != "" {
allowed[strings.ToLower(requester.CompanyCode)] = true
}
for _, tenant := range requester.ManageableTenants {
allowed[strings.ToLower(tenant.ID)] = true
allowed[strings.ToLower(tenant.Slug)] = true
}
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowed) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot update user in another tenant")
}
}
@@ -1797,7 +1838,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.CompanyCode = tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
tenantSlug, err := tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
req.CompanyCode = tenantSlug
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
if req.Role != nil {
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
@@ -1866,24 +1911,6 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
delete(traits, "hanmacFamily")
delete(traits, "userType")
// [Preserve & Merge] Multi-Tenant Info
var existingCodes []string
if codes, ok := traits["companyCodes"].([]interface{}); ok {
for _, v := range codes {
if str, ok := v.(string); ok && str != "" {
existingCodes = append(existingCodes, str)
}
}
}
// Keto에서 "실제" 소속 정보를 먼저 확인 (엑셀 임포트 사용자 대응)
if len(existingCodes) <= 1 && h.TenantService != nil {
if joined, err := h.TenantService.ListJoinedTenants(c.Context(), userID); err == nil {
for _, t := range joined {
existingCodes = append(existingCodes, t.Slug)
}
}
}
if req.Name != nil {
traits["name"] = strings.TrimSpace(*req.Name)
}
@@ -1898,29 +1925,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if req.CompanyCode != nil {
code := strings.TrimSpace(*req.CompanyCode)
if req.IsAddTenant {
// Add to existingCodes if not present
found := false
for _, existing := range existingCodes {
if existing == code {
found = true
break
}
}
if !found && code != "" {
existingCodes = append(existingCodes, code)
}
} else if req.IsRemoveTenant {
// Remove from existingCodes
var newCodes []string
for _, existing := range existingCodes {
if existing != code {
newCodes = append(newCodes, existing)
}
}
existingCodes = newCodes
// [Keto Sync] Remove membership for the target tenant
if req.IsRemoveTenant {
if h.TenantService != nil && h.KetoOutboxRepo != nil && code != "" {
go func(removedSlug string) {
bgCtx := context.Background()
@@ -1935,87 +1940,26 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
}(code)
}
// If removing the primary company code, pick another one as primary if available
currentPrimary := extractTraitString(traits, "companyCode")
if currentPrimary == code {
if len(existingCodes) > 0 {
traits["companyCode"] = existingCodes[0]
if h.TenantService != nil {
if t, err := h.TenantService.GetTenantBySlug(c.Context(), existingCodes[0]); err == nil && t != nil {
traits["tenant_id"] = t.ID
}
}
} else {
traits["companyCode"] = ""
traits["tenant_id"] = ""
}
}
} else {
// Normal update (Move): replace primary company code and remove the old one from existingCodes
currentPrimary := extractTraitString(traits, "companyCode")
if currentPrimary != "" && currentPrimary != code {
// Remove old primary from existingCodes
var newCodes []string
for _, existing := range existingCodes {
if existing != currentPrimary {
newCodes = append(newCodes, existing)
if h.TenantService != nil && code != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
currentTenantID := extractTraitString(traits, "tenant_id")
if currentTenantID == tenant.ID {
traits["tenant_id"] = ""
}
}
existingCodes = newCodes
// [Keto Sync] Remove membership for the old tenant
if h.TenantService != nil && h.KetoOutboxRepo != nil {
go func(removedSlug string) {
bgCtx := context.Background()
if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil {
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: t.ID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
}(currentPrimary)
}
}
traits["companyCode"] = code
// Resolve TenantID for Kratos Trait
} else if !req.IsAddTenant {
if h.TenantService != nil && code != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
traits["tenant_id"] = tenant.ID
} else {
traits["tenant_id"] = ""
}
}
found := false
for _, existing := range existingCodes {
if existing == code {
found = true
break
}
}
if !found && code != "" {
existingCodes = append(existingCodes, code)
}
}
}
// Deduplicate and save back companyCodes
var codesToSave []string
seenCodes := map[string]bool{}
for _, c := range existingCodes {
if !seenCodes[c] && c != "" {
seenCodes[c] = true
codesToSave = append(codesToSave, c)
}
}
if len(codesToSave) > 0 {
traits["companyCodes"] = codesToSave
} else {
delete(traits, "companyCodes")
}
delete(traits, "companyCode")
delete(traits, "companyCodes")
if req.Department != nil {
traits["department"] = strings.TrimSpace(*req.Department)
@@ -2233,8 +2177,18 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
}
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
if identity != nil {
compCode := extractTraitString(identity.Traits, "companyCode")
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
allowed := map[string]bool{}
if requester.TenantID != nil {
allowed[strings.ToLower(*requester.TenantID)] = true
}
if requester.CompanyCode != "" {
allowed[strings.ToLower(requester.CompanyCode)] = true
}
for _, tenant := range requester.ManageableTenants {
allowed[strings.ToLower(tenant.ID)] = true
allowed[strings.ToLower(tenant.Slug)] = true
}
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowed) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot delete user in another tenant")
}
}
@@ -2277,8 +2231,16 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
traits := identity.Traits
role := roleFromTraits(traits)
compCode := extractTraitString(traits, "companyCode")
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode)
tenantID := extractTraitString(traits, "tenant_id")
tenantSlug := ""
var tenantSummary *domain.Tenant
if tenantID != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenant(ctx, tenantID); err == nil && tenant != nil {
tenantSlug = tenant.Slug
tenantSummary = tenant
}
}
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "tenantID", tenantID, "tenantSlug", tenantSlug)
var customLoginIDs []string
if raw, ok := traits["custom_login_ids"]; ok {
@@ -2302,8 +2264,8 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
Phone: extractTraitString(traits, "phone_number"),
Role: role,
Status: normalizeStatus(identity.State),
TenantSlug: compCode,
CompanyCode: compCode,
TenantSlug: tenantSlug,
CompanyCode: tenantSlug,
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"),
@@ -2326,7 +2288,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
// Otherwise, we put them in a "legacy" or "flat" bucket if needed, but for now let's keep them in summary.Metadata
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"grade": true, "companyCode": true, "company_code": true, "companyCodes": true, "department": true,
"position": true, "jobTitle": true,
"affiliationType": true, "role": true, "tenant_id": true,
"custom_login_ids": true, "id": true,
@@ -2341,11 +2303,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
summary.Metadata[k] = v
}
if compCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
summary.Tenant = tenant
}
}
summary.Tenant = tenantSummary
return summary
}
@@ -2357,10 +2315,6 @@ func (h *UserHandler) normalizePhoneNumber(phone string) string {
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
traits := identity.Traits
role := roleFromTraits(traits)
compCode := extractTraitString(traits, "companyCode")
if compCode == "" {
compCode = extractTraitString(traits, "company_code")
}
user := &domain.User{
ID: identity.ID,
@@ -2369,7 +2323,6 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
Phone: extractTraitString(traits, "phone_number"),
Role: role,
Status: normalizeStatus(identity.State),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"),
@@ -2379,37 +2332,17 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
UpdatedAt: identity.UpdatedAt,
}
// [New] Sync multi-tenant codes
if codes, ok := traits["companyCodes"].([]interface{}); ok {
for _, v := range codes {
if str, ok := v.(string); ok && str != "" {
user.CompanyCodes = append(user.CompanyCodes, str)
}
}
} else if codes, ok := traits["companyCodes"].([]string); ok {
user.CompanyCodes = codes
}
// 1. Try to get tenant_id directly from Kratos traits first (Fastest & most reliable)
tID := extractTraitString(traits, "tenant_id")
if tID != "" {
user.TenantID = &tID
}
// 2. Fallback to slug lookup only if tenant_id trait is missing
if (user.TenantID == nil || *user.TenantID == "") && compCode != "" && h.TenantService != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
user.TenantID = &tenant.ID
}
}
// Metadata handling (exclude core fields)
user.Metadata = make(domain.JSONMap)
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"grade": true, "companyCode": true, "companyCodes": true, "department": true,
"position": true, "jobTitle": true,
"affiliationType": true, "role": true, "tenant_id": true, "company_code": true,
"custom_login_ids": true, "id": true,

View File

@@ -583,7 +583,10 @@ func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t
}, nil).Once()
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
if user.Attributes["tenant_id"] != "t-gpdtdc" || user.Attributes["companyCode"] != "gpdtdc" {
if user.Attributes["tenant_id"] != "t-gpdtdc" {
return false
}
if _, hasCompanyCode := user.Attributes["companyCode"]; hasCompanyCode {
return false
}
appointments, ok := user.Attributes["additionalAppointments"].([]any)
@@ -653,8 +656,9 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
}, nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
_, hasCompanyCode := user.Attributes["companyCode"]
return user.Attributes["tenant_id"] == "t-saman" &&
user.Attributes["companyCode"] == "saman" &&
!hasCompanyCode &&
user.Attributes["additionalAppointments"] == nil
}), mock.Anything).Return("u-domain-primary", nil).Once()
@@ -891,9 +895,9 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
mockRepo.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac"}).Return([]domain.User{}, nil).Once()
payload := map[string]interface{}{
"email": "han@samaneng.com",
"name": "한치영",
"companyCode": "hanmac",
"email": "han@samaneng.com",
"name": "한치영",
"tenantSlug": "hanmac",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
@@ -1404,9 +1408,9 @@ func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
mockTenant.On("ListManageableTenants", mock.Anything, "u-1").Return([]domain.Tenant{}, nil).Once()
payload := map[string]interface{}{
"email": "new@test.com",
"name": "New User",
"companyCode": "test-tenant",
"email": "new@test.com",
"name": "New User",
"tenantSlug": "test-tenant",
"metadata": map[string]interface{}{
tenantID: map[string]interface{}{
"emp_no": "E1001",
@@ -1451,8 +1455,9 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
_, hasCompanyCode := user.Attributes["companyCode"]
return user.Attributes["tenant_id"] == tenantID &&
user.Attributes["companyCode"] == "saman" &&
!hasCompanyCode &&
user.Attributes["additionalAppointments"] != nil &&
user.Attributes["userType"] == nil
}), mock.Anything).Return("u-appointment", nil).Once()
@@ -1531,7 +1536,7 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil).Once()
}, nil).Twice()
mockTenant.On("GetTenantBySlug", mock.Anything, "personal-01970f0d96667548963d2890351f03dd").Return(&domain.Tenant{
ID: personalTenantID,
Slug: "personal-01970f0d96667548963d2890351f03dd",
@@ -1539,11 +1544,12 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
Type: domain.TenantTypePersonal,
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil).Once()
}, nil).Maybe()
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
_, hasCompanyCode := user.Attributes["companyCode"]
return user.Email == "personal-user@example.com" &&
user.Attributes["tenant_id"] == personalTenantID &&
user.Attributes["companyCode"] == "personal-01970f0d96667548963d2890351f03dd"
!hasCompanyCode
}), mock.Anything).Return("u-personal", nil).Once()
mockKratos.On("GetIdentity", mock.Anything, "u-personal").Return(&service.KratosIdentity{
ID: "u-personal",
@@ -1587,12 +1593,12 @@ func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "tenant-id",
Slug: "test-tenant",
}, nil).Twice()
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, "tenant-id").Return(&domain.Tenant{
ID: "tenant-id",
Slug: "test-tenant",
Config: domain.JSONMap{},
}, nil).Once()
}, nil).Twice()
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
_, hasCompanyCode := user.Attributes["companyCode"]
return !hasCompanyCode && user.Attributes["tenant_id"] == "tenant-id"
@@ -1601,10 +1607,10 @@ func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "tenant-id",
"role": domain.RoleUser,
"email": "user@test.com",
"name": "Test User",
"tenant_id": "tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
@@ -1619,12 +1625,9 @@ func TestUserHandler_CreateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
mockOry.AssertExpectations(t)
mockKratos.AssertExpectations(t)
req = httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(`{"email":"legacy@test.com","password":"Password1!","name":"Legacy User","companyCode":"test-tenant"}`))
req.Header.Set("Content-Type", "application/json")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
_, legacyErr := tenantSlugFromRequest("", "test-tenant")
require.Error(t, legacyErr)
require.Contains(t, legacyErr.Error(), "companyCode is deprecated")
}
func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing.T) {
@@ -1641,22 +1644,22 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
"email": "user@test.com",
"name": "Test User",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(identity, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
}, nil).Twice()
}, nil).Once()
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
Config: domain.JSONMap{},
}, nil).Once()
}, nil).Twice()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
_, hasCompanyCode := traits["companyCode"]
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
@@ -1664,10 +1667,10 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "new-tenant-id",
"role": domain.RoleUser,
"email": "user@test.com",
"name": "Test User",
"tenant_id": "new-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
@@ -1703,10 +1706,10 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
"email": "user@test.com",
"name": "Test User",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
@@ -1720,10 +1723,10 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"tenant_id": "new-tenant-id",
"role": domain.RoleUser,
"email": "user@test.com",
"name": "Test User",
"tenant_id": "new-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
@@ -1737,12 +1740,10 @@ func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugAndRejectsCompanyCode(t *te
mockTenant.AssertExpectations(t)
mockKratos.AssertExpectations(t)
req = httptest.NewRequest(http.MethodPut, "/users/bulk", strings.NewReader(`{"userIds":["legacy-id"],"companyCode":"legacy-tenant"}`))
req.Header.Set("Content-Type", "application/json")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
legacyTenantSlug := "legacy-tenant"
_, legacyErr := tenantSlugPointerFromRequest(nil, &legacyTenantSlug)
require.Error(t, legacyErr)
require.Contains(t, legacyErr.Error(), "companyCode is deprecated")
}
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {

View File

@@ -6,6 +6,25 @@ import (
"gorm.io/gorm"
)
func CountOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
var count int64
err := db.WithContext(ctx).Raw(`
SELECT COUNT(*)
FROM users AS u
WHERE u.deleted_at IS NULL
AND (
u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE t.id = u.tenant_id
AND t.deleted_at IS NULL
)
)
`).Scan(&count).Error
return count, err
}
func ClearOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
result := db.WithContext(ctx).Exec(`
WITH orphan_users AS (
@@ -13,41 +32,17 @@ WITH orphan_users AS (
FROM users AS u
WHERE u.deleted_at IS NULL
AND (
(
u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE t.id = u.tenant_id
AND t.deleted_at IS NULL
)
)
OR (
NULLIF(BTRIM(u.company_code), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(u.company_code))
AND t.deleted_at IS NULL
)
)
OR EXISTS (
u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM UNNEST(COALESCE(u.company_codes, ARRAY[]::text[])) AS code(value)
WHERE NULLIF(BTRIM(code.value), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(code.value))
AND t.deleted_at IS NULL
)
FROM tenants AS t
WHERE t.id = u.tenant_id
AND t.deleted_at IS NULL
)
)
)
UPDATE users AS u
SET tenant_id = NULL,
company_code = '',
company_codes = NULL,
updated_at = NOW()
FROM orphan_users AS ou
WHERE u.id = ou.id

View File

@@ -44,6 +44,10 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
require.NoError(t, repo.Create(ctx, activeUser))
require.NoError(t, repo.Create(ctx, orphanUser))
count, err := CountOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)
assert.Equal(t, int64(1), count)
affected, err := ClearOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)
assert.Equal(t, int64(1), affected)
@@ -60,4 +64,8 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
assert.Nil(t, foundOrphan.TenantID)
assert.Empty(t, foundOrphan.CompanyCode)
assert.Empty(t, foundOrphan.CompanyCodes)
count, err = CountOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)
assert.Equal(t, int64(0), count)
}

View File

@@ -90,11 +90,6 @@ func (r *userProjectionRepository) CountTenantMembers(ctx context.Context, tenan
FROM requested
LEFT JOIN users ON users.deleted_at IS NULL AND (
users.tenant_id::text = requested.tenant_id
OR LOWER(users.company_code) = LOWER(requested.slug)
OR EXISTS (
SELECT 1 FROM unnest(users.company_codes) AS company_code
WHERE LOWER(company_code) = LOWER(requested.slug)
)
)
GROUP BY requested.tenant_id
`, strings.Join(valuePlaceholders, ","))

View File

@@ -5,7 +5,6 @@ import (
"context"
"strings"
"github.com/lib/pq"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -152,33 +151,25 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
}
type result struct {
CompanyCode string
Count int64
TenantSlug string
Count int64
}
var results []result
lowerCodes := lowerStrings(codes)
// Combine singular company_code and array company_codes using a subquery
// to ensure we count each user accurately per company code they belong to.
query := `
SELECT LOWER(comp_code) as company_code, count(DISTINCT id) as count
FROM (
SELECT id, company_code as comp_code FROM users WHERE deleted_at IS NULL AND LOWER(company_code) = ANY($1)
UNION ALL
SELECT id, unnest(company_codes) as comp_code FROM users WHERE deleted_at IS NULL AND company_codes IS NOT NULL
) as combined
WHERE LOWER(comp_code) = ANY($1)
GROUP BY LOWER(comp_code)
`
err := r.db.WithContext(ctx).Raw(query, pq.Array(lowerCodes)).Scan(&results).Error
if err != nil {
if err := r.db.WithContext(ctx).Table("users").
Select("LOWER(tenants.slug) AS tenant_slug, count(DISTINCT users.id) AS count").
Joins("JOIN tenants ON users.tenant_id = tenants.id").
Where("users.deleted_at IS NULL AND LOWER(tenants.slug) IN ?", lowerCodes).
Group("LOWER(tenants.slug)").
Scan(&results).Error; err != nil {
return nil, err
}
counts := make(map[string]int64)
for _, res := range results {
counts[strings.ToLower(res.CompanyCode)] = res.Count
counts[strings.ToLower(res.TenantSlug)] = res.Count
}
// Ensure all requested codes are present in results (even if count is 0)
@@ -207,13 +198,13 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
if tenantSlug != "" {
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
Where("users.company_code = ? OR ? = ANY(users.company_codes) OR tenants.slug = ?", tenantSlug, tenantSlug, tenantSlug)
Where("tenants.slug = ?", tenantSlug)
}
if search != "" {
searchTerm := "%" + search + "%"
db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.company_code LIKE ? OR ? = ANY(users.company_codes) OR users.metadata::text LIKE ?)",
searchTerm, searchTerm, searchTerm, search, searchTerm)
db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.metadata::text LIKE ?)",
searchTerm, searchTerm, searchTerm)
}
if err := db.Count(&total).Error; err != nil {
@@ -281,6 +272,10 @@ func (r *userRepository) FindByTenantIDs(ctx context.Context, tenantIDs []string
func (r *userRepository) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
var users []domain.User
err := r.db.WithContext(ctx).Where("company_code IN ?", codes).Find(&users).Error
err := r.db.WithContext(ctx).
Joins("JOIN tenants ON users.tenant_id = tenants.id").
Where("LOWER(tenants.slug) IN ?", lowerStrings(codes)).
Preload("Tenant").
Find(&users).Error
return users, err
}

View File

@@ -44,7 +44,7 @@ func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) {
return &domain.IDPMetadata{
SupportedFields: []string{
"id", "custom_login_ids", "login_id", "email", "name", "phone_number",
"grade", "department", "affiliationType", "companyCode",
"grade", "department", "affiliationType", "tenant_id",
},
}, nil
}

View File

@@ -9,7 +9,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
)
type UserGroupService interface {
@@ -228,7 +227,8 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
if traits == nil {
traits = make(map[string]interface{})
}
traits["companyCode"] = tenant.Slug
delete(traits, "companyCode")
delete(traits, "companyCodes")
traits["tenant_id"] = tenant.ID
traits["department"] = group.Name
@@ -257,7 +257,6 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
}
}
if localUser != nil {
localUser.CompanyCode = tenant.Slug
localUser.TenantID = &tenant.ID
localUser.Department = group.Name
if err := s.userRepo.Update(ctx, localUser); err != nil {
@@ -313,11 +312,6 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
grade = ""
}
companyCode := userGroupTraitString(traits, "companyCode")
if companyCode == "" {
companyCode = userGroupTraitString(traits, "company_code")
}
user := &domain.User{
ID: identity.ID,
Email: userGroupTraitString(traits, "email"),
@@ -325,7 +319,6 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
Phone: userGroupTraitString(traits, "phone_number"),
Role: role,
Status: userGroupIdentityStatus(identity.State),
CompanyCode: companyCode,
Department: userGroupTraitString(traits, "department"),
Grade: grade,
Position: userGroupTraitString(traits, "position"),
@@ -341,8 +334,6 @@ func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User
if relyingPartyID := userGroupTraitString(traits, "relying_party_id"); relyingPartyID != "" {
user.RelyingPartyID = &relyingPartyID
}
user.CompanyCodes = pq.StringArray(userGroupTraitStringArray(traits, "companyCodes"))
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "role": true, "companyCode": true, "company_code": true,

View File

@@ -302,15 +302,15 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
State: "active",
}, nil)
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["companyCode"] == tenantSlug && traits["tenant_id"] == tenantID && traits["department"] == "Sales"
_, hasCompanyCode := traits["companyCode"]
return !hasCompanyCode && traits["tenant_id"] == tenantID && traits["department"] == "Sales"
}), "active").Return(&KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "User Test",
"companyCode": tenantSlug,
"tenant_id": tenantID,
"department": "Sales",
"email": "user@test.com",
"name": "User Test",
"tenant_id": tenantID,
"department": "Sales",
},
State: "active",
}, nil)
@@ -325,7 +325,7 @@ func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T
assert.NoError(t, err)
assert.Len(t, mockUserRepo.updatedUsers, 1)
assert.Equal(t, userID, mockUserRepo.updatedUsers[0].ID)
assert.Equal(t, tenantSlug, mockUserRepo.updatedUsers[0].CompanyCode)
assert.Empty(t, mockUserRepo.updatedUsers[0].CompanyCode)
assert.NotNil(t, mockUserRepo.updatedUsers[0].TenantID)
assert.Equal(t, tenantID, *mockUserRepo.updatedUsers[0].TenantID)
assert.Equal(t, "Sales", mockUserRepo.updatedUsers[0].Department)

View File

@@ -7,8 +7,6 @@ import (
"fmt"
"strings"
"time"
"github.com/lib/pq"
)
type UserProjectionSyncService struct {
@@ -73,11 +71,6 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
grade = ""
}
companyCode := kratosProjectionTraitString(traits, "companyCode")
if companyCode == "" {
companyCode = kratosProjectionTraitString(traits, "company_code")
}
user := domain.User{
ID: identity.ID,
Email: kratosProjectionTraitString(traits, "email"),
@@ -85,8 +78,6 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
Phone: kratosProjectionTraitString(traits, "phone_number"),
Role: role,
Status: normalizeProjectionStatus(identity.State),
CompanyCode: companyCode,
CompanyCodes: pq.StringArray(kratosProjectionTraitStringArray(traits, "companyCodes")),
Department: kratosProjectionTraitString(traits, "department"),
Grade: grade,
Position: kratosProjectionTraitString(traits, "position"),

View File

@@ -70,8 +70,8 @@ func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *test
assert.Equal(t, "one@example.com", repo.replacedUsers[0].Email)
assert.Equal(t, "One", repo.replacedUsers[0].Name)
assert.Equal(t, "+821012345678", repo.replacedUsers[0].Phone)
assert.Equal(t, "saman", repo.replacedUsers[0].CompanyCode)
assert.Equal(t, []string{"saman", "group-a"}, []string(repo.replacedUsers[0].CompanyCodes))
assert.Empty(t, repo.replacedUsers[0].CompanyCode)
assert.Empty(t, repo.replacedUsers[0].CompanyCodes)
require.NotNil(t, repo.replacedUsers[0].TenantID)
assert.Equal(t, tenantID, *repo.replacedUsers[0].TenantID)
assert.Equal(t, "kept", repo.replacedUsers[0].Metadata["customAttr"])

View File

@@ -69,6 +69,22 @@ Kratos identity traits도 같은 기준으로 정리한다.
### Baron users만 실행
Go 구현 경로를 사용하는 권장 명령은 다음과 같다.
```bash
go run ./cmd/adminctl clear-orphan-user-tenant-memberships --dry-run
go run ./cmd/adminctl clear-orphan-user-tenant-memberships
```
컨테이너나 배포 환경에서는 같은 명령을 adminctl 바이너리로 실행한다.
```bash
adminctl clear-orphan-user-tenant-memberships --dry-run
adminctl clear-orphan-user-tenant-memberships
```
SQL만 직접 실행해야 하는 경우에는 다음 스크립트를 사용할 수 있다.
```bash
docker exec -i baron_postgres psql -U baron -d baron_sso < scripts/clear_orphan_user_tenant_memberships.sql
```

View File

@@ -985,12 +985,12 @@ class AuthProxyService {
static Future<Map<String, dynamic>> checkLoginIDAvailability(
String loginId, {
String? companyCode,
String? tenantSlug,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/check-login-id');
final bodyData = {'loginId': loginId};
if (companyCode != null && companyCode.isNotEmpty) {
bodyData['companyCode'] = companyCode;
if (tenantSlug != null && tenantSlug.isNotEmpty) {
bodyData['tenantSlug'] = tenantSlug;
}
final response = await http.post(
url,
@@ -1074,7 +1074,7 @@ class AuthProxyService {
required String name,
required String phone,
required String affiliationType,
String? companyCode,
String? tenantSlug,
required String department,
required bool termsAccepted,
}) async {
@@ -1089,7 +1089,7 @@ class AuthProxyService {
'name': name,
'phone': phone,
'affiliationType': affiliationType,
'companyCode': companyCode,
'tenantSlug': tenantSlug,
'department': department,
'termsAccepted': termsAccepted,
}),

View File

@@ -333,7 +333,7 @@ class _SignupScreenState extends State<SignupScreen> {
name: _nameController.text.trim(),
phone: _phoneController.text.trim(),
affiliationType: _affiliationType,
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
tenantSlug: _affiliationType == 'AFFILIATE' ? _companyCode : null,
department: _deptController.text.trim(),
termsAccepted: true,
);