Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@

# Riptide artifacts (cloud-synced)
.humanlayer/tasks/
.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.ritense.buildingblock.listener.BuildingBlockTaskTeamAutoAssignListene
import com.ritense.buildingblock.listener.CaseDefinitionBuildingBlockLinkCaseEventListener
import com.ritense.buildingblock.processlink.mapper.BuildingBlockProcessLinkMapper
import com.ritense.buildingblock.processlink.service.BuildingBlockCallActivityListener
import com.ritense.buildingblock.processlink.service.BuildingBlockProcessLinkCopyValidator
import com.ritense.buildingblock.processlink.service.BuildingBlockSupportedProcessLinksHandler
import com.ritense.buildingblock.processlink.service.DefaultBuildingBlockPluginConfigurationResolver
import com.ritense.buildingblock.repository.BuildingBlockDefinitionArtworkRepository
Expand Down Expand Up @@ -530,6 +531,14 @@ class BuildingBlockAutoConfiguration {
operatonRepositoryService
)

@Bean
@ConditionalOnMissingBean(BuildingBlockProcessLinkCopyValidator::class)
fun buildingBlockProcessLinkCopyValidator(
repositoryService: RepositoryService,
) = BuildingBlockProcessLinkCopyValidator(
repositoryService
)

@Bean
@ConditionalOnMissingBean(BuildingBlockStartEventListener::class)
fun buildingBlockStartEventListener(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.ritense.buildingblock.processlink.domain.BuildingBlockInputMapping
import com.ritense.buildingblock.processlink.domain.BuildingBlockOutputMapping
import com.ritense.buildingblock.processlink.domain.BuildingBlockProcessLink
import com.ritense.buildingblock.processlink.service.BuildingBlockCallActivityBusinessKeyValidator
import com.ritense.buildingblock.processlink.dto.BuildingBlockProcessLinkCreateRequestDto
import com.ritense.buildingblock.processlink.dto.BuildingBlockProcessLinkDeployDto
import com.ritense.buildingblock.processlink.dto.BuildingBlockProcessLinkExportResponseDto
Expand All @@ -45,7 +46,6 @@ import java.util.UUID
import org.operaton.bpm.engine.RepositoryService
import org.operaton.bpm.model.bpmn.instance.CallActivity
import org.operaton.bpm.model.bpmn.instance.Process
import org.operaton.bpm.model.bpmn.instance.operaton.OperatonIn
import org.springframework.stereotype.Component

@Component
Expand Down Expand Up @@ -147,6 +147,9 @@ class BuildingBlockProcessLinkMapper(
createRequestDto.buildingBlockDefinitionKey,
createRequestDto.buildingBlockDefinitionVersionTag
)
val inputMappings = createRequestDto.inputMappings.toInputDomain()
val outputMappings = createRequestDto.outputMappings.toOutputDomain()
verifyMappingsUseBuildingBlockFields(inputMappings, outputMappings)
return BuildingBlockProcessLink(
id = UUID.randomUUID(),
processDefinitionId = createRequestDto.processDefinitionId,
Expand All @@ -158,8 +161,8 @@ class BuildingBlockProcessLinkMapper(
createRequestDto.pluginConfigurationMappings,
buildingBlockDefinitionId
),
inputMappings = createRequestDto.inputMappings.toInputDomain(),
outputMappings = createRequestDto.outputMappings.toOutputDomain()
inputMappings = inputMappings,
outputMappings = outputMappings
)
}

Expand All @@ -170,12 +173,19 @@ class BuildingBlockProcessLinkMapper(
): ProcessLink {
processLinkToUpdate as BuildingBlockProcessLink
updateRequestDto as BuildingBlockProcessLinkUpdateRequestDto
verifyBuildingBlockDocumentIdBusinessKey(
processLinkToUpdate.processDefinitionId,
processLinkToUpdate.activityId
)
val isNestedBuildingBlockLink = isNestedBuildingBlockLink(blueprintId, processLinkToUpdate.processDefinitionId)
return withLoggingContext(ProcessLink::class, processLinkToUpdate.id) {
val buildingBlockDefinitionId = toDefinitionId(
updateRequestDto.buildingBlockDefinitionKey,
updateRequestDto.buildingBlockDefinitionVersionTag
)
val inputMappings = updateRequestDto.inputMappings.toInputDomain()
val outputMappings = updateRequestDto.outputMappings.toOutputDomain()
verifyMappingsUseBuildingBlockFields(inputMappings, outputMappings)
BuildingBlockProcessLink(
id = updateRequestDto.id,
processDefinitionId = processLinkToUpdate.processDefinitionId,
Expand All @@ -187,8 +197,8 @@ class BuildingBlockProcessLinkMapper(
updateRequestDto.pluginConfigurationMappings,
buildingBlockDefinitionId
),
inputMappings = updateRequestDto.inputMappings.toInputDomain(),
outputMappings = updateRequestDto.outputMappings.toOutputDomain()
inputMappings = inputMappings,
outputMappings = outputMappings
)
}
}
Expand Down Expand Up @@ -271,6 +281,32 @@ class BuildingBlockProcessLinkMapper(
return BuildingBlockDefinitionId.of(key, versionTag)
}

