Book a Demo

unibpm-integration-inbound

Purpose

unibpm-integration-inbound is a Cabinee integration module for receiving operations from external systems and creating a Ticket in Cabinee.

The module is used as an embedded jar inside unibpm-integration-runtime and is not started as a standalone service.

Main scenario:

  • an external system calls a runtime endpoint;
  • the runtime authenticates the JWT through Keycloak;
  • the inbound module matches the token client id with the external system profile;
  • the inbound module validates the request;
  • the inbound module maps the request to the Ticket model from unibpm-api-client;
  • the inbound module creates a Ticket in Cabinee;
  • the external system stores the UUID of the created Ticket.

Architecture

External System
      │
      │ Bearer JWT + ExternalOperationRequest
      ▼
unibpm-integration-runtime
      │
      │ Resource Server authentication
      ▼
unibpm-integration-inbound
      │
      │ profile authorization + validation + mapping
      ▼
unibpm-api-client
      │
      ▼
unibpm-app

Cabinee remains the only target. External systems are source / initiator systems.


Runtime model

The inbound module:

  • does not have its own server.port;
  • does not have a separate Docker image;
  • does not have a separate entrypoint;
  • does not have a separate deploy pipeline;
  • does not store state in a database;
  • does not have its own OAuth2 Client Credentials config;
  • uses the shared unibpm-api-client configured in the runtime.

It is connected through a Maven dependency in unibpm-integration-runtime.


API

Creating a Ticket

POST /api/inbound/tickets
Authorization: Bearer <jwt>
Content-Type: application/json

Request envelope:

{
  "externalSystemCode": "TASK_TRACKER",
  "externalObject": {
    "type": "PROJECT",
    "id": "project-id",
    "number": "PRJ-123",
    "url": "https://task-tracker.example.com/projects/PRJ-123"
  },
  "externalSubObject": {
    "type": "TASK",
    "id": "task-id"
  },
  "accessContext": {
    "initiator": {
      "userId": "initiator@reunico.com",
      "login": "initiator",
      "email": "initiator@reunico.com",
      "fullName": "Initiator"
    },
    "responsible": {
      "userId": "responsible@reunico.com",
      "login": "responsible",
      "email": "responsible@reunico.com",
      "fullName": "Responsible"
    },
    "owner": {
      "userId": "manager.login",
      "login": "manager.login",
      "email": "manager.login@reunico.com",
      "fullName": "Manager"
    }
  },
  "payload": {
    "task": {
      "id": "task-id",
      "name": "Prepare documents",
      "description": "Task description",
      "plannedDate": "2026-05-15"
    },
    "client": {
      "fullName": "Ivan Ivanov"
    }
  }
}

Response:

{
  "status": "CREATED",
  "alreadyExisted": false,
  "unibpmObjectType": "TICKET",
  "unibpmObjectId": "770e8400-e29b-41d4-a716-446655440000",
  "unibpmObjectNumber": "12345",
  "unibpmObjectUrl": "https://cabinee.example.kz/tickets/770e8400-e29b-41d4-a716-446655440000"
}

status is the ExternalOperationStatus enum.

unibpmObjectType is the UnibpmObjectType enum.

unibpmObjectId is the UUID of the created Ticket.

Getting a Ticket by UUID

GET /api/inbound/tickets?externalSystemCode=TASK_TRACKER&ids=<ticketUuid>&ids=<ticketUuid>
Authorization: Bearer <jwt>

The endpoint is used by an external system to check whether previously created Tickets exist by UUID.

Before reading, profile-based authorization is performed using externalSystemCode.

To avoid a DoS risk from N sequential requests, the inbound module does not call TicketApiClient.getTicket(UUID) in a loop. Instead, it uses batch lookup through the generated method TicketApiClient.getTicketListShort(...) with the id.in criteria filter.

The number of UUIDs is limited by the setting:

unibpm:
  integration:
    inbound:
      max-ticket-lookup-batch-size: ${INBOUND_MAX_TICKET_LOOKUP_BATCH_SIZE:200}

If the number of ids exceeds the limit, the request fails with TICKET_LOOKUP_BATCH_SIZE_EXCEEDED.


External system profile

Each external system is described in the runtime config:

