Skip to content

fix(platform): 修复 GenericRequest payload 吞掉 project-id 归一化,并补齐 oauth JSON body 的签名剥离#135

Merged
Episkey-G merged 2 commits into
masterfrom
fix/platform-generic-projectid-and-json-oauth
Jul 15, 2026
Merged

fix(platform): 修复 GenericRequest payload 吞掉 project-id 归一化,并补齐 oauth JSON body 的签名剥离#135
Episkey-G merged 2 commits into
masterfrom
fix/platform-generic-projectid-and-json-oauth

Conversation

@Episkey-G

Copy link
Copy Markdown
Collaborator

两个平台侧改动,都落在 cmd/internal/platform/client.go 的请求链上,各自独立成 commit。第一个是真实 bug 修复(master 上三个产品今天就在坑里),第二个是加固(不修当前故障)。


1. project-id 归一化被 GenericRequest 的 payload 吃掉(真修复)

平台补全给出的 project 候选是 org-xxx/ProjectNamecmd/project.gogetProjectList),平台 handler 负责把它还原成纯 id。但 SDK 的 BaseGenericRequestGetProjectId() override 成 payload 优先、却没有 override SetProjectId

  • SetProjectId 写进 CommonBase
  • GetPayload() 末尾 for k, v := range r.payload 用 payload 覆盖 CommonBase(SDK ucloud/request/generic.go)。

于是平台的归一化被 payload 里的原值吃掉。凡是把 ProjectId 放进 generic payload map 的产品都会中招,master 上已有三处:

产品 位置
umongodb list.go:28create_replset.go:61completion.go:56
utidb api.go:49mergeCommonParams
sqlserver create.go:89create_alwayson.go:70

cloudwatchBindProjectID 绑 CommonBase)、ukafkagenReq.SetProjectId)、mysql(payload 不含 ProjectId)、uddos(不传 project)不受影响 —— 判据不是「用不用 GenericRequest」,而是「有没有把 ProjectId 塞进 payload map」。

实测复现与修复(api.ucloud.cn,2026-07-14)

# 今天的 master
$ ucloud umongodb list --project-id "org-xxxxxx/Default"
Something wrong. RetCode:292. Message:Project [org-xxxxxx/Default] not exists
exit=1

# 本 PR
$ ucloud umongodb list --project-id "org-xxxxxx/Default"
[]
exit=0

# 基线:两个二进制传纯 id 都正常
$ ucloud umongodb list --project-id org-xxxxxx    → exit=0(master 与本 PR 一致)

触发条件:显式传 --project-id 且按 Tab 补全。不传(用 profile 默认的纯 id)或手敲纯 id 都不受影响 —— 这是它一直没被发现的原因,也是同一个产品里两种写法能并存至今的原因(sqlserverdelete.go/list.go 绑 typed request 是好的,只有两个 create 走 payload map)。

改法

归一化后若值确实变了,才把结果同步进 payload:

raw := req.GetProjectId()
normalized := PickResourceID(raw)
if err := req.SetProjectId(normalized); err != nil { return req, err }
if raw == normalized {
    return req, nil   // 未发生归一化 → payload 一字不动,行为与历史逐字节一致
}
// 发生了归一化 → 同步进 GenericRequest 的 payload

绝大多数调用(project-id 本就是纯 id)走的是那个 early return,payload 完全不被触碰


2. oauth 签名参数未从 JSON body 剥离(加固,不是当前故障)

injector 此前只对 form-urlencoded body 剥离 SDK 无条件附加的 Signature/PublicKeyCredential.Apply 即使空密钥也会算出签名),JSON body 走不到剥离分支。

平台不变式(buildCredential 注释)要求「一个请求只携带一种凭据机制」。JSON 编码器此前不可达,故该约束在 JSON 路径上从未被满足;#127 (pgsql) 是第一个走 JSON 编码器的产品(UPgSQL 网关无法把 form 的字符串 "100" unmarshal 进 Go 的 *int,RetCode 214001,故 SetEncoder 换成 NewJSONEncoder)。

需要说清楚:这不修任何当前故障

实测确认(api.ucloud.cn,2026-07-14):oauth + JSON body 携带 Signature 时,网关照常返回 RetCode 0。网关不从 JSON body 读签名参数,看到 Bearer 即走 Bearer 鉴权。用今天 master 的平台代码 + #127 的 pgsql 实跑 pgsql db list / pgsql supabase list,OAuth profile 下均 exit=0 正常返回。

附带更正:#127client.go 注释称「until then OAuth+pgsql remains non-functional」,实测为,该注释建议随 #127 一并删除或改写。

本改动的价值是加固:把不变式在 JSON 路径上补齐,不再依赖网关当前的宽容 —— 那是实现细节而非契约,一旦收紧就会变成一个难查的 171。改动很小,且 form / AK-SK 路径逐字节不变。

改法

与 form 分支同构:按 Content-Type 精确分派,不认识的一律不碰 body。原先「只处理 form」的理由(url.ParseQuery 对 JSON 往往"成功",盲目重编码会毁掉 body)在此升级为精确分派,那条洞察保留在注释里。


测试

新增 11 个测试,全部做过证伪(摘掉对应修复后必红,恢复后转绿):

