未验证 提交 41b60754 编写于 作者: B Bassam Al-Sarori 提交者: GitHub

Restrict starting processes to candidate starters (#4181)

* #4180 Restrict starting processes to candidate starters

* fix codacy warnings

* fix failing tests

* fix license header

* java doc typo fix

* add static property EVERYONE_GROUP for * group

* rename candidateStarter from exists to defined
上级 0c6311a8
......@@ -15,6 +15,7 @@
*/
package org.activiti.runtime.api.impl;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
......@@ -67,7 +68,6 @@ import org.activiti.runtime.api.model.impl.APIProcessDefinitionConverter;
import org.activiti.runtime.api.model.impl.APIProcessInstanceConverter;
import org.activiti.runtime.api.model.impl.APIVariableInstanceConverter;
import org.activiti.runtime.api.query.impl.PageImpl;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
......@@ -75,6 +75,8 @@ import org.springframework.transaction.annotation.Transactional;
@PreAuthorize("hasRole('ACTIVITI_USER')")
public class ProcessRuntimeImpl implements ProcessRuntime {
private static final String EVERYONE_GROUP = "*";
private final RepositoryService repositoryService;
private final APIProcessDefinitionConverter processDefinitionConverter;
......@@ -99,9 +101,6 @@ public class ProcessRuntimeImpl implements ProcessRuntime {
private final SecurityManager securityManager;
@Value("${activiti.candidateStarters.enabled:false}")
private boolean candidateStartersEnabled;
public ProcessRuntimeImpl(RepositoryService repositoryService,
APIProcessDefinitionConverter processDefinitionConverter,
RuntimeService runtimeService,
......@@ -147,11 +146,9 @@ public class ProcessRuntimeImpl implements ProcessRuntime {
}
private ProcessDefinitionQuery createProcessDefinitionQueryWithAccessCheck() {
ProcessDefinitionQuery processDefinitionQuery = repositoryService.createProcessDefinitionQuery();
if (candidateStartersEnabled) {
processDefinitionQuery.startableByUser(securityManager.getAuthenticatedUserId());
}
return processDefinitionQuery;
return repositoryService.createProcessDefinitionQuery()
.startableByUser(securityManager.getAuthenticatedUserId())
.startableByGroups(getCurrentUserGroupsIncludingEveryOneGroup());
}
private Optional<org.activiti.engine.repository.ProcessDefinition> findLatestProcessDefinition(ProcessDefinitionQuery processDefinitionQuery) {
......@@ -555,4 +552,9 @@ public class ProcessRuntimeImpl implements ProcessRuntime {
return taskQuery.count() > 0;
}
private List<String> getCurrentUserGroupsIncludingEveryOneGroup() {
List<String> groups = new ArrayList<>(securityManager.getAuthenticatedUserGroups());
groups.add(EVERYONE_GROUP);
return groups;
}
}
......@@ -155,6 +155,8 @@ public class ProcessRuntimeImplTest {
@Test
public void should_getProcessDefinitionById_when_appVersionIsNull() {
doReturn("user").when(securityManager).getAuthenticatedUserId();
String processDefinitionId = "processDefinitionId";
String processDefinitionKey = "processDefinitionKey";
......@@ -179,6 +181,8 @@ public class ProcessRuntimeImplTest {
@Test
public void should_throwActivitiUnprocessableEntryException_when_processDefinitionAppVersionDiffersFromCurrentDeploymentVersion() {
doReturn("user").when(securityManager).getAuthenticatedUserId();
String processDefinitionId = "processDefinitionId";
ProcessDefinitionEntityImpl processDefinition = new ProcessDefinitionEntityImpl();
processDefinition.setId(processDefinitionId);
......@@ -207,6 +211,8 @@ public class ProcessRuntimeImplTest {
@Test
public void should_throwActivitiObjectNotFoundException_when_canReadFalse() {
doReturn("user").when(securityManager).getAuthenticatedUserId();
String processDefinitionId = "processDefinitionId";
String processDefinitionKey = "processDefinitionKey";
ProcessDefinitionEntityImpl processDefinition = new ProcessDefinitionEntityImpl();
......
......@@ -42,12 +42,20 @@ public class ProcessParser implements BpmnXMLConstants {
if (StringUtils.isNotEmpty(xtr.getAttributeValue(null, ATTRIBUTE_PROCESS_EXECUTABLE))) {
process.setExecutable(Boolean.parseBoolean(xtr.getAttributeValue(null, ATTRIBUTE_PROCESS_EXECUTABLE)));
}
String candidateUsersString = xtr.getAttributeValue(ACTIVITI_EXTENSIONS_NAMESPACE, ATTRIBUTE_PROCESS_CANDIDATE_USERS);
if (candidateUsersString != null) {
process.setCandidateStarterUsersDefined(true);
}
if (StringUtils.isNotEmpty(candidateUsersString)) {
List<String> candidateUsers = BpmnXMLUtil.parseDelimitedList(candidateUsersString);
process.setCandidateStarterUsers(candidateUsers);
}
String candidateGroupsString = xtr.getAttributeValue(ACTIVITI_EXTENSIONS_NAMESPACE, ATTRIBUTE_PROCESS_CANDIDATE_GROUPS);
if (candidateGroupsString != null) {
process.setCandidateStarterGroupsDefined(true);
}
if (StringUtils.isNotEmpty(candidateGroupsString)) {
List<String> candidateGroups = BpmnXMLUtil.parseDelimitedList(candidateGroupsString);
process.setCandidateStarterGroups(candidateGroups);
......
......@@ -38,6 +38,8 @@ public class Process extends BaseElement implements FlowElementsContainer, HasEx
protected List<String> candidateStarterGroups = new ArrayList<String>();
protected List<EventListener> eventListeners = new ArrayList<EventListener>();
protected Map<String, FlowElement> flowElementMap = new LinkedHashMap<String, FlowElement>();
protected boolean candidateStarterUsersDefined;
protected boolean candidateStarterGroupsDefined;
// Added during process definition parsing
protected FlowElement initialFlowElement;
......@@ -274,6 +276,22 @@ public class Process extends BaseElement implements FlowElementsContainer, HasEx
this.candidateStarterGroups = candidateStarterGroups;
}
public boolean isCandidateStarterUsersDefined() {
return candidateStarterUsersDefined;
}
public void setCandidateStarterUsersDefined(boolean candidateStarterUsersDefined) {
this.candidateStarterUsersDefined = candidateStarterUsersDefined;
}
public boolean isCandidateStarterGroupsDefined() {
return candidateStarterGroupsDefined;
}
public void setCandidateStarterGroupsDefined(boolean candidateStarterGroupsDefined) {
this.candidateStarterGroupsDefined = candidateStarterGroupsDefined;
}
public List<EventListener> getEventListeners() {
return eventListeners;
}
......
......@@ -59,6 +59,7 @@ public class ProcessDefinitionQueryImpl extends AbstractQuery<ProcessDefinitionQ
private boolean latest;
private SuspensionState suspensionState;
private String authorizationUserId;
private List<String> authorizationGroups;
private String procDefId;
private String tenantId;
private String tenantIdLike;
......@@ -297,13 +298,17 @@ public class ProcessDefinitionQueryImpl extends AbstractQuery<ProcessDefinitionQ
}
public List<String> getAuthorizationGroups() {
if (authorizationGroups != null) {
return authorizationGroups;
}
// Similar behaviour as the TaskQuery.taskCandidateUser() which
// includes the groups the candidate
// user is part of
if (authorizationUserId != null) {
UserGroupManager userGroupManager = Context.getProcessEngineConfiguration().getUserGroupManager();
if (userGroupManager != null) {
return userGroupManager.getUserGroups(authorizationUserId);
authorizationGroups = userGroupManager.getUserGroups(authorizationUserId);
return authorizationGroups;
} else {
log.warn("No UserGroupManager set on ProcessEngineConfiguration. Tasks queried only where user is directly related, not through groups.");
}
......@@ -491,4 +496,9 @@ public class ProcessDefinitionQueryImpl extends AbstractQuery<ProcessDefinitionQ
this.authorizationUserId = userId;
return this;
}
public ProcessDefinitionQuery startableByGroups(List<String> groupIds) {
authorizationGroups = groupIds;
return this;
}
}
......@@ -165,7 +165,7 @@ public class BpmnDeploymentHelper {
timerManager.scheduleTimers(processDefinition, process);
}
enum ExpressionType {
protected enum ExpressionType {
USER, GROUP
}
......
......@@ -15,6 +15,7 @@
*/
package org.activiti.engine.repository;
import java.util.List;
import java.util.Set;
import org.activiti.engine.ActivitiIllegalArgumentException;
......@@ -140,10 +141,16 @@ public interface ProcessDefinitionQuery extends Query<ProcessDefinitionQuery, Pr
ProcessDefinitionQuery processDefinitionResourceNameLike(String resourceNameLike);
/**
* Only selects process definitions which given userId is authoriezed to start
* Only selects process definitions which given userId is authorized to start
*/
ProcessDefinitionQuery startableByUser(String userId);
/**
* Only selects process definitions which given group members are authorized to start
* If not set and startableByUser is set, the groups of that user will be used
*/
ProcessDefinitionQuery startableByGroups(List<String> groupIds);
/**
* Only selects process definitions which are suspended
*/
......
......@@ -252,10 +252,16 @@
<if test="eventSubscriptionType != null">
and (EVT.EVENT_TYPE_ = #{eventSubscriptionType} and EVT.EVENT_NAME_ = #{eventSubscriptionName})
</if>
<if test="authorizationUserId != null">
AND (exists (select ID_ from ${prefix}ACT_RU_IDENTITYLINK IDN where IDN.PROC_DEF_ID_ = RES.ID_ and IDN.USER_ID_ = #{authorizationUserId})
<if test="authorizationUserId != null || (authorizationGroups != null &amp;&amp; authorizationGroups.size() &gt; 0)">
AND (
<if test="authorizationUserId != null" >
exists (select ID_ from ${prefix}ACT_RU_IDENTITYLINK IDN where IDN.PROC_DEF_ID_ = RES.ID_ and IDN.USER_ID_ = #{authorizationUserId})
<if test="authorizationGroups != null &amp;&amp; authorizationGroups.size() &gt; 0">
OR
</if>
</if>
<if test="authorizationGroups != null &amp;&amp; authorizationGroups.size() &gt; 0">
OR exists (select ID_ from ${prefix}ACT_RU_IDENTITYLINK IDN where IDN.PROC_DEF_ID_ = RES.ID_ and IDN.GROUP_ID_ IN
exists (select ID_ from ${prefix}ACT_RU_IDENTITYLINK IDN where IDN.PROC_DEF_ID_ = RES.ID_ and IDN.GROUP_ID_ IN
<foreach item="group" index="index" collection="authorizationGroups"
open="(" separator="," close=")">
#{group}
......
......@@ -348,8 +348,39 @@ public class ProcessDefinitionQueryTest extends PluggableActivitiTestCase {
}
public void testQueryWithEmptyIdSet() {
List<ProcessDefinition> processDefinitionList = repositoryService.createProcessDefinitionQuery().processDefinitionIds(new HashSet<>(0)).list();
List<ProcessDefinition> processDefinitionList = repositoryService.createProcessDefinitionQuery()
.processDefinitionIds(new HashSet<>(0)).list();
assertThat(processDefinitionList).isNotEmpty();
}
public void testQueryWithNoCandidateStarters() {
List<ProcessDefinition> processDefinitionList = repositoryService.createProcessDefinitionQuery()
.startableByUser("user1").list();
assertThat(processDefinitionList).isEmpty();
}
public void testQueryWithCandidateStarterUser() {
String processDefinitionId = repositoryService.createProcessDefinitionQuery()
.processDefinitionKey("one")
.latestVersion()
.singleResult()
.getId();
repositoryService.addCandidateStarterUser(processDefinitionId,"user1");
List<ProcessDefinition> processDefinitionList = repositoryService.createProcessDefinitionQuery()
.startableByUser("user1").list();
assertThat(processDefinitionList).hasSize(1);
}
public void testQueryWithCandidateStarterGroup() {
String processDefinitionId = repositoryService.createProcessDefinitionQuery().
processDefinitionKey("one")
.latestVersion()
.singleResult()
.getId();
repositoryService.addCandidateStarterGroup(processDefinitionId,"group1");
List<ProcessDefinition> processDefinitionList = repositoryService.createProcessDefinitionQuery()
.startableByGroups(List.of("group1")).list();
assertThat(processDefinitionList).hasSize(1);
}
}
/*
* Copyright 2010-2020 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.activiti.spring.boot;
import org.activiti.bpmn.model.Process;
import org.activiti.engine.impl.bpmn.deployer.BpmnDeploymentHelper;
import org.activiti.engine.impl.context.Context;
import org.activiti.engine.impl.persistence.entity.ProcessDefinitionEntity;
import org.activiti.spring.SpringProcessEngineConfiguration;
import java.util.List;
public class CandidateStartersDeploymentConfigurer implements ProcessEngineConfigurationConfigurer {
private static final String EVERYONE_GROUP = "*";
@Override
public void configure(SpringProcessEngineConfiguration processEngineConfiguration) {
processEngineConfiguration.setBpmnDeploymentHelper(new CandidateStartersDeploymentHelper());
}
public class CandidateStartersDeploymentHelper extends BpmnDeploymentHelper {
@Override
public void addAuthorizationsForNewProcessDefinition(Process process, ProcessDefinitionEntity processDefinition) {
super.addAuthorizationsForNewProcessDefinition(process, processDefinition);
if (process != null &&
!process.isCandidateStarterUsersDefined() &&
!process.isCandidateStarterGroupsDefined()) {
addAuthorizationsFromIterator(Context.getCommandContext(), List.of(EVERYONE_GROUP), processDefinition, ExpressionType.GROUP);
}
}
}
}
......@@ -61,7 +61,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
......@@ -220,7 +219,6 @@ public class ProcessEngineAutoConfiguration extends AbstractProcessEngineAutoCon
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty("activiti.candidateStarters.enabled")
public ProcessCandidateStartersEventProducer processCandidateStartersEventProducer(RepositoryService repositoryService,
@Autowired(required = false) List<ProcessRuntimeEventListener<ProcessCandidateStarterUserAddedEvent>> candidateStarterUserListeners,
@Autowired(required = false) List<ProcessRuntimeEventListener<ProcessCandidateStarterGroupAddedEvent>> candidateStarterGroupListeners) {
......@@ -295,4 +293,10 @@ public class ProcessEngineAutoConfiguration extends AbstractProcessEngineAutoCon
eventPublisher);
}
@Bean
@ConditionalOnMissingBean
public CandidateStartersDeploymentConfigurer candidateStartersDeploymentConfigurer() {
return new CandidateStartersDeploymentConfigurer();
}
}
......@@ -25,7 +25,6 @@ import org.activiti.spring.boot.process.listener.ProcessCandidateStarterUserRemo
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import java.util.List;
......@@ -34,7 +33,6 @@ import static org.assertj.core.groups.Tuple.tuple;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@TestPropertySource(properties = "activiti.candidateStarters.enabled=true")
public class ProcessCandidateStarterEventIT {
@Autowired
......
......@@ -22,23 +22,26 @@ import org.activiti.api.process.runtime.ProcessRuntime;
import org.activiti.api.runtime.shared.query.Page;
import org.activiti.api.runtime.shared.query.Pageable;
import org.activiti.engine.ActivitiObjectNotFoundException;
import org.activiti.engine.RepositoryService;
import org.activiti.spring.boot.security.util.SecurityUtil;
import org.activiti.spring.boot.test.util.ProcessCleanUpUtil;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@TestPropertySource("classpath:application-with-candidate-starters-enabled.properties")
public class ProcessRuntimeCandidateStartersIT {
private static final String NO_CANDIDATES_SET_PROCESS_DEFINITION_KEY = "SingleTaskProcess";
private static final String RESTRICTED_PROCESS_DEFINITION_KEY = "SingleTaskProcessRestricted";
@Autowired
private RepositoryService repositoryService;
@Autowired
private ProcessRuntime processRuntime;
......@@ -60,8 +63,8 @@ public class ProcessRuntimeCandidateStartersIT {
Page<ProcessDefinition> processDefinitionPage = processRuntime.processDefinitions(Pageable.of(0,
50));
assertThat(processDefinitionPage.getContent()).isNotNull();
assertThat(processDefinitionPage.getContent()).hasSize(1);
assertThat(processDefinitionPage.getContent().get(0).getKey()).isEqualTo(RESTRICTED_PROCESS_DEFINITION_KEY);
assertThat(processDefinitionPage.getContent()) // All processes except UnstartableProcess
.hasSize((int) repositoryService.createProcessDefinitionQuery().count() -1);
}
@Test
......@@ -71,18 +74,20 @@ public class ProcessRuntimeCandidateStartersIT {
Page<ProcessDefinition> processDefinitionPage = processRuntime.processDefinitions(Pageable.of(0,
50));
assertThat(processDefinitionPage.getContent()).isNotNull();
assertThat(processDefinitionPage.getContent()).hasSize(1);
assertThat(processDefinitionPage.getContent().get(0).getKey()).isEqualTo(RESTRICTED_PROCESS_DEFINITION_KEY);
assertThat(processDefinitionPage.getContent()) // All processes except UnstartableProcess
.hasSize((int) repositoryService.createProcessDefinitionQuery().count() -1);
}
@Test
public void nonCandidateStarters_shouldNot_getProcessDefinitions() {
public void nonCandidateStarters_shouldNot_getRestrictedProcessDefinitions() {
loginAsANonCandidateStarter();
Page<ProcessDefinition> processDefinitionPage = processRuntime.processDefinitions(Pageable.of(0,
50));
assertThat(processDefinitionPage.getContent()).isNotNull();
assertThat(processDefinitionPage.getContent()).isEmpty();
// All processes except SingleTaskProcessRestricted and UnstartableProcess
assertThat(processDefinitionPage.getContent())
.hasSize((int) repositoryService.createProcessDefinitionQuery().count() - 2);
}
......@@ -105,7 +110,16 @@ public class ProcessRuntimeCandidateStartersIT {
}
@Test
public void nonCandidateStarters_shouldNot_getProcessDefinition() {
public void nonCandidateStarter_should_getProcessDefinitionWithNoCandidatesSet() {
loginAsCandidateStarterUser();
ProcessDefinition processDefinition = processRuntime.processDefinition(NO_CANDIDATES_SET_PROCESS_DEFINITION_KEY);
assertThat(processDefinition).isNotNull();
assertThat(processDefinition.getKey()).isEqualTo(NO_CANDIDATES_SET_PROCESS_DEFINITION_KEY);
}
@Test
public void nonCandidateStarters_shouldNot_getProcessDefinitionWithCandidatesSet() {
loginAsANonCandidateStarter();
Throwable throwable = catchThrowable(() -> processRuntime.processDefinition(RESTRICTED_PROCESS_DEFINITION_KEY));
......
<?xml version="1.0" encoding="UTF-8"?>
<bpmn2:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmn2="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:activiti="http://activiti.org/bpmn" id="sample-diagram" targetNamespace="http://bpmn.io/schema/bpmn" xsi:schemaLocation="http://www.omg.org/spec/BPMN/20100524/MODEL BPMN20.xsd">
<bpmn2:process id="UnstartableProcess" name="Non Startable Process" activiti:candidateStarterUsers="" activiti:candidateStarterGroups="" isExecutable="true">
<bpmn2:startEvent id="StartEvent_1" activiti:formKey="startForm">
<bpmn2:outgoing>SequenceFlow_1tec9n0</bpmn2:outgoing>
</bpmn2:startEvent>
<bpmn2:sequenceFlow id="SequenceFlow_1tec9n0" sourceRef="StartEvent_1" targetRef="Task_03l0zc2" />
<bpmn2:endEvent id="EndEvent_00jfcl0">
<bpmn2:incoming>SequenceFlow_00ryslt</bpmn2:incoming>
</bpmn2:endEvent>
<bpmn2:sequenceFlow id="SequenceFlow_00ryslt" sourceRef="Task_03l0zc2" targetRef="EndEvent_00jfcl0" />
<bpmn2:userTask id="Task_03l0zc2" name="my-task" activiti:assignee="garth" activiti:candidateUsers="firstCandidateUser, secondCandidateUser"
activiti:candidateGroups="firstCandidateGroup, secondCandidateGroup" activiti:formKey="taskForm">
<bpmn2:incoming>SequenceFlow_1tec9n0</bpmn2:incoming>
<bpmn2:outgoing>SequenceFlow_00ryslt</bpmn2:outgoing>
</bpmn2:userTask>
</bpmn2:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="SingleTaskProcess">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="412" y="240" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="SequenceFlow_1tec9n0_di" bpmnElement="SequenceFlow_1tec9n0">
<di:waypoint x="448" y="258" />
<di:waypoint x="498" y="258" />
<bpmndi:BPMNLabel>
<dc:Bounds x="473" y="236.5" width="0" height="13" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="EndEvent_00jfcl0_di" bpmnElement="EndEvent_00jfcl0">
<dc:Bounds x="648" y="240" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="666" y="279" width="0" height="13" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="SequenceFlow_00ryslt_di" bpmnElement="SequenceFlow_00ryslt">
<di:waypoint x="598" y="258" />
<di:waypoint x="648" y="258" />
<bpmndi:BPMNLabel>
<dc:Bounds x="623" y="236.5" width="0" height="13" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="UserTask_07zs0fg_di" bpmnElement="Task_03l0zc2">
<dc:Bounds x="498" y="218" width="100" height="80" />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn2:definitions>
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册