diff --git a/backend/controllers/account_group.go b/backend/controllers/account_group.go index 15845fe..b901d5b 100644 --- a/backend/controllers/account_group.go +++ b/backend/controllers/account_group.go @@ -125,14 +125,27 @@ func SetAccountGroupMembership(ctx *context.Context, w http.ResponseWriter, req writeAccountGroupError(w, accountgroup.ErrConflict) return } - if err := accountgroup.SetMembership( - db.Instance, - user, - input.GroupIDs, + guards := []accountgroup.WriteGuard{ accountWriteGuard(ctx, user), accountgroup.MembershipScopeGuard(ctx.UserID, ctx.EffectiveRole(), input.GroupIDs), - ); err != nil { - writeAccountGroupError(w, err) + } + var setErr error + if ctx.EffectiveRole() == access.RoleAdmin { + visibleGroups, visibleErr := accountgroup.VisibleGroups(db.Instance, ctx.UserID, ctx.EffectiveRole()) + if visibleErr != nil { + writeAccountGroupError(w, visibleErr) + return + } + editableGroupIDs := make([]int, 0, len(visibleGroups)) + for _, group := range visibleGroups { + editableGroupIDs = append(editableGroupIDs, group.ID) + } + setErr = accountgroup.SetScopedMembership(db.Instance, user, input.GroupIDs, editableGroupIDs, guards...) + } else { + setErr = accountgroup.SetMembership(db.Instance, user, input.GroupIDs, guards...) + } + if setErr != nil { + writeAccountGroupError(w, setErr) return } InvalidateSecurityRuntimeUser(user.ID, user.Account) diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 9f37bd9..75f5fd7 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -273,7 +273,9 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } items := make([]userListItem, 0, len(users)) memberships := map[int][]int{} - if ctx.EffectiveRole() == access.RoleSuperAdmin { + groupManager := ctx.EffectiveRole() == access.RoleSuperAdmin || ctx.EffectiveRole() == access.RoleAdmin + visibleMemberships := map[int]struct{}{} + if groupManager { ids := make([]int, 0, len(users)) for _, user := range users { ids = append(ids, user.ID) @@ -283,10 +285,30 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { writeAccountGroupError(w, err) return } + if ctx.EffectiveRole() == access.RoleAdmin { + visibleGroups, visibleErr := accountgroup.VisibleGroups(db.Instance, ctx.UserID, ctx.EffectiveRole()) + if visibleErr != nil { + writeAccountGroupError(w, visibleErr) + return + } + for _, group := range visibleGroups { + visibleMemberships[group.ID] = struct{}{} + } + } } for _, user := range users { + groupIDs := memberships[user.ID] + if ctx.EffectiveRole() == access.RoleAdmin { + filtered := make([]int, 0, len(groupIDs)) + for _, groupID := range groupIDs { + if _, ok := visibleMemberships[groupID]; ok { + filtered = append(filtered, groupID) + } + } + groupIDs = filtered + } items = append(items, userListItem{ - GroupIDs: memberships[user.ID], + GroupIDs: groupIDs, ID: user.ID, Account: user.Account, Name: user.Name, @@ -298,7 +320,7 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { ExternalEmailEnabled: user.ExternalEmailEnabled == 1, ExternalEmailConfigured: externalEmailConfigured[user.ID], }) - if ctx.EffectiveRole() == access.RoleSuperAdmin { + if groupManager { items[len(items)-1].MembershipVersion = user.SecurityVersion } } diff --git a/backend/services/accountgroup/service.go b/backend/services/accountgroup/service.go index 2f68413..f51eced 100644 --- a/backend/services/accountgroup/service.go +++ b/backend/services/accountgroup/service.go @@ -296,6 +296,15 @@ func Guard(actorID int, role access.Role, sessionVersion, securityVersion int, t } func SetMembership(engine *xorm.Engine, user models.User, input []int, guards ...WriteGuard) error { + return setMembership(engine, user, input, nil, guards...) +} + +// SetScopedMembership 只替换可管理分组,保留目标账号在其他分组中的成员关系。 +func SetScopedMembership(engine *xorm.Engine, user models.User, input []int, editableGroupIDs []int, guards ...WriteGuard) error { + return setMembership(engine, user, input, editableGroupIDs, guards...) +} + +func setMembership(engine *xorm.Engine, user models.User, input []int, editableGroupIDs []int, guards ...WriteGuard) error { if engine == nil || user.ID <= 0 { return ErrDenied } @@ -323,6 +332,24 @@ func SetMembership(engine *xorm.Engine, user models.User, input []int, guards .. if err != nil { return err } + if editableGroupIDs != nil { + editable := make(map[int]struct{}, len(editableGroupIDs)) + for _, groupID := range editableGroupIDs { + editable[groupID] = struct{}{} + } + existing := make([]models.AccountGroupMember, 0) + if err := tx.Where(`user_id = ?`, user.ID).Find(&existing); err != nil { + return err + } + preserved := make([]int, 0, len(existing)+len(ids)) + for _, row := range existing { + if _, ok := editable[row.GroupID]; !ok { + preserved = append(preserved, row.GroupID) + } + } + ids = append(preserved, ids...) + sort.Ints(ids) + } if err := ReplaceMembership(tx, user.ID, ids); err != nil { return err } diff --git a/backend/services/accountgroup/service_test.go b/backend/services/accountgroup/service_test.go index 8a0cf77..91bdca5 100644 --- a/backend/services/accountgroup/service_test.go +++ b/backend/services/accountgroup/service_test.go @@ -135,3 +135,35 @@ func TestValidateMembershipKeepsAdminMultiGroupAndUserSingleGroupRules(t *testin t.Fatalf("普通用户不应允许多个分组,得到:%v", err) } } + +func TestSetScopedMembershipPreservesGroupsOutsideAdminScope(t *testing.T) { + engine, err := xorm.NewEngine(`sqlite`, `:memory:`) + if err != nil { + t.Fatal(err) + } + defer engine.Close() + if err := engine.Sync2(new(models.User), new(models.AccountGroup), new(models.AccountGroupMember)); err != nil { + t.Fatal(err) + } + target := models.User{Account: `target`, Name: `Target`, Role: string(access.RoleUser), SessionVersion: 1, SecurityVersion: 1} + if _, err := engine.Insert(&target); err != nil { + t.Fatal(err) + } + if _, err := engine.Insert(&models.AccountGroup{ID: 1, Name: `visible`}, &models.AccountGroup{ID: 2, Name: `hidden`}); err != nil { + t.Fatal(err) + } + if _, err := engine.Insert(&models.AccountGroupMember{UserID: target.ID, GroupID: 1}, &models.AccountGroupMember{UserID: target.ID, GroupID: 2}); err != nil { + t.Fatal(err) + } + allow := func(*xorm.Session) error { return nil } + if err := SetScopedMembership(engine, target, []int{0}, []int{0, 1}, allow); err != nil { + t.Fatal(err) + } + rows := make([]models.AccountGroupMember, 0) + if err := engine.Where(`user_id = ?`, target.ID).Asc(`group_id`).Find(&rows); err != nil { + t.Fatal(err) + } + if len(rows) != 2 || rows[0].GroupID != 0 || rows[1].GroupID != 2 { + t.Fatalf(`scoped membership changed an inaccessible group: %+v`, rows) + } +}