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
Ticketmodel fromunibpm-api-client; - the inbound module creates a
Ticketin 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-clientconfigured 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.
Related Cabinee entities
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-idis required;ticket.ticket-type-idis required;- both values are converted to
UUIDby 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:
- The external system calls
POST /api/inbound/tickets. - Inbound creates a Ticket.
- Inbound returns the Ticket UUID in
unibpmObjectId. - The external system stores the UUID.
- On a repeated operation, the external system checks whether the UUID already exists and does not create a duplicate.
- 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.
| Code | HTTP status | Meaning | When it occurs | What to do |
|---|---|---|---|---|
INVALID_INBOUND_PROFILE | 500 | Invalid inbound profile configuration | At 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 enabled | Fix application.yaml / environment variables |
ACCESS_DENIED | 403 | Token client id does not match the profile | JWT is valid, but azp or client_id does not match the profile client-id | Check the external Keycloak client or fix the profile |
REQUIRED_FIELD_MISSING | 400 | A required field is missing from the incoming request | A field from required-fields was not found | Fix the external system contract/payload |
OBJECT_TYPE_NOT_ALLOWED | 400 | The primary external object type is not allowed | externalObject.type is not in allowed-object-types | Fix the profile or the request |
SUB_OBJECT_TYPE_NOT_ALLOWED | 400 | The external sub-object type is not allowed | externalSubObject.type is not in allowed-sub-object-types | Fix the profile or the request |
RESPONSIBLE_IS_REQUIRED | 400 | Responsible user is required but was not provided | responsible.required=true, but id-path produced no value | Pass accessContext.responsible.userId or change the profile |
OWNER_IS_REQUIRED | 400 | Owner is required but was not provided | owner.required=true, but id-path produced no value | Pass accessContext.owner.userId or change the profile |
TICKET_LOOKUP_BATCH_SIZE_EXCEEDED | 400 | Batch lookup limit exceeded | GET /api/inbound/tickets received more UUIDs than max-ticket-lookup-batch-size allows | Reduce the number of ids or change the limit |
UNIBPM_TICKET_LOOKUP_FAILED | 502 | Ticket batch lookup error in Cabinee | TicketApiClient.getTicketListShort(...) returned an empty body or failed | Check Cabinee / API client / auth availability |
UNIBPM_CREATE_TICKET_FAILED | 502 | Ticket creation error in Cabinee | TicketApiClient.createTicket(ticket) failed | Inspect 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:
- create a service client in Keycloak;
- add its client id to the
unibpm.integration.inbound.systemsprofile; - describe allowed object types and sub-object types;
- describe required fields;
- 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-inbounddependency. - 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-clientconfiguration.
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
Workflowexists. - Verify that
TicketTypeexists. - 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.
Related documents
unibpm-integration-runtime.mdunibpm-integration-unibpm.mdunibpm-integration-keycloak.mdunibpm-integration-feedback.md