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,123 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 租户自助管理(薄 Web 面入口):成员名册/邀请/改角色/移除 + 自助建组织。
|
||||||
|
//
|
||||||
|
// 与 /admin/tenants/:id/* 平台超管口径的关键区别:
|
||||||
|
// - 作用于「当前活跃租户」——租户 id 取请求 ctx(TenantContext 注入),天然只能管自己
|
||||||
|
// 所在的租户,不接受路径里的任意租户 id;
|
||||||
|
// - 角色门槛由路由上的 RequireTenantRole 把守(看 ≥viewer、写 ≥admin),真闸在后端;
|
||||||
|
// - owner 保护(不可改角色/不可移除)在 store 层已实现,这里不重写;
|
||||||
|
// - 自助版额外挡两个平台超管可为所欲为、租户 admin 不该有的动作:邀请为 owner、
|
||||||
|
// 以及借 AddMemberByEmail 的 upsert 语义给既有 owner「重新邀请」实现降权。
|
||||||
|
|
||||||
|
// TenantMembers: GET /api/v1/tenants/current/members —— 当前租户成员名册(任何成员可看)。
|
||||||
|
func (h *Handler) TenantMembers(c *gin.Context) {
|
||||||
|
rows, err := h.db.ListMembers(c.Request.Context(), tenantID(c))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"members": rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantAddMember: POST /api/v1/tenants/current/members {email, role} —— 邀请已注册用户加入。
|
||||||
|
func (h *Handler) TenantAddMember(c *gin.Context) {
|
||||||
|
var b struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Email) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "email 必填"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.Role == "" {
|
||||||
|
b.Role = store.RoleMember
|
||||||
|
}
|
||||||
|
if b.Role == store.RoleOwner {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "不能邀请为 owner(owner 须转让)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
tid := tenantID(c)
|
||||||
|
// AddMemberByEmail 是 upsert:对既有成员会覆盖角色。对 owner 这等于「重新邀请即降权」,
|
||||||
|
// 平台超管口径无所谓,租户自助必须挡——否则 admin 能借邀请把 owner 拉下马。
|
||||||
|
email := strings.ToLower(strings.TrimSpace(b.Email))
|
||||||
|
if members, err := h.db.ListMembers(ctx, tid); err == nil {
|
||||||
|
for _, m := range members {
|
||||||
|
if strings.ToLower(m.Email) == email && m.Role == store.RoleOwner {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "对方是 owner,不能通过邀请改其角色"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m, err := h.db.AddMemberByEmail(ctx, tid, email, b.Role)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"member": m})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantSetMemberRole: PUT /api/v1/tenants/current/members/:uid {role} —— 改成员角色。
|
||||||
|
func (h *Handler) TenantSetMemberRole(c *gin.Context) {
|
||||||
|
var b struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&b); err != nil || b.Role == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "role 必填"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// owner 不可被改/不可被授予,store.SetMemberRole 已拦,这里透传其错误文案即可。
|
||||||
|
if err := h.db.SetMemberRole(c.Request.Context(), tenantID(c), c.Param("uid"), b.Role); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantRemoveMember: DELETE /api/v1/tenants/current/members/:uid —— 软移除成员(owner 拦在 store)。
|
||||||
|
func (h *Handler) TenantRemoveMember(c *gin.Context) {
|
||||||
|
if err := h.db.RemoveMember(c.Request.Context(), tenantID(c), c.Param("uid")); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMyTenant: POST /api/v1/me/tenants {name} —— 自助建组织:创建者即 owner,并切为活跃租户。
|
||||||
|
// slug 用雪花 id 生成(org-<id>),不让用户自定义——slug 全局唯一,自助场景撞名只会添乱。
|
||||||
|
func (h *Handler) CreateMyTenant(c *gin.Context) {
|
||||||
|
var b struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Name) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name 必填"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
uid := userID(c)
|
||||||
|
t, err := h.db.CreateTenant(ctx, strings.TrimSpace(b.Name), "org-"+store.NewID(), "free")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.AddMember(ctx, t.ID, uid, store.RoleOwner); err != nil {
|
||||||
|
// 组织已建但 owner 没挂上 = 一个没人能管的孤儿租户,必须当错误暴露而不是带 warn 返回 200。
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "组织已创建但设置 owner 失败:" + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 建完即切入(和桌面端建空间的体验一致);切换失败不致命,前端下次切换即可。
|
||||||
|
_ = h.db.SetActiveTenant(ctx, uid, t.ID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"tenant": gin.H{"id": t.ID, "name": t.Name, "slug": t.Slug, "plan": t.Plan, "status": t.Status},
|
||||||
|
"active": t.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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, 期望 %d(body=%s)", w.Code, tc.wantCode, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,14 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
p.POST("/tasks/:id/approve", middleware.Audit(db), h.ApproveTask) // HITL 人工审批决定(批准/拒绝,审计)
|
p.POST("/tasks/:id/approve", middleware.Audit(db), h.ApproveTask) // HITL 人工审批决定(批准/拒绝,审计)
|
||||||
p.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色 + 可花余额(多租户)
|
p.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色 + 可花余额(多租户)
|
||||||
p.GET("/me/tenants", h.MyTenantsList) // 我所属租户(供切换)
|
p.GET("/me/tenants", h.MyTenantsList) // 我所属租户(供切换)
|
||||||
|
p.POST("/me/tenants", h.CreateMyTenant) // 自助建组织(创建者即 owner,建完切入)
|
||||||
p.POST("/me/tenant", h.SwitchTenant) // 切换当前活跃租户
|
p.POST("/me/tenant", h.SwitchTenant) // 切换当前活跃租户
|
||||||
|
|
||||||
|
// 租户成员自助管理(薄 Web 面):作用于活跃租户,看 ≥viewer、写 ≥admin + 审计。
|
||||||
|
p.GET("/tenants/current/members", middleware.RequireTenantRole(db, store.RoleViewer), h.TenantMembers)
|
||||||
|
p.POST("/tenants/current/members", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantAddMember)
|
||||||
|
p.PUT("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantSetMemberRole)
|
||||||
|
p.DELETE("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantRemoveMember)
|
||||||
p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
|
p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
|
||||||
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
|
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
|
||||||
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
||||||
@@ -135,24 +142,39 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// cors 控制跨源访问。允许来源经 CORS_ALLOW_ORIGIN 配置。
|
// cors 控制跨源访问。允许来源经 CORS_ALLOW_ORIGIN 配置,支持逗号分隔多值
|
||||||
|
// (桌面端 Web 预览 + 薄 Web 面 + admin 可能是不同 origin):命中则回显请求 Origin。
|
||||||
// 安全默认:开发期缺省放行 "*"(便利);生产期(APP_ENV=prod / GIN_MODE=release)未显式配置则
|
// 安全默认:开发期缺省放行 "*"(便利);生产期(APP_ENV=prod / GIN_MODE=release)未显式配置则
|
||||||
// **不放行任意源**(不发 ACAO 头 → 浏览器按同源策略拦截),逼运维显式配置允许的源。
|
// **不放行任意源**(不发 ACAO 头 → 浏览器按同源策略拦截),逼运维显式配置允许的源。
|
||||||
func cors() gin.HandlerFunc {
|
func cors() gin.HandlerFunc {
|
||||||
origin := os.Getenv("CORS_ALLOW_ORIGIN")
|
raw := os.Getenv("CORS_ALLOW_ORIGIN")
|
||||||
if origin == "" {
|
if raw == "" {
|
||||||
if isProd() {
|
if isProd() {
|
||||||
log.Printf("[cors] 生产环境未设 CORS_ALLOW_ORIGIN:默认不放行跨源,请显式配置允许的源(如 https://app.example.com)")
|
log.Printf("[cors] 生产环境未设 CORS_ALLOW_ORIGIN:默认不放行跨源,请显式配置允许的源(逗号分隔多个,如 https://app.example.com,https://console.example.com)")
|
||||||
} else {
|
} else {
|
||||||
origin = "*" // 开发便利
|
raw = "*" // 开发便利
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
for _, o := range strings.Split(raw, ",") {
|
||||||
|
if o = strings.TrimSpace(o); o != "" {
|
||||||
|
allowed[o] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if origin != "" {
|
switch {
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
case allowed["*"]:
|
||||||
if origin != "*" {
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
c.Header("Vary", "Origin")
|
case allowed[c.GetHeader("Origin")]:
|
||||||
|
// 多 origin 场景 ACAO 只能发单值:命中白名单则回显请求方,并以 Vary 防缓存串源。
|
||||||
|
c.Header("Access-Control-Allow-Origin", c.GetHeader("Origin"))
|
||||||
|
c.Header("Vary", "Origin")
|
||||||
|
case len(allowed) == 1:
|
||||||
|
// 单值配置保持旧行为:无条件直写(兼容不带 Origin 头的探活/工具请求也能看到头)。
|
||||||
|
for o := range allowed {
|
||||||
|
c.Header("Access-Control-Allow-Origin", o)
|
||||||
}
|
}
|
||||||
|
c.Header("Vary", "Origin")
|
||||||
}
|
}
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-ID, X-User-ID")
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-ID, X-User-ID")
|
||||||
|
|||||||
Reference in New Issue
Block a user