diff --git a/internal/logic/admin/admin_user.go b/internal/logic/admin/admin_user.go index da73e0c..bb92c1e 100644 --- a/internal/logic/admin/admin_user.go +++ b/internal/logic/admin/admin_user.go @@ -139,6 +139,14 @@ func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v return nil, common.NewBadRequestError(err.Error()) } + // 创建账号时附带角色属于角色分配,与角色管理同级:仅超管可用。 + // 否则拥有 user:create 的人可以造一个挂着高权限角色、密码由自己设定的账号, + // 等价于给自己提权。这里明确报错而非静默忽略——静默会造出一个零权限账号, + // 使用者以为分配成功了。 + if len(req.RoleIDs) > 0 && common.GetCtxUserRole(ctx) != "super_admin" { + return nil, common.NewBusinessError(consts.CodeForbidden, "分配角色仅超级管理员可用,请先创建账号再由超级管理员分配角色") + } + // 账号与角色关联同事务写入:此前新建的 admin 账号不带任何权限,登录后处处 403, // 是「系统缺乏角色管理」最直接的体感来源。 var id int64 diff --git a/internal/logic/admin/role.go b/internal/logic/admin/role.go index 3479e69..b5ca125 100644 --- a/internal/logic/admin/role.go +++ b/internal/logic/admin/role.go @@ -391,6 +391,9 @@ func replaceUserRoles(ctx context.Context, userID int64, roleIDs []int64) error // AssignUserRoles 为管理员分配角色(全量覆盖)。 func (s *sAdmin) AssignUserRoles(ctx context.Context, req *v1.AdminUserRoleAssignReq) (*v1.AdminUserRoleAssignRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } user, err := loadAdminUserRole(ctx, req.Id) if err != nil { return nil, err @@ -412,6 +415,9 @@ func (s *sAdmin) AssignUserRoles(ctx context.Context, req *v1.AdminUserRoleAssig // GetUserRoles 查询管理员已分配的角色。 func (s *sAdmin) GetUserRoles(ctx context.Context, req *v1.AdminUserRoleListReq) (*v1.AdminUserRoleListRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } if _, err := loadAdminUserRole(ctx, req.Id); err != nil { return nil, err } diff --git a/internal/logic/admin/role_crud.go b/internal/logic/admin/role_crud.go index 82d7a5e..f2a4423 100644 --- a/internal/logic/admin/role_crud.go +++ b/internal/logic/admin/role_crud.go @@ -9,6 +9,7 @@ import ( "github.com/gogf/gf/v2/frame/g" v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" lcommon "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/model/do" @@ -81,8 +82,23 @@ var builtinRoleDefaults = map[string][]string{ }, } +// assertSuperAdmin 校验调用者是超级管理员。 +// +// 与 middleware 的 superAdminOnlyRules 是两道独立的闸门:中间件按路由拦截,这里按方法拦截。 +// 双重校验不是冗余 —— 路由表漏配、内部调用绕过 HTTP 层、将来有人给角色接口加了新路径, +// 任何一种情况下都还有一道守住。权限体系的元操作值得这个代价。 +func assertSuperAdmin(ctx context.Context) error { + if lcommon.GetCtxUserRole(ctx) == "super_admin" { + return nil + } + return lcommon.NewBusinessError(consts.CodeForbidden, "角色权限管理仅超级管理员可用") +} + // ListRoles 返回角色列表(角色数量少,不分页)。 func (s *sAdmin) ListRoles(ctx context.Context, _ *v1.AdminRoleListReq) (*v1.AdminRoleListRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } var roles []struct { ID int64 `json:"id"` Code string `json:"code"` @@ -144,6 +160,9 @@ func countByRole(ctx context.Context, model *gdb.Model) (map[int64]int, error) { // GetRoleDetail 返回角色详情,同时给出权限点全集与按模块归纳的档位视图。 func (s *sAdmin) GetRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (*v1.AdminRoleDetailRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } role, err := loadRole(ctx, req.ID) if err != nil { return nil, err @@ -191,6 +210,9 @@ func buildModuleTiers(perms []string) []v1.AdminRoleModuleTier { // CreateRole 新建角色。权限可直接给出,也可从现有角色复制。 func (s *sAdmin) CreateRole(ctx context.Context, req *v1.AdminRoleCreateReq) (*v1.AdminRoleCreateRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } code := strings.ToLower(strings.TrimSpace(req.Code)) if code == reservedRoleCode { return nil, lcommon.NewBadRequestError("super_admin 是保留标识,超级管理员由账号属性判定,不能作为角色") @@ -258,6 +280,9 @@ func (s *sAdmin) CreateRole(ctx context.Context, req *v1.AdminRoleCreateReq) (*v // UpdateRole 更新角色的名称、说明、排序与权限(code 不可修改)。 func (s *sAdmin) UpdateRole(ctx context.Context, req *v1.AdminRoleUpdateReq) (*v1.AdminRoleUpdateRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } if _, err := loadRole(ctx, req.ID); err != nil { return nil, err } @@ -310,6 +335,9 @@ func (s *sAdmin) UpdateRole(ctx context.Context, req *v1.AdminRoleUpdateReq) (*v // UpdateRoleStatus 启用/禁用角色。禁用后该角色的权限对全部关联用户立即失效。 func (s *sAdmin) UpdateRoleStatus(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (*v1.AdminRoleStatusUpdateRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } if _, err := loadRole(ctx, req.ID); err != nil { return nil, err } @@ -332,6 +360,9 @@ func (s *sAdmin) UpdateRoleStatus(ctx context.Context, req *v1.AdminRoleStatusUp // 不存在把自己锁死的风险 —— 超级管理员由 sys_admin_users.role 判定并短路鉴权, // 不依赖任何角色记录,即便角色被删光也能登录并重建,因此不设「至少保留一个角色」的约束。 func (s *sAdmin) DeleteRole(ctx context.Context, req *v1.AdminRoleDeleteReq) (*v1.AdminRoleDeleteRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } if _, err := loadRole(ctx, req.ID); err != nil { return nil, err } @@ -361,6 +392,9 @@ func (s *sAdmin) DeleteRole(ctx context.Context, req *v1.AdminRoleDeleteReq) (*v // ResetRoleDefaults 把预置角色的权限恢复为出厂默认值。 func (s *sAdmin) ResetRoleDefaults(ctx context.Context, req *v1.AdminRoleResetReq) (*v1.AdminRoleResetRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } role, err := loadRole(ctx, req.ID) if err != nil { return nil, err diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 8d4341e..4ca7770 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -337,6 +337,46 @@ var adminPermissionRules = []permissionRule{ {method: "POST", prefix: "/api/admin/usage-logs/cleanup/tasks/", suffix: "/cancel", perm: "system:edit"}, } +// superAdminOnlyRules 列出仅超级管理员可访问的路由。 +// +// 角色管理不走「权限点授权」而是硬性限定超管,理由是它是权限体系的元操作: +// 谁能改角色,谁就能决定所有人的权限。一旦把它下放出去(哪怕只给 user:edit), +// 被下放者就能给自己挂上更高权限的角色——权限体系当场失效。 +// +// 这条限制让提权路径根本不存在,而不是靠「只能授予自己已有的权限」这类兜底去堵。 +// 普通账号管理(创建/禁用/改密/删除)不受影响,仍按 user:* 授权,可以正常下放。 +var superAdminOnlyRules = []permissionRule{ + // 角色的增删改查与启停 + {method: "GET", path: "/api/admin/roles"}, + {method: "POST", path: "/api/admin/roles"}, + {method: "GET", prefix: "/api/admin/roles/"}, + {method: "POST", prefix: "/api/admin/roles/"}, + {method: "PUT", prefix: "/api/admin/roles/"}, + {method: "DELETE", prefix: "/api/admin/roles/"}, + // 给账号分配角色 —— 这是最直接的提权入口 + {method: "GET", prefix: "/api/admin/users/", suffix: "/roles"}, + {method: "PUT", prefix: "/api/admin/users/", suffix: "/roles"}, +} + +// isSuperAdminOnly 判断路由是否仅限超级管理员。 +// 任一规则命中即为真,不需要优先级判定。 +func isSuperAdminOnly(method, path string) bool { + for _, rule := range superAdminOnlyRules { + if rule.method != method { + continue + } + if rule.path != "" && path == rule.path { + return true + } + if rule.prefix != "" && strings.HasPrefix(path, rule.prefix) { + if rule.suffix == "" || suffixMatches(path, rule.suffix) { + return true + } + } + } + return false +} + type permissionRule struct { method string // HTTP method path string // exact path match (mutually exclusive with prefix) @@ -367,6 +407,12 @@ func AdminPermissionGuard(r *ghttp.Request) { return } + // 角色管理是权限体系的元操作,硬性限定超管,不参与权限点授权 + if isSuperAdminOnly(r.Method, r.URL.Path) { + response.ErrorMsg(r, consts.CodeForbidden, "角色权限管理仅超级管理员可用") + return + } + perm := matchPermission(r.Method, r.URL.Path) if perm == "" { // 默认拒绝:未配置权限规则的接口不放行(super_admin 已在上方短路) diff --git a/internal/middleware/rbac_coverage_test.go b/internal/middleware/rbac_coverage_test.go index 407c09c..096e8eb 100644 --- a/internal/middleware/rbac_coverage_test.go +++ b/internal/middleware/rbac_coverage_test.go @@ -219,3 +219,51 @@ func TestMatchedPermissionsAreGrantable(t *testing.T) { } } } + +// TestRoleManagementIsSuperAdminOnly 保证角色管理的每个端点都被限定为超级管理员专属。 +// +// 角色管理是权限体系的元操作:谁能改角色,谁就能决定所有人的权限。若某个角色端点漏了这条 +// 限制,一个被下放了 user:edit 的账号就能给自己挂上更高权限的角色,整套角色体系当场失效。 +// 新增角色相关路由时,本测试会强制你同步登记 superAdminOnlyRules。 +func TestRoleManagementIsSuperAdminOnly(t *testing.T) { + var leaked []string + + for _, r := range collectAdminRoutes(t) { + path := concreteFromPattern(r.Path) + isRoleRoute := strings.HasPrefix(r.Path, "/api/admin/roles") || + strings.HasSuffix(r.Path, "/roles") + if !isRoleRoute { + continue + } + if !isSuperAdminOnly(r.Method, path) { + leaked = append(leaked, r.Method+" "+r.Path+" ("+r.Source+")") + } + } + + if len(leaked) > 0 { + t.Fatalf("以下角色管理接口未限定为超级管理员专属(请补入 superAdminOnlyRules):\n %s", + strings.Join(leaked, "\n ")) + } +} + +// TestSuperAdminOnlyRulesDoNotOverreach 反向确认:超管专属清单不应误伤普通账号管理。 +// +// 账号管理(创建/禁用/改密/删除)按 user:* 授权、可以正常下放,只有「分配角色」才是 +// 权限体系的元操作。两者路径相邻(都在 /api/admin/users/ 下),容易被前缀规则误伤。 +func TestSuperAdminOnlyRulesDoNotOverreach(t *testing.T) { + shouldStayDelegatable := []struct{ method, path string }{ + {"GET", "/api/admin/users"}, + {"POST", "/api/admin/users"}, + {"PUT", "/api/admin/users/1"}, + {"PUT", "/api/admin/users/1/status"}, + {"PUT", "/api/admin/users/1/reset-password"}, + {"PUT", "/api/admin/users/1/unlock"}, + {"DELETE", "/api/admin/users/1"}, + {"GET", "/api/admin/users/1/permissions"}, + } + for _, c := range shouldStayDelegatable { + if isSuperAdminOnly(c.method, c.path) { + t.Errorf("%s %s 属于普通账号管理,不应被限定为超管专属", c.method, c.path) + } + } +} diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts index 1f5e1b0..f2555ca 100644 --- a/web/admin/src/router/admin.ts +++ b/web/admin/src/router/admin.ts @@ -125,7 +125,7 @@ const adminRoutes: RouteRecordRaw[] = [ path: 'permissions', name: 'AdminPermissions', component: () => import('@/views/security/PermissionsPage.vue'), - meta: { title: '角色权限', perm: 'user:view' }, + meta: { title: '角色权限', superOnly: true }, }, { path: 'login-history', diff --git a/web/admin/src/router/index.ts b/web/admin/src/router/index.ts index 588dd67..ebf1c0e 100644 --- a/web/admin/src/router/index.ts +++ b/web/admin/src/router/index.ts @@ -2,7 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import adminRoutes from './admin' import { useAuthStore } from '@/stores/auth' -import { hasPermission } from '@/utils/permission' +import { hasPermission, isSuperAdmin } from '@/utils/permission' import request, { shouldRefresh, getRefreshToken } from '@/utils/request' import { useSiteName } from '@/composables/useSiteName' import { usePublicSettings } from '@/composables/usePublicSettings' @@ -103,6 +103,9 @@ router.beforeEach(async (to) => { // 页面级权限校验。菜单已按权限过滤,这里拦的是直接输 URL 的情况。 // 接口层本就会 403,但没有这道守卫,用户会先看到一个空白页再看到一片报错。 + if (to.meta.superOnly && !isSuperAdmin()) { + return { name: 'AdminForbidden' } + } const requiredPerm = to.meta.perm as string | undefined if (requiredPerm && !hasPermission(requiredPerm)) { return { name: 'AdminForbidden' } diff --git a/web/admin/src/utils/permission.ts b/web/admin/src/utils/permission.ts index dbf1c70..dfa7049 100644 --- a/web/admin/src/utils/permission.ts +++ b/web/admin/src/utils/permission.ts @@ -28,6 +28,18 @@ export function hasPermission(permission: string): boolean { return permissions.includes(permission) } +/** + * 是否超级管理员。 + * + * 用于「不参与权限点授权、硬性限定超管」的功能(如角色权限管理): + * 这类功能是权限体系的元操作,不能通过授予权限点下放,否则被下放者 + * 能给自己挂上更高权限的角色。后端有对应的 superAdminOnlyRules 拦截, + * 前端这份判断只用于隐藏入口,不构成安全边界。 + */ +export function isSuperAdmin(): boolean { + return hasRole(ADMIN_ROLES.SUPER_ADMIN) +} + export function hasRole(role: string): boolean { const currentRole = getStoredString(ADMIN_ROLE_KEY) return currentRole === role diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 5eba314..74c7a31 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 } from '@/utils/permission' +import { hasPermission, isSuperAdmin } from '@/utils/permission' const router = useRouter() const route = useRoute() @@ -356,7 +356,6 @@ const ALL_MENU_GROUPS = [ { name: 'AdminLoginHistory', label: '登录历史', icon: IconClockCircle, perm: 'audit:view' }, { name: 'AdminTenantLoginHistory', label: '租户登录历史', icon: IconClockCircle, perm: 'audit:view' }, { name: 'AdminSessions', label: '会话管理', icon: IconClockCircle }, - { name: 'AdminPermissions', label: '角色权限', icon: IconSafe, perm: 'user:view' }, { name: 'AdminAudit', label: '操作日志', icon: IconFile, perm: 'audit:view' }, ], }, @@ -398,6 +397,7 @@ const ALL_MENU_GROUPS = [ icon: IconSettings, items: [ { name: 'AdminUsers', label: '用户管理', icon: IconUserGroup, perm: 'user:view' }, + { name: 'AdminPermissions', label: '角色权限', icon: IconSafe, superOnly: true }, { name: 'AdminPlugins', label: '插件管理', icon: IconCodeBlock, perm: 'system:plugin' }, { name: 'AdminSettings', label: '系统设置', icon: IconSettings, perm: 'system:view' }, { name: 'AdminFiles', label: '文件管理', icon: IconStorage, perm: 'file:view' }, @@ -411,7 +411,12 @@ const menuGroups = computed(() => ALL_MENU_GROUPS .map(group => ({ ...group, - items: group.items.filter(item => !item.perm || hasPermission(item.perm)), + items: group.items.filter(item => { + // superOnly 的条目不参与权限点判定:它们是权限体系本身的元操作, + // 无法通过授予权限点下放(后端 superAdminOnlyRules 同样硬性拦截) + if ((item as any).superOnly) return isSuperAdmin() + return !item.perm || hasPermission(item.perm) + }), })) .filter(group => group.items.length > 0) ) diff --git a/web/admin/src/views/security/PermissionsPage.vue b/web/admin/src/views/security/PermissionsPage.vue index ddffeaf..d61901f 100644 --- a/web/admin/src/views/security/PermissionsPage.vue +++ b/web/admin/src/views/security/PermissionsPage.vue @@ -1,9 +1,12 @@ diff --git a/web/admin/src/views/system/UsersPage.vue b/web/admin/src/views/system/UsersPage.vue index 71cd8db..a52497a 100644 --- a/web/admin/src/views/system/UsersPage.vue +++ b/web/admin/src/views/system/UsersPage.vue @@ -1,7 +1,7 @@