diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index d74ef7e..d544164 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -263,7 +263,7 @@ export default function App() { {view === "home" ? ( ) : view === "studio" ? ( - + ) : view === "kb" ? ( ) : view === "report" ? ( diff --git a/sundynix-desktop/frontend/src/studio/StudioView.tsx b/sundynix-desktop/frontend/src/studio/StudioView.tsx index 88316c3..37a3e8a 100644 --- a/sundynix-desktop/frontend/src/studio/StudioView.tsx +++ b/sundynix-desktop/frontend/src/studio/StudioView.tsx @@ -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([]); @@ -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) = {/* 中画布 */} - - {running ? "运行中…" : "运行"} + + {running ? "运行中…" : readOnly ? "只读" : "运行"} setIssues(validate(nodes, edges))}> 校验 diff --git a/sundynix-gateway/internal/middleware/auth.go b/sundynix-gateway/internal/middleware/auth.go index 53630f5..53965ef 100644 --- a/sundynix-gateway/internal/middleware/auth.go +++ b/sundynix-gateway/internal/middleware/auth.go @@ -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), ",") { diff --git a/sundynix-gateway/internal/middleware/tenant_role_test.go b/sundynix-gateway/internal/middleware/tenant_role_test.go new file mode 100644 index 0000000..51eeef3 --- /dev/null +++ b/sundynix-gateway/internal/middleware/tenant_role_test.go @@ -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()) + } + }) + } +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index d8a4ce9..296e227 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -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) // 当前租户上下文 + 角色 + 可花余额(多租户) diff --git a/sundynix-gateway/internal/store/role_test.go b/sundynix-gateway/internal/store/role_test.go new file mode 100644 index 0000000..0f7b30b --- /dev/null +++ b/sundynix-gateway/internal/store/role_test.go @@ -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,一律拦下") + } +} diff --git a/sundynix-gateway/internal/store/tenant.go b/sundynix-gateway/internal/store/tenant.go index 565c66c..749b4c3 100644 --- a/sundynix-gateway/internal/store/tenant.go +++ b/sundynix-gateway/internal/store/tenant.go @@ -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 {