From bf5be08e96b7d75168e3e11b741425b8e4c528c9 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 13 Jul 2026 09:49:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(rbac):=20=E7=A7=9F=E6=88=B7=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E9=97=A8=E6=8E=A7=E8=90=BD=E5=9C=B0(=E6=9E=81?= =?UTF-8?q?=E7=AE=80=E7=89=88)=20=E2=80=94=E2=80=94=20viewer/billing=5Fadm?= =?UTF-8?q?in=20=E5=8F=AA=E8=AF=BB=E4=B8=8D=E5=8F=AF=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 角色此前能分配但不生效(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 --- sundynix-desktop/frontend/src/App.tsx | 2 +- .../frontend/src/studio/StudioView.tsx | 12 ++-- sundynix-gateway/internal/middleware/auth.go | 31 +++++++++ .../internal/middleware/tenant_role_test.go | 63 +++++++++++++++++++ sundynix-gateway/internal/router/router.go | 2 +- sundynix-gateway/internal/store/role_test.go | 24 +++++++ sundynix-gateway/internal/store/tenant.go | 18 ++++++ 7 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 sundynix-gateway/internal/middleware/tenant_role_test.go create mode 100644 sundynix-gateway/internal/store/role_test.go 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) = {/* 中画布 */}
-