Cursor 写完 CRUD 后,导出接口为什么最容易漏权限

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?keyworda # 2. 有列表权限但没有导出权限应该是 403 curl -i -H Authorization: Bearer $TOKEN_LIST_ONLY https://api.example.com/admin/crm/customer/export?keyworda # 3. 有导出权限应该是 200并且返回文件 curl -i -H Authorization: Bearer $TOKEN_EXPORT https://api.example.com/admin/crm/customer/export?keyworda期望日志也要看一眼INFO customer_export tenant_id1001 operator9003 fieldsname,mobile,level count128 WARN permission_denied tenant_id1001 operator9011 permissioncrm: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?fieldsname,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 以后最该补的不是“再美化一下页面”而是把这些边界测完。页面看起来能用只说明第一版出来了导出接口也能被权限、租户、字段白名单和日志一起收住才算后台能交付。