feat(gateway): 租户自助管理接口 —— 薄 Web 面增量1

SaaS P3 收口第一刀。成员管理此前只有平台超管口径(/admin/tenants/:id/*),
租户 owner 无法自助管自己的组织;建租户同样只有 admin 能做。

- 成员自助四路由(作用于活跃租户,id 取 ctx 不接受路径任意租户):
  GET /tenants/current/members(≥viewer) + POST/PUT/DELETE(≥admin+审计)。
  RequireTenantRole 是 RBAC 极简版预留的架子,这次直接挂上,零改动生效。
- handler 挡两个平台超管可以、租户 admin 不该有的动作:邀请为 owner;
  借 AddMemberByEmail 的 upsert 语义「重新邀请」owner 实现降权——后者
  是真实的越权路径,admin 拿 owner 邮箱重邀成 viewer 就把 owner 拉下马。
- POST /me/tenants 自助建组织:创建者即 owner、建完切入;slug 用雪花 id
  生成不让用户填(全局唯一,自助场景撞名只添乱);owner 没挂上按错误返回
  而不是 warn+200(孤儿租户没人能管)。
- CORS 支持逗号分隔多 origin(desktop 预览/薄 Web 面/admin 不同源):命中
  白名单回显请求 Origin+Vary;单值/通配/生产未配置全拒的旧语义不变。
- 测试:RequireTenantRole 补 ≥admin 门槛用例;cors 四场景单测。
  live 验证 13 项全过(门槛/owner 保护/降权攻击挡下/CORS 回显/审计落痕)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-16 17:33:04 +08:00
parent d14a10479e
commit 65340616df
4 changed files with 272 additions and 9 deletions
@@ -61,3 +61,28 @@ func TestRequireTenantRole_Gate(t *testing.T) {
})
}
}
// 成员自管写路由用 ≥admin 门槛(薄 Web 面):member 也只能看不能动。
func TestRequireTenantRole_AdminGate(t *testing.T) {
cases := []struct {
name string
memberRole string
wantCode int
}{
{"owner 放行", store.RoleOwner, http.StatusOK},
{"admin 放行", store.RoleAdmin, http.StatusOK},
{"member 拦下(成员管理要 admin)", store.RoleMember, http.StatusForbidden},
{"viewer 拦下", store.RoleViewer, http.StatusForbidden},
{"billing_admin 拦下", store.RoleBillingAdmin, http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := roleGateEngine("u1", "t1", tc.memberRole, store.RoleAdmin)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/tasks", nil))
if w.Code != tc.wantCode {
t.Errorf("状态码=%d, 期望 %dbody=%s", w.Code, tc.wantCode, w.Body.String())
}
})
}
}