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 以后,最该补的不是“再美化一下页面”,而是把这些边界测完。页面看起来能用,只说明第一版出来了;导出接口也能被权限、租户、字段白名单和日志一起收住,才算后台能交付。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值