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
+1 -1
View File
@@ -263,7 +263,7 @@ export default function App() {
{view === "home" ? (
<Home onSelect={setView} />
) : view === "studio" ? (
<StudioView onRun={onRun} phase={run.phase} identity={identity} />
<StudioView onRun={onRun} phase={run.phase} identity={identity} readOnly={tenant?.role === "viewer"} />
) : view === "kb" ? (
<KbView identity={identity} />
) : view === "report" ? (
@@ -49,7 +49,7 @@ function buildExample(): { nodes: Node[]; edges: Edge[] } {
}
// 编排 Studio:左(节点面板 + 我的编排) · 中画布 · 右检查器 · 顶工具栏。
export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) => void; phase: RunPhase; identity: Identity }) {
export function StudioView({ onRun, phase, identity, readOnly = false }: { onRun: (dsl: TaskDsl) => void; phase: RunPhase; identity: Identity; readOnly?: boolean }) {
const toast = useToast();
const { theme } = useTheme();
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
@@ -185,10 +185,14 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) =
}, [phase, setNodes]);
const run = useCallback(() => {
if (readOnly) {
toast.push("error", "当前身份为只读成员(viewer),无权在此租户运行编排");
return;
}
const found = validate(nodes, edges);
setIssues(found);
if (!found.some((i) => i.level === "error")) onRun(exportDsl(nodes, edges));
}, [nodes, edges, onRun]);
}, [nodes, edges, onRun, readOnly, toast]);
const selected = useMemo(() => nodes.find((n) => n.id === selId) ?? null, [nodes, selId]);
const running = phase === "submitting" || phase === "streaming";
@@ -236,8 +240,8 @@ export function StudioView({ onRun, phase, identity }: { onRun: (dsl: TaskDsl) =
{/* 中画布 */}
<div className="relative flex-1 bg-ink-950">
<div className="absolute left-0 right-0 top-0 z-10 flex flex-wrap items-center gap-1.5 border-b border-line bg-ink-900/90 px-2 py-1.5 backdrop-blur">
<Button variant="primary" size="sm" icon={Play} onClick={run} disabled={running || nodes.length === 0}>
{running ? "运行中…" : "运行"}
<Button variant="primary" size="sm" icon={Play} onClick={run} disabled={running || nodes.length === 0 || readOnly} title={readOnly ? "只读成员(viewer)无权在此租户运行编排" : undefined}>
{running ? "运行中…" : readOnly ? "只读" : "运行"}
</Button>
<Button size="sm" icon={ShieldCheck} onClick={() => setIssues(validate(nodes, edges))}>
@@ -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, 期望 %dbody=%s", w.Code, tc.wantCode, w.Body.String())
}
})
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
// —— 受保护:owner 作用域业务,必须携带有效 JWT ——
p := api.Group("", middleware.RequireAuth())
{
p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid
p.POST("/tasks", middleware.RequireTenantRole(db, store.RoleMember), h.SubmitTask) // 提交任务(烧租户积分):viewer 只读拦下
p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected
p.POST("/tasks/:id/approve", middleware.Audit(db), h.ApproveTask) // HITL 人工审批决定(批准/拒绝,审计)
p.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色 + 可花余额(多租户)
@@ -0,0 +1,24 @@
package store
import "testing"
// RoleRank 是 RequireTenantRole 门控的判定核心:阶梯顺序 + billing_admin 与 viewer 同为只读 + 未知记 0。
func TestRoleRank(t *testing.T) {
if !(RoleRank(RoleOwner) > RoleRank(RoleAdmin) &&
RoleRank(RoleAdmin) > RoleRank(RoleMember) &&
RoleRank(RoleMember) > RoleRank(RoleViewer)) {
t.Fatal("阶梯应为 owner > admin > member > viewer")
}
if RoleRank(RoleBillingAdmin) != RoleRank(RoleViewer) {
t.Errorf("billing_admin 应与 viewer 同为只读级:%d vs %d", RoleRank(RoleBillingAdmin), RoleRank(RoleViewer))
}
if RoleRank(RoleBillingAdmin) >= RoleRank(RoleMember) {
t.Error("billing_admin(财务只读) 不应达到 member 操作级,否则能提交烧积分")
}
if RoleRank(RoleViewer) < RoleRank(RoleMember) == false {
t.Error("viewer 应低于 member(提交任务门控靠此挡下)")
}
if RoleRank("") != 0 || RoleRank("nonsense") != 0 {
t.Error("未知/非成员角色应记 0,一律拦下")
}
}
+18
View File
@@ -28,6 +28,24 @@ const (
RoleBillingAdmin = "billing_admin"
)
// RoleRank 返回角色在「操作/管理」阶梯上的等级(越大权越高),供 RequireTenantRole 门控。
// 语义:viewer 与 billing_admin 均为只读(1)——billing_admin 是「财务只读」角色,管账不跑任务,
// 故不能提交(烧租户积分)member 可操作(2)admin(3)/owner(4) 递增管理权。
// 未知角色 / 非成员(空串)记 0,一律拦下。当前唯一接入点:SubmitTask 要求 ≥ member(挡 viewer/billing_admin 烧租户积分)。
func RoleRank(role string) int {
switch role {
case RoleOwner:
return 4
case RoleAdmin:
return 3
case RoleMember:
return 2
case RoleViewer, RoleBillingAdmin:
return 1
}
return 0
}
// CreateTenant 建一个租户。
func (p *Postgres) CreateTenant(ctx context.Context, name, slug, plan string) (*Tenant, error) {
if p.db == nil {