Skip to content

Security

Stephan Pelikan edited this page Oct 11, 2026 · 21 revisions

The cockpit and the workflow modules are separate applications, and the user should notice none of that. One login, one address in the browser, and every part knows who is looking at it. This page says how that works and what each part decides.

The delivered application asks for a user name and a password and takes its users from a directory it fetches at startup. That is a demo. A deployment replaces one bean and points it at an identity provider, which the last section describes.

  1. Logging in and the token
    What the cockpit issues, what is in it, and how long it lasts.
  2. Groups and how they grow
    Where a user's groups come from, and what a workflow module adds to them.
  3. Reaching a workflow module
    The proxy path, what travels with a request, and what the module has to do with it.
  4. Who sees which workflow
    The rule the delivered workflow list applies, and what a detail view answers.
  5. Who sees which user task
    The three lists, the exclusion rule, and what knowing an id buys.
  6. Who gets which live update
    Which browser hears about which change, and when new rights reach an open tab.
  7. Putting an identity provider in front of it
    The bean to replace, and what has to stay.

Logging in and the token

There is no login page of the cockpit's own. Any request to the user interface's API asks for HTTP basic authentication, and the answer carries a cookie the browser sends from then on. The cookie holds a JWT, signed with HMAC-SHA256 and the key business-cockpit.jwt.hmacSHA256-base64.

The token says who the user is and which groups they are in. It does not carry fine-grained business permissions, and that is on purpose rather than an oversight. A cockpit and its workflow modules need many small permissions rather than three roles, and permissions in a token travel in every single request. Browsers and proxies cut a header off somewhere around two kilobytes, and they disagree about where. So the token carries the identity, and what the user may do is worked out per request.

The identity provider is asked while a user logs in, not on every request after that. The groups it answers with travel in the token, and every later request reads them from there. A group somebody is given or loses in the identity provider therefore reaches the cockpit at that user's next login. So it takes at most as long as a login lasts, which the next paragraphs describe.

That is what the delivered application does. JwtUserDetailsProvider is what its name says: it reads the user out of the token and asks nobody. An application of your own may answer differently, because the bean is replaceable: give it a userDetailsProvider bean of your own and it decides what a request knows about the person making it. That is the place to reach an identity manager directly, when roles have to be current rather than as old as the login.

If you do that, cache what you resolve. Without a cache every HTTP request becomes a question to the identity manager, and a list which loads thirty rows asks thirty times. What has proven itself in customer projects: hold a resolved user for up to a day, empty the cache once a night at a quiet hour, and keep a way to empty it by hand for the moment somebody's roles change and the change has to be seen now. None of this is in the cockpit, it belongs in the provider you write.

The cookie is bc by default, HttpOnly always, and the token in it lives twelve hours unless business-cockpit.jwt.cookie.expires-duration says otherwise. While the user works, the login is renewed: a request made when less than half of that time is left gets a new cookie, which lives the full twelve hours again. The new token carries the same user and the same groups as the old one. The update stream of an open tab does not count as work, so a tab nobody looks at is logged out after twelve hours.

Renewal stops at business-cockpit.jwt.cookie.max-login-duration, seven days by default, counted from the login. After that the user logs in again, even in the middle of work. This limit is there so that a stolen cookie does not stay valid forever. Both values are ISO-8601 durations, and a value the cockpit cannot read stops the start.

The cookie outlives the browser window on purpose: a link from an e-mail or a bookmark opens without a second login. Set business-cockpit.jwt.cookie.secure and business-cockpit.jwt.cookie.same-site for a deployment; neither has a default which would be right everywhere.

Without a configured signing key the application generates one for that start and prints it. Every restart then logs everybody out, and two instances behind a load balancer never agree, so a deployment writes the key into its configuration once.

/logout clears the cookie and sends the browser to the start page.

Groups and how they grow

A user's groups come from the user directory: whatever answers who a user is says which groups they are in. In the demo profile that is the simulator, in a deployment it is your identity provider. They are read once, while the user logs in, and written into the token as the claim authorities.

