fix(permissions):修复提权漏洞

This commit is contained in:
chengqian committed 2026-09-02 19:43:42 +08:00
1 parent ec182c5a71
commit 652c6903f6
6 files changed
+201 -39

No files matched your search

+88
View File
@@ -86,6 +86,64 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A
}, nil
}
// assertCanManageAdminUser 校验当前调用者是否有资格操作目标管理员账号。
//
// 账号管理(改资料 / 改密 / 禁用 / 删除 / 配特批权限)按 user:* 授权、允许下放,
// 但下放的前提是「操作本身不能带来提权」。两条底线:
// - 目标是超级管理员 → 非超管一律不可操作。否则重置一次超管密码就能接管整个系统,
// 角色管理限定超管的那道闸门形同虚设。
// - 目标的有效权限不是调用者的子集 → 不可操作。否则一个只有 user:edit 的账号可以
// 重置权限更高账号的密码,登进去就完成了横向提权。
//
// 超级管理员短路放行;操作自己不受限(自身权限的变更另有 sanitizeGrantedPermissions 把关)。
// 与 sanitizeGrantedPermissions 的「不能授予自己不具备的权限」同源:能碰的对象不得强于自己。
func assertCanManageAdminUser(ctx context.Context, targetUserID int64, targetRole string) error {
operatorRole := common.GetCtxUserRole(ctx)
if operatorRole == "super_admin" {
return nil
}
if targetRole == "super_admin" {
return common.NewBusinessError(consts.CodeForbidden, "不能操作超级管理员账号")
}
operatorID := common.GetCtxUserID(ctx)
// 无调用者身份(命令行工具、内部调用)时不做判定,交由上层接口鉴权把关
if operatorID == 0 || operatorID == targetUserID {
return nil
}
targetPerms, err := GetEffectivePermissions(ctx, targetUserID, targetRole)
if err != nil {
return err
}
ownPerms, err := GetEffectivePermissions(ctx, operatorID, operatorRole)
if err != nil {
return err
}
ownSet := make(map[string]bool, len(ownPerms))
for _, p := range ownPerms {
ownSet[p] = true
}
for _, p := range targetPerms {
if !ownSet[p] {
return common.NewBusinessError(consts.CodeForbidden, "目标账号拥有你不具备的权限,无法对其执行此操作")
}
}
return nil
}
// assertCanGrantSuperAdmin 拦截「非超管把账号设成超级管理员」。
//
// role 字段的取值校验(ValidateAdminRole)只管值合不合法,管不了谁有资格赋这个值。
// 少了这道判断,一个拿到 user:create / user:edit 的账号可以直接把自己或新建账号
// 提成 super_admin —— 这是比分配角色更直接的提权入口,必须与角色分配同级管控。
func assertCanGrantSuperAdmin(ctx context.Context, role string) error {
if role != "super_admin" || common.GetCtxUserRole(ctx) == "super_admin" {
return nil
}
return common.NewBusinessError(consts.CodeForbidden, "只有超级管理员才能设置超级管理员账号")
}
// CreateUser creates a new admin user.
func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v1.AdminUserCreateRes, error) {
username := strings.TrimSpace(req.Username)
@@ -138,6 +196,9 @@ func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v
if err := common.ValidateAdminRole(role); err != nil {
return nil, common.NewBadRequestError(err.Error())
}
if err := assertCanGrantSuperAdmin(ctx, role); err != nil {
return nil, err
}
// 创建账号时附带角色属于角色分配,与角色管理同级:仅超管可用。
// 否则拥有 user:create 的人可以造一个挂着高权限角色、密码由自己设定的账号,
@@ -204,6 +265,9 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v
if targetUser.Role == "super_admin" {
return nil, common.NewBadRequestError("不能修改超级管理员信息")
}
if err := assertCanManageAdminUser(ctx, req.Id, targetUser.Role); err != nil {
return nil, err
}
data := do.SysAdminUsers{}
if req.DisplayName != nil {
@@ -226,6 +290,9 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v
if err := common.ValidateAdminRole(*req.Role); err != nil {
return nil, common.NewBadRequestError("角色无效")
}
if err := assertCanGrantSuperAdmin(ctx, *req.Role); err != nil {
return nil, err
}
data.Role = *req.Role
}
@@ -263,6 +330,9 @@ func (s *sAdmin) DeleteUser(ctx context.Context, req *v1.AdminUserDeleteReq) (*v
if user.Role == "super_admin" {
return nil, common.NewBadRequestError("不能删除超级管理员")
}
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
return nil, err
}
// Delete user first, then revoke sessions to avoid leaving the user
// in a state where sessions are gone but the user still exists.
@@ -321,6 +391,9 @@ func (s *sAdmin) UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateSt
if user.Role == "super_admin" {
return nil, common.NewBadRequestError("不能修改超级管理员状态")
}
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
return nil, err
}
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Update(do.SysAdminUsers{
Status: req.Status,
@@ -366,6 +439,21 @@ func (s *sAdmin) ResetUserPassword(ctx context.Context, req *v1.AdminUserResetPa
return nil, common.NewBusinessError(consts.CodePasswordTooWeak, consts.MsgPasswordTooWeak)
}
// 改密等于账号接管:此前不校验目标是谁,拿到 user:edit 就能重置超级管理员的密码。
var targetUser *struct {
Role string `json:"role"`
}
err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Fields("role").Scan(&targetUser)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if targetUser == nil {
return nil, common.NewNotFoundError("管理员")
}
if err := assertCanManageAdminUser(ctx, req.Id, targetUser.Role); err != nil {
return nil, err
}
passwordHash, err := crypto.HashPassword(req.NewPassword)
if err != nil {
return nil, err
+18 -12
View File
@@ -6,7 +6,6 @@ import (
"github.com/gogf/gf/v2/database/gdb"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
@@ -222,6 +221,18 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss
if user.Role == "super_admin" {
return nil, common.NewBadRequestError("超级管理员无需配置权限")
}
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
return nil, err
}
// 特批权限与角色权限一样进有效权限的并集,因此必须走同一道 sanitize:
// 校验权限点存在 + 去重排序 + 禁止授予自己不具备的权限。
// 此前这里只做「权限点是否存在」的校验,一个拿到 user:edit 的账号可以给自己
// 特批 system:plugin / billing:refund,绕开「角色管理仅超管」的全部管控。
perms, err := sanitizeGrantedPermissions(ctx, req.Permissions)
if err != nil {
return nil, err
}
// 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
@@ -233,18 +244,10 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss
return err
}
// Validate permission points against predefined set
validPerms := buildValidPermissionSet()
for _, p := range req.Permissions {
if !validPerms[p] {
return gerror.Newf("无效的权限点: %s", p)
}
}
// Insert new permissions
if len(req.Permissions) > 0 {
data := make([]do.SysAdminRolePerms, len(req.Permissions))
for i, p := range req.Permissions {
if len(perms) > 0 {
data := make([]do.SysAdminRolePerms, len(perms))
for i, p := range perms {
data[i] = do.SysAdminRolePerms{
AdminUserId: req.Id,
PermissionPoint: p,
@@ -285,6 +288,9 @@ func (s *sAdmin) UpdateUserDataScopes(ctx context.Context, req *v1.AdminDataScop
if user.Role == "super_admin" {
return nil, common.NewBadRequestError("超级管理员无需配置数据范围")
}
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
return nil, err
}
// 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
@@ -0,0 +1,68 @@
package admin
import (
"context"
"testing"
)
// ctxAs 构造带调用者身份的上下文。
// 键名与 middleware 写入 ctx 的保持一致(见 internal/logic/common/session.go)。
func ctxAs(role string, userID int64) context.Context {
ctx := context.WithValue(context.Background(), "role", role)
return context.WithValue(ctx, "userId", userID)
}
// TestAssertCanGrantSuperAdmin 守住「谁能把账号设成超级管理员」。
//
// sys_admin_users.role 是超管的唯一判定依据,鉴权时直接短路放行。若允许非超管写这个字段,
// 一个拿到 user:create / user:edit 的账号可以直接把自己提成超管 —— 比分配角色更直接,
// 且完全绕过 superAdminOnlyRules。字段级取值校验(ValidateAdminRole)管不了这件事。
func TestAssertCanGrantSuperAdmin(t *testing.T) {
cases := []struct {
name string
operator string
targetRole string
wantBlocked bool
}{
{"超管可以设置超管", "super_admin", "super_admin", false},
{"超管可以设置普通管理员", "super_admin", "admin", false},
{"非超管不能设置超管", "admin", "super_admin", true},
{"非超管可以设置普通管理员", "admin", "admin", false},
{"无身份上下文时不能设置超管", "", "super_admin", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := assertCanGrantSuperAdmin(ctxAs(c.operator, 1), c.targetRole)
if c.wantBlocked && err == nil {
t.Fatalf("应被拦截,实际放行")
}
if !c.wantBlocked && err != nil {
t.Fatalf("应放行,实际被拦截: %v", err)
}
})
}
}
// TestAssertCanManageAdminUserBlocksSuperAdminTarget 守住「非超管不得操作超管账号」。
//
// 账号管理(改密/禁用/删除/配特批权限)按 user:* 授权、允许下放。若不限制目标,
// 拿到 user:edit 的账号重置一次超管密码就能接管系统,角色管理限定超管那道闸门形同虚设。
//
// 只覆盖不触库的分支:超管调用者短路、目标为超管、无调用者身份、操作自己。
// 「目标权限须是调用者子集」需要读库,由集成测试覆盖。
func TestAssertCanManageAdminUserBlocksSuperAdminTarget(t *testing.T) {
if err := assertCanManageAdminUser(ctxAs("super_admin", 1), 2, "super_admin"); err != nil {
t.Fatalf("超管操作任意账号应放行: %v", err)
}
if err := assertCanManageAdminUser(ctxAs("admin", 2), 1, "super_admin"); err == nil {
t.Fatal("非超管操作超管账号应被拦截,实际放行")
}
// 无调用者身份(命令行工具、内部调用):交由上层接口鉴权把关,此处不拦
if err := assertCanManageAdminUser(ctxAs("admin", 0), 3, "admin"); err != nil {
t.Fatalf("无调用者身份时不应拦截: %v", err)
}
// 操作自己:自身权限的变更另有 sanitizeGrantedPermissions 把关
if err := assertCanManageAdminUser(ctxAs("admin", 5), 5, "admin"); err != nil {
t.Fatalf("操作自己不应被拦截: %v", err)
}
}
+15 -13
View File
@@ -14,19 +14,21 @@ import (
// tenantPublicPaths lists tenant routes that skip JWT auth.
// Keep in sync with api/tenant/v1/ structs tagged group:"public" middleware:"-".
var tenantPublicPaths = map[string]bool{
"/api/tenant/auth/register": true,
"/api/tenant/auth/login": true,
"/api/tenant/auth/refresh": true,
"/api/tenant/auth/2fa/verify": true,
"/api/tenant/email/send-code": true,
"/api/tenant/email/reset-password": true,
"/api/tenant/members/join": true,
"/api/tenant/members/invite-info": true,
"/api/tenant/agreements/current": true,
"/api/tenant/oauth/authorize": true,
"/api/tenant/marketplace/models": true,
"/api/tenant/help/categories": true,
"/api/tenant/help/search": true,
"/api/tenant/auth/register": true,
// 注册页在提交前展示剩余注册次数,必须匿名可访问(只读不计数)
"/api/tenant/auth/register-rate-limit": true,
"/api/tenant/auth/login": true,
"/api/tenant/auth/refresh": true,
"/api/tenant/auth/2fa/verify": true,
"/api/tenant/email/send-code": true,
"/api/tenant/email/reset-password": true,
"/api/tenant/members/join": true,
"/api/tenant/members/invite-info": true,
"/api/tenant/agreements/current": true,
"/api/tenant/oauth/authorize": true,
"/api/tenant/marketplace/models": true,
"/api/tenant/help/categories": true,
"/api/tenant/help/search": true,
}
// tenantPublicPrefixes lists path prefixes that skip JWT auth (for dynamic routes like /current/{code}).
@@ -160,16 +160,6 @@ SELECT r.id, unnest(ARRAY[
FROM sys_admin_roles r WHERE r.code = 'support'
ON CONFLICT (role_id, permission_point) DO NOTHING;
-- ── 存量账号回填 ──
-- 非超管账号一律挂到「管理员」角色,保持升级前后的可用权限不缩水
-- (升级前 admin 账号的实际权限来自 sys_admin_role_perms,特批权限继续并集生效)。
INSERT INTO sys_admin_user_roles (admin_user_id, role_id)
SELECT u.id, r.id
FROM sys_admin_users u
CROSS JOIN sys_admin_roles r
WHERE u.role <> 'super_admin' AND r.code = 'admin'
ON CONFLICT (admin_user_id, role_id) DO NOTHING;
-- 移除不再使用的配置项
DELETE FROM sys_options
+12 -4
View File
@@ -37,7 +37,7 @@ import {
IconCheckCircleFill,
} from '@arco-design/web-vue/es/icon'
import { useAuthStore } from '@/stores/auth'
import { hasPermission, isSuperAdmin } from '@/utils/permission'
import { ADMIN_ROLES } from '@/utils/permission'
const router = useRouter()
const route = useRoute()
@@ -405,8 +405,15 @@ const ALL_MENU_GROUPS = [
},
]
// 权限判定读 store 而不是 utils/permission 里的 localStorage 版本:
// localStorage 不是响应式数据源,computed 求值一次后就再也不会失效,
// fetchMe() 拉到新权限、或切换账号后菜单不会跟着变。
// utils 版本仍用于按钮级 v-if(每次渲染都会重新调用,不依赖响应式追踪)。
const isSuperAdminUser = computed(() => authStore.user?.role === ADMIN_ROLES.SUPER_ADMIN)
const permissionSet = computed(() => new Set(authStore.permissions))
// 按当前用户权限过滤菜单:整组条目都被过滤掉时,分组本身也不再渲染,
// 避免出现点开是空的分组。super_admin 在 hasPermission 内直接放行。
// 避免出现点开是空的分组。super_admin 拥有全部权限,直接放行。
const menuGroups = computed(() =>
ALL_MENU_GROUPS
.map(group => ({
@@ -414,8 +421,9 @@ const menuGroups = computed(() =>
items: group.items.filter(item => {
// superOnly 的条目不参与权限点判定:它们是权限体系本身的元操作,
// 无法通过授予权限点下放(后端 superAdminOnlyRules 同样硬性拦截)
if ((item as any).superOnly) return isSuperAdmin()
return !item.perm || hasPermission(item.perm)
if ((item as any).superOnly) return isSuperAdminUser.value
if (isSuperAdminUser.value) return true
return !item.perm || permissionSet.value.has(item.perm)
}),
}))
.filter(group => group.items.length > 0)