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:
@@ -0,0 +1,93 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// corsEngine 起一个只挂 cors() 的引擎(env 由各用例 t.Setenv 预置)。
|
||||
func corsEngine() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(cors())
|
||||
r.GET("/ping", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
||||
return r
|
||||
}
|
||||
|
||||
func doCORS(t *testing.T, r *gin.Engine, method, origin string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, "/ping", nil)
|
||||
if origin != "" {
|
||||
req.Header.Set("Origin", origin)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestCORS_MultiOrigin(t *testing.T) {
|
||||
t.Setenv("CORS_ALLOW_ORIGIN", "http://a.example,http://b.example")
|
||||
t.Setenv("APP_ENV", "")
|
||||
t.Setenv("GIN_MODE", "")
|
||||
r := corsEngine()
|
||||
|
||||
// 命中白名单 → 回显请求方 + Vary: Origin(多 origin 时 ACAO 只能发单值)。
|
||||
w := doCORS(t, r, http.MethodGet, "http://b.example")
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://b.example" {
|
||||
t.Errorf("命中白名单应回显请求 Origin,得到 %q", got)
|
||||
}
|
||||
if w.Header().Get("Vary") != "Origin" {
|
||||
t.Errorf("多 origin 回显必须带 Vary: Origin,否则缓存串源")
|
||||
}
|
||||
|
||||
// 未命中 → 不发 ACAO(浏览器按同源策略拦截)。
|
||||
w = doCORS(t, r, http.MethodGet, "http://evil.example")
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("白名单外的 Origin 不应发 ACAO,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_SingleOriginLegacy(t *testing.T) {
|
||||
t.Setenv("CORS_ALLOW_ORIGIN", "http://only.example")
|
||||
t.Setenv("APP_ENV", "")
|
||||
t.Setenv("GIN_MODE", "")
|
||||
r := corsEngine()
|
||||
|
||||
// 单值配置保持旧行为:不带 Origin 的请求(探活/curl)也能看到头。
|
||||
w := doCORS(t, r, http.MethodGet, "")
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://only.example" {
|
||||
t.Errorf("单值配置应无条件直写,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_DevWildcardAndPreflight(t *testing.T) {
|
||||
t.Setenv("CORS_ALLOW_ORIGIN", "")
|
||||
t.Setenv("APP_ENV", "")
|
||||
t.Setenv("GIN_MODE", "")
|
||||
r := corsEngine()
|
||||
|
||||
w := doCORS(t, r, http.MethodGet, "http://whatever.example")
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||
t.Errorf("开发缺省应放行 *,得到 %q", got)
|
||||
}
|
||||
// 预检直接 204 短路。
|
||||
w = doCORS(t, r, http.MethodOptions, "http://whatever.example")
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("OPTIONS 预检应 204,得到 %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_ProdUnsetDeniesAll(t *testing.T) {
|
||||
t.Setenv("CORS_ALLOW_ORIGIN", "")
|
||||
t.Setenv("APP_ENV", "prod")
|
||||
t.Setenv("GIN_MODE", "")
|
||||
r := corsEngine()
|
||||
|
||||
w := doCORS(t, r, http.MethodGet, "http://a.example")
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("生产未配置不应发 ACAO,得到 %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user