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:
@@ -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))}>
|
||||
校验
|
||||
|
||||
Reference in New Issue
Block a user