Files
sundynix-agentix/sundynix-gateway/internal/handler/tenant_self.go
T
Blizzard 65340616df 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>
2026-07-16 17:33:04 +08:00

124 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
})
}