/**
* The building-block side of a mapping is always a building block field (`doc:`). Values enter a
* building block through its document before its process instance exists, and are read back from
* its document after its process instance has ended. Targets/sources with other prefixes (like
* `pv:`) can never be delivered on the building-block side and would fail silently at runtime.
* The caller side (input sources, output targets) is unrestricted.
*/
private fun verifyMappingsUseBuildingBlockFields(
inputMappings: List<BuildingBlockInputMapping>,
outputMappings: List<BuildingBlockOutputMapping>
) {
val invalidInputTargets = inputMappings
.map { it.getPrefixedTarget() }
.filter { !it.startsWith(DOC_TARGET_PREFIX) }
require(invalidInputTargets.isEmpty()) {
"Input mapping targets must be 'doc:' building block fields. Invalid: ${invalidInputTargets.joinToString()}"
}

val invalidOutputSources = outputMappings
.map { it.getPrefixedSource() }
.filter { !it.startsWith(DOC_TARGET_PREFIX) }
require(invalidOutputSources.isEmpty()) {
"Output mapping sources must be 'doc:' building block fields. Invalid: ${invalidOutputSources.joinToString()}"
}
}

private fun verifyBuildingBlockDocumentIdBusinessKey(processDefinitionId: String, activityId: String) {
val bpmnModel = repositoryService.getBpmnModelInstance(processDefinitionId)
?: error("BPMN model not found for process definition '$processDefinitionId'")
Expand All @@ -279,20 +315,9 @@ class BuildingBlockProcessLinkMapper(
"Activity '$activityId' in process definition '$processDefinitionId' " +
"must be a call activity for a building-block process link."
)
val hasBuildingBlockBusinessKey = callActivity.extensionElements
?.elementsQuery
?.filterByType(OperatonIn::class.java)
?.list()
?.any { it.operatonBusinessKey == BUILDING_BLOCK_DOCUMENT_ID_EXPRESSION }
?: false
if (!hasBuildingBlockBusinessKey) {
val processDefinitionKey = bpmnModel.getModelElementsByType<Process?>(Process::class.java)
.singleOrNull()?.getId() ?: processDefinitionId
error(
"Call activity '$activityId' in process definition '$processDefinitionKey' must define " +
"<camunda:in businessKey=\"$BUILDING_BLOCK_DOCUMENT_ID_EXPRESSION\" />."
)
}
val processDefinitionKey = bpmnModel.getModelElementsByType<Process?>(Process::class.java)
.singleOrNull()?.getId() ?: processDefinitionId
BuildingBlockCallActivityBusinessKeyValidator.validate(callActivity, processDefinitionKey)
}

private fun List<com.ritense.buildingblock.processlink.dto.BuildingBlockInputMappingDto>.toInputDomain(): List<BuildingBlockInputMapping> =
Expand Down Expand Up @@ -320,6 +345,6 @@ class BuildingBlockProcessLinkMapper(
}

