feat(rbac): 租户角色门控落地(极简版) —— viewer/billing_admin 只读不可提交任务

角色此前能分配但不生效(viewer 也能烧租户积分)。本次让角色真正挡动作:

- store.RoleRank 角色层级:owner(4)>admin(3)>member(2)>viewer/billing_admin(1),
  未知/非成员=0;billing_admin 定位为「财务只读」不跑任务
- 可复用中间件 RequireTenantRole(db, minRole),抽 MemberRoleResolver 小接口便于单测
- POST /tasks 挂 ≥member 门控:唯一会烧租户积分的入口(KB 入库不计租户),挡住即够
- 桌面端 StudioView 收 readOnly:viewer 时「运行」禁用+只读提示(UX 兜底,真闸在后端)
- 测试:RoleRank 纯逻辑 + 中间件 7 门控 case(owner/admin/member 放行,
  viewer/billing_admin/非成员 403,未登录 401)

实机验证(gateway+docker):viewer 提交 403 → 升 member 202 → 降回 viewer 403 →
owner 自租户 202(solo 用户不受影响),三态可逆、角色驱动、中间件先于 handler。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-13 09:49:24 +08:00
parent 484cc18664
commit bf5be08e96
7 changed files with 146 additions and 6 deletions
@@ -0,0 +1,63 @@
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, 期望 %dbody=%s", w.Code, tc.wantCode, w.Body.String())
}
})
}
}