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 UpdatePassword bool
} }
type clearOrphanUserTenantMembershipsConfig struct {
DryRun bool
}
func main() { func main() {
loadEnv() loadEnv()
logger.Init(logger.Config{ logger.Init(logger.Config{
@@ -47,6 +51,11 @@ func main() {
slog.Error("create-super-admin failed", "error", err) slog.Error("create-super-admin failed", "error", err)
os.Exit(1) 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: default:
printUsage() printUsage()
os.Exit(2) os.Exit(2)
@@ -107,6 +116,37 @@ func runCreateSuperAdmin(args []string) error {
return nil 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) { func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error) {
fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError) fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError)
fs.SetOutput(os.Stderr) fs.SetOutput(os.Stderr)
@@ -135,6 +175,19 @@ func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error
return config, nil 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) { func openDB() (*gorm.DB, error) {
dsn := fmt.Sprintf( dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul", "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() { func printUsage() {
fmt.Fprintln(os.Stderr, "usage:") 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 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") 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{}{ Attributes: map[string]interface{}{
"department": "Admin", "department": "Admin",
"affiliationType": "internal", "affiliationType": "internal",
"companyCode": "",
"grade": "", "grade": "",
"role": domain.RoleSuperAdmin, "role": domain.RoleSuperAdmin,
}, },

View File

@@ -37,6 +37,9 @@ func migrateSchemas(db *gorm.DB) error {
if err := dropLegacyTenantDomainUniqueIndex(db); err != nil { if err := dropLegacyTenantDomainUniqueIndex(db); err != nil {
return err return err
} }
if err := dropLegacyUserCompanyColumns(db); err != nil {
return err
}
// Add all domain models here // Add all domain models here
return db.AutoMigrate( 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 { func dropLegacyTenantDomainUniqueIndex(db *gorm.DB) error {
if !db.Migrator().HasTable(&domain.TenantDomain{}) { if !db.Migrator().HasTable(&domain.TenantDomain{}) {
return nil return nil

View File

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

View File

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

View File

@@ -59,8 +59,8 @@ type User struct {
Phone string `gorm:"column:phone" json:"phone"` 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 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"` AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
CompanyCode string `gorm:"column:company_code;index" json:"companyCode"` CompanyCode string `gorm:"-" json:"companyCode,omitempty"`
CompanyCodes pq.StringArray `gorm:"column:company_codes;type:text[]" json:"companyCodes"` CompanyCodes pq.StringArray `gorm:"-" json:"companyCodes,omitempty"`
TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"` TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"`
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 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") 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를 자동 생성해 대표소속을 보장합니다. // 소속이 비어 있는 일반 가입자는 PERSONAL tenant를 자동 생성해 대표소속을 보장합니다.
companyCode := "" tenantSlug := strings.TrimSpace(req.TenantSlug)
var tenantID *string var tenantID *string
parts := strings.Split(req.Email, "@") 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) slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email)
} }
// If user provided a CompanyCode, verify it exists and is a family affiliate if tenantSlug != "" {
if req.CompanyCode != "" { // [Security] Cross-check: If domain is NOT internal, they cannot provide a tenantSlug
// [Security] Cross-check: If domain is NOT internal, they cannot provide a CompanyCode
if !isInternal { 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.") 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(tenantSlug)] {
if !affiliateSlugs[strings.ToLower(req.CompanyCode)] {
return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.") 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 err == nil && tenant != nil {
if tenant.Status == domain.TenantStatusActive { 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) slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug)
companyCode = tenant.Slug tenantSlug = tenant.Slug
tenantID = &tenant.ID tenantID = &tenant.ID
} else { } else {
return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.") return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.")
} }
} else { } 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.") return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.")
} }
} else { } else {
@@ -770,7 +769,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant") return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant")
} }
companyCode = tenant.Slug tenantSlug = tenant.Slug
tenantID = &tenant.ID tenantID = &tenant.ID
} }
@@ -789,7 +788,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
attributes := map[string]interface{}{ attributes := map[string]interface{}{
"department": req.Department, "department": req.Department,
"affiliationType": req.AffiliationType, "affiliationType": req.AffiliationType,
"companyCode": companyCode,
"grade": "", "grade": "",
"role": domain.RoleUser, "role": domain.RoleUser,
} }
@@ -844,7 +842,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
Phone: normalizedPhone, Phone: normalizedPhone,
Role: "user", Role: "user",
AffiliationType: req.AffiliationType, AffiliationType: req.AffiliationType,
CompanyCode: companyCode,
Department: req.Department, Department: req.Department,
Status: "active", Status: "active",
CreatedAt: time.Now(), CreatedAt: time.Now(),
@@ -7508,7 +7505,6 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
phone, _ := traits["phone_number"].(string) phone, _ := traits["phone_number"].(string)
dept, _ := traits["department"].(string) dept, _ := traits["department"].(string)
affType, _ := traits["affiliationType"].(string) affType, _ := traits["affiliationType"].(string)
compCode, _ := traits["companyCode"].(string)
role, _ := traits["role"].(string) role, _ := traits["role"].(string)
tenantID, _ := traits["tenant_id"].(string) tenantID, _ := traits["tenant_id"].(string)
relyingPartyID, _ := traits["relying_party_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), Phone: h.formatPhoneForDisplay(phone),
Department: dept, Department: dept,
AffiliationType: affType, AffiliationType: affType,
CompanyCode: compCode,
Role: domain.NormalizeRole(role), Role: domain.NormalizeRole(role),
Metadata: make(map[string]any), Metadata: make(map[string]any),
} }
@@ -7591,16 +7586,6 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
localUser.AffiliationType = affType 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 != "" { if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
localUser.TenantID = &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, "New Name", userRepo.updated.Name)
require.Equal(t, "+821087654321", userRepo.updated.Phone) require.Equal(t, "+821087654321", userRepo.updated.Phone)
require.Equal(t, "New Dept", userRepo.updated.Department) 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.NotNil(t, userRepo.updated.TenantID)
require.Equal(t, "11111111-1111-1111-1111-111111111111", *userRepo.updated.TenantID) require.Equal(t, "11111111-1111-1111-1111-111111111111", *userRepo.updated.TenantID)
} }

View File

@@ -5,7 +5,6 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -78,7 +77,7 @@ func (m *MockIdpForSignup) UpdateUserPassword(loginID, newPassword string, r *ht
return nil return nil
} }
func TestSignup_CompanyCodeValidation(t *testing.T) { func TestSignup_TenantSlugValidation(t *testing.T) {
app := fiber.New() app := fiber.New()
mockTenantSvc := new(MockTenantService) mockTenantSvc := new(MockTenantService)
mockRedis := new(MockRedisForSignup) mockRedis := new(MockRedisForSignup)
@@ -99,7 +98,7 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
}) })
mockRedis.On("Get", mock.Anything).Return(string(verifiedState), nil) 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{ reqBody := domain.SignupRequest{
Email: "user@gmail.com", Email: "user@gmail.com",
Password: "StrongPass123!", Password: "StrongPass123!",
@@ -110,25 +109,21 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
} }
body, _ := json.Marshal(reqBody) 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 := httptest.NewRequest("POST", "/signup", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req) 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{ reqBody := domain.SignupRequest{
Email: "user@hanmaceng.co.kr", Email: "user@hanmaceng.co.kr",
Password: "StrongPass123!", Password: "StrongPass123!",
Name: "Test User", Name: "Test User",
Phone: "010-1234-5678", Phone: "010-1234-5678",
TermsAccepted: true, TermsAccepted: true,
CompanyCode: "hanmac", TenantSlug: "hanmac",
} }
body, _ := json.Marshal(reqBody) 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)] tenant := tenantBySlug[strings.ToLower(user.Tenant.Slug)]
addTenant(tenant, tenant.ID != "", nil) 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 return assignments
} }
@@ -2596,7 +2588,6 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
sharedRootID := findRoot(link.TenantID) sharedRootID := findRoot(link.TenantID)
var filteredTenants []domain.Tenant var filteredTenants []domain.Tenant
var tenantIDs []string var tenantIDs []string
var slugs []string
for _, t := range allTenants { for _, t := range allTenants {
if findRoot(t.ID) == sharedRootID { if findRoot(t.ID) == sharedRootID {
@@ -2606,16 +2597,15 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
filteredTenants = filterPublicTenants(filteredTenants) filteredTenants = filterPublicTenants(filteredTenants)
for _, t := range filteredTenants { for _, t := range filteredTenants {
tenantIDs = append(tenantIDs, t.ID) tenantIDs = append(tenantIDs, t.ID)
slugs = append(slugs, t.Slug)
} }
type publicUserSummary struct { type publicUserSummary struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Position string `json:"position"` Position string `json:"position"`
JobTitle string `json:"jobTitle"` JobTitle string `json:"jobTitle"`
CompanyCode string `json:"companyCode"` TenantSlug string `json:"tenantSlug"`
Status string `json:"status"` Status string `json:"status"`
} }
var publicUsers []publicUserSummary var publicUsers []publicUserSummary
@@ -2629,29 +2619,12 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
continue continue
} }
seen[u.ID] = true seen[u.ID] = true
cc := u.CompanyCode tenantSlug := ""
if cc == "" && u.Tenant != nil { if u.Tenant != nil {
cc = u.Tenant.Slug tenantSlug = u.Tenant.Slug
} }
publicUsers = append(publicUsers, publicUserSummary{ 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,
})
}
// 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,
}) })
} }

View File

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

View File

@@ -256,6 +256,26 @@ func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string)
return nil, nil 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 { type userSummary struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
@@ -590,7 +610,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body") 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)) req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
email := strings.TrimSpace(req.Email) email := strings.TrimSpace(req.Email)
@@ -643,7 +667,6 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
"position": req.Position, "position": req.Position,
"jobTitle": req.JobTitle, "jobTitle": req.JobTitle,
"affiliationType": "internal", "affiliationType": "internal",
"companyCode": req.CompanyCode,
} }
// [Override with explicit LoginID if provided] // [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, "") loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
attributes["role"] = role attributes["role"] = role
attributes["companyCode"] = req.CompanyCode
if tenantID != "" { if tenantID != "" {
attributes["tenant_id"] = tenantID attributes["tenant_id"] = tenantID
} }
@@ -969,13 +991,17 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
email := strings.TrimSpace(item.Email) email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name) name := strings.TrimSpace(item.Name)
tenantID := strings.TrimSpace(item.TenantID) tenantID := strings.TrimSpace(item.TenantID)
tenantSlug := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode) tenantSlug, tenantSlugErr := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode)
dept := strings.TrimSpace(item.Department) dept := strings.TrimSpace(item.Department)
if email == "" || name == "" { if email == "" || name == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "email and name are required"}) results = append(results, bulkUserResult{Email: email, Success: false, Message: "email and name are required"})
continue continue
} }
if tenantSlugErr != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()})
continue
}
var tItem tenantCacheItem var tItem tenantCacheItem
var err error var err error
@@ -1156,7 +1182,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
"position": strings.TrimSpace(item.Position), "position": strings.TrimSpace(item.Position),
"jobTitle": strings.TrimSpace(item.JobTitle), "jobTitle": strings.TrimSpace(item.JobTitle),
"affiliationType": "internal", "affiliationType": "internal",
"companyCode": tenantSlug,
"tenant_id": tItem.ID, "tenant_id": tItem.ID,
"role": role, "role": role,
} }
@@ -1307,7 +1332,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error { func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
search := strings.TrimSpace(c.Query("search")) 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 requesterRole string
var manageableSlugs []string var manageableSlugs []string
@@ -1481,7 +1509,11 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body") 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 { if len(req.UserIDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided") return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided")
@@ -1539,9 +1571,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
} }
// Authorization check // Authorization check
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
if requester.Role == domain.RoleTenantAdmin { 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"}) results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"})
continue continue
} }
@@ -1560,7 +1591,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
traits["role"] = *req.Role traits["role"] = *req.Role
} }
if req.CompanyCode != nil { if req.CompanyCode != nil {
traits["companyCode"] = *req.CompanyCode delete(traits, "companyCode")
delete(traits, "companyCodes")
// Resolve and update tenant_id in traits if changed // Resolve and update tenant_id in traits if changed
if tItem, exists := tenantCache[*req.CompanyCode]; exists { if tItem, exists := tenantCache[*req.CompanyCode]; exists {
@@ -1702,8 +1734,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
// Authorization check // Authorization check
if requester.Role == domain.RoleTenantAdmin { if requester.Role == domain.RoleTenantAdmin {
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode")) if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
if !manageableSlugs[userComp] {
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"}) results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"})
continue continue
} }
@@ -1767,8 +1798,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
// [New] Check access scope // [New] Check access scope
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin { if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
compCode := extractTraitString(identity.Traits, "companyCode") allowed := map[string]bool{}
if requester.CompanyCode == "" || compCode != requester.CompanyCode { 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") 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 { if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body") 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)) req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
if req.Role != nil { if req.Role != nil {
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin { 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, "hanmacFamily")
delete(traits, "userType") 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 { if req.Name != nil {
traits["name"] = strings.TrimSpace(*req.Name) traits["name"] = strings.TrimSpace(*req.Name)
} }
@@ -1898,29 +1925,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if req.CompanyCode != nil { if req.CompanyCode != nil {
code := strings.TrimSpace(*req.CompanyCode) code := strings.TrimSpace(*req.CompanyCode)
if req.IsAddTenant { if req.IsRemoveTenant {
// 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 h.TenantService != nil && h.KetoOutboxRepo != nil && code != "" { if h.TenantService != nil && h.KetoOutboxRepo != nil && code != "" {
go func(removedSlug string) { go func(removedSlug string) {
bgCtx := context.Background() bgCtx := context.Background()
@@ -1935,87 +1940,26 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
} }
}(code) }(code)
} }
if h.TenantService != nil && code != "" {
// If removing the primary company code, pick another one as primary if available if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
currentPrimary := extractTraitString(traits, "companyCode") currentTenantID := extractTraitString(traits, "tenant_id")
if currentPrimary == code { if currentTenantID == tenant.ID {
if len(existingCodes) > 0 { traits["tenant_id"] = ""
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)
} }
} }
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)
}
} }
} else if !req.IsAddTenant {
traits["companyCode"] = code
// Resolve TenantID for Kratos Trait
if h.TenantService != nil && code != "" { if h.TenantService != nil && code != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
traits["tenant_id"] = tenant.ID 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)
}
} }
} }
delete(traits, "companyCode")
// Deduplicate and save back companyCodes delete(traits, "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")
}
if req.Department != nil { if req.Department != nil {
traits["department"] = strings.TrimSpace(*req.Department) 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 requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
if identity != nil { if identity != nil {
compCode := extractTraitString(identity.Traits, "companyCode") allowed := map[string]bool{}
if requester.CompanyCode == "" || compCode != requester.CompanyCode { 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") 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 traits := identity.Traits
role := roleFromTraits(traits) role := roleFromTraits(traits)
compCode := extractTraitString(traits, "companyCode") tenantID := extractTraitString(traits, "tenant_id")
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode) 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 var customLoginIDs []string
if raw, ok := traits["custom_login_ids"]; ok { 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"), Phone: extractTraitString(traits, "phone_number"),
Role: role, Role: role,
Status: normalizeStatus(identity.State), Status: normalizeStatus(identity.State),
TenantSlug: compCode, TenantSlug: tenantSlug,
CompanyCode: compCode, CompanyCode: tenantSlug,
Department: extractTraitString(traits, "department"), Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits), Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"), 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 // 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{ coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true, "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, "position": true, "jobTitle": true,
"affiliationType": true, "role": true, "tenant_id": true, "affiliationType": true, "role": true, "tenant_id": true,
"custom_login_ids": true, "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 summary.Metadata[k] = v
} }
if compCode != "" && h.TenantService != nil { summary.Tenant = tenantSummary
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
summary.Tenant = tenant
}
}
return summary return summary
} }
@@ -2357,10 +2315,6 @@ func (h *UserHandler) normalizePhoneNumber(phone string) string {
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User { func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
traits := identity.Traits traits := identity.Traits
role := roleFromTraits(traits) role := roleFromTraits(traits)
compCode := extractTraitString(traits, "companyCode")
if compCode == "" {
compCode = extractTraitString(traits, "company_code")
}
user := &domain.User{ user := &domain.User{
ID: identity.ID, ID: identity.ID,
@@ -2369,7 +2323,6 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
Phone: extractTraitString(traits, "phone_number"), Phone: extractTraitString(traits, "phone_number"),
Role: role, Role: role,
Status: normalizeStatus(identity.State), Status: normalizeStatus(identity.State),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"), Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits), Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"), Position: extractTraitString(traits, "position"),
@@ -2379,37 +2332,17 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
UpdatedAt: identity.UpdatedAt, 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) // 1. Try to get tenant_id directly from Kratos traits first (Fastest & most reliable)
tID := extractTraitString(traits, "tenant_id") tID := extractTraitString(traits, "tenant_id")
if tID != "" { if tID != "" {
user.TenantID = &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) // Metadata handling (exclude core fields)
user.Metadata = make(domain.JSONMap) user.Metadata = make(domain.JSONMap)
coreTraits := map[string]bool{ coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true, "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, "position": true, "jobTitle": true,
"affiliationType": true, "role": true, "tenant_id": true, "company_code": true, "affiliationType": true, "role": true, "tenant_id": true, "company_code": true,
"custom_login_ids": true, "id": true, "custom_login_ids": true, "id": true,

View File

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

View File

@@ -6,6 +6,25 @@ import (
"gorm.io/gorm" "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) { func ClearOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
result := db.WithContext(ctx).Exec(` result := db.WithContext(ctx).Exec(`
WITH orphan_users AS ( WITH orphan_users AS (
@@ -13,41 +32,17 @@ WITH orphan_users AS (
FROM users AS u FROM users AS u
WHERE u.deleted_at IS NULL WHERE u.deleted_at IS NULL
AND ( AND (
( u.tenant_id IS NOT NULL
u.tenant_id IS NOT NULL AND NOT EXISTS (
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 (
SELECT 1 SELECT 1
FROM UNNEST(COALESCE(u.company_codes, ARRAY[]::text[])) AS code(value) FROM tenants AS t
WHERE NULLIF(BTRIM(code.value), '') IS NOT NULL WHERE t.id = u.tenant_id
AND NOT EXISTS ( AND t.deleted_at IS NULL
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(code.value))
AND t.deleted_at IS NULL
)
) )
) )
) )
UPDATE users AS u UPDATE users AS u
SET tenant_id = NULL, SET tenant_id = NULL,
company_code = '',
company_codes = NULL,
updated_at = NOW() updated_at = NOW()
FROM orphan_users AS ou FROM orphan_users AS ou
WHERE u.id = ou.id 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, activeUser))
require.NoError(t, repo.Create(ctx, orphanUser)) 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) affected, err := ClearOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, int64(1), affected) assert.Equal(t, int64(1), affected)
@@ -60,4 +64,8 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
assert.Nil(t, foundOrphan.TenantID) assert.Nil(t, foundOrphan.TenantID)
assert.Empty(t, foundOrphan.CompanyCode) assert.Empty(t, foundOrphan.CompanyCode)
assert.Empty(t, foundOrphan.CompanyCodes) 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 FROM requested
LEFT JOIN users ON users.deleted_at IS NULL AND ( LEFT JOIN users ON users.deleted_at IS NULL AND (
users.tenant_id::text = requested.tenant_id 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 GROUP BY requested.tenant_id
`, strings.Join(valuePlaceholders, ",")) `, strings.Join(valuePlaceholders, ","))

View File

@@ -5,7 +5,6 @@ import (
"context" "context"
"strings" "strings"
"github.com/lib/pq"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -152,33 +151,25 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
} }
type result struct { type result struct {
CompanyCode string TenantSlug string
Count int64 Count int64
} }
var results []result var results []result
lowerCodes := lowerStrings(codes) lowerCodes := lowerStrings(codes)
// Combine singular company_code and array company_codes using a subquery if err := r.db.WithContext(ctx).Table("users").
// to ensure we count each user accurately per company code they belong to. Select("LOWER(tenants.slug) AS tenant_slug, count(DISTINCT users.id) AS count").
query := ` Joins("JOIN tenants ON users.tenant_id = tenants.id").
SELECT LOWER(comp_code) as company_code, count(DISTINCT id) as count Where("users.deleted_at IS NULL AND LOWER(tenants.slug) IN ?", lowerCodes).
FROM ( Group("LOWER(tenants.slug)").
SELECT id, company_code as comp_code FROM users WHERE deleted_at IS NULL AND LOWER(company_code) = ANY($1) Scan(&results).Error; err != nil {
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 {
return nil, err return nil, err
} }
counts := make(map[string]int64) counts := make(map[string]int64)
for _, res := range results { 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) // 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 != "" { if tenantSlug != "" {
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id"). 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 != "" { if search != "" {
searchTerm := "%" + 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 ?)", db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.metadata::text LIKE ?)",
searchTerm, searchTerm, searchTerm, search, searchTerm) searchTerm, searchTerm, searchTerm)
} }
if err := db.Count(&total).Error; err != nil { 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) { func (r *userRepository) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
var users []domain.User 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 return users, err
} }

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,6 @@ import (
"fmt" "fmt"
"strings" "strings"
"time" "time"
"github.com/lib/pq"
) )
type UserProjectionSyncService struct { type UserProjectionSyncService struct {
@@ -73,11 +71,6 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
grade = "" grade = ""
} }
companyCode := kratosProjectionTraitString(traits, "companyCode")
if companyCode == "" {
companyCode = kratosProjectionTraitString(traits, "company_code")
}
user := domain.User{ user := domain.User{
ID: identity.ID, ID: identity.ID,
Email: kratosProjectionTraitString(traits, "email"), Email: kratosProjectionTraitString(traits, "email"),
@@ -85,8 +78,6 @@ func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User {
Phone: kratosProjectionTraitString(traits, "phone_number"), Phone: kratosProjectionTraitString(traits, "phone_number"),
Role: role, Role: role,
Status: normalizeProjectionStatus(identity.State), Status: normalizeProjectionStatus(identity.State),
CompanyCode: companyCode,
CompanyCodes: pq.StringArray(kratosProjectionTraitStringArray(traits, "companyCodes")),
Department: kratosProjectionTraitString(traits, "department"), Department: kratosProjectionTraitString(traits, "department"),
Grade: grade, Grade: grade,
Position: kratosProjectionTraitString(traits, "position"), 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@example.com", repo.replacedUsers[0].Email)
assert.Equal(t, "One", repo.replacedUsers[0].Name) assert.Equal(t, "One", repo.replacedUsers[0].Name)
assert.Equal(t, "+821012345678", repo.replacedUsers[0].Phone) assert.Equal(t, "+821012345678", repo.replacedUsers[0].Phone)
assert.Equal(t, "saman", repo.replacedUsers[0].CompanyCode) assert.Empty(t, repo.replacedUsers[0].CompanyCode)
assert.Equal(t, []string{"saman", "group-a"}, []string(repo.replacedUsers[0].CompanyCodes)) assert.Empty(t, repo.replacedUsers[0].CompanyCodes)
require.NotNil(t, repo.replacedUsers[0].TenantID) require.NotNil(t, repo.replacedUsers[0].TenantID)
assert.Equal(t, tenantID, *repo.replacedUsers[0].TenantID) assert.Equal(t, tenantID, *repo.replacedUsers[0].TenantID)
assert.Equal(t, "kept", repo.replacedUsers[0].Metadata["customAttr"]) assert.Equal(t, "kept", repo.replacedUsers[0].Metadata["customAttr"])

View File

@@ -69,6 +69,22 @@ Kratos identity traits도 같은 기준으로 정리한다.
### Baron users만 실행 ### 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 ```bash
docker exec -i baron_postgres psql -U baron -d baron_sso < scripts/clear_orphan_user_tenant_memberships.sql 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( static Future<Map<String, dynamic>> checkLoginIDAvailability(
String loginId, { String loginId, {
String? companyCode, String? tenantSlug,
}) async { }) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/check-login-id'); final url = Uri.parse('$_baseUrl/api/v1/auth/signup/check-login-id');
final bodyData = {'loginId': loginId}; final bodyData = {'loginId': loginId};
if (companyCode != null && companyCode.isNotEmpty) { if (tenantSlug != null && tenantSlug.isNotEmpty) {
bodyData['companyCode'] = companyCode; bodyData['tenantSlug'] = tenantSlug;
} }
final response = await http.post( final response = await http.post(
url, url,
@@ -1074,7 +1074,7 @@ class AuthProxyService {
required String name, required String name,
required String phone, required String phone,
required String affiliationType, required String affiliationType,
String? companyCode, String? tenantSlug,
required String department, required String department,
required bool termsAccepted, required bool termsAccepted,
}) async { }) async {
@@ -1089,7 +1089,7 @@ class AuthProxyService {
'name': name, 'name': name,
'phone': phone, 'phone': phone,
'affiliationType': affiliationType, 'affiliationType': affiliationType,
'companyCode': companyCode, 'tenantSlug': tenantSlug,
'department': department, 'department': department,
'termsAccepted': termsAccepted, 'termsAccepted': termsAccepted,
}), }),

View File

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