Those groups are then widened, on every request rather than once at login. A workflow module may report a group hierarchy while it registers, saying that one group stands for others:

vanillabp:
  workflow-modules:
    taxi-ride:
      cockpit:
        group-hierarchy:
          TEAM_LEAD:
            - TEAM_MEMBER
            - ASSISTANT

A team lead therefore sees what a team member sees, without anybody putting them in that group. The hierarchies of every registered workflow module are merged and followed through, so a chain of two steps resolves as well. Because this happens per request, a workflow module which registers again with a changed hierarchy changes what its users see right away, with no new login.

The widening lives in the security context of the request and nowhere else. It is never written back into the token, so it costs no room in any header and it is gone once the request is answered. The hierarchies themselves are held in memory: a registration puts the module's hierarchy there, and after a restart the cockpit reads it back from the registrations it has stored.

Many companies will not allow the team lead example. A bank or an insurer asks that roles are handed out in the identity provider alone, where they are reviewed, and not by the deployment of a workflow module. The hierarchy is still worth having there, in another shape: as a mapping of roles onto permissions.

A permission is technical. Access to an HTTP resource, or the right to work on one kind of user task. Give it a name and say which roles hold it:

vanillabp:
  workflow-modules:
    taxi-ride:
      cockpit:
        group-hierarchy:
          ACCOUNTING:
            - RELEASE_PAYMENT
          TEAM_LEAD:
            - RELEASE_PAYMENT

Anybody in ACCOUNTING or TEAM_LEAD now has RELEASE_PAYMENT among their groups. Report the user task with RELEASE_PAYMENT as its candidate group and it reaches those users and nobody else. Protect the REST controller behind it with hasAuthority('RELEASE_PAYMENT') and it asks for the permission rather than naming a role. Letting a third role release payments is then a line of configuration instead of a change of the code.

The cockpit puts the resolved groups into the security context as Spring Security authorities, so hasAuthority works there as it stands. A workflow module builds a security context of its own, which the next section describes. The adapter hands it the same hierarchy as a WorkflowModuleGroupHierarchy bean, and GroupHierarchyResolver is the class which resolves groups with it.

Reaching a workflow module

