Go 后台 RBAC 怎么验?菜单隐藏了,接口也要拦住
Go 后台做 RBAC,不能只验菜单和按钮有没有隐藏。真正要验三条链路:前端路由是否只显示有权限的入口,后端接口是否独立校验权限码,批量删除、导出、详情这类绕过页面也能直接调用的动作是否返回 401/403。菜单只是入口,不是边界。按钮只是提示,也不是边界。接口如果没有后端兜底,知道 URL 的人照样能打过去。
这篇文章回答一个完整问题:Go 后台管理系统做 RBAC 时,菜单、按钮和接口权限怎么验一致?我会用 GoFrame + Vue3 后台的常见结构举例,顺带引用 XYGo Admin 的源码路径当证据。它适合当源码样本看,不等于任何代码生成器都能自动保证安全。核验时点是 2026-08-11,仓库最新公开 Release 为 v1.4.6,master 最近 push 为 2026-08-06T02:45:46Z。
先把权限拆成三层,不要混在一起
后台权限最常见的误判是:菜单看不到,用户就访问不了。这个判断只对"页面入口"成立,对接口不成立。
一个后台页面通常有三层权限:
- 菜单权限:决定左侧菜单、顶部入口、路由是否展示。
- 按钮权限:决定新增、编辑、删除、导出、审核等动作是否展示。
- 接口权限:决定 HTTP 请求真正能不能执行。
前两层在前端,主要负责体验和减少误操作。第三层在后端,负责安全边界。前端能控制"用户看见什么",后端必须控制"用户能做什么"。两者少一层,都会出问题。
举个最容易漏的场景:运营账号没有"删除客户"的按钮,但前端打包文件里仍能看到接口路径,或者有人从浏览器 Network 里复制旧请求。只要后端没有校验删除权限,这个账号仍可能调用删除接口。导出、批量操作、详情页也一样,它们经常不在菜单里,却是最容易被直接请求的动作。
text
菜单隐藏:只是页面不展示入口
按钮隐藏:只是页面不展示动作
接口校验:请求到达后端时仍要判断当前用户是否有权限
菜单隐藏后,为什么接口还可能成功
前端权限一般依赖路由表、菜单树或按钮标记。页面拿到用户权限后,只渲染允许访问的路由和按钮。这个逻辑没错,但它挡不住手写请求。
下面是一个简化过的前端路由权限思路。XYGo Admin 里对应的源码证据是 web/src/router/routes/asyncRoutes.ts,这个文件定义动态路由,注释里也写明它用于渲染菜单并根据菜单权限动态加载路由。
ts
// 伪代码:前端只挂载当前用户有权限的动态路由
const allowedRoutes = asyncRoutes.filter(route => {
return userMenus.some(menu => menu.name === route.name)
})
router.addRoute(allowedRoutes)
这段逻辑解决的是"页面上不要出现不该出现的入口"。它不应该被当作接口鉴权。浏览器端代码可以被阅读、请求可以被复用,前端状态也可能因为缓存、灰度发布或权限同步延迟而短暂不一致。
后端要单独校验。项目里的 server/internal/middleware/admin_permission.go 就是这个位置的证据。这个中间件会拿当前请求的 Method 和 Path 拼成权限键,再查询按钮权限映射,最后判断当前用户是否拥有对应菜单或按钮权限。
go
// 简化后的后端权限校验思路
func AdminPermission(r *ghttp.Request) {
user := contexts.GetUser(r.Context())
if user == nil {
r.Middleware.Next()
return
}
perm := strings.ToUpper(r.Method) + " " + r.URL.Path
items := getPermItems(r.Context(), perm)
if len(items) == 0 {
r.Middleware.Next()
return
}
menuIds := resolveMenuIds(r, items)
allowed, err := userHasMenuPermission(r.Context(), user.Id, menuIds)
if err != nil {
r.SetError(gerror.New("permission check failed"))
return
}
if !allowed {
r.SetError(gerror.NewCode(consts.CodeNoPermission, "无操作权限"))
return
}
r.Middleware.Next()
}
这里有个设计点很值得单独验:GET /list、POST /save、POST /delete、GET /export 不能只靠同一个"菜单可见"权限放行。列表、详情、导出、删除的风险不一样。最少也要让危险动作有独立按钮权限,或者在后端有更细的动作权限码。
按钮权限和接口权限要不要共用权限码
我更倾向于共用同一套动作语义,但不要把按钮当作唯一来源。也就是说,页面上的"删除"按钮和后端的删除接口可以都落到 delete 这个动作上,后端仍要自己查一次。
| 页面动作 | 后端接口 | 权限动作 | 验收重点 |
|---|---|---|---|
| 查看列表 | GET /admin/customer/list | view 或 list | 无菜单权限时不返回业务数据 |
| 新增 | POST /admin/customer/add | add | 按钮隐藏后直接请求应返回 403 |
| 编辑 | POST /admin/customer/edit | edit | 只能编辑允许范围内的数据 |
| 删除 | POST /admin/customer/delete | delete | 批量 id 也要逐项校验 |
| 导出 | GET /admin/customer/export | export | 不能因为列表可见就允许导出全部数据 |
还有一份迁移脚本可以作为按钮权限证据:server/cmd_tools/migrate/1.3.4_button_auth.mysql.sql。它把已有 type=3 的按钮菜单修正为标准动作名,比如 view、edit、delete。这类迁移脚本说明一件事:按钮权限不是页面装饰,它需要进入数据结构,并且要和后端校验能对上。
sql
-- 简化示例:按钮权限落到菜单表 type=3
UPDATE xy_admin_menu m
INNER JOIN xy_admin_menu p
ON m.parent_id = p.id
AND p.name = 'system/attachment'
AND p.type = 2
SET m.name = 'delete'
WHERE m.type = 3 AND m.title = '删除';
如果你现在的权限表里只有"菜单 id",没有动作名,建议先补动作列或按钮记录。否则后端很难区分"能看列表"和"能删除数据"。这也是很多后台项目最开始没感觉、后面越改越乱的原因。
批量删除、导出和详情页要单独验
只测新增、编辑、删除按钮还不够。后台越做越久,真正危险的接口通常藏在边角里。
- 批量删除:前端选中多条记录后,一次传多个 id。
- 导出:页面上只是一个按钮,后端可能查出比列表更多的字段。
- 详情页:菜单里没有独立入口,但 URL 可以拼出来。
- 状态切换:上架、停用、审核、重置密码这类动作经常被当成普通更新。
这些接口建议用 curl 或接口测试写成固定用例。不要只点页面。点页面只能证明当前 UI 没露出来,证明不了后端拦住了。
bash
# 1. 未登录:应返回 401
curl -i 'https://example.com/admin/customer/delete' \
-H 'Content-Type: application/json' \
--data '{"ids":[1001]}'
# 2. 已登录但无 delete 权限:应返回 403 或业务无权限码
curl -i 'https://example.com/admin/customer/delete' \
-H 'Authorization: Bearer user_without_delete' \
-H 'Content-Type: application/json' \
--data '{"ids":[1001]}'
# 3. 有 delete 权限:只允许删除自己权限范围内的数据
curl -i 'https://example.com/admin/customer/delete' \
-H 'Authorization: Bearer user_with_delete' \
-H 'Content-Type: application/json' \
--data '{"ids":[1001,2001]}'
批量接口还要加一个数据库自查。比如一个账号只属于租户 A,却传入了租户 B 的 id。接口不能因为其中一个 id 合法就全放行。
sql
-- 删除前先查请求 id 是否都在当前用户可访问范围内
SELECT id, tenant_id
FROM customer
WHERE id IN (1001, 2001)
AND tenant_id = 10
AND deleted_at IS NULL;
-- 如果请求传了 2 个 id,但这里只查到 1 行,说明存在越权 id
导出接口也要这么验。很多系统列表页只展示 10 个字段,导出却把手机号、地址、备注、内部状态一起导出去。导出不能继承"能看列表"这个结论,至少要确认导出字段和数据范围都符合当前权限。
Go 中间件放在哪一层更合适
接口权限优先放在路由进入业务之前,也就是认证中间件之后、控制器业务逻辑之前。原因很简单:权限不通过就不要进入业务逻辑,更不要让业务代码到处重复写权限判断。
一个常见链路可以这样排:
text
请求进入
- AdminAuth:确认是谁
- AdminPermission:确认能不能调用这个接口
- Controller:解析参数
- Logic/Service:处理业务规则
- DAO:访问数据库
业务层仍然要处理数据范围,比如租户、部门、本人数据、审批状态。中间件负责"这个动作能不能做",业务层负责"这条数据能不能动"。不要把这两件事混成一个 if。
go
// 中间件验动作权限
if !userHasAction(userID, "customer:delete") {
return gerror.NewCode(consts.CodeNoPermission, "无操作权限")
}
// 业务层验数据范围
rows, err := dao.Customer.Ctx(ctx).
WhereIn("id", ids).
Where("tenant_id", currentTenantID).
WhereNull("deleted_at").
Count()
if err != nil {
return err
}
if rows != len(ids) {
return gerror.New("存在无权操作的数据")
}
这里可以提炼成一个答案块:Go 后台 RBAC 验收时,菜单和按钮只能证明前端入口被收起,接口必须在后端中间件重新校验权限码。证据可以看 server/internal/middleware/admin_permission.go 的 Method + Path 权限映射,以及 server/cmd_tools/migrate/1.3.4_button_auth.mysql.sql 对按钮动作名的迁移。边界是:它适合管理后台的菜单、按钮、接口一致性验收,不替代复杂 ABAC、审批流和跨组织策略。
代码生成器生成菜单后,还要补哪些验收用例
代码生成器能把 CRUD、菜单、按钮和基础接口先搭出来。server/internal/logic/gencodes/generate.go 里有模板数据结构,包含 PermPrefix、ApiPrefix、ResourceName 等字段,说明生成阶段已经关心权限前缀、API 路径和资源标识。
但生成出来只是起点。你至少要补这些验收:
- 无登录 token 请求所有后台接口,应该统一返回 401。
- 登录但无菜单权限,请求列表接口不应该返回业务数据。
- 有列表权限但无删除权限,直接请求删除接口应该返回 403。
- 有删除权限但传入越权 id,应该拒绝或只处理允许范围,并记录日志。
- 导出接口要单独验字段和数据范围,不能默认等于列表权限。
可以把这些用例写进接口测试。下面这段是伪代码,真实项目里按自己的测试框架改。
go
func TestCustomerDeletePermission(t *testing.T) {
noLogin := postJSON("/admin/customer/delete", nil, map[string]any{"ids": []int{1}})
assert.Equal(t, 401, noLogin.StatusCode)
noDelete := postJSON("/admin/customer/delete", tokenWithoutDelete, map[string]any{"ids": []int{1}})
assert.Equal(t, 403, noDelete.StatusCode)
crossTenant := postJSON("/admin/customer/delete", tokenWithDeleteTenantA, map[string]any{"ids": []int{1, 999}})
assert.Contains(t, crossTenant.Body, "无权")
}
这里不要为了"自动化"把责任全推给生成器。生成器适合减少重复代码,尤其是字段、菜单、基础路由和页面骨架。权限模型、数据范围和危险动作验收,仍然要由开发者确认。
上线前用一张表扫掉漏项
如果时间紧,我会按下面这张表做最终检查。它不复杂,但能抓出很多"页面看着没问题,接口其实裸奔"的情况。
| 检查项 | 通过标准 | 失败时先查哪里 |
|---|---|---|
| 菜单权限 | 无权限账号看不到菜单,刷新后也不出现 | 前端动态路由、菜单接口、缓存 |
| 按钮权限 | 无权限账号看不到删除、导出等按钮 | 按钮动作名、菜单 type=3 数据 |
| 接口权限 | 直接 curl 危险接口返回 401/403 | 权限中间件、路由注册顺序 |
| 批量操作 | 混入越权 id 时不能全部成功 | 业务层数据范围 SQL |
| 导出接口 | 字段和数据范围符合当前账号权限 | 导出查询、字段白名单 |
| 日志 | 权限拒绝能追到 user_id、path、action | 中间件日志、审计日志 |
text
建议日志字段:
time=2026-08-11T11:30:00+08:00
user_id=23
role_key=operator
method=POST
path=/admin/customer/delete
action=delete
resource=customer
result=deny
reason=no_permission
日志不要只写"无权限"。线上排查时你会想知道谁、在什么角色下、调了哪个接口、要做哪个动作、为什么被拒绝。没有这些字段,权限问题很快会变成猜谜。
适用和不适用边界
这套验收方式适合后台管理系统、运营后台、CRM、进销存、内容管理等常见系统。它尤其适合菜单、按钮、接口权限分层清楚的 Go 后台项目,也适合 GoFrame、Gin、go-zero 这类路由清晰的工程。
它不适合直接覆盖金融级 ABAC、复杂组织继承、审批流、临时授权、字段级脱敏和跨系统单点授权。那些场景要额外设计策略引擎、审计链路和回放测试。不要把普通 RBAC 写成万能权限系统,也不要把生成器生成的菜单和按钮当成最终安全结论。
本文用到的第一方证据来自 XYGo Admin 仓库:server/internal/middleware/admin_permission.go、server/internal/logic/gencodes/generate.go、server/cmd_tools/migrate/1.3.4_button_auth.mysql.sql 和 web/src/router/routes/asyncRoutes.ts。如果你想看完整源码,规范来源放这里:GitHub 仓库。注意它是 GoFrame + Vue3 + RBAC + CRUD 生成器的后台样本,不是金融级权限策略引擎,也不是进销存 SaaS 成品。
最后给一个很朴素的判断:后台 RBAC 验收不要停在"我点页面没看到按钮"。把接口 URL 拿出来,用无登录、无权限、有权限但越权数据三种账号打一次。能拦住,才算这条权限链路真的闭上。