unibpm:
  integration:
    inbound:
      enabled: true
      max-ticket-lookup-batch-size: ${INBOUND_MAX_TICKET_LOOKUP_BATCH_SIZE:200}
      ticket-url-template: ${UNIBPM_TICKET_URL_TEMPLATE:http://localhost:3001/tickets/{ticketId}}
      systems:
        TASK_TRACKER:
          enabled: true
          client-id: task-tracker
          allowed-object-types:
            - PROJECT
          allowed-sub-object-types:
            - TASK
          mapping-profile: DEFAULT_TICKET_MAPPING
          required-fields:
            - externalSystemCode
            - externalObject.type
            - externalObject.id
            - externalObject.number
            - externalSubObject.type
            - externalSubObject.id
            - accessContext.initiator.userId
            - accessContext.responsible.userId
            - payload.task.id
            - payload.task.name
            - payload.task.plannedDate

The profile defines:

  • whether the external system is enabled;
  • which token client id is allowed to access;
  • which external object types are allowed;
  • which external sub-object types are allowed;
  • which fields are required;
  • how the request is mapped to a Ticket.

Mapping to Ticket

Ticket mapping settings:

ticket:
  workflow-id: ${TASK_TRACKER_WORKFLOW_ID}
  ticket-type-id: ${TASK_TRACKER_TICKET_TYPE_ID}
  ticket-priority: ${TICKET_PRIORITY:MEDIUM}
  responsible:
    enabled: true
    id-path: "accessContext.responsible.userId"
    required: true
  owner:
    enabled: true
    id-path: "accessContext.owner.userId"
    required: true
  title-template: 'Task for project \${externalObject.number}: \${payload.task.name}'
  description-template: |
    <ul>
      <li><strong>Project: </strong> ${externalObject.number} </li>
      <li><strong>Task: </strong> ${payload.task.name} </li>
      <li><strong>Task description: </strong> ${payload.task.description} </li>
      <li><strong>Client: </strong> ${payload.client.fullName} </li>
      <li><strong>Planned date: </strong> ${payload.task.plannedDate} </li>
    </ul>
  due-date-path: "payload.task.plannedDate"
  custom-fields:
    externalObjectUrl: '\${externalObject.url}'
    projectUrl: '\${externalObject.url}'
    responsibleUserId: '\${accessContext.responsible.userId}'
    responsibleLogin: '\${accessContext.responsible.login}'
    responsibleEmail: '\${accessContext.responsible.email}'
    responsibleFullName: '\${accessContext.responsible.fullName}'

Important: placeholders of the form \${externalObject.number} are escaped so that Spring does not treat them as environment placeholders.


The inbound module does not create related entities on its own.

During mapping, the module sets id-only references:

Ticket.workflow.id    = ticket.workflow-id
Ticket.type.id        = ticket.ticket-type-id
Ticket.responsible.id = value from responsible.id-path
Ticket.owner.id       = value from owner.id-path

The Workflow, TicketType, and User entities themselves must already exist in Cabinee. The inbound module creates only an id-only User reference with the id from the configured path; it does not create a user and does not perform a lookup. If the references are invalid or unavailable to the runtime service client, Cabinee returns an error when creating the Ticket.


Workflow and TicketType

A Ticket must be created within a preconfigured Workflow and a preconfigured TicketType.

The profile must contain:

ticket:
  workflow-id: ${TASK_TRACKER_WORKFLOW_ID}
  ticket-type-id: ${TASK_TRACKER_TICKET_TYPE_ID}

ExternalSystemRegistry validates these settings at runtime startup:

  • ticket.workflow-id is required;
  • ticket.ticket-type-id is required;
  • both values are converted to UUID by Spring Boot binding.

If the configuration is invalid, the runtime fails at startup with a clear error. An invalid UUID is usually caught by Spring Boot configuration binding before the module starts.


Responsible

The responsible user is passed by the external system in accessContext.responsible.userId.

Setting:

responsible:
  enabled: true
  id-path: "accessContext.responsible.userId"
  required: true

During mapping:

accessContext.responsible.userId → Ticket.responsible.id

If required: true, a Ticket is not created without a responsible user. If the user does not exist in Cabinee, Cabinee returns an error when creating the Ticket.


Owner

The owner is passed by the external system in accessContext.owner.userId.

Setting:

owner:
  enabled: true
  id-path: "accessContext.owner.userId"
  required: true

During mapping:

accessContext.owner.userId → Ticket.owner.id

If owner is missing from the profile or owner.enabled=false, Ticket.owner is not set. If owner.required=true, a Ticket is not created without an owner: a missing or blank value at owner.id-path returns OWNER_IS_REQUIRED.


Priority

ticket-priority is a TicketPriority enum value, not a related entity.

Priority does not need to be retrieved through the API client.

Ticket.priority = ticket.ticket-priority

ticket.ticket-priority is converted to the TicketPriority enum by Spring Boot binding. If the value is invalid, the application does not start because of a binding error.


Custom fields

custom-fields are passed into Ticket.customFields as values by key.

Custom field definitions must be created in Cabinee/workflow in advance. The inbound module does not create custom field definitions.


Idempotency

Idempotency is the responsibility of the external system.

Agreed scenario:

  1. The external system calls POST /api/inbound/tickets.
  2. Inbound creates a Ticket.
  3. Inbound returns the Ticket UUID in unibpmObjectId.
  4. The external system stores the UUID.
  5. On a repeated operation, the external system checks whether the UUID already exists and does not create a duplicate.
  6. To check whether a Ticket exists, the external system calls GET /api/inbound/tickets.

The inbound module does not use:

  • legacyId;
  • idempotencyKey;
  • external natural key;
  • TicketLookupClient;
  • NoopTicketLookupClient;
  • IdempotencyKeyFactory.

Security

Authentication of inbound requests is performed in unibpm-integration-runtime through Spring Security OAuth2 Resource Server.

unibpm-integration-inbound does not verify the JWT signature itself. It receives an already verified Authentication and performs a profile-based check: it matches the token client id with the external system setting.

client-id: task-tracker

Security chains in the runtime

Recommended runtime security model:

/api/inbound/**                  authenticated JWT, only if inbound.enabled=true
/public/**                       permitAll
/error                           permitAll
/**                              denyAll

The inbound security chain is activated only when the inbound module is enabled:

unibpm:
  integration:
    inbound:
      enabled: true

If inbound is disabled:

unibpm:
  integration:
    inbound:
      enabled: false

then the JWT Resource Server chain for /api/inbound/** is not created.

Runtime fallback security

The fallback security chain provides baseline runtime protection:

@Bean
@Order(100)
SecurityFilterChain runtimeSecurityFilterChain(HttpSecurity http) throws Exception {
    return http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(Customizer.withDefaults())
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/public/**", "/error").permitAll()
                    .anyRequest().denyAll()
            )
            .build();
}

This chain is always active and closes all endpoints that were not handled by a higher-priority chain.

Inbound security

The inbound security chain must live in a separate config class and be enabled only when unibpm.integration.inbound.enabled=true.

@Configuration
@ConditionalOnProperty(
        prefix = "unibpm.integration.inbound",
        name = "enabled",
        havingValue = "true"
)
public class InboundSecurityConfig {

    @Bean
    @Order(10)
    SecurityFilterChain inboundSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
                .securityMatcher("/api/inbound/**")
                .csrf(AbstractHttpConfigurer::disable)
                .cors(Customizer.withDefaults())
                .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .anyRequest().authenticated()
                )
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt
                        .jwtAuthenticationConverter(this::inboundJwtAuthentication)
                ))
                .build();
    }

    JwtAuthenticationToken inboundJwtAuthentication(Jwt jwt) {
        String clientId = claim(jwt, "azp")
                .or(() -> claim(jwt, "client_id"))
                .or(() -> Optional.ofNullable(jwt.getSubject()).filter(this::hasText))
                .orElse("");

        return new JwtAuthenticationToken(jwt, List.of(), clientId);
    }

    private Optional<String> claim(Jwt jwt, String claimName) {
        String value = jwt.getClaimAsString(claimName);
        return hasText(value) ? Optional.of(value) : Optional.empty();
    }

    private boolean hasText(String value) {
        return value != null && !value.isBlank();
    }
}

@Order(10) is required so that the chain for /api/inbound/** is applied before the fallback chain with denyAll.

JwtAuthenticationToken#getName() is normalized to the external system client id: azp is used first, then client_id, then sub. Roles and authorities are not mapped for inbound.

Resource Server settings

When inbound is enabled, the runtime must be able to validate the external system’s JWT.

Minimal option via issuer:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${KEYCLOAK_ISSUER_URI}

Alternative option via JWK endpoint:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: ${KEYCLOAK_JWK_SET_URI}

Usually one of these options is enough. If the Keycloak discovery endpoint is available, prefer issuer-uri.

If inbound is disabled, spring.security.oauth2.resourceserver.jwt.* can be omitted.

Difference from OAuth2 client settings

The runtime has two distinct security directions:

External System -> runtime /api/inbound/**
runtime validates the incoming JWT via oauth2.resourceserver.jwt.*

runtime -> Cabinee
unibpm-api-client uses the incoming JWT only for the current servlet request /api/inbound/**,
for other runtime flows it obtains a service token via oauth2.client.*

Outgoing Cabinee client settings:

spring:
  security:
    oauth2:
      client:
        registration:
          client-name:
            authorization-grant-type: client_credentials
            client-id: ${UNIBPM_CLIENT_ID}
            client-secret: ${UNIBPM_CLIENT_SECRET}
            provider: keycloak
        provider:
          keycloak:
            token-uri: ${KEYCLOAK_TOKEN_URI}

unibpm-api-client uses a single composite authorization interceptor: the incoming JWT has priority only for /api/inbound/**, and client credentials are used for all other background/internal calls.

These settings do not replace Resource Server settings for incoming inbound requests.

Keycloak service client setup

Each external system needs a dedicated or shared service client in Keycloak.

Minimum requirements:

  • client_credentials / service account flow is enabled;
  • a client secret is present;
  • the client id is specified in the inbound profile;
  • the access token is passed to the inbound API:
Authorization: Bearer <access_token>

If the JWT is valid but the token client id does not match the profile, the inbound module returns:

ACCESS_DENIED

Using unibpm-api-client

The module uses generated clients from unibpm-api-client:

TicketApiClient.createTicket(Ticket ticket);
TicketApiClient.getTicketListShort(... id.in ...);

The inbound module does not have its own Feign client or its own OAuth2 Client Credentials config.


Errors

Error codes are defined by the InboundIntegrationErrorCode enum.

CodeHTTP statusMeaningWhen it occursWhat to do
INVALID_INBOUND_PROFILE500Invalid inbound profile configurationAt runtime startup: client-id, workflow-id, or ticket-type-id is empty, allowed-object-types is empty, allowed-sub-object-types is empty, or responsible.id-path / owner.id-path is missing when mapping is enabledFix application.yaml / environment variables
ACCESS_DENIED403Token client id does not match the profileJWT is valid, but azp or client_id does not match the profile client-idCheck the external Keycloak client or fix the profile
REQUIRED_FIELD_MISSING400A required field is missing from the incoming requestA field from required-fields was not foundFix the external system contract/payload
OBJECT_TYPE_NOT_ALLOWED400The primary external object type is not allowedexternalObject.type is not in allowed-object-typesFix the profile or the request
SUB_OBJECT_TYPE_NOT_ALLOWED400The external sub-object type is not allowedexternalSubObject.type is not in allowed-sub-object-typesFix the profile or the request
RESPONSIBLE_IS_REQUIRED400Responsible user is required but was not providedresponsible.required=true, but id-path produced no valuePass accessContext.responsible.userId or change the profile
OWNER_IS_REQUIRED400Owner is required but was not providedowner.required=true, but id-path produced no valuePass accessContext.owner.userId or change the profile
TICKET_LOOKUP_BATCH_SIZE_EXCEEDED400Batch lookup limit exceededGET /api/inbound/tickets received more UUIDs than max-ticket-lookup-batch-size allowsReduce the number of ids or change the limit
UNIBPM_TICKET_LOOKUP_FAILED502Ticket batch lookup error in CabineeTicketApiClient.getTicketListShort(...) returned an empty body or failedCheck Cabinee / API client / auth availability
UNIBPM_CREATE_TICKET_FAILED502Ticket creation error in CabineeTicketApiClient.createTicket(ticket) failedInspect the Cabinee error and the assembled Ticket

An invalid ticket-priority value is usually caught by Spring Boot configuration binding before the module starts.


AutoConfiguration

AutoConfiguration is connected through the file:

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

Contents:

com.reunico.unibpm.integration.inbound.conf.InboundIntegrationAutoConfiguration

The module is enabled by the property:

unibpm:
  integration:
    inbound:
      enabled: true

Build and tests

./mvnw -pl :unibpm-integration-inbound -am test

Verify the runtime together with its dependencies:

./mvnw -pl :unibpm-integration-runtime -am test

Extending to new systems

To connect a new external system:

  1. create a service client in Keycloak;
  2. add its client id to the unibpm.integration.inbound.systems profile;
  3. describe allowed object types and sub-object types;
  4. describe required fields;
  5. describe the mapping to Ticket.

Java code does not need to change if DEFAULT_TICKET_MAPPING is sufficient.


Onboarding checklist

unibpm-integration-runtime

  • Add the unibpm-integration-inbound dependency.
  • Enable unibpm.integration.inbound.enabled=true.
  • Add the external system profile.
  • Add Resource Server JWT config if inbound is enabled.
  • Verify that /api/inbound/** is protected by the inbound security chain.
  • Verify unibpm-api-client configuration.

Keycloak

  • Create a service client for the external system.
  • Enable Client Credentials / Service Account.
  • Specify that client’s client id in the inbound profile.

unibpm-app

  • Verify that Workflow exists.
  • Verify that TicketType exists.
  • Verify that the responsible user exists if responsible.enabled=true.
  • Verify that the owner user exists if owner.enabled=true.
  • Prepare the custom fields used in mapping.

External System

  • Obtain a token via Client Credentials.
  • Call POST /api/inbound/tickets.
  • Pass externalObject.url.
  • Store unibpmObjectId, unibpmObjectNumber, unibpmObjectUrl.
  • Check the stored UUID before creating again.
  • If needed, check Ticket existence via GET /api/inbound/tickets.

  • unibpm-integration-runtime.md
  • unibpm-integration-unibpm.md
  • unibpm-integration-keycloak.md
  • unibpm-integration-feedback.md