65340616df
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>
89 lines
2.9 KiB
Go
89 lines
2.9 KiB
Go
package middleware
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"github.com/sundynix/sundynix-gateway/internal/store"
|
||
)
|
||
|
||
// fakeRoleResolver 按 (tenant,user) 返回预置角色,用于隔离测试门控逻辑(不碰 DB)。
|
||
type fakeRoleResolver struct{ role string }
|
||
|
||
func (f fakeRoleResolver) MemberRole(context.Context, string, string) string { return f.role }
|
||
|
||
// 组装一条 /tasks 路由,模拟 RequireAuth+TenantContext 已注入 uid/tenant_id,再挂门控。
|
||
func roleGateEngine(uid, tenant, memberRole, minRole string) *gin.Engine {
|
||
gin.SetMode(gin.TestMode)
|
||
r := gin.New()
|
||
r.Use(func(c *gin.Context) {
|
||
if uid != "" {
|
||
c.Set(CtxUserID, uid)
|
||
}
|
||
if tenant != "" {
|
||
c.Set(CtxTenantID, tenant)
|
||
}
|
||
c.Next()
|
||
})
|
||
r.POST("/tasks", RequireTenantRole(fakeRoleResolver{role: memberRole}, minRole),
|
||
func(c *gin.Context) { c.String(http.StatusOK, "submitted") })
|
||
return r
|
||
}
|
||
|
||
func TestRequireTenantRole_Gate(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
uid string
|
||
tenant string
|
||
memberRole string
|
||
wantCode int
|
||
}{
|
||
{"owner 放行", "u1", "t1", store.RoleOwner, http.StatusOK},
|
||
{"admin 放行", "u1", "t1", store.RoleAdmin, http.StatusOK},
|
||
{"member 放行", "u1", "t1", store.RoleMember, http.StatusOK},
|
||
{"billing_admin 拦下(财务只读不能提交)", "u1", "t1", store.RoleBillingAdmin, http.StatusForbidden},
|
||
{"viewer 拦下(只读不能提交)", "u1", "t1", store.RoleViewer, http.StatusForbidden},
|
||
{"非成员(空角色) 拦下", "u1", "t1", "", http.StatusForbidden},
|
||
{"未登录 401", "", "t1", store.RoleOwner, http.StatusUnauthorized},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
r := roleGateEngine(tc.uid, tc.tenant, tc.memberRole, store.RoleMember)
|
||
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())
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 成员自管写路由用 ≥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())
|
||
}
|
||
})
|
||
}
|
||
}
|