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))}>