diff --git a/backend/cmd/adminctl/main.go b/backend/cmd/adminctl/main.go index 020d4e51..4fd536ce 100644 --- a/backend/cmd/adminctl/main.go +++ b/backend/cmd/adminctl/main.go @@ -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]") } diff --git a/backend/cmd/adminctl/main_test.go b/backend/cmd/adminctl/main_test.go index 0eb4c5f7..7d2551c9 100644 --- a/backend/cmd/adminctl/main_test.go +++ b/backend/cmd/adminctl/main_test.go @@ -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") + } +} diff --git a/backend/internal/bootstrap/admin_account.go b/backend/internal/bootstrap/admin_account.go index ddb33a02..3940d15e 100644 --- a/backend/internal/bootstrap/admin_account.go +++ b/backend/internal/bootstrap/admin_account.go @@ -139,7 +139,6 @@ func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser { Attributes: map[string]interface{}{ "department": "Admin", "affiliationType": "internal", - "companyCode": "", "grade": "", "role": domain.RoleSuperAdmin, }, diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index c49ab2ae..d4422168 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -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 diff --git a/backend/internal/bootstrap/kratos_seed.go b/backend/internal/bootstrap/kratos_seed.go index bca52dfc..8c5c0503 100644 --- a/backend/internal/bootstrap/kratos_seed.go +++ b/backend/internal/bootstrap/kratos_seed.go @@ -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 }, diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index 234003b2..999a2229 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -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"` } diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index 34550c84..6088c5d8 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -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용 diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index e9420cf3..9c842e03 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -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 } diff --git a/backend/internal/handler/auth_handler_profile_cache_test.go b/backend/internal/handler/auth_handler_profile_cache_test.go index c33b07b4..0ef112b6 100644 --- a/backend/internal/handler/auth_handler_profile_cache_test.go +++ b/backend/internal/handler/auth_handler_profile_cache_test.go @@ -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) } diff --git a/backend/internal/handler/auth_handler_signup_test.go b/backend/internal/handler/auth_handler_signup_test.go index 3a479bdd..76085df3 100644 --- a/backend/internal/handler/auth_handler_signup_test.go +++ b/backend/internal/handler/auth_handler_signup_test.go @@ -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) diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 34c61e4e..cf909220 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -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, }) } diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 6e34c078..00ec15b6 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -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"]) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 94f2c617..588485b1 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -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, diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index c58720b2..bd7d9f4d 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -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) { diff --git a/backend/internal/repository/user_membership_maintenance.go b/backend/internal/repository/user_membership_maintenance.go index f17aa90c..a22dfae9 100644 --- a/backend/internal/repository/user_membership_maintenance.go +++ b/backend/internal/repository/user_membership_maintenance.go @@ -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 diff --git a/backend/internal/repository/user_membership_maintenance_test.go b/backend/internal/repository/user_membership_maintenance_test.go index 365af3bb..d71589a3 100644 --- a/backend/internal/repository/user_membership_maintenance_test.go +++ b/backend/internal/repository/user_membership_maintenance_test.go @@ -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) } diff --git a/backend/internal/repository/user_projection_repository.go b/backend/internal/repository/user_projection_repository.go index f14b9879..d530e79e 100644 --- a/backend/internal/repository/user_projection_repository.go +++ b/backend/internal/repository/user_projection_repository.go @@ -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, ",")) diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 08c78456..9f7eef48 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -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 } diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go index 1cb13ab6..99affaaa 100644 --- a/backend/internal/service/ory_service.go +++ b/backend/internal/service/ory_service.go @@ -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 } diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index 32a75a85..e195cd4d 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -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, diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index d217323e..51f25686 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -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) diff --git a/backend/internal/service/user_projection_sync_service.go b/backend/internal/service/user_projection_sync_service.go index 8d5abbd8..14487998 100644 --- a/backend/internal/service/user_projection_sync_service.go +++ b/backend/internal/service/user_projection_sync_service.go @@ -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"), diff --git a/backend/internal/service/user_projection_sync_service_test.go b/backend/internal/service/user_projection_sync_service_test.go index 4b4e9ee8..cb64c9aa 100644 --- a/backend/internal/service/user_projection_sync_service_test.go +++ b/backend/internal/service/user_projection_sync_service_test.go @@ -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"]) diff --git a/docs/tenant-maintenance-procedures.md b/docs/tenant-maintenance-procedures.md index 3b6c92ae..08dc218a 100644 --- a/docs/tenant-maintenance-procedures.md +++ b/docs/tenant-maintenance-procedures.md @@ -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 ``` diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 8d455e11..8adb0e13 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -985,12 +985,12 @@ class AuthProxyService { static Future> 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, }), diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 66c607ef..321f011c 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -333,7 +333,7 @@ class _SignupScreenState extends State { 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, );