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:
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/sundynix-gateway/internal/auth"
|
||||
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||
)
|
||||
|
||||
// CtxUserID 是鉴权后写入 gin.Context 的已验证用户 ID 键。
|
||||
@@ -75,6 +77,35 @@ func RequireAdmin() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// MemberRoleResolver 解析用户在某租户的角色(*store.Postgres 天然满足)。抽成接口便于单测门控逻辑。
|
||||
type MemberRoleResolver interface {
|
||||
MemberRole(ctx context.Context, tenantID, userID string) string
|
||||
}
|
||||
|
||||
// RequireTenantRole 要求当前用户在活跃租户中的角色不低于 minRole(按 store.RoleRank 阶梯)。
|
||||
// 须挂在 RequireAuth + TenantContext 之后(依赖已注入的 uid 与 tenant_id)。
|
||||
// 无租户上下文(正常不会:TenantContext 兜底补建默认租户)或非成员 → rank 0 → 拦下。
|
||||
// 复用中间件:当前仅接 SubmitTask(≥member,挡只读 viewer 烧租户积分);将来成员自管/共享
|
||||
// 工作区上线,直接给对应写路由挂上更高 minRole 即生效,无需改 handler。
|
||||
func RequireTenantRole(db MemberRoleResolver, minRole string) gin.HandlerFunc {
|
||||
min := store.RoleRank(minRole)
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(CtxUserID)
|
||||
id, _ := uid.(string)
|
||||
if id == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
|
||||
return
|
||||
}
|
||||
tid, _ := c.Get(CtxTenantID)
|
||||
tenant, _ := tid.(string)
|
||||
if store.RoleRank(db.MemberRole(c.Request.Context(), tenant, id)) < min {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "当前角色为只读,无权执行该操作"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func splitEnv(key string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(os.Getenv(key), ",") {
|
||||
|
||||
@@ -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, 期望 %d(body=%s)", w.Code, tc.wantCode, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user