Everything a workflow module serves reaches the browser through the cockpit. The cockpit opens a path for each registered module, /wm/<workflow-module-id>/**, and the addresses the user interface receives are already written that way. So the browser only ever talks to the cockpit's origin, and the module's own address never reaches it.

The request is forwarded as it is. The cookie goes along, because a browser sends it to the cockpit and the proxy strips only the headers which belong to a single hop. That is what carries the user into the workflow module.

So the workflow module is the one which checks. /wm/** is not authenticated by the cockpit's own filter chain; the module behind it reads the cookie, verifies the signature and builds its own security context from it. Today that means the module needs the same HMAC key the cockpit signs with, configured in the module and kept in step with the cockpit's. It is the weakest part of this design, and it is the reason a module cannot simply be pointed at a different cockpit.

Other ways of signing in are being worked on. The plan is to let the cockpit check tokens the way Spring Boot does it with OAuth 2 and OpenID Connect: the token travels in the Authorization header, and a workflow module verifies it without holding the key it was signed with. Which release brings that is still open.

A module which checks nothing is reachable by every logged-in user of the cockpit, whatever the lists show them.

Who sees which workflow

The library decides nothing here. It offers AbstractWorkflowListGuiApiController, which shapes the answer and leaves open which cases go into it, and the delivered application answers it in its own controller. A cockpit of your own writes that answer itself, which Building a custom Business Cockpit describes.

The rule the delivered application applies is this. A business case shows up for a user named in its accessibleToUsers and for a member of a group named in its accessibleToGroups. The groups counted are the ones from the section above, so the list follows the same widening as everything else. A case which names neither is open to everybody. Both values are what your @WorkflowDetailsProvider set, described on Reporting workflows and user tasks.

Opening one case by its id follows the same rule. A case the user may not see is answered as unknown, which is the same answer an id that was never reported gets, so the answer itself tells nobody that the case exists.

Asking for the user tasks of a case is answered the same way first: no access to the case, no tasks. The usual caller is the status site of a workflow, which shows what is open on the case right now, either so the reader can pick a task up and work on it, or so they can see where the case stands.

Which of its tasks you then get is a second question, and the caller asks it with the query parameter llatcup, short for "limit list according to current user's permissions". Sent as true, and a missing value counts as true, the answer follows the same rule as the main task list: assigned to me, me as a candidate, one of my groups as a candidate, admitting me, or addressed to nobody, and no task which excludes me without admitting me. Sent as false that filter is left out and the tasks of the case come back whoever they address. Whether the ones which have already ended are among them is a third question, answered by mode in the request body: OpenTasks leaves them out, any other value keeps them.

Who sees which user task

A user task shows up for the user it is assigned to, for a user named in its candidateUsers, for a member of a group named in its candidateGroups, for a user named in its admittedUsers, and when it names none of those at all. On top of that comes one rule which takes tasks away: a user named in excludedCandidateUsers is no candidate for the task and does not see it. That is how four eyes are enforced, and only the workflow module can fill it, because only it knows who did the previous step.

The assignee, the candidates and the excluded users are set by the report which creates the task in the cockpit. A later report does not change them. Only the cockpit itself changes the assignee and the candidate users afterwards, when somebody takes the task over or assigns it to somebody. So a task does not leave a user's list because the workflow module reported it again with other names. A task which names nobody at all is the exception. It is shown to everybody, so it takes the names from the first later report which has some. admittedUsers is different and is read from every report.

admittedUsers is the one of those four which says nothing about working on the task. A workflow module writes it to let somebody reach a task although no work of theirs is waiting on it. Usually they worked on the task earlier and want to read back what they entered. They keep it whatever happened to their groups since, and they never had to be a candidate for it.

An admission is the stronger of the two. A user named in admittedUsers sees the task even when the same task names them in excludedCandidateUsers. Four eyes do not suffer from that, for two reasons. The first pair of eyes should never be handed the second task in the first place, so the case hardly arises. And if the task did reach them, the security of your own workflow module keeps the form shut. So the rule reads: the user is admitted, or the user is a candidate and not excluded.

What such a reader may then do is the module's question, not the cockpit's. The cockpit lists the task and opens the module's form, and the form decides what it shows, down to showing nothing at all. Reporting workflows and user tasks says how a details provider fills the list.

The delivered application offers that rule in three shapes, and the user interface uses all of them:

Endpoint What it holds
/gui/api/v1 The main list: assigned to me, mine as a candidate, my groups' tasks, the tasks admitting me, and the tasks nobody addressed
/gui/api/v1/current-user Only what is mine personally, without the groups and without unaddressed tasks
/gui/api/v1/user-roles The tasks my groups may take which are not already assigned to me

Only the main list carries admittedUsers. The other two say what is addressed to somebody today and what their groups may take, and a task somebody may merely read is neither.

All three are boundaries, not only ways of finding things. Asking for one task by its id is answered by the rule of the list it hangs below, and so are marking as read, claiming, assigning and setting a follow-up date. A task the list would not show is reported as unknown, which is the answer an id that was never reported gets, and an action on such a task changes nothing. So the narrow list has the narrow task page, and knowing an id buys nobody anything.

Who gets which live update

Each open browser tab has its own live update stream. A change of a user task or a workflow reaches a stream only if it concerns the person the stream was opened for. The rule is the rule of the lists above. There is no second rule for the live updates.

An event in the stream is a wake-up call. It names the kind of entity, its id and what happened to it, such as INSERT or UPDATE. It carries no title and no details. The browser answers it by loading its list again, and that answer is checked like any other list request.

A stream gets a change in two cases:

  • One of its lists may show the entity. A stream starts with the widest view of its person: the main task list, and the workflows addressed to them. Every list which answers that person adds its own view. So a support team whose workflow list shows every case also hears about every case.
  • The browser shows the entity already. The cockpit remembers which ids it has sent to the lists of a person. When a task is taken away from somebody, they may no longer see it, but their list still shows it. The wake-up call names an id the browser already has, and the list loads without the task. So the task leaves the list, and nothing new is told.

When a task moves from one person to another, both learn about it. One list drops it, the other one shows it, and neither person has to reload the page.

A change which is about no single entity reaches every stream. The ping which keeps a stream open is one of these.

The cockpit remembers at most business-cockpit.gui-sse.max-known-ids-per-stream ids per stream, 2,000 by default. A list request does not say which tab it comes from, so every tab of a person remembers the ids of all their lists. When a tab goes over the limit, it forgets the older ids and asks its lists to load again. A new stream asks its lists to load again as well, because it does not know yet what they show. Such a request is an event with the type RELOAD and an empty id.

A stream ends when the token it was opened with expires. The browser then connects again, with the cookie it has at that moment, and the new stream gets the rights of that login. If the user has worked in the meantime, that cookie holds a renewed token and the new stream simply goes on. This is how a new group or a substitute reaches an open tab without a new login, as long as the userDetailsProvider resolves it. Until the stream ends, it keeps the rights it started with. The browser waits up to 15 seconds before it connects again, and the reload of the new stream catches up on what happened in between.

Putting an identity provider in front of it

Basic authentication with a directory loaded at startup is what the delivered application does, and it is meant for a demo. Everything else is in place already: the cockpit's own security is Spring Security, so whatever it can talk to, the cockpit can talk to.

Replace the bean named guiHttpSecurity. The name is what counts, because the library steps aside for a bean of that name.

@Bean
@Order(Ordered.LOWEST_PRECEDENCE)
public SecurityFilterChain guiHttpSecurity(
        final JwtSecurityContextRepository securityContextRepository,
        final JwtMapper<? extends JwtAuthenticationToken> jwtMapper,
        final HttpSecurity http) throws Exception {

    // reads the JWT cookie on every following request; do not turn this into a bean, or
    // Spring Boot registers it a second time outside the security filter chain
    final var jwtSecurityFilter = new PassiveJwtSecurityFilter(
            applicationProperties.getJwt(), jwtMapper);
    // renews the cookie of a login which is in use; the update stream does not renew it
    final var jwtRenewalFilter = new JwtRenewalFilter(
            applicationProperties.getJwt(), jwtMapper,
            WebSecurityConfiguration.updatesRequestMatcher);

    http
            .anonymous(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(requests -> requests
                    .requestMatchers(
                            WebSecurityConfiguration.appInfoRequestMatcher,
                            WebSecurityConfiguration.currentUserRequestMatcher,
                            WebSecurityConfiguration.assetsRequestMatcher,
                            WebSecurityConfiguration.staticRequestMatcher,
                            WebSecurityConfiguration.workflowModulesProxyRequestMatcher)
                    .permitAll()
                    .anyRequest()
                    .authenticated())
            .oauth2Login(login -> login
                    .securityContextRepository(securityContextRepository))
            .logout(logout -> { /* whatever your environment needs */ })
            .addFilterAfter(jwtSecurityFilter, BasicAuthenticationFilter.class)
            .addFilterAfter(jwtRenewalFilter, PassiveJwtSecurityFilter.class);

    return http.build();

}

Four things have to stay. The five matchers stay permitted, because the application info, the current user, the static files and the proxy path are asked for before anybody is logged in. PassiveJwtSecurityFilter stays, because it is what reads the cookie on every later request. JwtRenewalFilter stays right behind it, because without it a login ends after expires-duration even while somebody works. And JwtSecurityContextRepository stays as the place the token is written, so the login you replaced still ends in the same cookie.

You also bring your own directory. The beans answering who a user is and which groups they are in are demo beans, active in the profile local only, and an application which drops that profile without replacing them has no users at all.

Clone this wiki locally