Cursor 写完 CRUD 后,导出接口为什么最容易漏权限
Cursor 或别的 AI 工具把后台 CRUD 写出来以后,最危险的地方往往不是列表页,也不是新增编辑弹窗,而是导出、批量删除、批量改状态这类“看起来只是按钮”的接口。页面上按钮隐藏了,不代表接口不能被直接调用;菜单没显示,也不代表后端一定拦住了请求。
我更愿意把这类问题当成接口验收,而不是前端权限问题。前端最多减少误点,真正挡住越权的只能是后端权限码、租户条件、数据范围和日志。下面用一个客户列表导出接口举例,代码可以直接改成自己的模块名。
先看一个容易漏的表结构
假设后台里有客户表和管理员权限表,客户表里带租户字段,权限表里保存菜单、按钮或接口权限码:
CREATE TABLE crm_customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL, name VARCHAR(80) NOT NULL, mobile VARCHAR(32) DEFAULT '', level TINYINT NOT NULL DEFAULT 1, deleted_at DATETIME NULL, created_at DATETIME NOT NULL, KEY idx_tenant_deleted (tenant_id, deleted_at), KEY idx_mobile (mobile) ); CREATE TABLE admin_role_permission ( role_id BIGINT NOT NULL, permission_code VARCHAR(120) NOT NULL, PRIMARY KEY (role_id, permission_code) );很多 AI 生成的 CRUD 会把列表、详情、新增、编辑、删除都补齐,但导出接口经常被当成“复用列表查询”。这一步如果少了权限码,就会出现一个很尴尬的结果:页面按钮不可见,复制接口地址却能下载整张表。
导出接口不能只复用列表查询
列表接口通常长这样:
func (s *CustomerService) List(ctx context.Context, in ListReq) (*ListRes, error) { tenantID := TenantIDFromCtx(ctx) q := dao.CrmCustomer.Ctx(ctx). Where("tenant_id", tenantID). WhereNull("deleted_at") if in.Keyword != "" { q = q.WhereLike("name", "%"+in.Keyword+"%") } var rows []CustomerListItem err := q.Page(in.Page, in.PageSize).Scan(&rows) return &ListRes{Rows: rows}, err }导出接口当然可以复用查询条件,但它还要多做三件事:权限码检查、导出字段白名单、导出日志。少一个都容易出事。
func (s *CustomerService) Export(ctx context.Context, in ExportReq) error { if err := RequirePermission(ctx, "crm:customer:export"); err != nil { return err } tenantID := TenantIDFromCtx(ctx) fields := normalizeExportFields(in.Fields) if len(fields) == 0 { fields = []string{"name", "mobile", "level", "created_at"} } q := dao.CrmCustomer.Ctx(ctx). Where("tenant_id", tenantID). WhereNull("deleted_at"). OrderDesc("created_at") if in.Keyword != "" { q = q.WhereLike("name", "%"+in.Keyword+"%") } var rows []CustomerExportRow if err := q.Fields(fields).Limit(5000).Scan(&rows); err != nil { return err } g.Log().Info(ctx, "customer_export", g.Map{ "tenant_id": tenantID, "operator": OperatorIDFromCtx(ctx), "fields": fields, "count": len(rows), }) return writeCustomerExcel(ctx, rows) }这里的重点不是 Go 语法,而是验收顺序:先查权限,再收租户,再收字段,最后写日志。很多“自动生成”的后台只做到了中间两步。
权限中间件要能拦住真实请求
如果项目里已经有统一中间件,导出接口最好走同一条链路,不要在 handler 里临时 if 一下。一个简化版中间件大概是这样:
func AdminPermissionMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { code := r.Context().Value("permission_code").(string) uid := r.Context().Value("admin_id").(int64) ok, err := permissionRepo.HasPermission(r.Context(), uid, code) if err != nil { writeJSON(w, 500, "permission check failed") return } if !ok { writeJSON(w, 403, "permission denied") return } next.ServeHTTP(w, r) }) }实际项目会更复杂:超级管理员、数据范围、路由元信息、按钮权限码都要处理。但验收口径不变,导出接口必须能被同一套权限链拦住。
用 curl 把 401、403、200 都测出来
别只测一个正常账号。至少准备三个 token:未登录、已登录但没有导出权限、有导出权限。
# 1. 未登录,应该是 401 curl -i 'https://api.example.com/admin/crm/customer/export?keyword=a' # 2. 有列表权限但没有导出权限,应该是 403 curl -i -H 'Authorization: Bearer $TOKEN_LIST_ONLY' 'https://api.example.com/admin/crm/customer/export?keyword=a' # 3. 有导出权限,应该是 200,并且返回文件 curl -i -H 'Authorization: Bearer $TOKEN_EXPORT' 'https://api.example.com/admin/crm/customer/export?keyword=a'期望日志也要看一眼:
INFO customer_export tenant_id=1001 operator=9003 fields=name,mobile,level count=128 WARN permission_denied tenant_id=1001 operator=9011 permission=crm:customer:export path=/admin/crm/customer/export如果第二条请求返回 200,说明按钮隐藏只是前端效果,后端权限没闭环。这个问题在普通列表页不容易暴露,因为列表页本来就有权限;导出接口一旦漏掉,泄露的是批量数据。
字段白名单也要验
导出接口还有一个常见坑:前端传什么字段,后端就导什么字段。AI 生成表单时尤其容易这样写,因为它会把字段配置当成可信输入。
var allowedExportFields = map[string]bool{ "name": true, "mobile": true, "level": true, "created_at": true, } func normalizeExportFields(fields []string) []string { out := make([]string, 0, len(fields)) for _, f := range fields { if allowedExportFields[f] { out = append(out, f) } } return out }再补一条夹带字段的请求:
curl -i -H 'Authorization: Bearer $TOKEN_EXPORT' 'https://api.example.com/admin/crm/customer/export?fields=name,mobile,password_hash'预期结果不是报错也不是导出 `password_hash`,而是只保留白名单字段,并在日志里记录这次请求带过非法字段。很多后台事故不是权限模型没设计,而是边角接口没有按同一个模型验。
代码生成器应该生成验收点
如果团队已经在用代码生成器,我建议不要只让它生成 controller、service、vue 页面。更有价值的是顺手生成下面这些验收项:
| 接口 | 权限码 | 必测结果 ||---|---|---|| 列表 | crm:customer:list | 401、403、200 || 新增 | crm:customer:create | 401、403、200 || 编辑 | crm:customer:update | 401、403、200 || 删除 | crm:customer:delete | 401、403、200 || 导出 | crm:customer:export | 401、403、200、字段白名单、日志 |我维护 XYGo Admin 时也会把这个问题放回生成器和权限中间件里看:`server/internal/middleware/admin_permission.go` 负责权限链路,`server/internal/logic/gencodes/generate.go` 负责生成器主流程,Issue #3 里提到的字段标签化也会影响搜索、必填和导出字段的语义。它不是让你照搬项目,而是给一个 GoFrame 后台里“生成器 + RBAC + 字段语义”怎么落到源码的样本:GitHub 仓库。
发布前检查清单
最后给一份我会放进 PR 里的检查清单:
[ ] 导出接口有独立权限码,不复用 list 权限 [ ] 后端中间件能返回 401 / 403 / 200 三种结果 [ ] 查询条件包含 tenant_id 或数据范围 [ ] 导出字段走后端白名单,不信前端传参 [ ] 批量操作、导出、删除都有结构化日志 [ ] 代码生成器输出接口清单和 curl 验收命令Cursor 写完 CRUD 以后,最该补的不是“再美化一下页面”,而是把这些边界测完。页面看起来能用,只说明第一版出来了;导出接口也能被权限、租户、字段白名单和日志一起收住,才算后台能交付。