feat: M28 增加工作流汇聚安全校验
This commit is contained in:
@@ -59,6 +59,8 @@ public class WorkflowCheckService {
|
|||||||
private static final String SYSTEM_START_PARAM_NAME = "user_input";
|
private static final String SYSTEM_START_PARAM_NAME = "user_input";
|
||||||
private static final int MIN_LOOP_COUNT = 1;
|
private static final int MIN_LOOP_COUNT = 1;
|
||||||
private static final int MAX_LOOP_COUNT = 300;
|
private static final int MAX_LOOP_COUNT = 300;
|
||||||
|
private static final String JOIN_MODE_ANY = "any";
|
||||||
|
private static final String JOIN_MODE_ALL = "all";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowService workflowService;
|
private WorkflowService workflowService;
|
||||||
@@ -196,6 +198,10 @@ public class WorkflowCheckService {
|
|||||||
edge.id = trimToNull(edgeJson.getString("id"));
|
edge.id = trimToNull(edgeJson.getString("id"));
|
||||||
edge.source = trimToNull(edgeJson.getString("source"));
|
edge.source = trimToNull(edgeJson.getString("source"));
|
||||||
edge.target = trimToNull(edgeJson.getString("target"));
|
edge.target = trimToNull(edgeJson.getString("target"));
|
||||||
|
JSONObject edgeData = edgeJson.getJSONObject("data");
|
||||||
|
edge.condition = edgeData == null
|
||||||
|
? null
|
||||||
|
: trimToNull(edgeData.getString("condition"));
|
||||||
|
|
||||||
if (!StringUtils.hasText(edge.id)) {
|
if (!StringUtils.hasText(edge.id)) {
|
||||||
addIssue(issues, issueKeys, "EDGE_ID_EMPTY", "存在连线缺少 id", null, null, null);
|
addIssue(issues, issueKeys, "EDGE_ID_EMPTY", "存在连线缺少 id", null, null, null);
|
||||||
@@ -228,10 +234,162 @@ public class WorkflowCheckService {
|
|||||||
parsedWorkflow.nodes = nodes;
|
parsedWorkflow.nodes = nodes;
|
||||||
parsedWorkflow.edges = edges;
|
parsedWorkflow.edges = edges;
|
||||||
parsedWorkflow.nodeMap = nodeMap;
|
parsedWorkflow.nodeMap = nodeMap;
|
||||||
|
checkJoinModes(parsedWorkflow, issues, issueKeys);
|
||||||
checkDatacenterNodes(parsedWorkflow, issues, issueKeys);
|
checkDatacenterNodes(parsedWorkflow, issues, issueKeys);
|
||||||
return parsedWorkflow;
|
return parsedWorkflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验节点汇聚模式及其静态可证明的到达安全性。
|
||||||
|
*
|
||||||
|
* @param parsed 工作流视图
|
||||||
|
* @param issues 问题列表
|
||||||
|
* @param issueKeys 问题去重键
|
||||||
|
*/
|
||||||
|
private void checkJoinModes(
|
||||||
|
ParsedWorkflow parsed,
|
||||||
|
List<WorkflowCheckIssue> issues,
|
||||||
|
Set<String> issueKeys) {
|
||||||
|
Map<String, List<EdgeView>> inwardEdges = new LinkedHashMap<>();
|
||||||
|
for (EdgeView edge : parsed.edges) {
|
||||||
|
if (edge == null || !StringUtils.hasText(edge.target)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inwardEdges.computeIfAbsent(
|
||||||
|
edge.target, ignored -> new ArrayList<>()).add(edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (NodeView node : parsed.nodes) {
|
||||||
|
String joinMode = resolveJoinMode(node);
|
||||||
|
if (joinMode == null) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
issueKeys,
|
||||||
|
"JOIN_MODE_INVALID",
|
||||||
|
"执行时机配置无效,joinMode 仅支持 any 或 all",
|
||||||
|
node.id,
|
||||||
|
null,
|
||||||
|
node.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (JOIN_MODE_ALL.equals(joinMode)
|
||||||
|
&& StringUtils.hasText(node.parentId)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
issueKeys,
|
||||||
|
"JOIN_MODE_LOOP_CHILD_UNSUPPORTED",
|
||||||
|
"显式循环子图暂不支持“全部上游完成”,请改为“任一上游完成”",
|
||||||
|
node.id,
|
||||||
|
null,
|
||||||
|
node.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> guaranteedNodes = findGuaranteedNodes(
|
||||||
|
parsed, inwardEdges);
|
||||||
|
for (NodeView node : parsed.nodes) {
|
||||||
|
if (!JOIN_MODE_ALL.equals(resolveJoinMode(node))
|
||||||
|
|| StringUtils.hasText(node.parentId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<EdgeView> directInward = inwardEdges.getOrDefault(
|
||||||
|
node.id, Collections.emptyList());
|
||||||
|
if (directInward.size() <= 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean allGuaranteed = directInward.stream().allMatch(edge ->
|
||||||
|
!edge.hasCondition()
|
||||||
|
&& guaranteedNodes.contains(edge.source));
|
||||||
|
if (!allGuaranteed) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
issueKeys,
|
||||||
|
"JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED",
|
||||||
|
"“全部上游完成”可能永久等待:存在条件、互斥或无法证明必达的上游路径。"
|
||||||
|
+ "请改为“任一上游完成”或调整连线,确保所有直接入边都会到达",
|
||||||
|
node.id,
|
||||||
|
null,
|
||||||
|
node.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用保守固定点传播计算能够保证执行的根级节点。
|
||||||
|
*
|
||||||
|
* @param parsed 工作流视图
|
||||||
|
* @param inwardEdges 直接入边索引
|
||||||
|
* @return 保证执行的节点 ID
|
||||||
|
*/
|
||||||
|
private Set<String> findGuaranteedNodes(
|
||||||
|
ParsedWorkflow parsed,
|
||||||
|
Map<String, List<EdgeView>> inwardEdges) {
|
||||||
|
Set<String> guaranteed = parsed.nodes.stream()
|
||||||
|
.filter(NodeView::isRootLevel)
|
||||||
|
.filter(node -> TYPE_START.equals(node.type))
|
||||||
|
.map(node -> node.id)
|
||||||
|
.filter(StringUtils::hasText)
|
||||||
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
|
|
||||||
|
boolean changed;
|
||||||
|
do {
|
||||||
|
changed = false;
|
||||||
|
for (NodeView node : parsed.nodes) {
|
||||||
|
if (!node.isRootLevel()
|
||||||
|
|| guaranteed.contains(node.id)
|
||||||
|
|| hasAdvancedCondition(node)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String joinMode = resolveJoinMode(node);
|
||||||
|
if (joinMode == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<EdgeView> directInward = inwardEdges.getOrDefault(
|
||||||
|
node.id, Collections.emptyList());
|
||||||
|
boolean isGuaranteed;
|
||||||
|
if (JOIN_MODE_ALL.equals(joinMode)) {
|
||||||
|
isGuaranteed = !directInward.isEmpty()
|
||||||
|
&& directInward.stream().allMatch(edge ->
|
||||||
|
!edge.hasCondition()
|
||||||
|
&& guaranteed.contains(edge.source));
|
||||||
|
} else {
|
||||||
|
isGuaranteed = directInward.stream().anyMatch(edge ->
|
||||||
|
!edge.hasCondition()
|
||||||
|
&& guaranteed.contains(edge.source));
|
||||||
|
}
|
||||||
|
if (isGuaranteed && guaranteed.add(node.id)) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (changed);
|
||||||
|
return guaranteed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取节点汇聚模式。字段缺失时兼容为 any,显式非法值返回 null。
|
||||||
|
*/
|
||||||
|
private String resolveJoinMode(NodeView node) {
|
||||||
|
if (node == null || node.data == null
|
||||||
|
|| !node.data.containsKey("joinMode")) {
|
||||||
|
return JOIN_MODE_ANY;
|
||||||
|
}
|
||||||
|
String value = trimToNull(node.data.getString("joinMode"));
|
||||||
|
if (JOIN_MODE_ANY.equalsIgnoreCase(value)) {
|
||||||
|
return JOIN_MODE_ANY;
|
||||||
|
}
|
||||||
|
if (JOIN_MODE_ALL.equalsIgnoreCase(value)) {
|
||||||
|
return JOIN_MODE_ALL;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasAdvancedCondition(NodeView node) {
|
||||||
|
return node != null
|
||||||
|
&& node.data != null
|
||||||
|
&& StringUtils.hasText(
|
||||||
|
trimToNull(node.data.getString("condition")));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验普通循环、显式循环和循环父子层级。
|
* 校验普通循环、显式循环和循环父子层级。
|
||||||
*
|
*
|
||||||
@@ -1610,5 +1768,10 @@ public class WorkflowCheckService {
|
|||||||
private String id;
|
private String id;
|
||||||
private String source;
|
private String source;
|
||||||
private String target;
|
private String target;
|
||||||
|
private String condition;
|
||||||
|
|
||||||
|
private boolean hasCondition() {
|
||||||
|
return StringUtils.hasText(condition);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,163 @@ import java.util.Map;
|
|||||||
|
|
||||||
public class WorkflowCheckServiceTest {
|
public class WorkflowCheckServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSaveAndPreExecuteShouldPassGuaranteedAllJoin() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
JSONObject joinData = data("汇聚");
|
||||||
|
joinData.put("joinMode", "all");
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("start", "startNode", null, data("开始")),
|
||||||
|
node("a", "codeNode", null, data("分支 A")),
|
||||||
|
node("b", "codeNode", null, data("分支 B")),
|
||||||
|
node("join", "codeNode", null, joinData),
|
||||||
|
node("end", "endNode", null, data("结束"))),
|
||||||
|
array(
|
||||||
|
edge("start-a", "start", "a"),
|
||||||
|
edge("start-b", "start", "b"),
|
||||||
|
edge("a-join", "a", "join"),
|
||||||
|
edge("b-join", "b", "join"),
|
||||||
|
edge("join-end", "join", "end")));
|
||||||
|
|
||||||
|
Assert.assertTrue(service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null).isPassed());
|
||||||
|
Assert.assertTrue(service.checkContent(
|
||||||
|
content, WorkflowCheckStage.PRE_EXECUTE, null).isPassed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSaveAndPreExecuteShouldBlockConditionalAllJoin() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
JSONObject joinData = data("汇聚");
|
||||||
|
joinData.put("joinMode", "all");
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("start", "startNode", null, data("开始")),
|
||||||
|
node("a", "codeNode", null, data("条件来源")),
|
||||||
|
node("b", "codeNode", null, data("普通来源")),
|
||||||
|
node("join", "codeNode", null, joinData),
|
||||||
|
node("end", "endNode", null, data("结束"))),
|
||||||
|
array(
|
||||||
|
conditionalEdge("start-a", "start", "a", "enabled === true"),
|
||||||
|
edge("start-b", "start", "b"),
|
||||||
|
edge("a-join", "a", "join"),
|
||||||
|
edge("b-join", "b", "join"),
|
||||||
|
edge("join-end", "join", "end")));
|
||||||
|
|
||||||
|
WorkflowCheckResult save = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null);
|
||||||
|
WorkflowCheckResult preExecute = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.PRE_EXECUTE, null);
|
||||||
|
|
||||||
|
Assert.assertFalse(save.isPassed());
|
||||||
|
Assert.assertFalse(preExecute.isPassed());
|
||||||
|
assertHasCode(save, "JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED");
|
||||||
|
assertHasCode(preExecute, "JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED");
|
||||||
|
Assert.assertTrue(save.getIssues().stream().anyMatch(issue ->
|
||||||
|
"join".equals(issue.getNodeId())
|
||||||
|
&& issue.getMessage().contains("永久等待")
|
||||||
|
&& issue.getMessage().contains("任一上游完成")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockAllJoinWithDirectConditionalEdge() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
JSONObject joinData = data("汇聚");
|
||||||
|
joinData.put("joinMode", "all");
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("start", "startNode", null, data("开始")),
|
||||||
|
node("a", "codeNode", null, data("分支 A")),
|
||||||
|
node("b", "codeNode", null, data("分支 B")),
|
||||||
|
node("join", "codeNode", null, joinData)),
|
||||||
|
array(
|
||||||
|
edge("start-a", "start", "a"),
|
||||||
|
edge("start-b", "start", "b"),
|
||||||
|
conditionalEdge("a-join", "a", "join", "matched === true"),
|
||||||
|
edge("b-join", "b", "join")));
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null);
|
||||||
|
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockAllJoinFromCustomConditionSource() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
JSONObject conditionalSource = data("高级条件来源");
|
||||||
|
conditionalSource.put("condition", "score > 0");
|
||||||
|
JSONObject joinData = data("汇聚");
|
||||||
|
joinData.put("joinMode", "all");
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("start", "startNode", null, data("开始")),
|
||||||
|
node("a", "codeNode", null, conditionalSource),
|
||||||
|
node("b", "codeNode", null, data("普通来源")),
|
||||||
|
node("join", "codeNode", null, joinData)),
|
||||||
|
array(
|
||||||
|
edge("start-a", "start", "a"),
|
||||||
|
edge("start-b", "start", "b"),
|
||||||
|
edge("a-join", "a", "join"),
|
||||||
|
edge("b-join", "b", "join")));
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null);
|
||||||
|
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockInvalidAndLoopChildJoinModes() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
JSONObject invalidData = data("非法汇聚");
|
||||||
|
invalidData.put("joinMode", "first");
|
||||||
|
JSONObject loopData = loopData(
|
||||||
|
fixedParameter("count", "2", "Number"), null);
|
||||||
|
JSONObject childData = data("循环子节点");
|
||||||
|
childData.put("joinMode", "all");
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("invalid", "codeNode", null, invalidData),
|
||||||
|
node("loop", "loopNode", null, loopData),
|
||||||
|
node("child", "codeNode", "loop", childData)),
|
||||||
|
new JSONArray());
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null);
|
||||||
|
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "JOIN_MODE_INVALID");
|
||||||
|
assertHasCode(result, "JOIN_MODE_LOOP_CHILD_UNSUPPORTED");
|
||||||
|
Assert.assertTrue(result.getIssues().stream().anyMatch(issue ->
|
||||||
|
"invalid".equals(issue.getNodeId())
|
||||||
|
&& "JOIN_MODE_INVALID".equals(issue.getCode())));
|
||||||
|
Assert.assertTrue(result.getIssues().stream().anyMatch(issue ->
|
||||||
|
"child".equals(issue.getNodeId())
|
||||||
|
&& "JOIN_MODE_LOOP_CHILD_UNSUPPORTED".equals(issue.getCode())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldAllowSingleConditionalInboundAllJoin() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
JSONObject joinData = data("单入边汇聚");
|
||||||
|
joinData.put("joinMode", "all");
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("start", "startNode", null, data("开始")),
|
||||||
|
node("join", "codeNode", null, joinData)),
|
||||||
|
array(conditionalEdge(
|
||||||
|
"start-join", "start", "join", "enabled === true")));
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null);
|
||||||
|
|
||||||
|
Assert.assertTrue(result.isPassed());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证保存阶段接受合法的正则条件规则。
|
* 验证保存阶段接受合法的正则条件规则。
|
||||||
*/
|
*/
|
||||||
@@ -992,4 +1149,13 @@ public class WorkflowCheckServiceTest {
|
|||||||
edge.put("target", target);
|
edge.put("target", target);
|
||||||
return edge;
|
return edge;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static JSONObject conditionalEdge(
|
||||||
|
String id, String source, String target, String condition) {
|
||||||
|
JSONObject edge = edge(id, source, target);
|
||||||
|
JSONObject data = new JSONObject();
|
||||||
|
data.put("condition", condition);
|
||||||
|
edge.put("data", data);
|
||||||
|
return edge;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user