归一化normalize_projectid_test.go)—— 表驱动,按 master 上每个 generic 产品的实际传法取样,断言 wire payloadrequest.EncodeForm 的编码结果)而非中间态:

  • generic payload map + id/name(umongodb/utidb/sqlserver/pgsql-supabase)→ 归一化 ← 本次修复目标
  • generic payload map + 纯 id(未按 Tab,最常见)→ 不变
  • generic 仅 CommonBase + id/name(cloudwatch/ukafka)→ 保持原有正确行为
  • generic 无 project-id(mysql/uddos)→ wire 上不凭空出现该字段
  • typed request + id/name / + 纯 id(绝大多数产品)→ 历史行为不变
  • payload 里其它字段不受殃及:业务字段中的 / 不被 pick、int 仍是 intbool 仍是 bool(JSON 编码器依赖类型)

injectorclient_test.go):

  • oauth + JSON → 剥离签名、业务字段与 Content-Type 保留、int 仍是 JSON number、Bearer 照常注入
  • CRITICAL 回归:aksk + JSON body 逐字节不变(AK/SK 的签名活在 body 里,碰一下就验签失败)
  • CRITICAL 回归:oauth + form 行为与历史一致
  • 不认识的 Content-Type 不碰 body,但 Bearer 照常注入
  • 端到端:真实 SDK JSONEncoder + 真实 oauth 凭据,先断言「SDK 即使空凭据也会附加 Signature」这个前提成立,再验证 injector 剥掉了它

既有回归全部保留通过:TestOAuthProfileWithRetainedKeysDoesNotSignTestAkskProfileStillSignsTestInjectorAkskAndCloudShellUnchanged、oauth 401 重放矩阵。

go build ./...                          exit=0
go vet ./...                            clean
go test ./pkg/... ./cmd/... ./hack/...  全部 ok

影响面

  • 产品代码零改动PickResourceID 幂等(无 / 时原样返回),已在产品侧自行 pick 的调用点不受影响。
  • 时序上无冲突:SetupRequest(用 cfg 回填 Region/Zone/ProjectId)在 NewXxxRequest() / NewGenericRequest() 构造时就跑完了,远早于 InvokeAction 的 request handler 链;归一化 handler 之后链上只剩 encoder 读一次 GetPayload(),没有任何东西再写 CommonBase。
  • 合入后,Feat/pgsql cli #127 (pgsql) 的 supabase --project-id 问题自动消失,产品侧无需自行 pick。

平台补全给出的 project 候选是 "org-xxx/ProjectName"(cmd/project.go
getProjectList),平台 handler 负责还原成纯 id。但 SDK 的 BaseGenericRequest 把
GetProjectId() override 成 payload 优先、却没有 override SetProjectId:
SetProjectId 写进 CommonBase,而 GetPayload() 末尾用 payload 覆盖 CommonBase
(ucloud/request/generic.go),于是平台的归一化被 payload 里的原值吃掉。

凡是把 ProjectId 放进 generic payload map 的产品都会中招,master 上已有三处:
  - products/umongodb/internal/umongodb/create_replset.go:61, completion.go:56
  - products/utidb/internal/tidb/api.go:49
  - products/sqlserver/internal/sqlserver/create.go:89, create_alwayson.go:70

用户按 Tab 补全 --project-id 后 "org-x/Name" 原样上行,网关报
RetCode 292 "Project [org-x/Name] not exists"(2026-07-14 对 api.ucloud.cn 实测)。
不按 Tab、手敲纯 id 不受影响,故一直未被发现。

修复:归一化后若值确实变了,把结果同步进 GenericRequest 的 payload。未发生
归一化时(已是纯 id 或为空,即绝大多数调用)直接返回,payload 一字不动,
行为与历史逐字节一致。

测试覆盖 master 上全部 generic 产品的传法(payload map / 仅 CommonBase /
无 project-id / typed request),断言 wire 上的最终值而非中间态。
injector 此前只对 form-urlencoded body 剥离 SDK 无条件附加的 Signature/PublicKey
(Credential.Apply 即使空密钥也会算出签名),JSON body 走不到剥离分支。

平台不变式(见 buildCredential 注释)要求「一个请求只携带一种凭据机制」。JSON
编码器此前不可达,故该约束在 JSON 路径上从未被满足;products/pgsql(#127) 是第一个
走 JSON 编码器的产品(UPgSQL 网关无法把 form 的字符串 "100" unmarshal 进 Go 的
*int,RetCode 214001,故 SetEncoder 换成 NewJSONEncoder)。

这不是在修一个当前故障。2026-07-14 对 api.ucloud.cn 实测确认:oauth + JSON body
携带 Signature 时网关照常返回 RetCode 0 —— 网关不从 JSON body 读签名参数,看到
Bearer 即走 Bearer 鉴权,pgsql 在 OAuth 下今天可正常使用。本改动是加固:把不变式
在 JSON 路径上补齐,不再依赖网关当前的宽容(那是实现细节而非契约,一旦收紧就会
变成难查的 171)。

改法与 form 分支同构:按 Content-Type 精确分派,不认识的一律不碰 body
(url.ParseQuery 对 JSON 往往"成功",盲目重编码会毁掉 body —— 这正是原先只处理
form 的理由,现在升级为精确分派)。

回归保护:aksk + JSON body 逐字节不变(AK/SK 的签名活在 body 里)、oauth + form
行为与历史一致、不认识的 Content-Type 不碰 body。
@github-actions

Copy link
Copy Markdown

🔴 平台 PR 默认硬拦,需管理员 Approve 放行(或由管理员提交)。判定:改动触及平台/受保护路径(cmd/internal/platform/client.go, cmd/internal/platform/client_test.go, cmd/internal/platform/normalize_projectid_test.go)

@Episkey-G
Episkey-G merged commit 22d2941 into master Jul 15, 2026
12 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant