I've been around Red Hat JBoss BPM Suite (jBPM) and Red Hat Process Automation Manager (RHPAM) for many years. Over that time, I've learned a lot about the lesser-known aspects of this business process management engine.
If you are like most people, you might believe that user tasks are trivial, and learning about their details is unnecessary. Then, one day, you will find yourself troubleshooting an error like this one:
User '[User:'admin']' was unable to execution operation 'Start' on task id 287271 due to a no 'current status' match.
Receiving one too many similar error messages led me to learn everything that I know about user tasks, and I have decided to share my experience.
User tasks are a vital part of any business process management engine, jBPM included. Their behavior is defined by the OASIS Web Services—Human Task Specification, which has been fully adopted by Business Process Model and Notation (BPMN) 2.0—the standard for business processes diagrams. The spec defines two exceptionally important things that I will discuss in this article: The user task lifecycle and task access control. Without further ado, let's jump right in.
Note: These troubleshooting tips are applicable to Red Hat JBoss BPM Suite 6.2 and above and Red Hat Process Automation Manager 7.
The user task lifecycle
The diagram in Figure 1 illustrates how a task transitions from one state to another, along with the valid executable actions to execute for every state.
To see how this diagram can be helpful, consider a practical example. Imagine a Start - >User Task - > end process. As shown in Figure 2, the task has only one actor assigned, anton
.
Upon starting this process, the task automatically transitions into the Reserved state, which is dictated by the WS-Human Task spec, section 4.10.1: "When the task has a single potential owner, it transitions into the Reserved state."
Now, let's see what happens when we execute the following call:
$ curl -X PUT -u anton:password1! "http://localhost:8080/kie-server/services/rest/server/containers /HumanTaskExamples/tasks/1/states/completed?user=anton" -H "accept: application/json" -H "content-type: application/json" -d "{}"
We will observe this error:
Could not commit session: org.jbpm.services.task.exception.PermissionDeniedException: User '[UserImpl:anton
]' was unable to execute operation 'Complete' on task id 1 due to a no 'current status' matchCould not commit session: org.jbpm.services.task.exception.PermissionDeniedException: User '[UserImpl:anton
]' was unable to execute operation 'Complete' on task id 1 due to a no 'current status' match at org.jbpm.services.task.internals.lifecycle.MVELLifeCycleManager.evalCommand(MVELLifeCycleManager.java:163) at org.jbpm.services.task.internals.lifecycle.MVELLifeCycleManager.taskOperation(MVELLifeCycleManager.java:392)
But there's no reason to panic: We can refer to the diagram in Figure 1 to understand what happened. Look closely, and you will see that from the Reserved status, the allowed operation is Start. This moves our task status to InProgress, which lets us execute the Complete operation.
So, to solve this error, we call start
and then complete
. Or, if you can't be bothered with this task lifecycle mess, use the auto-progress
option:
$ curl -X PUT -u anton:password1! "http://localhost:8080/kie-server/services/rest/server/containers /HumanTaskExamples/tasks/1/states/completed?user=anton&auto-progress=true" -H "accept: application/json" -H "content-type: application/json" -d "{}"
Internally, the engine falls back to the logic in this implementation example (follow the link for the complete code):
TaskService taskService = engine.getTaskService(); // auto progress if needed if (task.getStatus().equals(Status.Ready.name())) { taskService.claim(taskId.longValue(), userId); taskService.start(taskId.longValue(), userId); } else if (task.getStatus().equals(Status.Reserved.name())) { taskService.start(taskId.longValue(), userId); } // perform actual operation taskService.complete(taskId, userId, params);
Based on the current task status, it executes all of the necessary intermediate steps (in our case, that intermediate step was the Start operation). Whether to use autoProgress
depends on your client's needs: Some clients require fine-grained distinctions between task states, and some don't care. Using autoProgress
certainly simplifies life for you as a developer, but either way, it's important to understand what happens behind the scenes.
Study the source
You might have noticed that the stack trace above mentions useful classes. If you want to go really deep (which I occasionally do), you can dig into these. Low-level implementation details matter for one simple reason: The source code never lies—and the documentation might. Don't trust diagrams, articles, or documentation, for that matter; trust the source code.
The operations-dsl.mvel and MVELLifeCycleManager.java files hold the actual implementation of the task lifecycle illustrated in Figure 1.
Hopefully, this covers the lifecycle-related errors and gives you enough information to help with troubleshooting.
Task access control
The next important aspect of dealing with user tasks is task access control. Essentially, if you want to execute a task-related action, the user must be eligible to execute that action. For a user to be eligible, the engine must consider that user to be a potential owner of the task.
The component that plays a vital role in checking task access is UserGroupCallback. It's a simple interface, and jBPM allows you to plug in various (even custom) implementations. We'll get to UserGroupCallback
later.
jBPM task access example
Note that in jBPM, potential owner refers to both individual actors and groups. To illustrate, imagine a task with one group assigned: sampleGroup
, shown in Figure 3.
The task also has two users, which are defined in Red Hat JBoss Enterprise Application Platform as follows:
anton1=kie-server,sampleGroup,admin anton2=kie-server,admin
Now, we execute a claim operation that is authenticated with the user anton2
:
curl -X PUT -u anton2:password1! "http://localhost:8080/kie-server/services/rest/server/containers /HumanTaskExamples/tasks/1/states/claimed?user=anton2" -H "accept: application/json"
The claim fails, resulting in the following error:
12:13:20,117 WARN [org.jbpm.services.task.persistence.TaskTransactionInterceptor] (default task-8) Could not commit session: org.jbpm.services.task.exception.PermissionDeniedException: User '[UserImpl:anton2
]' does not have permissions to execute operation 'Claim' on task id 112:13:20,117 WARN [org.jbpm.services.task.persistence.TaskTransactionInterceptor] (default task-8) Could not commit session: org.jbpm.services.task.exception.PermissionDeniedException: User '[UserImpl:anton2
]' does not have permissions to execute operation 'Claim' on task id 1 at org.jbpm.services.task.internals.lifecycle.MVELLifeCycleManager.evalCommand(MVELLifeCycleManager.java:127) at org.jbpm.services.task.internals.lifecycle.MVELLifeCycleManager.taskOperation(MVELLifeCycleManager.java:392) at org.jbpm.services.task.impl.TaskInstanceServiceImpl.claim(TaskInstanceServiceImpl.java:157) at org.jbpm.services.task.commands.ClaimTaskCommand.execute(ClaimTaskCommand.java:52) at org.jbpm.services.task.commands.ClaimTaskCommand.execute(ClaimTaskCommand.java:33)
Now, this error is expected and obvious because anton2
is not part of sampleGroup
. The jBPM engine does not consider anton2
a potential owner, and so the operation fails. But let's really try to understand what happened during this call. The stack trace gives us all the clues that we need.
Study the stack trace
First—before the actual claim— UserGroupCallback
operation was executed (follow the link for the complete source code):
public Void execute(Context cntxt) { TaskContext context = (TaskContext) cntxt; doCallbackUserOperation(userId, context, true); groupIds = doUserGroupCallbackOperation(userId, null, context); context.set("local:groups", groupIds); context.getTaskInstanceService().claim(taskId, userId); return null; }
We can see it is passing null
as the value for the groups
attribute.
This leads us to the next call, which eventually executes the registered UserGroupCallback
:
protected List doCallbackGroupsOperation(String userId, List groupIds, TaskContext context) { ... if (!(userGroupsMap.containsKey(userId) && userGroupsMap.get(userId).booleanValue())) { //usergroupcallback invocation List userGroups = filterGroups(context.getUserGroupCallback().getGroupsForUser(userId)); if (userGroups != null && userGroups.size() > 0) { for (String group: userGroups) { addGroupFromCallbackOperation(group, context); } userGroupsMap.put(userId, true); groupIds = userGroups; } } } } else { if (groupIds != null) { for (String groupId: groupIds) { addGroupFromCallbackOperation(groupId, context); } } } return groupIds; }
We pass userId
to the getGroupsForUser
callback operation, which results in the list of groups to which our user belongs. By default, jBPM is configured with the JAASUserGroupCallbackImpl callback implementation.
This particular callback implementation delegates the "heavy lifting" to the underlying web container. The container returns the list of groups for the currently authenticated user. The output for the user anton2
will be a list including kie-server,admin
.
If we trace the code execution further to MVELLifeCycleManager, the engine will try to find the intersection between potential owners defined on a task (sampleGroup
) and the groups that our authenticated user—anton2
—belongs to:
private boolean isAllowed(final User user, final List < String > groupIds, final List < OrganizationalEntity > entities) { for (OrganizationalEntity entity: entities) { if (entity instanceof User && entity.equals(user)) { return true; } if (entity instanceof Group && groupIds != null && groupIds.contains(entity.getId())) { return true; } } return false; }
Our user, anton2
, belongs to the kie-server
and admin
roles. This user does not belong to the sampleGroup
. As a result, the engine determines that anton2
is not a potential owner, and the operation fails with a PermissionDeniedException
.
Note: There are different callback implementations, and your source of truth doesn't necessarily need to come from the web container. It can come from an LDAP server, a database, a Red Hat Single Sign-On (SSO) instance, and so on. But JAASUSerGroupCallback
is a sensible default. If you want to learn more about other out-of-the-box implementations, see the following module with all of its UserGroupCallbacks.
Simplifying user task access control
We have discussed the task lifecycle, task access control, and how user-group callbacks fit into the picture. The relationship between these components presents a challenge, especially when using JAASUserGroupCallback
.
Imagine these three tasks:
- Task1(group1)
- Task2(group2)
- Task3(group3)
If you want to claim these tasks, you must execute three separate claim operations (well, unless you implement a kie-server
extension to allow multiple claimed tasks at once, but that's a separate topic). Moreover, you must authenticate with three different users, because that is how JAASUserGroupCallback
works. If you are using a Java client (kie-server-client
), you will also have to create three different instances of KieServicesClient
, and you would probably have to cache them to save time.
It's likely that you would end up with something like this:
Map<String,KieServicesClient> clientMap; // String == userId
Let's just say that this code is cumbersome, at best. Fortunately, you can use the following system property to bypass server authentication for a user that is executing a task operation:
org.kie.server.bypass.auth.user = true
This bypass property is a holy grail for our understanding of user task lifecycle and access and will test your knowledge of what you have learned so far.
Note for Spring users: This is a system property, not a Spring property, so don't put it in application.properties
.
Using the bypass property
So, what can the final implementation look like when using this property? Again, imagine these three tasks:
- Task1(group1)
- Task2(group2)
- Task3(group3)
And imagine the following users defined in JBoss EAP:
anton1=kie-server,admin,group1 anton2=kie-server,admin,group2 anton3=kie-server,admin,group3 serviceAccount=kie-server,admin
We still have to call claim
three separate times. But, with the bypass property enabled, we only have to authenticate once (so, we'll need just one instance of KieServicesClient
) and we will pass user1
, user2
, and user3
in a query parameter. We are effectively bypassing the server authentication for these user instances. Internally, the engine either selects the authenticated user or the user from the query parameter, depending on the value of the bypass property. The selected user is then propagated all the way to the UserGroupCallback
. But, again, don't just trust me: Check the source code from the kie-server
:
protected String getUser(String queryParamUser) { if (bypassAuthUser && queryParamUser != null) { return queryParamUser; } return identityProvider.getName(); }
This is literally it: All the magic behind the bypass property. Changing the input parameter to the UserGroupCallback
method completely changes the task access control behavior.
Now, let's test further to see if we understand the bypass correctly.
Consider that the previous three tasks are active. Now we want to claim one of these with a bypass enabled on the server-side:
$ curl -X PUT -u serviceAccount:password1! "http://localhost:8080/kie-server/services/rest/server/containers /HumanTaskExamples/tasks/1/states/claimed?user=anton1" -H "accept: application/json"
As you can see, we are authenticating with a single user (serviceAccount
) and bypassing the authentication of the user requesting the claim (anton1
). Let's pause for a second: What do you think will be the outcome of this operation? We have bypass enabled, so user anton1
will go through as an input to the UserGroupCallback
. It should work. And yet, it ends with this:
WARN [org.jbpm.services.task.persistence.TaskTransactionInterceptor] (default task-3) Could not commit session: org.jbpm.services.task.exception.PermissionDeniedException: User '[UserImpl:anton1
]' does not have permissions to execute operation 'Claim' on task id 1WARN [org.jbpm.services.task.persistence.TaskTransactionInterceptor] (default task-3) Could not commit session: org.jbpm.services.task.exception.PermissionDeniedException: User '[UserImpl:anton1
]' does not have permissions to execute operation 'Claim' on task id 1 at org.jbpm.services.task.internals.lifecycle.MVELLifeCycleManager.evalCommand(MVELLifeCycleManager.java:127) at org.jbpm.services.task.internals.lifecycle.MVELLifeCycleManager.taskOperation(MVELLifeCycleManager.java:392
That doesn't make sense, does it? In fact, we've just received one of the most confusing errors in the jBPM world. The anton1
user clearly belongs to group1
So, why does the log say the exact opposite? Why has the world stopped making sense?
The bypass behaves like this due to a tiny implementation detail of JAASUserGroupCallback
:
public List getGroupsForUser(String userId) { List roles = new ArrayList(); try { Subject subject = getSubjectFromContainer(); ...
Although we are passing userId
equal to anton1
(which you can see in the error message), the getGroupsForUser
method ignores this parameter. It still gets the actual user from the container. As a result, our authenticated user is serviceAccount
, but serviceAccount
is not a potential owner of this task. There is no intersection between kie-server
,admin
, and group1
, so the call fails.
So, what is the solution? The bypass alone doesn't solve anything for us. We have to use it in combination with either a task administrator or a custom UserGroupCallback
.
The task administrator strategy
In jBPM, the task administrator is a superuser that is eligible to execute all task operations, even though it isn't defined as a potential owner. If we combine our existing serviceAccount
user with the task administrator's capabilities, we will achieve the behavior that we desire: The user anton1
will inherit the task admin's superpowers from the serviceAccount
user.
By default, the task admin is defined as a user with the name of Administrator
, or a user who is part of the group Administrators
. We can change these two values via the following properties:
public static final String DEFAULT_ADMIN_USER = System.getProperty("org.jbpm.ht.admin.user", "Administrator"); public static final String DEFAULT_ADMIN_GROUP = System.getProperty("org.jbpm.ht.admin.group", "Administrators");
For simplicity, let's add the Administrators
role to the serviceAccount
user, and then test again:
serviceAccount=kie-server,admin,Administrators
And now this call finally succeeds:
$ curl -X PUT -u serviceAccount:password1! "http://localhost:8080/kie-server/services/rest/server/containers /HumanTaskExamples/tasks/1/states/claimed?user=anton1" -H "accept: application/json"
We can now use a single user (in this case, serviceAccount
) to authenticate all of our kie-server
requests. This dramatically simplifies the integration with the kie-server
.
Note: If you are using the kie-server-client
API to interact with the kie-server
, you need to set the org.kie.server.bypass.auth.user
property to true
, even on the client-side. Otherwise, the user you want to bypass will not be passed to the queryParameter
, and you will end up with confusing behavior again.
Caution
There is an important consequence of combining the task administrator with the bypass property, which should not come as a surprise at this point.
Imagine that we have Task1(group1)
and user anton2=kie-server,admin,group2
. What do you think will happen after issuing this call?
$ curl -X PUT -u serviceAccount:password1! "http://localhost:8080/kie-server/services/rest/server/containers /HumanTaskExamples/tasks/1/states/claimed?user=anton2" -H "accept: application/json"
It will succeed, even though anton2
is not a potential owner (this user is not part of group1
). The call succeeds because we have received roles from the authenticated user, which is serviceAccount
. This user has the Administrators
role set, so our user, anton2
, is now a superuser and is thus eligible to execute any action on any task.
Similarly, what do you think will happen after this call?
$ curl -X PUT -u serviceAccount:password1! "http://localhost:8080/kie-server/services/rest/server/containers /HumanTaskExamples/tasks/2/states/claimed?user=totallyRandomNonExistentUser" -H "accept: application/json"
In this case, we pass a user that does not even exist in the container, and the operation still succeeds. If that is unexpected, it shouldn't be. The JAASUserGroupCallbackImpl only looks for the authenticated user (serviceAccount
). In this case, the user has superpowers, via the Administrators
role. Our totallyRandomNonExistentUser
inherits these powers.
The custom UserGroupCallback strategy
If you (or your client) are not willing to accept this behavior, you can choose to use a custom UserGroupCallback
, instead of the task admin. For testing purposes, it will be easiest to reuse the already existing application-roles.properties
. JBossUserGroupCallbackImpl is already compatible with this file, so at least we don't have to implement custom logic to parse it. This callback implementation respects the user passed as an input parameter: It ignores the authenticated user and instead returns a list of roles for this user as defined in a properties file.
Once again, you can use LDAP or a database as the source of your user-group mappings. Here's the final configuration:
<property name="org.kie.server.bypass.auth.user" value="true"/> <property name="jbpm.user.group.mapping" value="file:///Users/agiertli/Documents/work/rhpam77/standalone/configuration/application-roles.properties"/> <property name="org.jbpm.ht.callback" value="props"/>
The value props
is misleading, but it defaults to JBossUserGroupCallbackImpl. Again, if you don't believe me (which you should not), you can double-check the source code:
} else if ("props".equalsIgnoreCase(USER_CALLBACK_IMPL)) { callback = new JBossUserGroupCallbackImpl(true);
We can now afford to remove the Administrators
role from the serviceAccount
, so the application-roles.properties
looks like this:
anton1=kie-server,admin,group1 anton2=kie-server,admin,group2 anton3=kie-server,admin,group3 serviceAccount=kie-server,rest-all
And the following call works, as well:
$ curl -X PUT -u serviceAccount:password1! "http://localhost:8080/kie-server/services/rest/server/containers /HumanTaskExamples/tasks/1/states/claimed?user=anton1" -H "accept: application/json"
I hope it's clear why the above configuration works. We pass in anton1
(as opposed to the authenticated user) as the user to the callback. The JBossUserGroupsCallbackImpl then parses the application-roles.properties
to give us the list of groups that anton1
belongs to. This list is anton1=kie-server,admin,group1
and the group set on Task1
is group1
. Seeing the intersection between these two components, the engine marks the user anton1
as a potential owner for the claim
operation, so the operation succeeds.
Conclusion
I hope you now have the sufficient resources to debug, troubleshoot, and configure everything you need in regards to jBPM user tasks. As I've demonstrated with the examples in this article, user tasks can seem trivial, until all of a sudden, they are not. Feel free to share your user task troubleshooting stories in the comments!
Last updated: September 21, 2020