companion object {
private const val BUILDING_BLOCK_DOCUMENT_ID_EXPRESSION = "#{buildingBlockDocumentId}"
private const val DOC_TARGET_PREFIX = "doc:"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Copyright 2015-2026 Ritense BV, the Netherlands.
*
* Licensed under EUPL, Version 1.2 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* 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 com.ritense.buildingblock.processlink.service

import com.ritense.valtimo.contract.buildingblock.BuildingBlockConstants.Companion.BUILDING_BLOCK_DOCUMENT_ID_VARIABLE
import org.operaton.bpm.model.bpmn.impl.BpmnModelConstants.CAMUNDA_NS
import org.operaton.bpm.model.bpmn.impl.BpmnModelConstants.OPERATON_NS
import org.operaton.bpm.model.bpmn.instance.CallActivity

/**
* Validates that a building-block call activity propagates the building block document id as the
* business key of the called process. Everything inside a building block resolves its context
* (`doc:` references, plugin configuration mappings) through that business key; with a wrong or
* missing mapping the building block silently runs against the wrong document.
*
* Validation follows the engine's namespace semantics, not the BPMN model API's: the engine reads
* `<camunda:in>` elements only as a fallback when a call activity has no `<operaton:in>` elements
* at all. A correct `<camunda:in businessKey="..."/>` next to any `<operaton:in>` element is
* therefore dead configuration, even though the model API still returns it.
*/
object BuildingBlockCallActivityBusinessKeyValidator {

const val BUSINESS_KEY_EXPRESSION = "#{$BUILDING_BLOCK_DOCUMENT_ID_VARIABLE}"

/**
* @throws IllegalStateException when the call activity does not effectively map the business
* key to [BUSINESS_KEY_EXPRESSION], with a message that explains what the engine will do and
* how to fix the BPMN.
*/
fun validate(callActivity: CallActivity, processDefinitionKey: String) {
val context = "Call activity '${callActivity.id}' in process definition '$processDefinitionKey'"

val inElements = callActivity.extensionElements
?.domElement
?.childElements
?.filter { it.localName == IN_ELEMENT }
?: emptyList()

val operatonInElements = inElements.filter { it.namespaceURI == OPERATON_NS }
val camundaInElements = inElements.filter { it.namespaceURI == CAMUNDA_NS }
// The engine reads camunda:in elements only when there are no operaton:in elements
val effectiveInElements = operatonInElements.ifEmpty { camundaInElements }

val businessKeys = effectiveInElements
.mapNotNull { element -> element.getAttribute(BUSINESS_KEY_ATTRIBUTE)?.takeIf { it.isNotBlank() } }
.distinct()

val camundaMappingIsShadowed = operatonInElements.isNotEmpty() &&
camundaInElements.any { it.getAttribute(BUSINESS_KEY_ATTRIBUTE) == BUSINESS_KEY_EXPRESSION }

when {
businessKeys.isEmpty() -> error(
"$context must define <camunda:in businessKey=\"$BUSINESS_KEY_EXPRESSION\" />, so that the " +
"building block runs under its own document." +
shadowingExplanation(camundaMappingIsShadowed)
)

businessKeys.size > 1 -> error(
"$context defines multiple business key mappings " +
"(${businessKeys.joinToString { "'$it'" }}). Define exactly one: " +
"<camunda:in businessKey=\"$BUSINESS_KEY_EXPRESSION\" />." +
shadowingExplanation(camundaMappingIsShadowed)
)

businessKeys.single() != BUSINESS_KEY_EXPRESSION -> error(
"$context must map the business key to $BUSINESS_KEY_EXPRESSION, but maps it to " +
"'${businessKeys.single()}'. With this mapping the building block runs under the wrong " +
"document, and doc: references inside the building block silently resolve to null." +
shadowingExplanation(camundaMappingIsShadowed)
)
}
}

private fun shadowingExplanation(camundaMappingIsShadowed: Boolean): String =
if (camundaMappingIsShadowed) {
" Note: this call activity does contain <camunda:in businessKey=\"$BUSINESS_KEY_EXPRESSION\" />, " +
"but the engine ignores it because <operaton:in> elements are also present; camunda-namespace " +
"elements are only read when no operaton-namespace elements exist. Move the business key " +
"mapping to an <operaton:in> element, or remove all <operaton:in> elements."
} else {
""
}

private const val IN_ELEMENT = "in"
private const val BUSINESS_KEY_ATTRIBUTE = "businessKey"
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ import com.ritense.valtimo.contract.process.ProcessConstants.OPERATON_CASE_DEFIN
import com.ritense.valtimo.event.OperatonExecutionEvent
import com.ritense.valtimo.operaton.service.OperatonRepositoryService
import com.ritense.valueresolver.ValueResolverService
import io.github.oshai.kotlinlogging.KotlinLogging
import java.util.UUID
import org.operaton.bpm.engine.delegate.DelegateExecution
import org.operaton.bpm.model.bpmn.instance.CallActivity
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component

Expand Down Expand Up @@ -60,6 +62,11 @@ class BuildingBlockCallActivityListener(
val buildingBlockProcessLink = links.getOrNull(0)

buildingBlockProcessLink?.let {
// Fail fast, before any building block state is created: with a wrong or shadowed
// business key mapping the building block would run against the wrong document and all
// doc: references inside it would silently resolve to null.
validateBusinessKeyMapping(execution)

val parentBuildingBlockInstance = findParentBuildingBlockInstance(execution)
val isIndependentProcess = parentBuildingBlockInstance == null && isIndependentProcess(execution)

Expand All @@ -81,6 +88,15 @@ class BuildingBlockCallActivityListener(
}
}

private fun validateBusinessKeyMapping(execution: DelegateExecution) {
val callActivity = execution.bpmnModelElementInstance as? CallActivity ?: return
val processDefinitionKey = operatonRepositoryService
.findProcessDefinitionById(execution.processDefinitionId)
?.key
?: execution.processDefinitionId
BuildingBlockCallActivityBusinessKeyValidator.validate(callActivity, processDefinitionKey)
}

/**
* Independent process = version tag doesn't start with "CD:" (case) or "BB:" (building block).
*/
Expand Down Expand Up @@ -159,7 +175,21 @@ class BuildingBlockCallActivityListener(
}
if (endSyncOutputMappings.isEmpty()) return

val sourceMappings = endSyncOutputMappings.map { mapping ->
// The building block process instance has already ended here; only its document is still
// readable, so output values can only be sourced from building block fields.
val (fieldSourcedMappings, unsupportedSourceMappings) = endSyncOutputMappings.partition {
it.getPrefixedSource().startsWith("$DOC_PREFIX:")
}
if (unsupportedSourceMappings.isNotEmpty()) {
logger.warn {
"Skipping output mappings with sources [${unsupportedSourceMappings.joinToString { it.source }}] " +
"for building block document '$buildingBlockDocumentId': when a building block completes, " +
"output values can only be read from building block fields (doc:)."
}
}
if (fieldSourcedMappings.isEmpty()) return

val sourceMappings = fieldSourcedMappings.map { mapping ->
mapping.getPrefixedSource() to mapping.target
}

Expand Down Expand Up @@ -203,9 +233,21 @@ class BuildingBlockCallActivityListener(
activityId: String,
parentBuildingBlockInstanceId: UUID?
): BuildingBlockInstance {
val inputSources = buildingBlockProcessLink.inputMappings.map { it.source }
// Values can only be delivered to building block fields: the building block process instance
// does not exist yet at this point, so any other target has no destination.
val (fieldMappings, unsupportedMappings) = buildingBlockProcessLink.inputMappings.partition {
it.getPrefixedTarget().startsWith("$DOC_PREFIX:")
}
if (unsupportedMappings.isNotEmpty()) {
logger.warn {
"Skipping input mappings with targets [${unsupportedMappings.joinToString { it.target }}] for " +
"building block '${buildingBlockProcessLink.buildingBlockDefinitionId}': values passed to a " +
"building block can only be delivered to building block fields (doc:)."
}
}
val inputSources = fieldMappings.map { it.source }
val resolvedValues = valueResolverService.resolveValues(execution.processInstanceId, execution, inputSources)
val valuesToHandle = buildingBlockProcessLink.inputMappings.associate {
val valuesToHandle = fieldMappings.associate {
it.getPrefixedTarget() to resolvedValues[it.source]
}
val preProcessValues = valueResolverService.preProcessValuesForNewCase(valuesToHandle)
Expand All @@ -220,23 +262,17 @@ class BuildingBlockCallActivityListener(
documentContent,
)

val buildingBlockInstance = buildingBlockInstanceService.create(
return buildingBlockInstanceService.create(
documentRequest,
rootCaseDocumentId,
activityId,
parentBuildingBlockInstanceId,
callerProcessDefinitionId = execution.processDefinitionId
)

valueResolverService.handleValues(
buildingBlockInstance.documentId,
preProcessValues.filterKeys { !it.startsWith(DOC_PREFIX) }
)

return buildingBlockInstance
}

private companion object {
private val logger = KotlinLogging.logger {}
private const val DOC_PREFIX = "doc"
}
}
Loading
Loading