Go 后台接口别只测 200:这次用 TaoToken 让 Codex 走通 JWT/RBAC/tenant_id 验证
2026/9/19 0:40:16 网站建设 项目流程

Cursor 把用户管理的 CRUD 生成出来之后,最容易过的一道检查是go build通过、手动点一下列表页返回 200。这两件事加在一起,仍然不能说明 JWT、RBAC 和 tenant_id 三条线是通的。我这次没再一条条手敲 curl,而是先把 Codex 的 Base URL 指向 TaoToken(官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 创建 Key),让 Codex 读 Go 代码、产出权限用例、逐条执行,最后回到控制台确认调用确实打出去了。跑完一轮的结论很直接:接口 200 只是三条线里的一条,剩下两条得单独制造请求才能验出来。

1. Cursor 生成 CRUD 后只测 200,JWT/RBAC/tenant_id 三条线都还是空的

先说清楚三条线各自证什么。JWT 只回答"你是谁",它验的是签名、过期时间、issuer,过了不代表有权限;RBAC 回答"你能不能做这个动作",验的是角色绑定的权限码;tenant_id 回答"这条数据是不是属于你",验的是数据范围,跟权限码无关。三条线里任意一条漏掉,表现都不是列表页白屏,而是:

  • 一个没有user:delete的账号,拿着合法 token 直接打删除接口,返回 200;
  • A 租户管理员请求 B 租户的记录 id,接口把 B 的记录原样吐出来;
  • 列表接口带了 tenant_id,导出接口重新拼 SQL 时忘了带,导出任务把全库数据丢进一个 Excel。

这些问题的共同点是:正常路径永远测不出来。所以第一步不是补按钮,而是把接口和权限码列成矩阵。以用户模块为例,我让 Codex 从路由注册文件里反推的矩阵长这样:

接口权限码额外校验
GET /admin/user/listuser:list查询条件强制带 tenant_id
GET /admin/user/detail/:iduser:detail记录级 tenant_id 比对
POST /admin/user/updateuser:update禁止通过 body 改 tenant_id
POST /admin/user/deleteuser:delete批量 id 逐条查租户
GET /admin/user/exportuser:export导出 SQL 与队列消息都带 tenant_id

矩阵的价值在于,它把"生成 CRUD"从一句模糊需求变成了一份可打勾的清单。Codex 拿到这份清单,才知道要生成哪些用例,而不是只生成一个 200 的冒烟测试。

2. 把 Codex 的 Base URL 指向 TaoToken:config.toml 先跑通一次调用

在让 Codex 读代码之前,先确认它自己能发出请求。打开官网创建 Key,然后在 Codex 的配置文件里改两处:provider 的base_urlhttps://taotoken.net/api,注意不带/v1,也不要带任何 UTM 参数,末尾不加斜杠;Key 走环境变量,不写进配置文件。

# ~/.codex/config.toml model = "MODEL_ID" model_provider = "taotoken" [model_providers.taotoken] name = "taotoken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "responses"

MODEL_ID不要照抄,去官网模型广场看当前可用的编码模型 ID,填那里列出的值。Codex 吃的是 OpenAI 风格的配置,别把ANTHROPIC_*那套变量塞进这个文件,两套协议的字段不通用。

导出环境变量后做一次最小验证:

export TAOTOKEN_API_KEY=YOUR_API_KEY codex exec "只回复一行文字:permission-check-ready"

能拿到那行回复,说明 Codex 到模型的通道是通的,后面所有 curl 用例都由它来生成和执行。这一步别省,通道没通就去调业务接口,报错会混在一起,分不清是配置问题还是权限问题。

3. 让 Codex 读 Go 代码,先产出接口矩阵而不是直接改业务

接入完成之后,第一件事不是让 Codex 改代码,而是让它读代码并只输出结果。这里的边界要写死:只生成脚本,不改业务文件。

codex exec --sandbox read-only "阅读 ./internal/router 和 ./internal/middleware 下的 Go 文件,\ 反推 user 模块的接口、权限码中间件和 tenant_id 校验位置,\ 输出一份 markdown 表格,列出每个接口需要的权限码以及 tenant_id 应该从哪里取。\ 不要修改任何文件。"

读完之后,重点看两处。第一处是权限中间件有没有真的挂在路由上,而不是只写在注释里。第二处是租户条件从哪里取——如果是从 query 或 header 取,那基本就是漏洞,正确做法是从 JWT claims 解出来的上下文里取。我在项目里用的是把租户上下文和查询入口一起收起来的方式:

