forked from baron/baron-sso
사용자 테넌트 소속 데이터 정리
This commit is contained in:
@@ -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]")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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용
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ","))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user