diff --git a/internal/logic/admin/admin_user.go b/internal/logic/admin/admin_user.go index bb92c1e..e57fab5 100644 --- a/internal/logic/admin/admin_user.go +++ b/internal/logic/admin/admin_user.go @@ -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 diff --git a/internal/logic/admin/permission.go b/internal/logic/admin/permission.go index 2b3011e..d0e9e06 100644 --- a/internal/logic/admin/permission.go +++ b/internal/logic/admin/permission.go @@ -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 { diff --git a/internal/logic/admin/privilege_guard_test.go b/internal/logic/admin/privilege_guard_test.go new file mode 100644 index 0000000..343a7b4 --- /dev/null +++ b/internal/logic/admin/privilege_guard_test.go @@ -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) + } +} diff --git a/internal/middleware/tenant_auth.go b/internal/middleware/tenant_auth.go index 35d7792..83990c2 100644 --- a/internal/middleware/tenant_auth.go +++ b/internal/middleware/tenant_auth.go @@ -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}). diff --git a/migrations/000020_admin_role_management.sql b/migrations/000020_admin_role_management.sql index 0eded35..0edc5e5 100644 --- a/migrations/000020_admin_role_management.sql +++ b/migrations/000020_admin_role_management.sql @@ -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 diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 74c7a31..b3a505a 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -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)