type TenantCtx struct { TenantID string UserID int64 } func TenantFrom(ctx context.Context) (TenantCtx, error) { t, ok := ctx.Value(tenantCtxKey{}).(TenantCtx) if !ok || t.TenantID == "" { return TenantCtx{}, errors.New("missing tenant in context") } return t, nil } func Scoped(ctx context.Context, q Query) (Query, error) { t, err := TenantFrom(ctx) if err != nil { return q, err } return q.Where("tenant_id = ?", t.TenantID), nil }

这样做的好处是可检索:review 的时候只要搜Scoped(,就知道这个接口有没有进入租户边界。比每个 handler 里手写一遍Where("tenant_id", ...)可靠得多,因为漏写不会有编译错误。

同样的检查也要往前推到 SQL 层。多租户字段往往是后补的,唯一索引经常还压在username单列上,导致两个租户不能重名:

-- 历史数据里有没有空租户 SELECT id, username, tenant_id FROM admin_user WHERE tenant_id IS NULL OR tenant_id = '' LIMIT 20; -- 唯一索引是不是还只按 username SHOW INDEX FROM admin_user WHERE Column_name IN ('username', 'tenant_id');

如果确认要租户内唯一,就改成组合唯一键:ALTER TABLE admin_user DROP INDEX uk_username, ADD UNIQUE KEY uk_tenant_username (tenant_id, username);。PostgreSQL 下语义一致,写成CREATE UNIQUE INDEX uk_tenant_username ON admin_user (tenant_id, username);即可。这些 SQL 让 Codex 生成、你自己在本地库执行,不要让它连生产库。

4. 无 token 401、缺权限 403、正常 200:让 Codex 逐条执行并对照期望

矩阵和上下文都确认之后,再让 Codex 生成可执行的验收脚本。核心不是脚本本身,而是每个用例都带上"期望值",然后断言,而不是靠人看响应正文。

codex exec --sandbox workspace-write "生成 scripts/perm_check.sh,覆盖 5 个用例:\ 无 token 期望 401;有 token 但角色缺 user:list 期望 403;\ 有 token 且有权限期望 200;A 租户 token 请求 B 租户详情期望 403 或 404;\ 导出接口用 A 租户 token 调用后期望响应中不含 B 租户数据。\ 用 http_code 做断言,通过打印 PASS,不通过打印 FAIL,不要自动执行。"

生成的脚本骨架大致是这样:

#!/usr/bin/env bash set -u HOST="http://127.0.0.1:8080" T_NO_PERM="${T_NO_PERM:?need token without user:list}" T_OK="${T_OK:?need token with user:list}" T_A="${T_A:?need tenant A admin token}" B_ID="${B_ID:?need tenant B record id}" code() { curl -s -o /dev/null -w '%{http_code}' "$@"; } check() { if [ "$2" = "$3" ]; then echo "PASS $1 want=$2 got=$3" else echo "FAIL $1 want=$2 got=$3"; fi } check "no-token" 401 "$(code "$HOST/admin/user/list")" check "no-perm" 403 "$(code -H "Authorization: Bearer $T_NO_PERM" "$HOST/admin/user/list")" check "normal" 200 "$(code -H "Authorization: Bearer $T_OK" "$HOST/admin/user/list")" got=$(code -H "Authorization: Bearer $T_A" "$HOST/admin/user/detail/$B_ID") if [ "$got" = "403" ] || [ "$got" = "404" ]; then echo "PASS cross-tenant got=$got" else echo "FAIL cross-tenant got=$got expect 403/404" fi

脚本里用${T_NO_PERM:?}这种写法是故意的:token 变量为空时直接退出,而不是让所有请求都返回 401、看起来"缺权限用例通过了"。空 token 造成的假通过,是这类验收脚本里最隐蔽的坑。

生成之后先 review,再执行:

bash scripts/perm_check.sh

也可以交给 Codex 跑并把输出贴回来:codex exec --sandbox workspace-write "执行 scripts/perm_check.sh,只贴出原始输出,不要修改脚本"。跑通的标准不是"有输出",而是 5 行里没有 FAIL。跨租户那条要重点看,它往往不是权限码问题,而是数据范围问题——中间件放行了,查询里没带租户条件,于是返回 200。

5. 跨租户 403/404 与导出接口:tenant_id 最容易漏的两处

列表和详情修好之后,导出接口仍然要单独验一遍,因为它是后写的,而且经常被当成"列表的附属功能",重新拼一段 SQL。检查点有两个:导出任务表里有没有记录租户和操作者,导出查询本身有没有带上租户条件。

SELECT id, tenant_id, operator_id, export_type, created_at FROM export_task ORDER BY id DESC LIMIT 10;

查询构造上,租户条件应该是入参的一部分,而不是查询字符串里顺手拼的:

func BuildUserExportSQL(tenantID string, f UserFilter) (string, []any) { sql := `SELECT id, username, mobile, created_at FROM admin_user WHERE tenant_id = ?` args := []any{tenantID} if f.Keyword != "" { sql += ` AND username LIKE ?` args = append(args, "%"+f.Keyword+"%") } return sql, args }

如果导出走异步队列,消息体里也要带 tenant_id,不能只带task_id然后到执行阶段再去数据库里"猜"属于哪个租户。这条用 Codex 验的方式是:让它分别用 A、B 两个租户的 token 触发导出,然后比对两次导出结果的行数或 id 集合是否有交集。有交集就是漏了租户条件。

另外两个容易忽略的地方:缓存 key 要包含 tenant_id,否则 A 租户的列表会被 B 租户命中;访问日志至少要打出user_idtenant_idpermissionstatus这几个字段,只记 success 的话,线上出现 403 时你无法判断是谁缺了哪个权限。

6. 调用成功与否怎么看:用量记录与 Codex 侧报错排查

Codex 把脚本跑完、5 行全 PASS 之后,回到控制台确认这一轮调用确实发出去了。看两个地方:Key 的调用记录是否有你这轮的请求,以及请求量是否与你实际执行的次数大致对得上。如果脚本显示全 PASS,但用量记录里一条都没有,那说明 Codex 执行的是本地缓存的旧版本,或者 sandbox 权限把它挡在了执行之外,这种情况下"通过"没有意义。

接入侧最常见的几个错误,和业务接口的 401/403 不是一回事,别混着排查:

  • base_url写成了https://taotoken.net/api/v1。Codex 拼路径时会再带一层/v1,最终路径不对,表现为 404 而不是 401。正确写法就是https://taotoken.net/api,不带/v1
  • base_url后面粘了 UTM 查询串。URL 里多了 query 参数,路由匹配异常,同样是 404 类的报错。
  • env_key里写的变量名和实际 export 的不一致,报的是通道层 401,不是业务接口的 401。前者修环境变量,后者修 JWT 中间件,别搞反。
  • config.toml里混写ANTHROPIC_API_KEY之类的字段。Codex 走的是 OpenAI 风格配置,这些字段不生效,等于没配 Key。
  • MODEL_ID填了一个模型广场里不存在的值。报错通常比较含糊,先去模型广场核对 ID 再填。

排查顺序建议是:先确认 Codex 的单行回复能出来,再确认单条 curl 返回 401,最后才跑整份脚本。顺序反了,一次失败会同时包含通道问题和权限问题两堆信息。

7. 上线前权限验收对照表

把这一轮跑下来的检查项固化成表,每次改动权限中间件或租户取值逻辑后重跑一遍:

检查项期望结果怎么验证
无 token 请求401脚本中不带 Authorization 的用例
缺权限角色请求403用没有 user:list 的 token
正常角色请求200用带 user:list 的 token
跨租户详情403 或 404A 租户 token 请求 B 租户记录 id
导出结果只含当前租户数据比对两次导出 id 集合
缓存 key包含 tenant_id搜索 user:list 相关缓存前缀
日志字段有 user_id、tenant_id、permission查访问日志
通道调用控制台有对应调用记录对照脚本执行次数

Cursor 生成 CRUD 只解决了重复代码的产出速度,权限边界仍然要靠人把期望值写清楚,再让 Codex 逐条执行去对齐。三条线都跑出预期结果,配合控制台上可见的调用记录,这一轮验收才算真的开始有底。

如果你现在的情况是 Codex 能启动、但不确定请求有没有落到模型上,先去控制台看 Key 和调用记录:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=console-api-keys&utm_campaign=rewrite 。只是想把一条请求先试通,从模型对话进:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 。要把这套接口矩阵和验收脚本变成每次提交都跑的长期任务,用 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询