做后台权限时,很多问题不是出在 RBAC 这个词本身,而是把几种权限混在了一起:菜单能不能看到、按钮能不能点、字段能不能展示、接口能不能访问。它们看起来都叫"权限",落到代码里却不是一层东西。
最容易出错的是前端路由和按钮控制。页面上把"删除"按钮藏掉,用户界面确实干净了,但接口并不会因此安全。只要浏览器里还有 token,或者有人从 Network 面板复制请求,照样可以绕过按钮直接打接口。
这篇只讨论一个具体问题:Go 后台管理系统里,为什么隐藏菜单以后,接口还是可能被调用,以及怎么把接口中间件、按钮权限和字段权限拆开。
先分清四类权限
| 权限类型 | 控制什么 | 常见位置 | 不能替代谁 |
|---|---|---|---|
| 菜单权限 | 用户能不能看到某个页面入口 | 路由、侧边栏、菜单表 | 不能替代接口校验 |
| 按钮权限 | 页面里能不能看到新增、编辑、删除按钮 | 前端指令、路由 meta、按钮子菜单 | 不能替代接口校验 |
| 接口权限 | 某个 HTTP 请求能不能执行 | 后端中间件、路由权限点 | 不能替代字段权限 |
| 字段权限 | 某个字段能不能看、只读或编辑 | 表单、列表、导出、接口响应 | 不能替代菜单权限 |
菜单权限偏"入口",按钮权限偏"操作提示",接口权限才是服务端边界。字段权限更细,它关心的是同一张表里不同角色看到什么字段、能不能改某个字段。
如果只做前端控制,权限会变成"防误点",不是"防越权"。这点在后台系统里很常见,尤其是删除、导出、改状态这类接口。
一个绕过前端按钮的反例
假设页面上没有删除按钮,但接口还是这样暴露:
bash
curl 'https://admin.example.com/admin/user/delete' \
-H 'Authorization: Bearer <access-token>' \
-H 'Content-Type: application/json' \
--data '{"id": 1001}'
前端按钮是否存在,对这条请求没有任何约束。服务端如果只判断 token 有效,不判断这个用户是否有 `DELETE /admin/user/delete` 对应的菜单或按钮权限,请求就可能继续往下走。
所以后端至少要有一层接口权限校验。它不一定要写得很复杂,但必须独立存在。
后端中间件应该查什么
我更倾向于把权限点挂在菜单或按钮配置上,再由中间件把当前请求转成一个稳定的 key,例如:
go
perm := strings.ToUpper(r.Method) + " " + r.URL.Path
// 例子:DELETE /admin/user/delete
然后查这个接口权限点关联了哪些菜单或按钮,再判断当前用户的角色是否拥有这些菜单 ID。伪代码大概是这样:
go
func AdminPermission(r *ghttp.Request) {
user := contexts.GetUser(r.Context())
if user == nil {
r.Middleware.Next()
return
}
if consts.IsSuperRole(user.RoleKey) {
r.Middleware.Next()
return
}
perm := strings.ToUpper(r.Method) + " " + r.URL.Path
menuIds := findMenuIdsByPerm(perm)
if len(menuIds) == 0 {
r.Middleware.Next()
return
}
if !userHasMenuPermission(r.Context(), user.Id, menuIds) {
r.SetError(gerror.NewCode(consts.CodeNoPermission, "无操作权限"))
return
}
r.Middleware.Next()
}
这里有个细节:同一个保存接口可能同时处理新增和编辑。接口路径一样,但请求里的 `id` 为空时是新增,不为空时是编辑。这个时候不要只按路径判断,要结合请求参数把 `add` 和 `edit` 拆开,否则一个"编辑"权限可能误放行"新增"。
XYGo Admin 的 `server/internal/middleware/admin_permission.go` 里就是类似思路:用 `METHOD + path` 做权限 key,再通过 `resolveMenuIds` 根据请求里的 `id` 区分 add/edit。这个实现不神秘,但方向是对的:按钮和接口最终要在服务端重新对齐一遍。
前端指令只负责用户体验
前端仍然要做按钮控制,不然普通用户会看到一堆点了也报错的按钮,体验很差。Vue3 里常见写法是做一个 `v-auth` 指令:
vue
<el-button v-auth="'add'">新增</el-button>
<el-button v-auth="'edit'">编辑</el-button>
<el-button v-auth="'delete'">删除</el-button>
指令内部从当前路由的 `meta.authList` 里取权限标识。没有对应 `authMark` 时,把 DOM 节点移除:
ts
const authList = router.currentRoute.value.meta.authList || []
const hasPermission = authList.some(item => item.authMark === binding.value)
if (!hasPermission) {
el.parentNode?.removeChild(el)
}
这段逻辑的边界要说清楚:它只能减少误操作,不能当安全边界。安全边界在后端中间件。前端指令和后端中间件应该使用同一套权限配置,至少要能从同一个菜单/按钮模型里推出来,不要两边各写一套字符串。
字段权限不要塞进菜单权限里
字段权限经常被低估。比如财务字段、手机号、成本价、内部备注,不一定要按页面隔离。有些角色可以进同一个页面,但只能看部分字段,或者只能编辑一部分字段。
字段权限可以单独建模,至少要包含这些信息:
sql
CREATE TABLE admin_field_perm (
id bigint PRIMARY KEY,
role_id bigint NOT NULL,
module varchar(64) NOT NULL,
resource varchar(128) NOT NULL,
field_name varchar(128) NOT NULL,
field_label varchar(128) NOT NULL,
perm_type tinyint NOT NULL COMMENT '0=不可见,1=只读,2=可编辑',
status tinyint NOT NULL DEFAULT 1
);
这类表不要和菜单表硬绑死。菜单回答"能不能进入用户管理页",字段权限回答"进入以后能不能看手机号、能不能改余额"。两个问题不一样。
XYGo Admin 里有 `server/internal/model/entity/admin_field_perm.go`,字段包括 `role_id`、`module`、`resource`、`field_name`、`perm_type`。如果你在做 GoFrame + Vue3 后台,可以把这种模型当成参考:字段权限独立存,页面和接口按资源再去消费。
排查清单
遇到"菜单隐藏了,接口还能调用"时,我通常按这个顺序查:
- 后端有没有认证中间件,只判断登录还是也判断权限。
- 接口权限点是否保存成稳定格式,例如 `POST /admin/user/save`。
- 菜单、按钮和接口权限点有没有关联,还是只在前端路由里写了按钮列表。
- 新增和编辑是否共用接口,如果共用,是否按 `id` 或业务动作拆权限。
- 字段权限是否单独处理,还是误以为菜单权限能覆盖字段展示和编辑。
验证也很简单。先用低权限账号登录,确认页面上按钮不可见;再复制同一个接口,用 curl 带 token 请求。如果接口返回"无操作权限",说明服务端边界存在。如果接口成功了,前端按钮隐藏得再漂亮也没用。
放到工程里的建议
权限链路可以按这个顺序落地:
text
登录认证中间件
-> 注入当前用户
-> 接口权限中间件
-> 查询接口权限点关联的菜单/按钮
-> 判断角色是否拥有这些菜单/按钮
-> 进入具体业务逻辑
前端拿到菜单和按钮权限后,只做展示控制。后端每个敏感接口都重新校验。字段权限单独存,读列表、导出、编辑表单时再按资源和字段过滤。
这样写会多一些表和中间件,但后面排查问题会轻很多。看到某个接口被放行,可以直接查接口权限点、角色菜单关系和字段权限表,而不是在前端路由、按钮组件、后端 handler 里来回猜。
如果想看一套 GoFrame + Vue3 项目里怎么把菜单、按钮和字段权限拆开,可以看 XYGo Admin 里的权限中间件、前端 `v-auth` 指令和字段权限模型。重点看拆分方式,不要只看页面长什么样。