From e2247e3736ab943de4b0cc6974356526f25c621e Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 5 Jun 2026 16:55:29 +0800 Subject: [PATCH 1/5] feat: support MFA status filter for users Signed-off-by: huanghongbo-hhb --- .../user/core/repository/orm/user.go | 74 +++++++++++++------ .../user/core/service/permission/user.go | 15 ++-- 2 files changed, 60 insertions(+), 29 deletions(-) diff --git a/pkg/microservice/user/core/repository/orm/user.go b/pkg/microservice/user/core/repository/orm/user.go index c45755a5e3..8a8940902e 100644 --- a/pkg/microservice/user/core/repository/orm/user.go +++ b/pkg/microservice/user/core/repository/orm/user.go @@ -46,6 +46,19 @@ func GetUser(account string, identityType string, db *gorm.DB) (*models.User, er return &user, nil } +func GetUserByAccountAndMFAEnabled(account string, identityType string, mfaEnabled *bool, db *gorm.DB) (*models.User, error) { + var user models.User + err := applyMFAEnabledFilter(db.Where("account = ? and identity_type = ?", account, identityType), mfaEnabled). + First(&user).Error + if err != nil && err != gorm.ErrRecordNotFound { + return nil, err + } + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return &user, nil +} + // GetUserByUid Get a user based on uid func GetUserByUid(uid string, db *gorm.DB) (*models.User, error) { var user models.User @@ -80,13 +93,14 @@ func ListAllUsers(db *gorm.DB) ([]*models.User, error) { } // ListUsers gets a list of users based on paging constraints -func ListUsers(page int, perPage int, name string, db *gorm.DB) ([]models.User, error) { +func ListUsers(page int, perPage int, name string, mfaEnabled *bool, db *gorm.DB) ([]models.User, error) { var ( users []models.User err error ) - err = db.Where("name LIKE ?", "%"+name+"%").Order("account ASC").Offset((page - 1) * perPage).Limit(perPage).Find(&users).Error + query := applyMFAEnabledFilter(db.Where("name LIKE ?", "%"+name+"%"), mfaEnabled) + err = query.Order("account ASC").Offset((page - 1) * perPage).Limit(perPage).Find(&users).Error if err != nil && err != gorm.ErrRecordNotFound { return nil, err @@ -95,15 +109,17 @@ func ListUsers(page int, perPage int, name string, db *gorm.DB) ([]models.User, return users, nil } -func ListUsersByLoginTime(page int, perPage int, name string, order setting.ListUserOrder, db *gorm.DB) ([]models.UserWithLoginTime, error) { +func ListUsersByLoginTime(page int, perPage int, name string, order setting.ListUserOrder, mfaEnabled *bool, db *gorm.DB) ([]models.UserWithLoginTime, error) { var ( users []models.UserWithLoginTime err error ) - err = db.Select("user.uid, user.name, user.account, user.identity_type, user.api_token_enabled, IFNULL(user_login.last_login_time, 0) as last_login_time"). + query := db.Select("user.uid, user.name, user.account, user.identity_type, user.api_token_enabled, IFNULL(user_login.last_login_time, 0) as last_login_time"). Where("user.name LIKE ?", "%"+name+"%"). - Joins("LEFT JOIN user_login on user_login.uid = user.uid"). + Joins("LEFT JOIN user_login on user_login.uid = user.uid") + query = applyMFAEnabledFilter(query, mfaEnabled) + err = query. Order("IFNULL(user_login.last_login_time, 0) " + string(order)). Offset((page - 1) * perPage). Limit(perPage). @@ -136,7 +152,7 @@ func listUIDsByRoles(roles []string, namespace string, db *gorm.DB) ([]string, e // ordered by last_login_time with pagination. It is implemented in two simple steps: // 1. Find the uids of users that have any of the given roles (role_binding + role) within the namespace. // 2. Query user + user_login for those uids, filter by name, order by last_login_time and paginate. -func ListUsersByNameAndRoleWithLoginTime(page int, perPage int, name string, roles []string, namespace string, order setting.ListUserOrder, db *gorm.DB) ([]models.UserWithLoginTime, error) { +func ListUsersByNameAndRoleWithLoginTime(page int, perPage int, name string, roles []string, namespace string, order setting.ListUserOrder, mfaEnabled *bool, db *gorm.DB) ([]models.UserWithLoginTime, error) { uids, err := listUIDsByRoles(roles, namespace, db) if err != nil { return nil, err @@ -146,10 +162,12 @@ func ListUsersByNameAndRoleWithLoginTime(page int, perPage int, name string, rol } var users []models.UserWithLoginTime - err = db.Table("user"). + query := db.Table("user"). Select("user.uid, user.name, user.account, user.identity_type, user.api_token_enabled, IFNULL(user_login.last_login_time, 0) AS last_login_time"). Joins("LEFT JOIN user_login ON user_login.uid = user.uid"). - Where("user.uid IN ? AND user.name LIKE ?", uids, "%"+name+"%"). + Where("user.uid IN ? AND user.name LIKE ?", uids, "%"+name+"%") + query = applyMFAEnabledFilter(query, mfaEnabled) + err = query. Order("last_login_time " + string(order)). Offset((page - 1) * perPage). Limit(perPage). @@ -162,15 +180,17 @@ func ListUsersByNameAndRoleWithLoginTime(page int, perPage int, name string, rol } // ListUsersByNameAndRole gets a list of users based on paging constraints, the name of the user, the roles, and namespace -func ListUsersByNameAndRole(page int, perPage int, name string, roles []string, namespace string, db *gorm.DB) ([]models.User, error) { +func ListUsersByNameAndRole(page int, perPage int, name string, roles []string, namespace string, mfaEnabled *bool, db *gorm.DB) ([]models.User, error) { var ( users []models.User err error ) - err = db.Where("user.name LIKE ? AND role.name IN ? AND role.namespace = ?", "%"+name+"%", roles, namespace). + query := db.Where("user.name LIKE ? AND role.name IN ? AND role.namespace = ?", "%"+name+"%", roles, namespace). Joins("INNER JOIN role_binding on role_binding.uid = user.uid"). - Joins("INNER JOIN role on role_binding.role_id = role.id").Order("account ASC").Offset((page - 1) * perPage). + Joins("INNER JOIN role on role_binding.role_id = role.id") + query = applyMFAEnabledFilter(query, mfaEnabled) + err = query.Order("account ASC").Offset((page - 1) * perPage). Group("user.uid"). Limit(perPage). Find(&users). @@ -251,14 +271,14 @@ func DeleteUserByUid(uid string, db *gorm.DB) error { } // GetUsersCount gets user count -func GetUsersCount(name string) (int64, error) { +func GetUsersCount(name string, mfaEnabled *bool) (int64, error) { var ( - users []models.User err error count int64 ) - err = repository.DB.Where("name LIKE ?", "%"+name+"%").Find(&users).Count(&count).Error + query := applyMFAEnabledFilter(repository.DB.Model(&models.User{}).Where("name LIKE ?", "%"+name+"%"), mfaEnabled) + err = query.Count(&count).Error if err != nil { return 0, err @@ -268,20 +288,18 @@ func GetUsersCount(name string) (int64, error) { } // GetUsersCountByRoles gets user count filtered by roles and namespace -func GetUsersCountByRoles(name string, roles []string, namespace string) (int64, error) { +func GetUsersCountByRoles(name string, roles []string, namespace string, mfaEnabled *bool) (int64, error) { var ( - users []models.User err error count int64 ) - err = repository.DB.Where("user.name LIKE ? AND role.name IN ? AND role.namespace = ?", "%"+name+"%", roles, namespace). + query := repository.DB.Model(&models.User{}). + Where("user.name LIKE ? AND role.name IN ? AND role.namespace = ?", "%"+name+"%", roles, namespace). Joins("INNER JOIN role_binding on role_binding.uid = user.uid"). - Joins("INNER JOIN role on role_binding.role_id = role.id"). - Group("user.uid"). - Find(&users). - Count(&count). - Error + Joins("INNER JOIN role on role_binding.role_id = role.id") + query = applyMFAEnabledFilter(query, mfaEnabled) + err = query.Distinct("user.uid").Count(&count).Error if err != nil { return 0, err @@ -290,6 +308,18 @@ func GetUsersCountByRoles(name string, roles []string, namespace string) (int64, return count, nil } +func applyMFAEnabledFilter(db *gorm.DB, mfaEnabled *bool) *gorm.DB { + if mfaEnabled == nil { + return db + } + + if *mfaEnabled { + return db.Where("EXISTS (SELECT 1 FROM user_mfa WHERE user_mfa.uid = user.uid AND user_mfa.enabled = ?)", true) + } + + return db.Where("NOT EXISTS (SELECT 1 FROM user_mfa WHERE user_mfa.uid = user.uid AND user_mfa.enabled = ?)", true) +} + // UpdateUser update user info func UpdateUser(uid string, user *models.User, db *gorm.DB) error { if err := db.Model(&models.User{}).Where("uid = ?", uid).Updates(user).Error; err != nil { diff --git a/pkg/microservice/user/core/service/permission/user.go b/pkg/microservice/user/core/service/permission/user.go index 27510fa060..2ef6a12f2e 100644 --- a/pkg/microservice/user/core/service/permission/user.go +++ b/pkg/microservice/user/core/service/permission/user.go @@ -87,6 +87,7 @@ type QueryArgs struct { Project string `json:"projectName,omitempty" form:"projectName"` OrderBy setting.ListUserOrderBy `json:"order_by,omitempty" form:"order_by"` Order setting.ListUserOrder `json:"order,omitempty" form:"order"` + MFAEnabled *bool `json:"mfa_enabled,omitempty"` } type Password struct { @@ -424,7 +425,7 @@ func GetUserSetting(uid string, logger *zap.SugaredLogger) (*types.UserSetting, } func SearchUserByAccount(args *QueryArgs, logger *zap.SugaredLogger) (*types.UsersResp, error) { - user, err := orm.GetUser(args.Account, args.IdentityType, repository.DB) + user, err := orm.GetUserByAccountAndMFAEnabled(args.Account, args.IdentityType, args.MFAEnabled, repository.DB) if err != nil { logger.Errorf("SearchUserByAccount GetUser By account:%s error, error msg:%s", args.Account, err.Error()) return nil, err @@ -489,13 +490,13 @@ func SearchUsers(args *QueryArgs, logger *zap.SugaredLogger) (*types.UsersResp, var count int64 var err error if len(args.Roles) == 0 { - count, err = orm.GetUsersCount(args.Name) + count, err = orm.GetUsersCount(args.Name, args.MFAEnabled) if err != nil { logger.Errorf("SeachUsers GetUsersCount By name:%s error, error msg:%s", args.Name, err.Error()) return nil, err } } else { - count, err = orm.GetUsersCountByRoles(args.Name, args.Roles, namespace) + count, err = orm.GetUsersCountByRoles(args.Name, args.Roles, namespace, args.MFAEnabled) if err != nil { logger.Errorf("SeachUsers GetUsersCount By name:%s error, error msg:%s", args.Name, err.Error()) return nil, err @@ -512,9 +513,9 @@ func SearchUsers(args *QueryArgs, logger *zap.SugaredLogger) (*types.UsersResp, var users []models.UserWithLoginTime if len(args.Roles) == 0 { if args.OrderBy == setting.ListUserOrderByLoginTime { - users, err = orm.ListUsersByLoginTime(args.Page, args.PerPage, args.Name, args.Order, repository.DB) + users, err = orm.ListUsersByLoginTime(args.Page, args.PerPage, args.Name, args.Order, args.MFAEnabled, repository.DB) } else { - us, err = orm.ListUsers(args.Page, args.PerPage, args.Name, repository.DB) + us, err = orm.ListUsers(args.Page, args.PerPage, args.Name, args.MFAEnabled, repository.DB) users = models.UsersToUserWithLoginTimes(us) } if err != nil { @@ -523,13 +524,13 @@ func SearchUsers(args *QueryArgs, logger *zap.SugaredLogger) (*types.UsersResp, } } else { if args.OrderBy == setting.ListUserOrderByLoginTime { - users, err = orm.ListUsersByNameAndRoleWithLoginTime(args.Page, args.PerPage, args.Name, args.Roles, namespace, args.Order, repository.DB) + users, err = orm.ListUsersByNameAndRoleWithLoginTime(args.Page, args.PerPage, args.Name, args.Roles, namespace, args.Order, args.MFAEnabled, repository.DB) if err != nil { logger.Errorf("SeachUsers SeachUsers By name:%s error, error msg:%s", args.Name, err.Error()) return nil, err } } else { - us, err = orm.ListUsersByNameAndRole(args.Page, args.PerPage, args.Name, args.Roles, namespace, repository.DB) + us, err = orm.ListUsersByNameAndRole(args.Page, args.PerPage, args.Name, args.Roles, namespace, args.MFAEnabled, repository.DB) if err != nil { logger.Errorf("SeachUsers SeachUsers By name:%s error, error msg:%s", args.Name, err.Error()) return nil, err From 802b21db6aae45bf38fbfa3043d9c163d2d181a9 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Mon, 8 Jun 2026 11:09:27 +0800 Subject: [PATCH 2/5] feat: optimize MFA and role queries for users Signed-off-by: huanghongbo-hhb --- .../user/core/handler/user/user.go | 1 + .../user/core/repository/models/user.go | 1 + .../user/core/repository/orm/role.go | 44 ++++++++ .../user/core/repository/orm/user.go | 50 +++++++-- .../user/core/repository/orm/user_mfa.go | 11 -- .../user/core/service/permission/role.go | 26 +++++ .../user/core/service/permission/user.go | 104 ++++++------------ pkg/shared/client/user/user.go | 11 +- 8 files changed, 150 insertions(+), 98 deletions(-) diff --git a/pkg/microservice/user/core/handler/user/user.go b/pkg/microservice/user/core/handler/user/user.go index 0f037b9cce..b06c0f0ce3 100644 --- a/pkg/microservice/user/core/handler/user/user.go +++ b/pkg/microservice/user/core/handler/user/user.go @@ -321,6 +321,7 @@ func OpenAPIListUsersBrief(c *gin.Context) { Roles: args.Roles, Project: args.Project, IdentityType: args.IdentityType, + MFAEnabled: args.MFAEnabled, } var resp *types.UsersResp diff --git a/pkg/microservice/user/core/repository/models/user.go b/pkg/microservice/user/core/repository/models/user.go index 24f87967ec..0d91acaff8 100644 --- a/pkg/microservice/user/core/repository/models/user.go +++ b/pkg/microservice/user/core/repository/models/user.go @@ -24,6 +24,7 @@ type User struct { Email string `json:"email"` Phone string `json:"phone"` Account string `json:"account"` + MFAEnabled bool `gorm:"->;column:mfa_enabled;-:migration" json:"mfa_enabled"` APIToken string `gorm:"api_token" json:"api_token"` APITokenEnabled bool `gorm:"column:api_token_enabled;default:0" json:"api_token_enabled"` diff --git a/pkg/microservice/user/core/repository/orm/role.go b/pkg/microservice/user/core/repository/orm/role.go index 2b726e0edf..c0b6555933 100644 --- a/pkg/microservice/user/core/repository/orm/role.go +++ b/pkg/microservice/user/core/repository/orm/role.go @@ -107,6 +107,50 @@ func ListRoleByUIDAndNamespace(uid, namespace string, db *gorm.DB) ([]*models.Ne return resp, nil } +// ListRoleByUIDsAndNamespace lists roles for the given users in a namespace with a single query. +func ListRoleByUIDsAndNamespace(uids []string, namespace string, db *gorm.DB) (map[string][]*models.NewRole, error) { + if len(uids) == 0 { + return map[string][]*models.NewRole{}, nil + } + + type uidRole struct { + UID string `gorm:"column:uid"` + ID uint `gorm:"column:id"` + Name string `gorm:"column:name"` + Description string `gorm:"column:description"` + Type int64 `gorm:"column:type"` + Namespace string `gorm:"column:namespace"` + GlobalReadOnly bool `gorm:"column:global_read_only"` + } + + rows := make([]*uidRole, 0) + err := db.Table("role"). + Select("role_binding.uid, role.id, role.name, role.description, role.type, role.namespace, role.global_read_only"). + Joins("INNER JOIN role_binding ON role.id = role_binding.role_id"). + Where("role.namespace = ?", namespace). + Where("role_binding.uid IN ?", uids). + Order("role_binding.uid ASC"). + Order("role.id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + resp := make(map[string][]*models.NewRole, len(rows)) + for _, row := range rows { + resp[row.UID] = append(resp[row.UID], &models.NewRole{ + ID: row.ID, + Name: row.Name, + Description: row.Description, + Type: row.Type, + Namespace: row.Namespace, + GlobalReadOnly: row.GlobalReadOnly, + }) + } + + return resp, nil +} + // ListRoleByUID list a set of roles that is used by specific user in ALL namespace func ListRoleByUID(uid string, db *gorm.DB) ([]*models.NewRole, error) { resp := make([]*models.NewRole, 0) diff --git a/pkg/microservice/user/core/repository/orm/user.go b/pkg/microservice/user/core/repository/orm/user.go index 8a8940902e..8da46d7950 100644 --- a/pkg/microservice/user/core/repository/orm/user.go +++ b/pkg/microservice/user/core/repository/orm/user.go @@ -25,6 +25,11 @@ import ( "github.com/koderover/zadig/v2/pkg/types" ) +const ( + userMFAJoinClause = "LEFT JOIN user_mfa ON user_mfa.uid = user.uid" + userMFAEnabledSelectExpr = "IFNULL(user_mfa.enabled, 0) AS mfa_enabled" +) + // CreateUser create a user func CreateUser(user *models.User, db *gorm.DB) error { if err := db.Create(&user).Error; err != nil { @@ -48,7 +53,10 @@ func GetUser(account string, identityType string, db *gorm.DB) (*models.User, er func GetUserByAccountAndMFAEnabled(account string, identityType string, mfaEnabled *bool, db *gorm.DB) (*models.User, error) { var user models.User - err := applyMFAEnabledFilter(db.Where("account = ? and identity_type = ?", account, identityType), mfaEnabled). + query := db.Model(&models.User{}). + Select("user.*, "+userMFAEnabledSelectExpr). + Where("account = ? and identity_type = ?", account, identityType) + err := applyMFAEnabledJoinFilter(query, mfaEnabled). First(&user).Error if err != nil && err != gorm.ErrRecordNotFound { return nil, err @@ -99,7 +107,10 @@ func ListUsers(page int, perPage int, name string, mfaEnabled *bool, db *gorm.DB err error ) - query := applyMFAEnabledFilter(db.Where("name LIKE ?", "%"+name+"%"), mfaEnabled) + query := db.Model(&models.User{}). + Select("user.*, "+userMFAEnabledSelectExpr). + Where("name LIKE ?", "%"+name+"%") + query = applyMFAEnabledJoinFilter(query, mfaEnabled) err = query.Order("account ASC").Offset((page - 1) * perPage).Limit(perPage).Find(&users).Error if err != nil && err != gorm.ErrRecordNotFound { @@ -115,10 +126,11 @@ func ListUsersByLoginTime(page int, perPage int, name string, order setting.List err error ) - query := db.Select("user.uid, user.name, user.account, user.identity_type, user.api_token_enabled, IFNULL(user_login.last_login_time, 0) as last_login_time"). + query := db.Model(&models.User{}). + Select("user.uid, user.name, user.account, user.identity_type, user.api_token_enabled, "+userMFAEnabledSelectExpr+", IFNULL(user_login.last_login_time, 0) as last_login_time"). Where("user.name LIKE ?", "%"+name+"%"). Joins("LEFT JOIN user_login on user_login.uid = user.uid") - query = applyMFAEnabledFilter(query, mfaEnabled) + query = applyMFAEnabledJoinFilter(query, mfaEnabled) err = query. Order("IFNULL(user_login.last_login_time, 0) " + string(order)). Offset((page - 1) * perPage). @@ -162,11 +174,11 @@ func ListUsersByNameAndRoleWithLoginTime(page int, perPage int, name string, rol } var users []models.UserWithLoginTime - query := db.Table("user"). - Select("user.uid, user.name, user.account, user.identity_type, user.api_token_enabled, IFNULL(user_login.last_login_time, 0) AS last_login_time"). + query := db.Model(&models.User{}). + Select("user.uid, user.name, user.account, user.identity_type, user.api_token_enabled, "+userMFAEnabledSelectExpr+", IFNULL(user_login.last_login_time, 0) AS last_login_time"). Joins("LEFT JOIN user_login ON user_login.uid = user.uid"). Where("user.uid IN ? AND user.name LIKE ?", uids, "%"+name+"%") - query = applyMFAEnabledFilter(query, mfaEnabled) + query = applyMFAEnabledJoinFilter(query, mfaEnabled) err = query. Order("last_login_time " + string(order)). Offset((page - 1) * perPage). @@ -186,10 +198,12 @@ func ListUsersByNameAndRole(page int, perPage int, name string, roles []string, err error ) - query := db.Where("user.name LIKE ? AND role.name IN ? AND role.namespace = ?", "%"+name+"%", roles, namespace). + query := db.Model(&models.User{}). + Select("user.*, "+userMFAEnabledSelectExpr). + Where("user.name LIKE ? AND role.name IN ? AND role.namespace = ?", "%"+name+"%", roles, namespace). Joins("INNER JOIN role_binding on role_binding.uid = user.uid"). Joins("INNER JOIN role on role_binding.role_id = role.id") - query = applyMFAEnabledFilter(query, mfaEnabled) + query = applyMFAEnabledJoinFilter(query, mfaEnabled) err = query.Order("account ASC").Offset((page - 1) * perPage). Group("user.uid"). Limit(perPage). @@ -225,7 +239,10 @@ func ListUsersByUIDs(uids []string, db *gorm.DB) ([]models.User, error) { err error ) - err = db.Find(&users, "uid in ?", uids).Error + query := db.Model(&models.User{}). + Select("user.*, "+userMFAEnabledSelectExpr). + Where("user.uid in ?", uids) + err = applyMFAEnabledJoinFilter(query, nil).Find(&users).Error if err != nil && err != gorm.ErrRecordNotFound { return nil, err @@ -320,6 +337,19 @@ func applyMFAEnabledFilter(db *gorm.DB, mfaEnabled *bool) *gorm.DB { return db.Where("NOT EXISTS (SELECT 1 FROM user_mfa WHERE user_mfa.uid = user.uid AND user_mfa.enabled = ?)", true) } +func applyMFAEnabledJoinFilter(db *gorm.DB, mfaEnabled *bool) *gorm.DB { + db = db.Joins(userMFAJoinClause) + if mfaEnabled == nil { + return db + } + + if *mfaEnabled { + return db.Where("user_mfa.enabled = ?", true) + } + + return db.Where("user_mfa.enabled IS NULL OR user_mfa.enabled = ?", false) +} + // UpdateUser update user info func UpdateUser(uid string, user *models.User, db *gorm.DB) error { if err := db.Model(&models.User{}).Where("uid = ?", uid).Updates(user).Error; err != nil { diff --git a/pkg/microservice/user/core/repository/orm/user_mfa.go b/pkg/microservice/user/core/repository/orm/user_mfa.go index ce735c2e9e..df746176b2 100644 --- a/pkg/microservice/user/core/repository/orm/user_mfa.go +++ b/pkg/microservice/user/core/repository/orm/user_mfa.go @@ -40,17 +40,6 @@ func GetUserMFA(uid string, db *gorm.DB) (*models.UserMFA, error) { return res, nil } -func ListUserMFAsByUIDs(uids []string, db *gorm.DB) ([]*models.UserMFA, error) { - if len(uids) == 0 { - return []*models.UserMFA{}, nil - } - res := make([]*models.UserMFA, 0) - if err := db.Where("uid IN ?", uids).Find(&res).Error; err != nil { - return nil, err - } - return res, nil -} - // EnableUserMFA enables MFA for a user without allowing overwrite of an already-enabled MFA config. func EnableUserMFA(uid, secretCipher, recoveryCodesJSON string, db *gorm.DB) error { now := time.Now().Unix() diff --git a/pkg/microservice/user/core/service/permission/role.go b/pkg/microservice/user/core/service/permission/role.go index b1a619efe6..2872d23435 100644 --- a/pkg/microservice/user/core/service/permission/role.go +++ b/pkg/microservice/user/core/service/permission/role.go @@ -547,6 +547,32 @@ func ListRolesByNamespaceAndUserID(projectName, uid string, log *zap.SugaredLogg return resp, nil } +func ListRolesByNamespaceAndUserIDs(projectName string, uids []string, log *zap.SugaredLogger) (map[string][]*types.Role, error) { + rolesByUID, err := orm.ListRoleByUIDsAndNamespace(uids, projectName, repository.DB) + if err != nil { + log.Errorf("failed to list roles in project: %s, error: %s", projectName, err) + return nil, fmt.Errorf("failed to list roles in project: %s, error: %s", projectName, err) + } + + resp := make(map[string][]*types.Role, len(rolesByUID)) + for uid, roles := range rolesByUID { + roleList := make([]*types.Role, 0, len(roles)) + for _, role := range roles { + roleList = append(roleList, &types.Role{ + ID: role.ID, + Name: role.Name, + Namespace: role.Namespace, + Description: role.Description, + Type: convertDBRoleType(role.Type), + GlobalReadOnly: role.GlobalReadOnly, + }) + } + resp[uid] = roleList + } + + return resp, nil +} + func GetRole(ns, name string, log *zap.SugaredLogger) (*types.DetailedRole, error) { role, err := orm.GetRole(name, ns, repository.DB) if err != nil { diff --git a/pkg/microservice/user/core/service/permission/user.go b/pkg/microservice/user/core/service/permission/user.go index 2ef6a12f2e..cdc94f5489 100644 --- a/pkg/microservice/user/core/service/permission/user.go +++ b/pkg/microservice/user/core/service/permission/user.go @@ -74,6 +74,7 @@ type OpenAPIQueryArgs struct { Name string `json:"name,omitempty" form:"name"` Roles []string `json:"roles,omitempty" form:"roles"` Project string `json:"projectName,omitempty" form:"projectName"` + MFAEnabled *bool `json:"mfa_enabled,omitempty" form:"mfa_enabled"` } type QueryArgs struct { @@ -443,27 +444,8 @@ func SearchUserByAccount(args *QueryArgs, logger *zap.SugaredLogger) (*types.Use } usersInfo := mergeUserLogin([]models.User{*user}, *userLogins, logger) - for _, uInfo := range usersInfo { - roles, err := ListRolesByNamespaceAndUserID("*", uInfo.Uid, logger) - if err != nil { - logger.Errorf("failed to get user role info for user: %s[%s], error: %s", uInfo.Name, uInfo.Account, err) - return nil, err - } - rolebindings := make([]*types.RoleBinding, 0) - for _, role := range roles { - rolebindings = append(rolebindings, &types.RoleBinding{ - UID: uInfo.Uid, - Role: role.Name, - }) - if role.Name == string(setting.SystemAdmin) { - uInfo.Admin = true - uInfo.APITokenEnabled = true - } - } - uInfo.SystemRoleBindings = rolebindings - } - if err := fillUsersMFAEnabled(usersInfo); err != nil { - logger.Errorf("SearchUserByAccount fillUsersMFAEnabled error, error msg:%s", err.Error()) + if err := fillUsersSystemRoleBindings(usersInfo, logger); err != nil { + logger.Errorf("SearchUserByAccount fillUsersSystemRoleBindings error, error msg:%s", err.Error()) return nil, err } @@ -555,6 +537,7 @@ func SearchUsers(args *QueryArgs, logger *zap.SugaredLogger) (*types.UsersResp, Email: user.Email, IdentityType: user.IdentityType, Account: user.Account, + MFAEnabled: user.MFAEnabled, APITokenEnabled: user.APITokenEnabled, }) } @@ -567,27 +550,8 @@ func SearchUsers(args *QueryArgs, logger *zap.SugaredLogger) (*types.UsersResp, usersInfo = mergeUserLoginWithLoginTime(users, *userLogins, logger) } - for _, uInfo := range usersInfo { - roles, err := ListRolesByNamespaceAndUserID("*", uInfo.Uid, logger) - if err != nil { - logger.Errorf("failed to get user role info for user: %s[%s], error: %s", uInfo.Name, uInfo.Account, err) - return nil, err - } - rolebindings := make([]*types.RoleBinding, 0) - for _, role := range roles { - rolebindings = append(rolebindings, &types.RoleBinding{ - UID: uInfo.Uid, - Role: role.Name, - }) - if role.Name == string(setting.SystemAdmin) { - uInfo.Admin = true - uInfo.APITokenEnabled = true - } - } - uInfo.SystemRoleBindings = rolebindings - } - if err := fillUsersMFAEnabled(usersInfo); err != nil { - logger.Errorf("SearchUsers fillUsersMFAEnabled error, error msg:%s", err.Error()) + if err := fillUsersSystemRoleBindings(usersInfo, logger); err != nil { + logger.Errorf("SearchUsers fillUsersSystemRoleBindings error, error msg:%s", err.Error()) return nil, err } @@ -597,39 +561,50 @@ func SearchUsers(args *QueryArgs, logger *zap.SugaredLogger) (*types.UsersResp, }, nil } -func fillUsersMFAEnabled(usersInfo []*types.UserInfo) error { +func fillUsersSystemRoleBindings(usersInfo []*types.UserInfo, logger *zap.SugaredLogger) error { if len(usersInfo) == 0 { return nil } uids := make([]string, 0, len(usersInfo)) + seen := make(map[string]struct{}, len(usersInfo)) for _, userInfo := range usersInfo { if userInfo == nil || userInfo.Uid == "" { continue } + if _, ok := seen[userInfo.Uid]; ok { + continue + } + seen[userInfo.Uid] = struct{}{} uids = append(uids, userInfo.Uid) } if len(uids) == 0 { return nil } - userMFAs, err := orm.ListUserMFAsByUIDs(uids, repository.DB) + rolesByUID, err := ListRolesByNamespaceAndUserIDs("*", uids, logger) if err != nil { return err } - enabledMap := make(map[string]bool, len(userMFAs)) - for _, userMFA := range userMFAs { - if userMFA == nil || !userMFA.Enabled { - continue - } - enabledMap[userMFA.UID] = true - } for _, userInfo := range usersInfo { if userInfo == nil { continue } - userInfo.MFAEnabled = enabledMap[userInfo.Uid] + + roles := rolesByUID[userInfo.Uid] + roleBindings := make([]*types.RoleBinding, 0, len(roles)) + for _, role := range roles { + roleBindings = append(roleBindings, &types.RoleBinding{ + UID: userInfo.Uid, + Role: role.Name, + }) + if role.Name == string(setting.SystemAdmin) { + userInfo.Admin = true + userInfo.APITokenEnabled = true + } + } + userInfo.SystemRoleBindings = roleBindings } return nil @@ -651,6 +626,7 @@ func mergeUserLoginWithLoginTime(users []models.UserWithLoginTime, userLogins [] Email: user.Email, IdentityType: user.IdentityType, Account: user.Account, + MFAEnabled: user.MFAEnabled, APITokenEnabled: user.APITokenEnabled, }) } else { @@ -676,6 +652,7 @@ func mergeUserLogin(users []models.User, userLogins []models.UserLogin, logger * Email: user.Email, IdentityType: user.IdentityType, Account: user.Account, + MFAEnabled: user.MFAEnabled, APIToken: user.APIToken, APITokenEnabled: user.APITokenEnabled, }) @@ -699,27 +676,8 @@ func SearchUsersByUIDs(uids []string, logger *zap.SugaredLogger) (*types.UsersRe } usersInfo := mergeUserLogin(users, *userLogins, logger) - for _, uInfo := range usersInfo { - roles, err := ListRolesByNamespaceAndUserID("*", uInfo.Uid, logger) - if err != nil { - logger.Errorf("failed to get user role info for user: %s[%s], error: %s", uInfo.Name, uInfo.Account, err) - return nil, err - } - rolebindings := make([]*types.RoleBinding, 0) - for _, role := range roles { - rolebindings = append(rolebindings, &types.RoleBinding{ - UID: uInfo.Uid, - Role: role.Name, - }) - if role.Name == string(setting.SystemAdmin) { - uInfo.Admin = true - uInfo.APITokenEnabled = true - } - } - uInfo.SystemRoleBindings = rolebindings - } - if err := fillUsersMFAEnabled(usersInfo); err != nil { - logger.Errorf("SearchUsersByUIDs fillUsersMFAEnabled error, error msg:%s", err.Error()) + if err := fillUsersSystemRoleBindings(usersInfo, logger); err != nil { + logger.Errorf("SearchUsersByUIDs fillUsersSystemRoleBindings error, error msg:%s", err.Error()) return nil, err } diff --git a/pkg/shared/client/user/user.go b/pkg/shared/client/user/user.go index 167d00cfd7..0020fed154 100644 --- a/pkg/shared/client/user/user.go +++ b/pkg/shared/client/user/user.go @@ -32,6 +32,7 @@ type User struct { Phone string `json:"phone"` IdentityType string `json:"identity_type"` Account string `json:"account"` + MFAEnabled bool `json:"mfa_enabled"` APITokenEnabled bool `json:"api_token_enabled"` } @@ -40,10 +41,11 @@ type usersResp struct { } type SearchArgs struct { - Name string `json:"name"` - UIDs []string `json:"uids"` - PerPage int `json:"per_page,omitempty"` - Page int `json:"page,omitempty"` + Name string `json:"name"` + UIDs []string `json:"uids"` + PerPage int `json:"per_page,omitempty"` + Page int `json:"page,omitempty"` + MFAEnabled *bool `json:"mfa_enabled,omitempty"` } func (c *Client) ListUsers(args *SearchArgs) ([]*User, error) { @@ -86,6 +88,7 @@ type SearchUserArgs struct { UIDs []string `json:"uids,omitempty"` PerPage int `json:"per_page,omitempty"` Page int `json:"page,omitempty"` + MFAEnabled *bool `json:"mfa_enabled,omitempty"` } type SearchUserResp struct { From 66078cfab90cdd5ede86f18b0ee915957c91903d Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Mon, 8 Jun 2026 11:32:55 +0800 Subject: [PATCH 3/5] refactor: unify MFA query helpers Signed-off-by: huanghongbo-hhb --- .../user/core/handler/user/user.go | 4 +-- .../user/core/repository/orm/user.go | 27 +++++++------------ .../user/core/service/permission/user.go | 4 +-- 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/pkg/microservice/user/core/handler/user/user.go b/pkg/microservice/user/core/handler/user/user.go index b06c0f0ce3..d35759ebd6 100644 --- a/pkg/microservice/user/core/handler/user/user.go +++ b/pkg/microservice/user/core/handler/user/user.go @@ -274,7 +274,7 @@ func ListUsers(c *gin.Context) { } if len(args.UIDs) > 0 { - ctx.Resp, ctx.RespErr = permission.SearchUsersByUIDs(args.UIDs, ctx.Logger) + ctx.Resp, ctx.RespErr = permission.SearchUsersByUIDs(args.UIDs, args.MFAEnabled, ctx.Logger) } else if len(args.Account) > 0 { if len(args.IdentityType) == 0 { args.IdentityType = config.SystemIdentityType @@ -384,7 +384,7 @@ func ListUsersBrief(c *gin.Context) { var resp *types.UsersResp if len(args.UIDs) > 0 { - resp, err = permission.SearchUsersByUIDs(args.UIDs, ctx.Logger) + resp, err = permission.SearchUsersByUIDs(args.UIDs, args.MFAEnabled, ctx.Logger) } else if len(args.Account) > 0 { if len(args.IdentityType) == 0 { args.IdentityType = config.SystemIdentityType diff --git a/pkg/microservice/user/core/repository/orm/user.go b/pkg/microservice/user/core/repository/orm/user.go index 8da46d7950..3a90ef53d7 100644 --- a/pkg/microservice/user/core/repository/orm/user.go +++ b/pkg/microservice/user/core/repository/orm/user.go @@ -217,6 +217,10 @@ func ListUsersByNameAndRole(page int, perPage int, name string, roles []string, return users, nil } +func joinUserMFA(db *gorm.DB) *gorm.DB { + return db.Joins(userMFAJoinClause) +} + func ListUsersByGroup(groupID string, db *gorm.DB) ([]*models.User, error) { resp := make([]*models.User, 0) @@ -233,7 +237,7 @@ func ListUsersByGroup(groupID string, db *gorm.DB) ([]*models.User, error) { } // ListUsersByUIDs gets a list of users based on paging constraints -func ListUsersByUIDs(uids []string, db *gorm.DB) ([]models.User, error) { +func ListUsersByUIDs(uids []string, mfaEnabled *bool, db *gorm.DB) ([]models.User, error) { var ( users []models.User err error @@ -242,7 +246,7 @@ func ListUsersByUIDs(uids []string, db *gorm.DB) ([]models.User, error) { query := db.Model(&models.User{}). Select("user.*, "+userMFAEnabledSelectExpr). Where("user.uid in ?", uids) - err = applyMFAEnabledJoinFilter(query, nil).Find(&users).Error + err = applyMFAEnabledJoinFilter(query, mfaEnabled).Find(&users).Error if err != nil && err != gorm.ErrRecordNotFound { return nil, err @@ -294,7 +298,8 @@ func GetUsersCount(name string, mfaEnabled *bool) (int64, error) { count int64 ) - query := applyMFAEnabledFilter(repository.DB.Model(&models.User{}).Where("name LIKE ?", "%"+name+"%"), mfaEnabled) + query := repository.DB.Model(&models.User{}).Where("name LIKE ?", "%"+name+"%") + query = applyMFAEnabledJoinFilter(query, mfaEnabled) err = query.Count(&count).Error if err != nil { @@ -315,7 +320,7 @@ func GetUsersCountByRoles(name string, roles []string, namespace string, mfaEnab Where("user.name LIKE ? AND role.name IN ? AND role.namespace = ?", "%"+name+"%", roles, namespace). Joins("INNER JOIN role_binding on role_binding.uid = user.uid"). Joins("INNER JOIN role on role_binding.role_id = role.id") - query = applyMFAEnabledFilter(query, mfaEnabled) + query = applyMFAEnabledJoinFilter(query, mfaEnabled) err = query.Distinct("user.uid").Count(&count).Error if err != nil { @@ -325,20 +330,8 @@ func GetUsersCountByRoles(name string, roles []string, namespace string, mfaEnab return count, nil } -func applyMFAEnabledFilter(db *gorm.DB, mfaEnabled *bool) *gorm.DB { - if mfaEnabled == nil { - return db - } - - if *mfaEnabled { - return db.Where("EXISTS (SELECT 1 FROM user_mfa WHERE user_mfa.uid = user.uid AND user_mfa.enabled = ?)", true) - } - - return db.Where("NOT EXISTS (SELECT 1 FROM user_mfa WHERE user_mfa.uid = user.uid AND user_mfa.enabled = ?)", true) -} - func applyMFAEnabledJoinFilter(db *gorm.DB, mfaEnabled *bool) *gorm.DB { - db = db.Joins(userMFAJoinClause) + db = joinUserMFA(db) if mfaEnabled == nil { return db } diff --git a/pkg/microservice/user/core/service/permission/user.go b/pkg/microservice/user/core/service/permission/user.go index cdc94f5489..aba4001873 100644 --- a/pkg/microservice/user/core/service/permission/user.go +++ b/pkg/microservice/user/core/service/permission/user.go @@ -663,8 +663,8 @@ func mergeUserLogin(users []models.User, userLogins []models.UserLogin, logger * return usersInfo } -func SearchUsersByUIDs(uids []string, logger *zap.SugaredLogger) (*types.UsersResp, error) { - users, err := orm.ListUsersByUIDs(uids, repository.DB) +func SearchUsersByUIDs(uids []string, mfaEnabled *bool, logger *zap.SugaredLogger) (*types.UsersResp, error) { + users, err := orm.ListUsersByUIDs(uids, mfaEnabled, repository.DB) if err != nil { logger.Errorf("SearchUsersByUIDs SeachUsers By uids:%s error, error msg:%s", uids, err.Error()) return nil, err From 084a3fdb1eb4d46483937ab1b386ef9a7f7f278d Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Mon, 8 Jun 2026 15:14:42 +0800 Subject: [PATCH 4/5] refactor: simplify role-filtered user queries Signed-off-by: huanghongbo-hhb --- .../user/core/repository/orm/user.go | 46 ++++++------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/pkg/microservice/user/core/repository/orm/user.go b/pkg/microservice/user/core/repository/orm/user.go index 3a90ef53d7..05efbddf7c 100644 --- a/pkg/microservice/user/core/repository/orm/user.go +++ b/pkg/microservice/user/core/repository/orm/user.go @@ -145,41 +145,24 @@ func ListUsersByLoginTime(page int, perPage int, name string, order setting.List return users, nil } -// listUIDsByRoles returns distinct user uids that have any of the given role names within the namespace. -func listUIDsByRoles(roles []string, namespace string, db *gorm.DB) ([]string, error) { - var uids []string - err := db.Table("role_binding"). - Distinct("role_binding.uid"). +func uidSubQueryByRoles(roles []string, namespace string, db *gorm.DB) *gorm.DB { + return db.Table("role_binding"). + Select("DISTINCT role_binding.uid"). Joins("INNER JOIN role ON role.id = role_binding.role_id"). - Where("role.name IN ? AND role.namespace = ?", roles, namespace). - Pluck("role_binding.uid", &uids).Error - - if err != nil && err != gorm.ErrRecordNotFound { - return nil, err - } - return uids, nil + Where("role.name IN ? AND role.namespace = ?", roles, namespace) } // ListUsersByNameAndRoleWithLoginTime gets a list of users filtered by name and roles, -// ordered by last_login_time with pagination. It is implemented in two simple steps: -// 1. Find the uids of users that have any of the given roles (role_binding + role) within the namespace. -// 2. Query user + user_login for those uids, filter by name, order by last_login_time and paginate. +// ordered by last_login_time with pagination. func ListUsersByNameAndRoleWithLoginTime(page int, perPage int, name string, roles []string, namespace string, order setting.ListUserOrder, mfaEnabled *bool, db *gorm.DB) ([]models.UserWithLoginTime, error) { - uids, err := listUIDsByRoles(roles, namespace, db) - if err != nil { - return nil, err - } - if len(uids) == 0 { - return []models.UserWithLoginTime{}, nil - } - var users []models.UserWithLoginTime + roleUIDs := uidSubQueryByRoles(roles, namespace, db) query := db.Model(&models.User{}). Select("user.uid, user.name, user.account, user.identity_type, user.api_token_enabled, "+userMFAEnabledSelectExpr+", IFNULL(user_login.last_login_time, 0) AS last_login_time"). Joins("LEFT JOIN user_login ON user_login.uid = user.uid"). - Where("user.uid IN ? AND user.name LIKE ?", uids, "%"+name+"%") + Where("user.uid IN (?) AND user.name LIKE ?", roleUIDs, "%"+name+"%") query = applyMFAEnabledJoinFilter(query, mfaEnabled) - err = query. + err := query. Order("last_login_time " + string(order)). Offset((page - 1) * perPage). Limit(perPage). @@ -198,14 +181,12 @@ func ListUsersByNameAndRole(page int, perPage int, name string, roles []string, err error ) + roleUIDs := uidSubQueryByRoles(roles, namespace, db) query := db.Model(&models.User{}). Select("user.*, "+userMFAEnabledSelectExpr). - Where("user.name LIKE ? AND role.name IN ? AND role.namespace = ?", "%"+name+"%", roles, namespace). - Joins("INNER JOIN role_binding on role_binding.uid = user.uid"). - Joins("INNER JOIN role on role_binding.role_id = role.id") + Where("user.uid IN (?) AND user.name LIKE ?", roleUIDs, "%"+name+"%") query = applyMFAEnabledJoinFilter(query, mfaEnabled) err = query.Order("account ASC").Offset((page - 1) * perPage). - Group("user.uid"). Limit(perPage). Find(&users). Error @@ -316,12 +297,11 @@ func GetUsersCountByRoles(name string, roles []string, namespace string, mfaEnab count int64 ) + roleUIDs := uidSubQueryByRoles(roles, namespace, repository.DB) query := repository.DB.Model(&models.User{}). - Where("user.name LIKE ? AND role.name IN ? AND role.namespace = ?", "%"+name+"%", roles, namespace). - Joins("INNER JOIN role_binding on role_binding.uid = user.uid"). - Joins("INNER JOIN role on role_binding.role_id = role.id") + Where("user.uid IN (?) AND user.name LIKE ?", roleUIDs, "%"+name+"%") query = applyMFAEnabledJoinFilter(query, mfaEnabled) - err = query.Distinct("user.uid").Count(&count).Error + err = query.Count(&count).Error if err != nil { return 0, err From aed153e39c3b4b35f275bb2dee37f2b0990471e3 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Mon, 8 Jun 2026 18:15:07 +0800 Subject: [PATCH 5/5] refactor: clarify role-filtered user SQL Signed-off-by: huanghongbo-hhb --- pkg/microservice/user/core/repository/orm/user.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pkg/microservice/user/core/repository/orm/user.go b/pkg/microservice/user/core/repository/orm/user.go index 05efbddf7c..79fbe6caa5 100644 --- a/pkg/microservice/user/core/repository/orm/user.go +++ b/pkg/microservice/user/core/repository/orm/user.go @@ -156,11 +156,12 @@ func uidSubQueryByRoles(roles []string, namespace string, db *gorm.DB) *gorm.DB // ordered by last_login_time with pagination. func ListUsersByNameAndRoleWithLoginTime(page int, perPage int, name string, roles []string, namespace string, order setting.ListUserOrder, mfaEnabled *bool, db *gorm.DB) ([]models.UserWithLoginTime, error) { var users []models.UserWithLoginTime - roleUIDs := uidSubQueryByRoles(roles, namespace, db) + roleUIDSubQuery := uidSubQueryByRoles(roles, namespace, db) query := db.Model(&models.User{}). Select("user.uid, user.name, user.account, user.identity_type, user.api_token_enabled, "+userMFAEnabledSelectExpr+", IFNULL(user_login.last_login_time, 0) AS last_login_time"). Joins("LEFT JOIN user_login ON user_login.uid = user.uid"). - Where("user.uid IN (?) AND user.name LIKE ?", roleUIDs, "%"+name+"%") + Where("user.uid IN (?)", roleUIDSubQuery). + Where("user.name LIKE ?", "%"+name+"%") query = applyMFAEnabledJoinFilter(query, mfaEnabled) err := query. Order("last_login_time " + string(order)). @@ -181,10 +182,11 @@ func ListUsersByNameAndRole(page int, perPage int, name string, roles []string, err error ) - roleUIDs := uidSubQueryByRoles(roles, namespace, db) + roleUIDSubQuery := uidSubQueryByRoles(roles, namespace, db) query := db.Model(&models.User{}). Select("user.*, "+userMFAEnabledSelectExpr). - Where("user.uid IN (?) AND user.name LIKE ?", roleUIDs, "%"+name+"%") + Where("user.uid IN (?)", roleUIDSubQuery). + Where("user.name LIKE ?", "%"+name+"%") query = applyMFAEnabledJoinFilter(query, mfaEnabled) err = query.Order("account ASC").Offset((page - 1) * perPage). Limit(perPage). @@ -297,9 +299,10 @@ func GetUsersCountByRoles(name string, roles []string, namespace string, mfaEnab count int64 ) - roleUIDs := uidSubQueryByRoles(roles, namespace, repository.DB) + roleUIDSubQuery := uidSubQueryByRoles(roles, namespace, repository.DB) query := repository.DB.Model(&models.User{}). - Where("user.uid IN (?) AND user.name LIKE ?", roleUIDs, "%"+name+"%") + Where("user.uid IN (?)", roleUIDSubQuery). + Where("user.name LIKE ?", "%"+name+"%") query = applyMFAEnabledJoinFilter(query, mfaEnabled) err = query.Count(&count).Error