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
@@ -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": "不能邀请为 ownerowner 须转让)"})
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,
})
}