diff --git a/backend/controllers/account_group.go b/backend/controllers/account_group.go index a5400d0c..15845feb 100644 --- a/backend/controllers/account_group.go +++ b/backend/controllers/account_group.go @@ -49,6 +49,14 @@ func requireAccountGroupManagement(ctx *context.Context, w http.ResponseWriter) return true } +func requireAccountGroupMembershipManagement(ctx *context.Context, w http.ResponseWriter) bool { + if err := authorizeCapability(ctx, access.CapabilityAccountGroupMemberManage, "", false); err != nil { + writeUserAuthorizationError(w, err, "无权管理账号所属分组") + return false + } + return true +} + func SaveAccountGroup(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if !requireAccountGroupManagement(ctx, w) { return @@ -91,7 +99,7 @@ func SaveAccountGroup(ctx *context.Context, w http.ResponseWriter, req *http.Req } func SetAccountGroupMembership(ctx *context.Context, w http.ResponseWriter, req *http.Request) { - if !requireAccountGroupManagement(ctx, w) { + if !requireAccountGroupMembershipManagement(ctx, w) { return } var input struct { @@ -117,7 +125,13 @@ func SetAccountGroupMembership(ctx *context.Context, w http.ResponseWriter, req writeAccountGroupError(w, accountgroup.ErrConflict) return } - if err := accountgroup.SetMembership(db.Instance, user, input.GroupIDs, accountWriteGuard(ctx, user)); err != nil { + if err := accountgroup.SetMembership( + db.Instance, + user, + input.GroupIDs, + accountWriteGuard(ctx, user), + accountgroup.MembershipScopeGuard(ctx.UserID, ctx.EffectiveRole(), input.GroupIDs), + ); err != nil { writeAccountGroupError(w, err) return } diff --git a/backend/controllers/user.go b/backend/controllers/user.go index d0377d21..9166c5ec 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -99,7 +99,7 @@ func CreateUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) groupIDs = *reqData.GroupIDs } if len(groupIDs) > 0 && (len(groupIDs) != 1 || groupIDs[0] != 0) { - if !requireAccountGroupManagement(ctx, w) { + if !requireAccountGroupMembershipManagement(ctx, w) { return } } @@ -378,7 +378,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { targetRole := user.EffectiveRole() writeGuard := accountWriteGuard(ctx, user) if reqData.GroupIDs != nil { - if !requireAccountGroupManagement(ctx, w) { + if !requireAccountGroupMembershipManagement(ctx, w) { return } if reqData.MembershipVersion < 1 || reqData.MembershipVersion != user.SecurityVersion { @@ -492,6 +492,13 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { writeAccountGroupError(w, err) return } + if reqData.GroupIDs != nil { + if err := accountgroup.CheckWrite(tx, accountgroup.MembershipScopeGuard(ctx.UserID, ctx.EffectiveRole(), *reqData.GroupIDs)); err != nil { + _ = tx.Rollback() + writeAccountGroupError(w, err) + return + } + } if reqData.Password != "" { updateColumns = append(updateColumns, "password", "must_change_password", "password_changed_at") } diff --git a/backend/services/access/capability.go b/backend/services/access/capability.go index 7efffae4..c077b477 100644 --- a/backend/services/access/capability.go +++ b/backend/services/access/capability.go @@ -28,6 +28,7 @@ const ( CapabilityNotificationManage Capability = "notification.manage" CapabilityNotificationReveal Capability = "notification.reveal" CapabilityAccountGroupManage Capability = "account_group.manage" + CapabilityAccountGroupMemberManage Capability = "account_group.member_manage" ) type Policy struct { @@ -41,6 +42,7 @@ type Policy struct { var policies = map[Capability]Policy{ CapabilityNotificationReveal: {Capability: CapabilityNotificationReveal, HighRisk: true, RequiresTarget: true}, CapabilityAccountGroupManage: {Capability: CapabilityAccountGroupManage, HighRisk: true, RequiresRecentAuth: true}, + CapabilityAccountGroupMemberManage: {Capability: CapabilityAccountGroupMemberManage, HighRisk: true, RequiresRecentAuth: true}, CapabilityNotificationManage: {Capability: CapabilityNotificationManage, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true}, CapabilityConfigRead: {Capability: CapabilityConfigRead}, CapabilityConfigWriteGlobal: {Capability: CapabilityConfigWriteGlobal, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true}, @@ -70,6 +72,7 @@ var grants = map[Role]map[Capability]struct{}{ RoleSuperAdmin: grant( CapabilityNotificationReveal, CapabilityAccountGroupManage, + CapabilityAccountGroupMemberManage, CapabilityNotificationManage, CapabilityPluginReadGlobal, CapabilityUserCreate, CapabilityProfileWriteOwn, CapabilityConfigRead, CapabilityConfigWriteGlobal, CapabilityConfigRollback, @@ -81,6 +84,7 @@ var grants = map[Role]map[Capability]struct{}{ ), RoleAdmin: grant( CapabilityNotificationReveal, + CapabilityAccountGroupMemberManage, CapabilityNotificationManage, CapabilityPluginReadGlobal, CapabilityPluginRead, CapabilityPluginWriteGlobal, CapabilityProfileWriteOwn, CapabilityPluginWriteAnyUser, CapabilityPluginWriteOwn, CapabilityUserRead, diff --git a/backend/services/access/catalog.go b/backend/services/access/catalog.go index 1d570419..cf94edb0 100644 --- a/backend/services/access/catalog.go +++ b/backend/services/access/catalog.go @@ -14,9 +14,9 @@ var managementEndpoints = []Endpoint{ {Method: "POST", Path: "/api/system/notification/authorization", Capability: CapabilityNotificationManage}, {Method: "POST", Path: "/api/system/notification/credential", Capability: CapabilityNotificationManage}, {Method: "POST", Path: "/api/v1/user/groups/save", Capability: CapabilityAccountGroupManage}, - {Method: "POST", Path: "/api/v1/user/groups/membership", Capability: CapabilityAccountGroupManage}, + {Method: "POST", Path: "/api/v1/user/groups/membership", Capability: CapabilityAccountGroupMemberManage}, {Method: "POST", Path: "/api/user/groups/save", Capability: CapabilityAccountGroupManage}, - {Method: "POST", Path: "/api/user/groups/membership", Capability: CapabilityAccountGroupManage}, + {Method: "POST", Path: "/api/user/groups/membership", Capability: CapabilityAccountGroupMemberManage}, {Method: "POST", Path: "/api/v1/system/notification/authorization", Capability: CapabilityNotificationManage}, {Method: "POST", Path: "/api/v1/system/notification/credential", Capability: CapabilityNotificationManage}, {Method: "POST", Path: "/api/settings/modify_password", Capability: CapabilityPasswordChangeOwn}, diff --git a/backend/services/accountgroup/service.go b/backend/services/accountgroup/service.go index b4d9158a..2f684137 100644 --- a/backend/services/accountgroup/service.go +++ b/backend/services/accountgroup/service.go @@ -170,6 +170,38 @@ func ValidateMembership(tx *xorm.Session, role access.Role, input []int) ([]int, return ids, nil } +// MembershipScopeGuard 限制管理员只能把成员放入自己所属的分组。 +func MembershipScopeGuard(actorID int, role access.Role, input []int) WriteGuard { + return func(tx *xorm.Session) error { + if tx == nil || actorID <= 0 || !role.Valid() { + return ErrDenied + } + if role == access.RoleSuperAdmin { + return nil + } + if role != access.RoleAdmin { + return ErrDenied + } + allowedRows := make([]models.AccountGroupMember, 0) + if err := tx.Where("user_id = ?", actorID).Find(&allowedRows); err != nil { + return err + } + allowed := make(map[int]struct{}, len(allowedRows)) + for _, row := range allowedRows { + allowed[row.GroupID] = struct{}{} + } + if len(allowed) == 0 { + allowed[0] = struct{}{} + } + for _, groupID := range input { + if _, ok := allowed[groupID]; !ok { + return ErrDenied + } + } + return nil + } +} + func ReplaceMembership(tx *xorm.Session, userID int, ids []int) error { if _, err := tx.Where("user_id = ?", userID).Delete(&models.AccountGroupMember{}); err != nil { return err diff --git a/frontend/src/views/components/UserManagement/AccountGroups.js b/frontend/src/views/components/UserManagement/AccountGroups.js index ef40dfc6..65c32efc 100644 --- a/frontend/src/views/components/UserManagement/AccountGroups.js +++ b/frontend/src/views/components/UserManagement/AccountGroups.js @@ -8,6 +8,7 @@ export function useAccountGroups(globalStatus, onMembershipSaved = () => {}) { const groupsLoading = ref(false) const groupsError = ref('') const canManageGroups = computed(() => globalStatus.hasCapability('account_group.manage')) + const canManageMembership = computed(() => globalStatus.hasCapability('account_group.member_manage')) const groupSaving = ref(false) const membershipVisible = ref(false) const membershipUser = ref(null) @@ -128,7 +129,7 @@ export function useAccountGroups(globalStatus, onMembershipSaved = () => {}) { } const editMembership = (user, options = {}) => { - if (!canManageGroups.value || groupSaving.value) return + if (!canManageMembership.value || groupSaving.value) return membershipUser.value = { ...user, Role: options.role || user.Role } membershipSelection.value = [...(options.groupIDs || user.GroupIDs || [0])] applyMembershipDraft = options.onApply || null @@ -137,7 +138,7 @@ export function useAccountGroups(globalStatus, onMembershipSaved = () => {}) { } const saveMembership = async () => { - if (!canManageGroups.value || groupSaving.value || !membershipUser.value) return + if (!canManageMembership.value || groupSaving.value || !membershipUser.value) return if (membershipSelection.value.length === 0) membershipSelection.value = [0] if (membershipSelection.value.length > membershipLimit.value) { ElMessage.error('普通用户只能选择一个分组,请重新选择') @@ -174,7 +175,7 @@ export function useAccountGroups(globalStatus, onMembershipSaved = () => {}) { } return { - accountGroups, groupsLoading, groupsError, canManageGroups, groupSaving, + accountGroups, groupsLoading, groupsError, canManageGroups, canManageMembership, groupSaving, membershipVisible, membershipUser, membershipSelection, membershipLimit, membershipDraft, membershipSingleSelection, refreshGroups, loadGroup, toggleGroup, saveGroup, createMembershipGroup, editMembership, saveMembership diff --git a/frontend/src/views/components/UserManagement/UserManagement.js b/frontend/src/views/components/UserManagement/UserManagement.js index a4fa4c13..58023887 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.js +++ b/frontend/src/views/components/UserManagement/UserManagement.js @@ -69,7 +69,7 @@ export function usePage() { const user = selectedUser.value if (!user && editModel.value !== 'create') return [] return groups.accountGroups.value.filter(group => - groups.canManageGroups.value + groups.canManageMembership.value ? editingGroupIDs.value.includes(group.id) : group.rows.some(member => member.ID === user.ID) ) @@ -243,7 +243,7 @@ export function usePage() { } if (creating || roleChanged.value) newData.role = editUserInfo.role if (creating || editUserInfo.password !== '') newData.password = editUserInfo.password - if (groups.canManageGroups.value && (creating || membershipChanged.value || roleChanged.value)) { + if (groups.canManageMembership.value && (creating || membershipChanged.value || roleChanged.value)) { if (editUserInfo.role !== 'admin' && editingGroupIDs.value.length !== 1) { throw new Error('普通用户只能属于一个分组,请在分组管理中选择一个分组') }